refactor(auth): add workspaces selection (#12098)

This commit is contained in:
Antoine Moreaux
2025-06-13 16:17:35 +02:00
committed by GitHub
parent 836e2f792c
commit b1af98f93d
162 changed files with 3542 additions and 1340 deletions

View File

@ -143,6 +143,14 @@ export type AvailableWorkspaceOutput = {
workspaceUrls: WorkspaceUrls;
};
export type AvailableWorkspacesToJoin = {
__typename?: 'AvailableWorkspacesToJoin';
displayName?: Maybe<Scalars['String']['output']>;
id: Scalars['String']['output'];
logo?: Maybe<Scalars['String']['output']>;
workspaceUrl: Scalars['String']['output'];
};
export type Billing = {
__typename?: 'Billing';
billingUrl?: Maybe<Scalars['String']['output']>;
@ -298,6 +306,13 @@ export enum CaptchaDriverType {
TURNSTILE = 'TURNSTILE'
}
export type CheckUserExistOutput = {
__typename?: 'CheckUserExistOutput';
availableWorkspaces: Array<AvailableWorkspaceOutput>;
exists: Scalars['Boolean']['output'];
isEmailVerified: Scalars['Boolean']['output'];
};
export type ClientConfig = {
__typename?: 'ClientConfig';
analyticsEnabled: Scalars['Boolean']['output'];
@ -942,6 +957,7 @@ export type Mutation = {
createOneRole: Role;
createOneServerlessFunction: ServerlessFunction;
createSAMLIdentityProvider: SetupSsoOutput;
createUserAndWorkspace: SignUpOutput;
createWorkflowVersionStep: WorkflowAction;
deactivateWorkflowVersion: Scalars['Boolean']['output'];
deleteApprovedAccessDomain: Scalars['Boolean']['output'];
@ -1103,6 +1119,16 @@ export type MutationCreateSamlIdentityProviderArgs = {
};
export type MutationCreateUserAndWorkspaceArgs = {
captchaToken?: InputMaybe<Scalars['String']['input']>;
email: Scalars['String']['input'];
firstName?: InputMaybe<Scalars['String']['input']>;
lastName?: InputMaybe<Scalars['String']['input']>;
locale?: InputMaybe<Scalars['String']['input']>;
picture?: InputMaybe<Scalars['String']['input']>;
};
export type MutationCreateWorkflowVersionStepArgs = {
input: CreateWorkflowVersionStepInput;
};
@ -1607,7 +1633,7 @@ export type PublishServerlessFunctionInput = {
export type Query = {
__typename?: 'Query';
billingPortalSession: BillingSessionOutput;
checkUserExists: UserExistsOutput;
checkUserExists: CheckUserExistOutput;
checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid;
clientConfig: ClientConfig;
currentUser: User;
@ -1640,6 +1666,7 @@ export type Query = {
getTimelineThreadsFromPersonId: TimelineThreadsWithTotal;
index: Index;
indexMetadatas: IndexConnection;
listAvailableWorkspaces: Array<AvailableWorkspaceOutput>;
object: Object;
objects: ObjectConnection;
plans: Array<BillingPlanOutput>;
@ -1771,6 +1798,12 @@ export type QueryIndexMetadatasArgs = {
};
export type QueryListAvailableWorkspacesArgs = {
captchaToken?: InputMaybe<Scalars['String']['input']>;
email: Scalars['String']['input'];
};
export type QueryObjectArgs = {
id: Scalars['UUID']['input'];
};
@ -2353,6 +2386,7 @@ export type UpsertSettingPermissionsInput = {
export type User = {
__typename?: 'User';
availableWorkspaces: Array<AvailableWorkspacesToJoin>;
canAccessFullAdminPanel: Scalars['Boolean']['output'];
canImpersonate: Scalars['Boolean']['output'];
createdAt: Scalars['DateTime']['output'];
@ -2386,15 +2420,6 @@ export type UserEdge = {
node: User;
};
export type UserExists = {
__typename?: 'UserExists';
availableWorkspaces: Array<AvailableWorkspaceOutput>;
exists: Scalars['Boolean']['output'];
isEmailVerified: Scalars['Boolean']['output'];
};
export type UserExistsOutput = UserExists | UserNotExists;
export type UserInfo = {
__typename?: 'UserInfo';
email: Scalars['String']['output'];
@ -2424,11 +2449,6 @@ 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'];

View File

@ -126,15 +126,30 @@ export type AuthorizeApp = {
redirectUrl: Scalars['String'];
};
export type AvailableWorkspaceOutput = {
__typename?: 'AvailableWorkspaceOutput';
export type AvailableWorkspace = {
__typename?: 'AvailableWorkspace';
displayName?: Maybe<Scalars['String']>;
id: Scalars['String'];
inviteHash?: Maybe<Scalars['String']>;
loginToken?: Maybe<Scalars['String']>;
logo?: Maybe<Scalars['String']>;
personalInviteToken?: Maybe<Scalars['String']>;
sso: Array<SsoConnection>;
workspaceUrls: WorkspaceUrls;
};
export type AvailableWorkspaces = {
__typename?: 'AvailableWorkspaces';
availableWorkspacesForSignIn: Array<AvailableWorkspace>;
availableWorkspacesForSignUp: Array<AvailableWorkspace>;
};
export type AvailableWorkspacesAndAccessTokensOutput = {
__typename?: 'AvailableWorkspacesAndAccessTokensOutput';
availableWorkspaces: AvailableWorkspaces;
tokens: AuthTokenPair;
};
export type Billing = {
__typename?: 'Billing';
billingUrl?: Maybe<Scalars['String']>;
@ -290,6 +305,13 @@ export enum CaptchaDriverType {
TURNSTILE = 'TURNSTILE'
}
export type CheckUserExistOutput = {
__typename?: 'CheckUserExistOutput';
availableWorkspacesCount: Scalars['Float'];
exists: Scalars['Boolean'];
isEmailVerified: Scalars['Boolean'];
};
export type ClientConfig = {
__typename?: 'ClientConfig';
analyticsEnabled: Scalars['Boolean'];
@ -922,8 +944,10 @@ export type Mutation = {
resendWorkspaceInvitation: SendInvitationsOutput;
runWorkflowVersion: WorkflowRun;
sendInvitations: SendInvitationsOutput;
signUp: SignUpOutput;
signIn: AvailableWorkspacesAndAccessTokensOutput;
signUp: AvailableWorkspacesAndAccessTokensOutput;
signUpInNewWorkspace: SignUpOutput;
signUpInWorkspace: SignUpOutput;
skipSyncEmailOnboardingStep: OnboardingStepSuccess;
submitFormStep: Scalars['Boolean'];
switchToEnterprisePlan: BillingUpdateOutput;
@ -1172,7 +1196,21 @@ export type MutationSendInvitationsArgs = {
};
export type MutationSignInArgs = {
captchaToken?: InputMaybe<Scalars['String']>;
email: Scalars['String'];
password: Scalars['String'];
};
export type MutationSignUpArgs = {
captchaToken?: InputMaybe<Scalars['String']>;
email: Scalars['String'];
password: Scalars['String'];
};
export type MutationSignUpInWorkspaceArgs = {
captchaToken?: InputMaybe<Scalars['String']>;
email: Scalars['String'];
locale?: InputMaybe<Scalars['String']>;
@ -1510,7 +1548,7 @@ export type PublishServerlessFunctionInput = {
export type Query = {
__typename?: 'Query';
billingPortalSession: BillingSessionOutput;
checkUserExists: UserExistsOutput;
checkUserExists: CheckUserExistOutput;
checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid;
clientConfig: ClientConfig;
currentUser: User;
@ -2183,6 +2221,7 @@ export type UpsertSettingPermissionsInput = {
export type User = {
__typename?: 'User';
availableWorkspaces: AvailableWorkspaces;
canAccessFullAdminPanel: Scalars['Boolean'];
canImpersonate: Scalars['Boolean'];
createdAt: Scalars['DateTime'];
@ -2202,7 +2241,7 @@ export type User = {
passwordHash?: Maybe<Scalars['String']>;
supportUserHash?: Maybe<Scalars['String']>;
updatedAt: Scalars['DateTime'];
userVars: Scalars['JSONObject'];
userVars?: Maybe<Scalars['JSONObject']>;
workspaceMember?: Maybe<WorkspaceMember>;
workspaceMembers?: Maybe<Array<WorkspaceMember>>;
workspaces: Array<UserWorkspace>;
@ -2216,15 +2255,6 @@ export type UserEdge = {
node: User;
};
export type UserExists = {
__typename?: 'UserExists';
availableWorkspaces: Array<AvailableWorkspaceOutput>;
exists: Scalars['Boolean'];
isEmailVerified: Scalars['Boolean'];
};
export type UserExistsOutput = UserExists | UserNotExists;
export type UserInfo = {
__typename?: 'UserInfo';
email: Scalars['String'];
@ -2244,11 +2274,6 @@ export type UserMappingOptionsUser = {
user?: Maybe<Scalars['String']>;
};
export type UserNotExists = {
__typename?: 'UserNotExists';
exists: Scalars['Boolean'];
};
export type UserWorkspace = {
__typename?: 'UserWorkspace';
createdAt: Scalars['DateTime'];
@ -2510,6 +2535,10 @@ export type AuthTokenFragmentFragment = { __typename?: 'AuthToken', token: strin
export type AuthTokensFragmentFragment = { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } };
export type AvailableWorkspaceFragmentFragment = { __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> };
export type AvailableWorkspacesFragmentFragment = { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> };
export type AvailableSsoIdentityProvidersFragmentFragment = { __typename?: 'FindAvailableSSOIDPOutput', id: string, issuer: string, name: string, status: SsoIdentityProviderStatus, workspace: { __typename?: 'WorkspaceNameAndId', id: string, displayName?: string | null } };
export type AuthorizeAppMutationVariables = Exact<{
@ -2600,7 +2629,30 @@ export type ResendEmailVerificationTokenMutationVariables = Exact<{
export type ResendEmailVerificationTokenMutation = { __typename?: 'Mutation', resendEmailVerificationToken: { __typename?: 'ResendEmailVerificationTokenOutput', success: boolean } };
export type SignInMutationVariables = Exact<{
email: Scalars['String'];
password: Scalars['String'];
captchaToken?: InputMaybe<Scalars['String']>;
}>;
export type SignInMutation = { __typename?: 'Mutation', signIn: { __typename?: 'AvailableWorkspacesAndAccessTokensOutput', availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
export type SignUpMutationVariables = Exact<{
email: Scalars['String'];
password: Scalars['String'];
captchaToken?: InputMaybe<Scalars['String']>;
}>;
export type SignUpMutation = { __typename?: 'Mutation', signUp: { __typename?: 'AvailableWorkspacesAndAccessTokensOutput', availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
export type SignUpInNewWorkspaceMutationVariables = Exact<{ [key: string]: never; }>;
export type SignUpInNewWorkspaceMutation = { __typename?: 'Mutation', signUpInNewWorkspace: { __typename?: 'SignUpOutput', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, workspace: { __typename?: 'WorkspaceUrlsAndId', id: string, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } } };
export type SignUpInWorkspaceMutationVariables = Exact<{
email: Scalars['String'];
password: Scalars['String'];
workspaceInviteHash?: InputMaybe<Scalars['String']>;
@ -2612,12 +2664,7 @@ export type SignUpMutationVariables = Exact<{
}>;
export type SignUpMutation = { __typename?: 'Mutation', signUp: { __typename?: 'SignUpOutput', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, workspace: { __typename?: 'WorkspaceUrlsAndId', id: string, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } } };
export type SignUpInNewWorkspaceMutationVariables = Exact<{ [key: string]: never; }>;
export type SignUpInNewWorkspaceMutation = { __typename?: 'Mutation', signUpInNewWorkspace: { __typename?: 'SignUpOutput', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, workspace: { __typename?: 'WorkspaceUrlsAndId', id: string, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } } };
export type SignUpInWorkspaceMutation = { __typename?: 'Mutation', signUpInWorkspace: { __typename?: 'SignUpOutput', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, workspace: { __typename?: 'WorkspaceUrlsAndId', id: string, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } } };
export type UpdatePasswordViaResetTokenMutationVariables = Exact<{
token: Scalars['String'];
@ -2633,7 +2680,7 @@ export type CheckUserExistsQueryVariables = Exact<{
}>;
export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename: 'UserExists', exists: boolean, isEmailVerified: boolean, availableWorkspaces: Array<{ __typename?: 'AvailableWorkspaceOutput', id: string, displayName?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } | { __typename: 'UserNotExists', exists: boolean } };
export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename?: 'CheckUserExistOutput', exists: boolean, availableWorkspacesCount: number, isEmailVerified: boolean } };
export type GetPublicWorkspaceDataByDomainQueryVariables = Exact<{
origin: Scalars['String'];
@ -2909,7 +2956,9 @@ export type OnDbEventSubscriptionVariables = Exact<{
export type OnDbEventSubscription = { __typename?: 'Subscription', onDbEvent: { __typename?: 'OnDbEventDTO', eventDate: string, action: DatabaseEventAction, objectNameSingular: string, updatedFields?: Array<string> | null, record: any } };
export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, 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, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, metadata: any }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> };
export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, 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, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, metadata: any }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } };
export type WorkspaceUrlsFragmentFragment = { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null };
export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>;
@ -2926,7 +2975,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, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, 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, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, metadata: any }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> } };
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, 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, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, metadata: any }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } };
export type ActivateWorkflowVersionMutationVariables = Exact<{
workflowVersionId: Scalars['String'];
@ -3208,6 +3257,12 @@ export const ObjectPermissionFragmentFragmentDoc = gql`
canDestroyObjectRecords
}
`;
export const WorkspaceUrlsFragmentFragmentDoc = gql`
fragment WorkspaceUrlsFragment on WorkspaceUrls {
subdomainUrl
customUrl
}
`;
export const RoleFragmentFragmentDoc = gql`
fragment RoleFragment on Role {
id
@ -3222,6 +3277,37 @@ export const RoleFragmentFragmentDoc = gql`
canDestroyAllObjectRecords
}
`;
export const AvailableWorkspaceFragmentFragmentDoc = gql`
fragment AvailableWorkspaceFragment on AvailableWorkspace {
id
displayName
loginToken
inviteHash
personalInviteToken
workspaceUrls {
subdomainUrl
customUrl
}
logo
sso {
type
id
issuer
name
status
}
}
`;
export const AvailableWorkspacesFragmentFragmentDoc = gql`
fragment AvailableWorkspacesFragment on AvailableWorkspaces {
availableWorkspacesForSignIn {
...AvailableWorkspaceFragment
}
availableWorkspacesForSignUp {
...AvailableWorkspaceFragment
}
}
${AvailableWorkspaceFragmentFragmentDoc}`;
export const UserQueryFragmentFragmentDoc = gql`
fragment UserQueryFragment on User {
id
@ -3264,8 +3350,7 @@ export const UserQueryFragmentFragmentDoc = gql`
customDomain
isCustomDomainEnabled
workspaceUrls {
subdomainUrl
customUrl
...WorkspaceUrlsFragment
}
featureFlags {
key
@ -3302,25 +3387,17 @@ export const UserQueryFragmentFragmentDoc = gql`
...RoleFragment
}
}
workspaces {
workspace {
id
logo
displayName
subdomain
customDomain
workspaceUrls {
subdomainUrl
customUrl
}
}
availableWorkspaces {
...AvailableWorkspacesFragment
}
userVars
}
${WorkspaceMemberQueryFragmentFragmentDoc}
${DeletedWorkspaceMemberQueryFragmentFragmentDoc}
${ObjectPermissionFragmentFragmentDoc}
${RoleFragmentFragmentDoc}`;
${WorkspaceUrlsFragmentFragmentDoc}
${RoleFragmentFragmentDoc}
${AvailableWorkspacesFragmentFragmentDoc}`;
export const GetTimelineCalendarEventsFromCompanyIdDocument = gql`
query GetTimelineCalendarEventsFromCompanyId($companyId: UUID!, $page: Int!, $pageSize: Int!) {
getTimelineCalendarEventsFromCompanyId(
@ -3858,12 +3935,12 @@ export const GetLoginTokenFromEmailVerificationTokenDocument = gql`
...AuthTokenFragment
}
workspaceUrls {
subdomainUrl
customUrl
...WorkspaceUrlsFragment
}
}
}
${AuthTokenFragmentFragmentDoc}`;
${AuthTokenFragmentFragmentDoc}
${WorkspaceUrlsFragmentFragmentDoc}`;
export type GetLoginTokenFromEmailVerificationTokenMutationFn = Apollo.MutationFunction<GetLoginTokenFromEmailVerificationTokenMutation, GetLoginTokenFromEmailVerificationTokenMutationVariables>;
/**
@ -3898,8 +3975,7 @@ export const ImpersonateDocument = gql`
impersonate(userId: $userId, workspaceId: $workspaceId) {
workspace {
workspaceUrls {
subdomainUrl
customUrl
...WorkspaceUrlsFragment
}
id
}
@ -3908,7 +3984,8 @@ export const ImpersonateDocument = gql`
}
}
}
${AuthTokenFragmentFragmentDoc}`;
${WorkspaceUrlsFragmentFragmentDoc}
${AuthTokenFragmentFragmentDoc}`;
export type ImpersonateMutationFn = Apollo.MutationFunction<ImpersonateMutation, ImpersonateMutationVariables>;
/**
@ -4005,31 +4082,60 @@ export function useResendEmailVerificationTokenMutation(baseOptions?: Apollo.Mut
export type ResendEmailVerificationTokenMutationHookResult = ReturnType<typeof useResendEmailVerificationTokenMutation>;
export type ResendEmailVerificationTokenMutationResult = Apollo.MutationResult<ResendEmailVerificationTokenMutation>;
export type ResendEmailVerificationTokenMutationOptions = Apollo.BaseMutationOptions<ResendEmailVerificationTokenMutation, ResendEmailVerificationTokenMutationVariables>;
export const SignUpDocument = gql`
mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $workspacePersonalInviteToken: String = null, $captchaToken: String, $workspaceId: String, $locale: String, $verifyEmailNextPath: String) {
signUp(
email: $email
password: $password
workspaceInviteHash: $workspaceInviteHash
workspacePersonalInviteToken: $workspacePersonalInviteToken
captchaToken: $captchaToken
workspaceId: $workspaceId
locale: $locale
verifyEmailNextPath: $verifyEmailNextPath
) {
loginToken {
...AuthTokenFragment
export const SignInDocument = gql`
mutation SignIn($email: String!, $password: String!, $captchaToken: String) {
signIn(email: $email, password: $password, captchaToken: $captchaToken) {
availableWorkspaces {
...AvailableWorkspacesFragment
}
workspace {
id
workspaceUrls {
subdomainUrl
customUrl
}
tokens {
...AuthTokensFragment
}
}
}
${AuthTokenFragmentFragmentDoc}`;
${AvailableWorkspacesFragmentFragmentDoc}
${AuthTokensFragmentFragmentDoc}`;
export type SignInMutationFn = Apollo.MutationFunction<SignInMutation, SignInMutationVariables>;
/**
* __useSignInMutation__
*
* To run a mutation, you first call `useSignInMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useSignInMutation` 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 [signInMutation, { data, loading, error }] = useSignInMutation({
* variables: {
* email: // value for 'email'
* password: // value for 'password'
* captchaToken: // value for 'captchaToken'
* },
* });
*/
export function useSignInMutation(baseOptions?: Apollo.MutationHookOptions<SignInMutation, SignInMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<SignInMutation, SignInMutationVariables>(SignInDocument, options);
}
export type SignInMutationHookResult = ReturnType<typeof useSignInMutation>;
export type SignInMutationResult = Apollo.MutationResult<SignInMutation>;
export type SignInMutationOptions = Apollo.BaseMutationOptions<SignInMutation, SignInMutationVariables>;
export const SignUpDocument = gql`
mutation SignUp($email: String!, $password: String!, $captchaToken: String) {
signUp(email: $email, password: $password, captchaToken: $captchaToken) {
availableWorkspaces {
...AvailableWorkspacesFragment
}
tokens {
...AuthTokensFragment
}
}
}
${AvailableWorkspacesFragmentFragmentDoc}
${AuthTokensFragmentFragmentDoc}`;
export type SignUpMutationFn = Apollo.MutationFunction<SignUpMutation, SignUpMutationVariables>;
/**
@ -4047,12 +4153,7 @@ export type SignUpMutationFn = Apollo.MutationFunction<SignUpMutation, SignUpMut
* variables: {
* email: // value for 'email'
* password: // value for 'password'
* workspaceInviteHash: // value for 'workspaceInviteHash'
* workspacePersonalInviteToken: // value for 'workspacePersonalInviteToken'
* captchaToken: // value for 'captchaToken'
* workspaceId: // value for 'workspaceId'
* locale: // value for 'locale'
* verifyEmailNextPath: // value for 'verifyEmailNextPath'
* },
* });
*/
@ -4072,13 +4173,13 @@ export const SignUpInNewWorkspaceDocument = gql`
workspace {
id
workspaceUrls {
subdomainUrl
customUrl
...WorkspaceUrlsFragment
}
}
}
}
${AuthTokenFragmentFragmentDoc}`;
${AuthTokenFragmentFragmentDoc}
${WorkspaceUrlsFragmentFragmentDoc}`;
export type SignUpInNewWorkspaceMutationFn = Apollo.MutationFunction<SignUpInNewWorkspaceMutation, SignUpInNewWorkspaceMutationVariables>;
/**
@ -4104,6 +4205,64 @@ export function useSignUpInNewWorkspaceMutation(baseOptions?: Apollo.MutationHoo
export type SignUpInNewWorkspaceMutationHookResult = ReturnType<typeof useSignUpInNewWorkspaceMutation>;
export type SignUpInNewWorkspaceMutationResult = Apollo.MutationResult<SignUpInNewWorkspaceMutation>;
export type SignUpInNewWorkspaceMutationOptions = Apollo.BaseMutationOptions<SignUpInNewWorkspaceMutation, SignUpInNewWorkspaceMutationVariables>;
export const SignUpInWorkspaceDocument = gql`
mutation SignUpInWorkspace($email: String!, $password: String!, $workspaceInviteHash: String, $workspacePersonalInviteToken: String = null, $captchaToken: String, $workspaceId: String, $locale: String, $verifyEmailNextPath: String) {
signUpInWorkspace(
email: $email
password: $password
workspaceInviteHash: $workspaceInviteHash
workspacePersonalInviteToken: $workspacePersonalInviteToken
captchaToken: $captchaToken
workspaceId: $workspaceId
locale: $locale
verifyEmailNextPath: $verifyEmailNextPath
) {
loginToken {
...AuthTokenFragment
}
workspace {
id
workspaceUrls {
subdomainUrl
customUrl
}
}
}
}
${AuthTokenFragmentFragmentDoc}`;
export type SignUpInWorkspaceMutationFn = Apollo.MutationFunction<SignUpInWorkspaceMutation, SignUpInWorkspaceMutationVariables>;
/**
* __useSignUpInWorkspaceMutation__
*
* To run a mutation, you first call `useSignUpInWorkspaceMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useSignUpInWorkspaceMutation` 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 [signUpInWorkspaceMutation, { data, loading, error }] = useSignUpInWorkspaceMutation({
* variables: {
* email: // value for 'email'
* password: // value for 'password'
* workspaceInviteHash: // value for 'workspaceInviteHash'
* workspacePersonalInviteToken: // value for 'workspacePersonalInviteToken'
* captchaToken: // value for 'captchaToken'
* workspaceId: // value for 'workspaceId'
* locale: // value for 'locale'
* verifyEmailNextPath: // value for 'verifyEmailNextPath'
* },
* });
*/
export function useSignUpInWorkspaceMutation(baseOptions?: Apollo.MutationHookOptions<SignUpInWorkspaceMutation, SignUpInWorkspaceMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<SignUpInWorkspaceMutation, SignUpInWorkspaceMutationVariables>(SignUpInWorkspaceDocument, options);
}
export type SignUpInWorkspaceMutationHookResult = ReturnType<typeof useSignUpInWorkspaceMutation>;
export type SignUpInWorkspaceMutationResult = Apollo.MutationResult<SignUpInWorkspaceMutation>;
export type SignUpInWorkspaceMutationOptions = Apollo.BaseMutationOptions<SignUpInWorkspaceMutation, SignUpInWorkspaceMutationVariables>;
export const UpdatePasswordViaResetTokenDocument = gql`
mutation UpdatePasswordViaResetToken($token: String!, $newPassword: String!) {
updatePasswordViaResetToken(
@ -4144,30 +4303,9 @@ export type UpdatePasswordViaResetTokenMutationOptions = Apollo.BaseMutationOpti
export const CheckUserExistsDocument = gql`
query CheckUserExists($email: String!, $captchaToken: String) {
checkUserExists(email: $email, captchaToken: $captchaToken) {
__typename
... on UserExists {
exists
availableWorkspaces {
id
displayName
workspaceUrls {
subdomainUrl
customUrl
}
logo
sso {
type
id
issuer
name
status
}
}
isEmailVerified
}
... on UserNotExists {
exists
}
exists
availableWorkspacesCount
isEmailVerified
}
}
`;
@ -4207,8 +4345,7 @@ export const GetPublicWorkspaceDataByDomainDocument = gql`
logo
displayName
workspaceUrls {
subdomainUrl
customUrl
...WorkspaceUrlsFragment
}
authProviders {
sso {
@ -4225,7 +4362,7 @@ export const GetPublicWorkspaceDataByDomainDocument = gql`
}
}
}
`;
${WorkspaceUrlsFragmentFragmentDoc}`;
/**
* __useGetPublicWorkspaceDataByDomainQuery__

View File

@ -12,6 +12,7 @@ import { OnboardingStatus } from '~/generated/graphql';
import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation';
import { UNTESTED_APP_PATHS } from '~/testing/constants/UntestedAppPaths';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
jest.mock('@/onboarding/hooks/useOnboardingStatus');
const setupMockOnboardingStatus = (
@ -50,6 +51,11 @@ jest.mocked(useDefaultHomePagePath).mockReturnValue({
defaultHomePagePath,
});
jest.mock('@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace');
jest.mocked(useIsCurrentLocationOnAWorkspace).mockReturnValue({
isOnAWorkspace: true,
});
jest.mock('react-router-dom');
const setupMockUseParams = (objectNamePlural?: string) => {
jest

View File

@ -12,9 +12,11 @@ import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { OnboardingStatus } from '~/generated/graphql';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
export const usePageChangeEffectNavigateLocation = () => {
const isLoggedIn = useIsLogged();
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
const onboardingStatus = useOnboardingStatus();
const isWorkspaceSuspended = useIsWorkspaceActivationStatusEqualsTo(
WorkspaceActivationStatus.SUSPENDED,
@ -41,13 +43,13 @@ export const usePageChangeEffectNavigateLocation = () => {
const objectNamePlural = useParams().objectNamePlural ?? '';
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const objectMetadataItem = objectMetadataItems.find(
const objectMetadataItem = objectMetadataItems?.find(
(objectMetadataItem) => objectMetadataItem.namePlural === objectNamePlural,
);
const verifyEmailNextPath = useRecoilValue(verifyEmailNextPathState);
if (
!isLoggedIn &&
(!isLoggedIn || (isLoggedIn && !isOnAWorkspace)) &&
!someMatchingLocationOf([
...onGoingUserCreationPaths,
AppPath.ResetPassword,

View File

@ -8,7 +8,6 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { previousUrlState } from '@/auth/states/previousUrlState';
import { tokenPairState } from '@/auth/states/tokenPairState';
import { workspacesState } from '@/auth/states/workspaces';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useUpdateEffect } from '~/hooks/useUpdateEffect';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
@ -33,8 +32,7 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
const setCurrentUser = useSetRecoilState(currentUserState);
const setCurrentUserWorkspace = useSetRecoilState(currentUserWorkspaceState);
const setWorkspaces = useSetRecoilState(workspacesState);
const [, setPreviousUrl] = useRecoilState(previousUrlState);
const setPreviousUrl = useSetRecoilState(previousUrlState);
const location = useLocation();
const apolloClient = useMemo(() => {
@ -65,7 +63,6 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
setCurrentWorkspaceMember(null);
setCurrentWorkspace(null);
setCurrentUserWorkspace(null);
setWorkspaces([]);
if (
!isMatchingLocation(location, AppPath.Verify) &&
!isMatchingLocation(location, AppPath.SignInUp) &&
@ -89,7 +86,6 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
setCurrentUser,
setCurrentWorkspaceMember,
setCurrentWorkspace,
setWorkspaces,
setPreviousUrl,
]);

View File

@ -11,6 +11,7 @@ type LogoProps = {
primaryLogo?: string | null;
secondaryLogo?: string | null;
placeholder?: string | null;
onClick?: () => void;
};
const StyledContainer = styled.div`
@ -53,6 +54,7 @@ export const Logo = ({
primaryLogo,
secondaryLogo,
placeholder,
onClick,
}: LogoProps) => {
const { redirectToDefaultDomain } = useRedirectToDefaultDomain();
const defaultPrimaryLogoUrl = `${window.location.origin}/images/icons/android/android-launchericon-192-192.png`;
@ -72,7 +74,7 @@ export const Logo = ({
const isUsingDefaultLogo = !isDefined(primaryLogo);
return (
<StyledContainer>
<StyledContainer onClick={() => onClick?.()}>
{isUsingDefaultLogo ? (
<UndecoratedLink
to={AppPath.SignInUp}

View File

@ -17,3 +17,36 @@ export const AUTH_TOKENS = gql`
}
}
`;
export const AVAILABLE_WORKSPACE_FOR_AUTH_FRAGMENT = gql`
fragment AvailableWorkspaceFragment on AvailableWorkspace {
id
displayName
loginToken
inviteHash
personalInviteToken
workspaceUrls {
subdomainUrl
customUrl
}
logo
sso {
type
id
issuer
name
status
}
}
`;
export const AVAILABLE_WORKSPACES_FOR_AUTH_FRAGMENT = gql`
fragment AvailableWorkspacesFragment on AvailableWorkspaces {
availableWorkspacesForSignIn {
...AvailableWorkspaceFragment
}
availableWorkspacesForSignUp {
...AvailableWorkspaceFragment
}
}
`;

View File

@ -17,8 +17,7 @@ export const GET_LOGIN_TOKEN_FROM_EMAIL_VERIFICATION_TOKEN = gql`
...AuthTokenFragment
}
workspaceUrls {
subdomainUrl
customUrl
...WorkspaceUrlsFragment
}
}
}

View File

@ -6,8 +6,7 @@ export const IMPERSONATE = gql`
impersonate(userId: $userId, workspaceId: $workspaceId) {
workspace {
workspaceUrls {
subdomainUrl
customUrl
...WorkspaceUrlsFragment
}
id
}

View File

@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const SIGN_IN = gql`
mutation SignIn($email: String!, $password: String!, $captchaToken: String) {
signIn(email: $email, password: $password, captchaToken: $captchaToken) {
availableWorkspaces {
...AvailableWorkspacesFragment
}
tokens {
...AuthTokensFragment
}
}
}
`;

View File

@ -1,35 +1,13 @@
import { gql } from '@apollo/client';
export const SIGN_UP = gql`
mutation SignUp(
$email: String!
$password: String!
$workspaceInviteHash: String
$workspacePersonalInviteToken: String = null
$captchaToken: String
$workspaceId: String
$locale: String
$verifyEmailNextPath: String
) {
signUp(
email: $email
password: $password
workspaceInviteHash: $workspaceInviteHash
workspacePersonalInviteToken: $workspacePersonalInviteToken
captchaToken: $captchaToken
workspaceId: $workspaceId
locale: $locale
verifyEmailNextPath: $verifyEmailNextPath
) {
loginToken {
...AuthTokenFragment
mutation SignUp($email: String!, $password: String!, $captchaToken: String) {
signUp(email: $email, password: $password, captchaToken: $captchaToken) {
availableWorkspaces {
...AvailableWorkspacesFragment
}
workspace {
id
workspaceUrls {
subdomainUrl
customUrl
}
tokens {
...AuthTokensFragment
}
}
}

View File

@ -9,8 +9,7 @@ export const SIGN_UP_IN_NEW_WORKSPACE = gql`
workspace {
id
workspaceUrls {
subdomainUrl
customUrl
...WorkspaceUrlsFragment
}
}
}

View File

@ -0,0 +1,36 @@
import { gql } from '@apollo/client';
export const SIGN_UP_IN_WORKSPACE = gql`
mutation SignUpInWorkspace(
$email: String!
$password: String!
$workspaceInviteHash: String
$workspacePersonalInviteToken: String = null
$captchaToken: String
$workspaceId: String
$locale: String
$verifyEmailNextPath: String
) {
signUpInWorkspace(
email: $email
password: $password
workspaceInviteHash: $workspaceInviteHash
workspacePersonalInviteToken: $workspacePersonalInviteToken
captchaToken: $captchaToken
workspaceId: $workspaceId
locale: $locale
verifyEmailNextPath: $verifyEmailNextPath
) {
loginToken {
...AuthTokenFragment
}
workspace {
id
workspaceUrls {
subdomainUrl
customUrl
}
}
}
}
`;

View File

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

View File

@ -1,4 +1,5 @@
import { gql } from '@apollo/client';
import { WORKSPACE_URLS_FRAGMENT } from '@/users/graphql/fragments/workspaceUrlsFragment';
export const GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN = gql`
query GetPublicWorkspaceDataByDomain($origin: String!) {
@ -7,8 +8,7 @@ export const GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN = gql`
logo
displayName
workspaceUrls {
subdomainUrl
customUrl
...WorkspaceUrlsFragment
}
authProviders {
sso {
@ -25,4 +25,5 @@ export const GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN = gql`
}
}
}
${WORKSPACE_URLS_FRAGMENT}
`;

View File

@ -3,6 +3,7 @@ import {
GetCurrentUserDocument,
GetLoginTokenFromCredentialsDocument,
SignUpDocument,
SignUpInWorkspaceDocument,
} from '~/generated/graphql';
export const queries = {
@ -10,6 +11,7 @@ export const queries = {
getAuthTokensFromLoginToken: GetAuthTokensFromLoginTokenDocument,
signup: SignUpDocument,
getCurrentUser: GetCurrentUserDocument,
signUpInWorkspace: SignUpInWorkspaceDocument,
};
export const email = 'test@test.com';
@ -29,7 +31,13 @@ export const variables = {
email,
password,
workspacePersonalInviteToken: null,
locale: "",
locale: '',
},
signUpInWorkspace: {
email,
password,
workspacePersonalInviteToken: null,
locale: '',
},
getCurrentUser: {},
};
@ -48,6 +56,16 @@ export const results = {
},
},
signUp: { loginToken: { token, expiresAt: 'expiresAt' } },
signUpInWorkspace: {
loginToken: { token, expiresAt: 'expiresAt' },
workspace: {
id: 'workspace-id',
workspaceUrls: {
subdomainUrl: 'https://subdomain.twenty.com',
customUrl: 'https://custom.twenty.com',
},
},
},
getCurrentUser: {
currentUser: {
id: 'id',
@ -67,6 +85,7 @@ export const results = {
avatarUrl: 'avatarUrl',
locale: 'locale',
},
availableWorkspaces: [],
currentWorkspace: {
id: 'id',
displayName: 'displayName',
@ -74,6 +93,11 @@ export const results = {
inviteHash: 'inviteHash',
allowImpersonation: true,
subscriptionStatus: 'subscriptionStatus',
customDomain: null,
workspaceUrls: {
customUrl: undefined,
subdomainUrl: 'https://twenty.com',
},
featureFlags: {
id: 'id',
key: 'key',
@ -85,8 +109,8 @@ export const results = {
},
};
export const mocks = [
{
export const mocks = {
getLoginTokenFromCredentials: {
request: {
query: queries.getLoginTokenFromCredentials,
variables: variables.getLoginTokenFromCredentials,
@ -97,7 +121,7 @@ export const mocks = [
},
})),
},
{
getAuthTokensFromLoginToken: {
request: {
query: queries.getAuthTokensFromLoginToken,
variables: variables.getAuthTokensFromLoginToken,
@ -108,7 +132,7 @@ export const mocks = [
},
})),
},
{
signup: {
request: {
query: queries.signup,
variables: variables.signup,
@ -119,7 +143,7 @@ export const mocks = [
},
})),
},
{
getCurrentUser: {
request: {
query: queries.getCurrentUser,
variables: variables.getCurrentUser,
@ -128,4 +152,15 @@ export const mocks = [
data: results.getCurrentUser,
})),
},
];
signUpInWorkspace: {
request: {
query: queries.signUpInWorkspace,
variables: variables.signUpInWorkspace,
},
result: jest.fn(() => ({
data: {
signUpInWorkspace: results.signUpInWorkspace,
},
})),
},
};

View File

@ -2,6 +2,7 @@ import { useAuth } from '@/auth/hooks/useAuth';
import { billingState } from '@/client-config/states/billingState';
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
import { supportChatState } from '@/client-config/states/supportChatState';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { useApolloClient } from '@apollo/client';
import { MockedProvider } from '@apollo/client/testing';
@ -30,9 +31,13 @@ jest.mock('@/object-metadata/hooks/useRefreshObjectMetadataItem', () => ({
}));
const Wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider mocks={mocks} addTypename={false}>
<MockedProvider mocks={Object.values(mocks)} addTypename={false}>
<RecoilRoot>
<MemoryRouter>{children}</MemoryRouter>
<MemoryRouter>
<SnackBarProviderScope snackBarManagerScopeId="test-scope-id">
{children}
</SnackBarProviderScope>
</MemoryRouter>
</RecoilRoot>
</MockedProvider>
);
@ -63,7 +68,7 @@ describe('useAuth', () => {
).toStrictEqual(results.getLoginTokenFromCredentials);
});
expect(mocks[0].result).toHaveBeenCalled();
expect(mocks.getLoginTokenFromCredentials.result).toHaveBeenCalled();
});
it('should verify user', async () => {
@ -73,19 +78,19 @@ describe('useAuth', () => {
await result.current.getAuthTokensFromLoginToken(token);
});
expect(mocks[1].result).toHaveBeenCalled();
expect(mocks[3].result).toHaveBeenCalled();
expect(mocks.getAuthTokensFromLoginToken.result).toHaveBeenCalled();
expect(mocks.getCurrentUser.result).toHaveBeenCalled();
});
it('should handle credential sign-in', async () => {
const { result } = renderHooks();
await act(async () => {
await result.current.signInWithCredentials(email, password);
await result.current.signInWithCredentialsInWorkspace(email, password);
});
expect(mocks[0].result).toHaveBeenCalled();
expect(mocks[1].result).toHaveBeenCalled();
expect(mocks.getLoginTokenFromCredentials.result).toHaveBeenCalled();
expect(mocks.getAuthTokensFromLoginToken.result).toHaveBeenCalled();
});
it('should handle google sign-in', async () => {
@ -94,6 +99,7 @@ describe('useAuth', () => {
await act(async () => {
await result.current.signInWithGoogle({
workspaceInviteHash: 'workspaceInviteHash',
action: 'join-workspace',
});
});
@ -163,9 +169,12 @@ describe('useAuth', () => {
const { result } = renderHooks();
await act(async () => {
await result.current.signUpWithCredentials({ email, password });
await result.current.signUpWithCredentialsInWorkspace({
email,
password,
});
});
expect(mocks[2].result).toHaveBeenCalled();
expect(mocks.signUpInWorkspace.result).toHaveBeenCalled();
});
});

View File

@ -11,19 +11,21 @@ import {
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { workspacesState } from '@/auth/states/workspaces';
import { billingState } from '@/client-config/states/billingState';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { supportChatState } from '@/client-config/states/supportChatState';
import { ColorScheme } from '@/workspace-member/types/WorkspaceMember';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import {
AuthTokenPair,
useCheckUserExistsLazyQuery,
useGetAuthTokensFromLoginTokenMutation,
useGetCurrentUserLazyQuery,
useGetLoginTokenFromCredentialsMutation,
useGetLoginTokenFromEmailVerificationTokenMutation,
useSignUpMutation,
useSignInMutation,
useSignUpInWorkspaceMutation,
} from '~/generated/graphql';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
@ -68,10 +70,17 @@ import { iconsState } from 'twenty-ui/display';
import { cookieStorage } from '~/utils/cookie-storage';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace';
import {
countAvailableWorkspaces,
getFirstAvailableWorkspaces,
} from '@/auth/utils/availableWorkspacesUtils';
export const useAuth = () => {
const setTokenPair = useSetRecoilState(tokenPairState);
const setCurrentUser = useSetRecoilState(currentUserState);
const setAvailableWorkspaces = useSetRecoilState(availableWorkspacesState);
const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState,
);
@ -87,16 +96,18 @@ export const useAuth = () => {
);
const { refreshObjectMetadataItems } = useRefreshObjectMetadataItems();
const { createWorkspace } = useSignUpInNewWorkspace();
const setSignInUpStep = useSetRecoilState(signInUpStepState);
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const setWorkspaces = useSetRecoilState(workspacesState);
const { redirect } = useRedirect();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const [getLoginTokenFromCredentials] =
useGetLoginTokenFromCredentialsMutation();
const [signIn] = useSignInMutation();
const [signUp] = useSignUpMutation();
const [signUpInWorkspace] = useSignUpInWorkspaceMutation();
const [getAuthTokensFromLoginToken] =
useGetAuthTokensFromLoginTokenMutation();
const [getLoginTokenFromEmailVerificationToken] =
@ -280,6 +291,10 @@ export const useAuth = () => {
setCurrentWorkspaceMembers(workspaceMembers);
}
if (isDefined(user.availableWorkspaces)) {
setAvailableWorkspaces(user.availableWorkspaces);
}
if (isDefined(user.currentUserWorkspace)) {
setCurrentUserWorkspace(user.currentUserWorkspace);
}
@ -326,17 +341,6 @@ export const useAuth = () => {
});
}
if (isDefined(user.workspaces)) {
const validWorkspaces = user.workspaces
.filter(
({ workspace }) => workspace !== null && workspace !== undefined,
)
.map((validWorkspace) => validWorkspace.workspace)
.filter(isDefined);
setWorkspaces(validWorkspaces);
}
return {
user,
workspaceMember,
@ -352,9 +356,17 @@ export const useAuth = () => {
setCurrentWorkspaceMembers,
setDateTimeFormat,
setLastAuthenticateWorkspaceDomain,
setWorkspaces,
setAvailableWorkspaces,
]);
const handleSetAuthTokens = useCallback(
(tokens: AuthTokenPair) => {
setTokenPair(tokens);
cookieStorage.setItem('tokenPair', JSON.stringify(tokens));
},
[setTokenPair],
);
const handleGetAuthTokensFromLoginToken = useCallback(
async (loginToken: string) => {
const getAuthTokensResult = await getAuthTokensFromLoginToken({
@ -372,14 +384,8 @@ export const useAuth = () => {
throw new Error('No getAuthTokensFromLoginToken result');
}
setTokenPair(
getAuthTokensResult.data?.getAuthTokensFromLoginToken.tokens,
);
cookieStorage.setItem(
'tokenPair',
JSON.stringify(
getAuthTokensResult.data?.getAuthTokensFromLoginToken.tokens,
),
handleSetAuthTokens(
getAuthTokensResult.data.getAuthTokensFromLoginToken.tokens,
);
// TODO: We can't parallelize this yet because when loadCurrentUSer is loaded
@ -390,14 +396,97 @@ export const useAuth = () => {
},
[
getAuthTokensFromLoginToken,
setTokenPair,
loadCurrentUser,
origin,
handleSetAuthTokens,
refreshObjectMetadataItems,
],
);
const handleCredentialsSignIn = useCallback(
async (email: string, password: string, captchaToken?: string) => {
signIn({
variables: { email, password, captchaToken },
onCompleted: async (data) => {
handleSetAuthTokens(data.signIn.tokens);
const { user } = await loadCurrentUser();
const availableWorkspacesCount = countAvailableWorkspaces(
user.availableWorkspaces,
);
if (availableWorkspacesCount === 0) {
return createWorkspace();
}
if (availableWorkspacesCount === 1) {
const targetWorkspace = getFirstAvailableWorkspaces(
user.availableWorkspaces,
);
return await redirectToWorkspaceDomain(
getWorkspaceUrl(targetWorkspace.workspaceUrls),
targetWorkspace.loginToken ? AppPath.Verify : AppPath.SignInUp,
{
...(targetWorkspace.loginToken && {
loginToken: targetWorkspace.loginToken,
}),
email: user.email,
},
);
}
setSignInUpStep(SignInUpStep.WorkspaceSelection);
},
onError: (error) => {
if (
error instanceof ApolloError &&
error.graphQLErrors[0]?.extensions?.subCode === 'EMAIL_NOT_VERIFIED'
) {
setSearchParams({ email });
setSignInUpStep(SignInUpStep.EmailVerification);
throw error;
}
throw error;
},
});
},
[
handleSetAuthTokens,
redirectToWorkspaceDomain,
signIn,
loadCurrentUser,
setSearchParams,
setSignInUpStep,
createWorkspace,
],
);
const handleCredentialsSignUp = useCallback(
async (email: string, password: string, captchaToken?: string) => {
signUp({
variables: { email, password, captchaToken },
onCompleted: async (data) => {
handleSetAuthTokens(data.signUp.tokens);
const { user } = await loadCurrentUser();
if (countAvailableWorkspaces(user.availableWorkspaces) === 0) {
return createWorkspace();
}
setSignInUpStep(SignInUpStep.WorkspaceSelection);
},
});
},
[
handleSetAuthTokens,
signUp,
loadCurrentUser,
setSignInUpStep,
createWorkspace,
],
);
const handleCredentialsSignInInWorkspace = useCallback(
async (email: string, password: string, captchaToken?: string) => {
const { loginToken } = await handleGetLoginTokenFromCredentials(
email,
@ -413,7 +502,7 @@ export const useAuth = () => {
await clearSession();
}, [clearSession]);
const handleCredentialsSignUp = useCallback(
const handleCredentialsSignUpInWorkspace = useCallback(
async ({
email,
password,
@ -429,7 +518,7 @@ export const useAuth = () => {
captchaToken?: string;
verifyEmailNextPath?: string;
}) => {
const signUpResult = await signUp({
const signUpInWorkspaceResult = await signUpInWorkspace({
variables: {
email,
password,
@ -444,11 +533,11 @@ export const useAuth = () => {
},
});
if (isDefined(signUpResult.errors)) {
throw signUpResult.errors;
if (isDefined(signUpInWorkspaceResult.errors)) {
throw signUpInWorkspaceResult.errors;
}
if (!signUpResult.data?.signUp) {
if (!signUpInWorkspaceResult.data?.signUpInWorkspace) {
throw new Error('No login token');
}
@ -460,11 +549,15 @@ export const useAuth = () => {
if (isMultiWorkspaceEnabled) {
return await redirectToWorkspaceDomain(
getWorkspaceUrl(signUpResult.data.signUp.workspace.workspaceUrls),
getWorkspaceUrl(
signUpInWorkspaceResult.data.signUpInWorkspace.workspace
.workspaceUrls,
),
isEmailVerificationRequired ? AppPath.SignInUp : AppPath.Verify,
{
...(!isEmailVerificationRequired && {
loginToken: signUpResult.data.signUp.loginToken.token,
loginToken:
signUpInWorkspaceResult.data.signUpInWorkspace.loginToken.token,
}),
email,
},
@ -472,11 +565,11 @@ export const useAuth = () => {
}
await handleGetAuthTokensFromLoginToken(
signUpResult.data?.signUp.loginToken.token,
signUpInWorkspaceResult.data?.signUpInWorkspace.loginToken.token,
);
},
[
signUp,
signUpInWorkspace,
workspacePublicData,
isMultiWorkspaceEnabled,
handleGetAuthTokensFromLoginToken,
@ -494,6 +587,7 @@ export const useAuth = () => {
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
billingCheckoutSession?: BillingCheckoutSession;
action?: string;
},
) => {
const url = new URL(`${REACT_APP_SERVER_BASE_URL}${path}`);
@ -513,6 +607,10 @@ export const useAuth = () => {
);
}
if (isDefined(params.action)) {
url.searchParams.set('action', params.action);
}
if (isDefined(workspacePublicData)) {
url.searchParams.set('workspaceId', workspacePublicData.id);
}
@ -527,6 +625,7 @@ export const useAuth = () => {
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
billingCheckoutSession?: BillingCheckoutSession;
action: string;
}) => {
redirect(buildRedirectUrl('/auth/google', params));
},
@ -538,6 +637,7 @@ export const useAuth = () => {
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
billingCheckoutSession?: BillingCheckoutSession;
action: string;
}) => {
redirect(buildRedirectUrl('/auth/microsoft', params));
},
@ -556,8 +656,11 @@ export const useAuth = () => {
clearSession,
signOut: handleSignOut,
signUpWithCredentials: handleCredentialsSignUp,
signUpWithCredentialsInWorkspace: handleCredentialsSignUpInWorkspace,
signInWithCredentialsInWorkspace: handleCredentialsSignInInWorkspace,
signInWithCredentials: handleCredentialsSignIn,
signInWithGoogle: handleGoogleLogin,
signInWithMicrosoft: handleMicrosoftLogin,
setAuthTokens: handleSetAuthTokens,
};
};

View File

@ -2,14 +2,17 @@ import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { useState } from 'react';
import { FormProvider } from 'react-hook-form';
import { useLocation } from 'react-router-dom';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
import { useAuth } from '@/auth/hooks/useAuth';
import { SignInUpEmailField } from '@/auth/sign-in-up/components/SignInUpEmailField';
import { SignInUpPasswordField } from '@/auth/sign-in-up/components/SignInUpPasswordField';
import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/SignInUpWithGoogle';
import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/SignInUpWithMicrosoft';
import { SignInUpEmailField } from '@/auth/sign-in-up/components/internal/SignInUpEmailField';
import { SignInUpPasswordField } from '@/auth/sign-in-up/components/internal/SignInUpPasswordField';
import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/internal/SignInUpWithGoogle';
import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/internal/SignInUpWithMicrosoft';
import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { signInUpModeState } from '@/auth/states/signInUpModeState';
@ -17,19 +20,23 @@ import {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { getAvailableWorkspacePathAndSearchParams } from '@/auth/utils/availableWorkspacesUtils';
import { SignInUpMode } from '@/auth/types/signInUpMode';
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { isDefined } from 'twenty-shared/utils';
import { HorizontalSeparator } from 'twenty-ui/display';
import {
HorizontalSeparator,
IconChevronRight,
IconPlus,
Avatar,
} from 'twenty-ui/display';
import { Loader } from 'twenty-ui/feedback';
import { MainButton } from 'twenty-ui/input';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace';
import { AvailableWorkspace } from '~/generated/graphql';
const StyledContentContainer = styled(motion.div)`
margin-bottom: ${({ theme }) => theme.spacing(8)};
@ -44,29 +51,101 @@ const StyledForm = styled.form`
width: 100%;
`;
const StyledWorkspaceContainer = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex-direction: column;
margin-bottom: ${({ theme }) => theme.spacing(8)};
margin-top: ${({ theme }) => theme.spacing(4)};
overflow: hidden;
width: 100%;
`;
const StyledWorkspaceItem = styled.div`
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
height: ${({ theme }) => theme.spacing(15)};
padding: 0;
overflow: hidden;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
cursor: pointer;
justify-content: space-between;
&:hover {
background-color: ${({ theme }) => theme.background.transparent.light};
}
&:last-child {
border-bottom: none;
}
`;
const StyledWorkspaceContent = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(4)};
width: 100%;
padding: 0 ${({ theme }) => theme.spacing(4)};
`;
const StyledWorkspaceTextContainer = styled.div`
display: flex;
flex-direction: column;
flex-grow: 1;
`;
const StyledWorkspaceLogo = styled.div`
border-radius: ${({ theme }) => theme.border.radius.sm};
height: ${({ theme }) => theme.spacing(6)};
width: ${({ theme }) => theme.spacing(6)};
background-color: ${({ theme }) => theme.background.transparent.light};
display: flex;
justify-content: center;
align-items: center;
`;
const StyledWorkspaceName = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.medium};
padding-bottom: ${({ theme }) => theme.spacing(1)};
`;
const StyledWorkspaceUrl = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.xs};
`;
const StyledChevronIcon = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
`;
export const SignInUpGlobalScopeForm = () => {
const authProviders = useRecoilValue(authProvidersState);
const signInUpStep = useRecoilValue(signInUpStepState);
const { buildWorkspaceUrl } = useBuildWorkspaceUrl();
const { checkUserExists } = useAuth();
const { readCaptchaToken } = useReadCaptchaToken();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const { createWorkspace } = useSignUpInNewWorkspace();
const setSignInUpStep = useSetRecoilState(signInUpStepState);
const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState);
const [signInUpMode] = useRecoilState(signInUpModeState);
const availableWorkspaces = useRecoilValue(availableWorkspacesState);
const theme = useTheme();
const { t } = useLingui();
const isRequestingCaptchaToken = useRecoilValue(
isRequestingCaptchaTokenState,
);
const { enqueueSnackBar } = useSnackBar();
const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
const [showErrors, setShowErrors] = useState(false);
const { form } = useSignInUpForm();
const { pathname } = useLocation();
const { submitCredentials } = useSignInUp(form);
const { submitCredentials, continueWithCredentials } = useSignInUp(form);
const handleSubmit = async () => {
if (isDefined(form?.formState?.errors?.email)) {
@ -79,38 +158,7 @@ export const SignInUpGlobalScopeForm = () => {
return;
}
const token = await readCaptchaToken();
await checkUserExists.checkUserExistsQuery({
variables: {
email: form.getValues('email').toLowerCase().trim(),
captchaToken: token,
},
onError: (error) => {
enqueueSnackBar(`${error.message}`, {
variant: SnackBarVariant.Error,
});
},
onCompleted: async (data) => {
requestFreshCaptchaToken();
const response = data.checkUserExists;
if (response.__typename === 'UserExists') {
if (response.availableWorkspaces.length >= 1) {
const workspace = response.availableWorkspaces[0];
return await redirectToWorkspaceDomain(
getWorkspaceUrl(workspace.workspaceUrls),
pathname,
{
email: form.getValues('email'),
},
);
}
}
if (response.__typename === 'UserNotExists') {
setSignInUpMode(SignInUpMode.SignUp);
setSignInUpStep(SignInUpStep.Password);
}
},
});
continueWithCredentials();
};
const onEmailChange = (email: string) => {
@ -119,42 +167,118 @@ export const SignInUpGlobalScopeForm = () => {
}
};
const getAvailableWorkspaceUrl = (availableWorkspace: AvailableWorkspace) => {
const { pathname, searchParams } = getAvailableWorkspacePathAndSearchParams(
availableWorkspace,
{ email: form.getValues('email') },
);
return buildWorkspaceUrl(
getWorkspaceUrl(availableWorkspace.workspaceUrls),
pathname,
searchParams,
);
};
return (
<>
<StyledContentContainer>
{authProviders.google && <SignInUpWithGoogle />}
{authProviders.microsoft && <SignInUpWithMicrosoft />}
{(authProviders.google || authProviders.microsoft) && (
<HorizontalSeparator />
)}
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<FormProvider {...form}>
<StyledForm onSubmit={form.handleSubmit(handleSubmit)}>
<SignInUpEmailField
showErrors={showErrors}
onInputChange={onEmailChange}
/>
{signInUpStep === SignInUpStep.Password && (
<SignInUpPasswordField
{signInUpStep === SignInUpStep.WorkspaceSelection && (
<StyledWorkspaceContainer>
{[
...availableWorkspaces.availableWorkspacesForSignIn,
...availableWorkspaces.availableWorkspacesForSignUp,
].map((availableWorkspace) => (
<UndecoratedLink
key={availableWorkspace.id}
to={getAvailableWorkspaceUrl(availableWorkspace)}
>
<StyledWorkspaceItem>
<StyledWorkspaceContent>
<Avatar
placeholder={availableWorkspace.displayName || ''}
avatarUrl={
availableWorkspace.logo ?? DEFAULT_WORKSPACE_LOGO
}
size="lg"
/>
<StyledWorkspaceTextContainer>
<StyledWorkspaceName>
{availableWorkspace.displayName || availableWorkspace.id}
</StyledWorkspaceName>
<StyledWorkspaceUrl>
{
new URL(
getWorkspaceUrl(availableWorkspace.workspaceUrls),
).hostname
}
</StyledWorkspaceUrl>
</StyledWorkspaceTextContainer>
<StyledChevronIcon>
<IconChevronRight size={theme.icon.size.md} />
</StyledChevronIcon>
</StyledWorkspaceContent>
</StyledWorkspaceItem>
</UndecoratedLink>
))}
<StyledWorkspaceItem onClick={() => createWorkspace()}>
<StyledWorkspaceContent>
<StyledWorkspaceLogo>
<IconPlus size={theme.icon.size.lg} />
</StyledWorkspaceLogo>
<StyledWorkspaceTextContainer>
<StyledWorkspaceName>{t`Create a workspace`}</StyledWorkspaceName>
</StyledWorkspaceTextContainer>
<StyledChevronIcon>
<IconChevronRight size={theme.icon.size.md} />
</StyledChevronIcon>
</StyledWorkspaceContent>
</StyledWorkspaceItem>
</StyledWorkspaceContainer>
)}
{signInUpStep !== SignInUpStep.WorkspaceSelection && (
<StyledContentContainer>
{authProviders.google && (
<SignInUpWithGoogle action="list-available-workspaces" />
)}
{authProviders.microsoft && (
<SignInUpWithMicrosoft action="list-available-workspaces" />
)}
{(authProviders.google || authProviders.microsoft) && (
<HorizontalSeparator />
)}
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<FormProvider {...form}>
<StyledForm onSubmit={form.handleSubmit(handleSubmit)}>
<SignInUpEmailField
showErrors={showErrors}
signInUpMode={signInUpMode}
onInputChange={onEmailChange}
/>
)}
<MainButton
disabled={isRequestingCaptchaToken}
title={
signInUpStep === SignInUpStep.Password ? 'Sign Up' : 'Continue'
}
type="submit"
variant={
signInUpStep === SignInUpStep.Init ? 'secondary' : 'primary'
}
Icon={() => (form.formState.isSubmitting ? <Loader /> : null)}
fullWidth
/>
</StyledForm>
</FormProvider>
</StyledContentContainer>
{signInUpStep === SignInUpStep.Password && (
<SignInUpPasswordField
showErrors={showErrors}
signInUpMode={signInUpMode}
/>
)}
<MainButton
disabled={isRequestingCaptchaToken}
title={
signInUpStep === SignInUpStep.Password
? signInUpMode === SignInUpMode.SignIn
? t`Sign In`
: t`Sign Up`
: t`Continue`
}
type="submit"
variant={
signInUpStep === SignInUpStep.Init ? 'secondary' : 'primary'
}
Icon={() => (form.formState.isSubmitting ? <Loader /> : null)}
fullWidth
/>
</StyledForm>
</FormProvider>
</StyledContentContainer>
)}
</>
);
};

View File

@ -1,7 +1,7 @@
import { SignInUpWithCredentials } from '@/auth/sign-in-up/components/SignInUpWithCredentials';
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/internal/SignInUpWithCredentials';
import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/internal/SignInUpWithGoogle';
import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/internal/SignInUpWithMicrosoft';
import { SignInUpWithSSO } from '@/auth/sign-in-up/components/internal/SignInUpWithSSO';
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';
@ -36,9 +36,13 @@ export const SignInUpWorkspaceScopeForm = () => {
return (
<>
<StyledContentContainer>
{workspaceAuthProviders.google && <SignInUpWithGoogle />}
{workspaceAuthProviders.google && (
<SignInUpWithGoogle action="join-workspace" />
)}
{workspaceAuthProviders.microsoft && <SignInUpWithMicrosoft />}
{workspaceAuthProviders.microsoft && (
<SignInUpWithMicrosoft action="join-workspace" />
)}
{workspaceAuthProviders.sso.length > 0 && <SignInUpWithSSO />}

View File

@ -0,0 +1,34 @@
import {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useAuth } from '@/auth/hooks/useAuth';
import { useSearchParams } from 'react-router-dom';
export const SignInUpGlobalScopeFormEffect = () => {
const setSignInUpStep = useSetRecoilState(signInUpStepState);
const [searchParams, setSearchParams] = useSearchParams();
const { setAuthTokens, loadCurrentUser } = useAuth();
useEffect(() => {
const tokenPair = searchParams.get('tokenPair');
if (isDefined(tokenPair)) {
setAuthTokens(JSON.parse(tokenPair));
searchParams.delete('tokenPair');
setSearchParams(searchParams);
loadCurrentUser();
setSignInUpStep(SignInUpStep.WorkspaceSelection);
}
}, [
searchParams,
setSearchParams,
setSignInUpStep,
loadCurrentUser,
setAuthTokens,
]);
return <></>;
};

View File

@ -5,8 +5,8 @@ import {
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { SignInUpEmailField } from '@/auth/sign-in-up/components/SignInUpEmailField';
import { SignInUpPasswordField } from '@/auth/sign-in-up/components/SignInUpPasswordField';
import { SignInUpEmailField } from '@/auth/sign-in-up/components/internal/SignInUpEmailField';
import { SignInUpPasswordField } from '@/auth/sign-in-up/components/internal/SignInUpPasswordField';
import { SignInUpMode } from '@/auth/types/signInUpMode';
import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState';
import { captchaState } from '@/client-config/states/captchaState';

View File

@ -9,23 +9,27 @@ import { memo } from 'react';
import { useRecoilValue } from 'recoil';
import { HorizontalSeparator, IconGoogle } from 'twenty-ui/display';
import { MainButton } from 'twenty-ui/input';
import { SocialSSOSignInUpActionType } from '@/auth/types/socialSSOSignInUp.type';
const GoogleIcon = memo(() => {
const theme = useTheme();
return <IconGoogle size={theme.icon.size.md} />;
});
export const SignInUpWithGoogle = () => {
export const SignInUpWithGoogle = ({
action,
}: {
action: SocialSSOSignInUpActionType;
}) => {
const { t } = useLingui();
const signInUpStep = useRecoilValue(signInUpStepState);
const { signInWithGoogle } = useSignInWithGoogle();
return (
<>
<MainButton
Icon={GoogleIcon}
title={t`Continue with Google`}
onClick={signInWithGoogle}
onClick={() => signInWithGoogle({ action })}
variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'}
fullWidth
/>

View File

@ -8,8 +8,13 @@ import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { HorizontalSeparator, IconMicrosoft } from 'twenty-ui/display';
import { MainButton } from 'twenty-ui/input';
import { SocialSSOSignInUpActionType } from '@/auth/types/socialSSOSignInUp.type';
export const SignInUpWithMicrosoft = () => {
export const SignInUpWithMicrosoft = ({
action,
}: {
action: SocialSSOSignInUpActionType;
}) => {
const theme = useTheme();
const { t } = useLingui();
@ -21,7 +26,7 @@ export const SignInUpWithMicrosoft = () => {
<MainButton
Icon={() => <IconMicrosoft size={theme.icon.size.md} />}
title={t`Continue with Microsoft`}
onClick={signInWithMicrosoft}
onClick={() => signInWithMicrosoft({ action })}
variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'}
fullWidth
/>

View File

@ -43,9 +43,12 @@ describe('useSignInWithGoogle', () => {
const { result } = renderHook(() => useSignInWithGoogle(), {
wrapper: Wrapper,
});
result.current.signInWithGoogle();
result.current.signInWithGoogle({
action: 'join-workspace',
});
expect(signInWithGoogleMock).toHaveBeenCalledWith({
action: 'join-workspace',
workspaceInviteHash: 'testHash',
workspacePersonalInviteToken: 'testToken',
billingCheckoutSession: mockBillingCheckoutSession,
@ -66,9 +69,12 @@ describe('useSignInWithGoogle', () => {
const { result } = renderHook(() => useSignInWithGoogle(), {
wrapper: Wrapper,
});
result.current.signInWithGoogle();
result.current.signInWithGoogle({
action: 'join-workspace',
});
expect(signInWithGoogleMock).toHaveBeenCalledWith({
action: 'join-workspace',
workspaceInviteHash: 'testHash',
workspacePersonalInviteToken: undefined,
billingCheckoutSession: mockBillingCheckoutSession,

View File

@ -43,9 +43,12 @@ describe('useSignInWithMicrosoft', () => {
const { result } = renderHook(() => useSignInWithMicrosoft(), {
wrapper: Wrapper,
});
result.current.signInWithMicrosoft();
result.current.signInWithMicrosoft({
action: 'join-workspace',
});
expect(signInWithMicrosoftMock).toHaveBeenCalledWith({
action: 'join-workspace',
workspaceInviteHash: workspaceInviteHashMock,
workspacePersonalInviteToken: inviteTokenMock,
billingCheckoutSession: mockBillingCheckoutSession,
@ -67,9 +70,12 @@ describe('useSignInWithMicrosoft', () => {
const { result } = renderHook(() => useSignInWithMicrosoft(), {
wrapper: Wrapper,
});
result.current.signInWithMicrosoft();
result.current.signInWithMicrosoft({
action: 'join-workspace',
});
expect(signInWithMicrosoftMock).toHaveBeenCalledWith({
action: 'join-workspace',
billingCheckoutSession: mockBillingCheckoutSession,
workspaceInviteHash: workspaceInviteHashMock,
workspacePersonalInviteToken: undefined,

View File

@ -19,12 +19,14 @@ import { useRecoilState } from 'recoil';
import { buildAppPathWithQueryParams } from '~/utils/buildAppPathWithQueryParams';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { useAuth } from '../../hooks/useAuth';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
export const useSignInUp = (form: UseFormReturn<Form>) => {
const { enqueueSnackBar } = useSnackBar();
const [signInUpStep, setSignInUpStep] = useRecoilState(signInUpStepState);
const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState);
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
const location = useLocation();
@ -38,7 +40,9 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
);
const {
signInWithCredentialsInWorkspace,
signInWithCredentials,
signUpWithCredentialsInWorkspace,
signUpWithCredentials,
checkUserExists: { checkUserExistsQuery },
} = useAuth();
@ -71,11 +75,11 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
},
onCompleted: (data) => {
requestFreshCaptchaToken();
if (data?.checkUserExists.exists) {
setSignInUpMode(SignInUpMode.SignIn);
} else {
setSignInUpMode(SignInUpMode.SignUp);
}
setSignInUpMode(
data?.checkUserExists.exists
? SignInUpMode.SignIn
: SignInUpMode.SignUp,
);
setSignInUpStep(SignInUpStep.Password);
},
});
@ -97,31 +101,60 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
throw new Error('Email and password are required');
}
if (signInUpMode === SignInUpMode.SignIn && !isInviteMode) {
await signInWithCredentials(
if (
!isInviteMode &&
signInUpMode === SignInUpMode.SignIn &&
isOnAWorkspace
) {
return await signInWithCredentialsInWorkspace(
data.email.toLowerCase().trim(),
data.password,
token,
);
} else {
const verifyEmailNextPath = buildAppPathWithQueryParams(
AppPath.PlanRequired,
await buildSearchParamsFromUrlSyncedStates(),
);
await signUpWithCredentials({
email: data.email.toLowerCase().trim(),
password: data.password,
workspaceInviteHash,
workspacePersonalInviteToken,
captchaToken: token,
verifyEmailNextPath,
});
}
if (
!isInviteMode &&
signInUpMode === SignInUpMode.SignIn &&
!isOnAWorkspace
) {
return await signInWithCredentials(
data.email.toLowerCase().trim(),
data.password,
token,
);
}
if (
!isInviteMode &&
signInUpMode === SignInUpMode.SignUp &&
!isOnAWorkspace
) {
return await signUpWithCredentials(
data.email.toLowerCase().trim(),
data.password,
token,
);
}
const verifyEmailNextPath = buildAppPathWithQueryParams(
AppPath.PlanRequired,
await buildSearchParamsFromUrlSyncedStates(),
);
await signUpWithCredentialsInWorkspace({
email: data.email.toLowerCase().trim(),
password: data.password,
workspaceInviteHash,
workspacePersonalInviteToken,
captchaToken: token,
verifyEmailNextPath,
});
} catch (err: any) {
enqueueSnackBar(err?.message, {
variant: SnackBarVariant.Error,
});
} finally {
requestFreshCaptchaToken();
}
},
@ -129,13 +162,16 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
readCaptchaToken,
signInUpMode,
isInviteMode,
signInWithCredentialsInWorkspace,
signInWithCredentials,
signUpWithCredentials,
signUpWithCredentialsInWorkspace,
workspaceInviteHash,
workspacePersonalInviteToken,
enqueueSnackBar,
requestFreshCaptchaToken,
buildSearchParamsFromUrlSyncedStates,
isOnAWorkspace,
],
);

View File

@ -1,7 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useLocation, useSearchParams } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { z } from 'zod';
@ -30,7 +30,6 @@ const makeValidationSchema = (signInUpStep: SignInUpStep) =>
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
@ -61,11 +60,6 @@ export const useSignInUpForm = () => {
form.setValue('email', prefilledEmail ?? 'tim@apple.dev');
form.setValue('password', 'tim@apple.dev');
}
}, [
form,
isDeveloperDefaultSignInPrefilled,
prefilledEmail,
location.search,
]);
}, [form, isDeveloperDefaultSignInPrefilled, prefilledEmail]);
return { form: form };
};

View File

@ -2,6 +2,7 @@ import { useParams, useSearchParams } from 'react-router-dom';
import { useAuth } from '@/auth/hooks/useAuth';
import { BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type';
import { SocialSSOSignInUpActionType } from '@/auth/types/socialSSOSignInUp.type';
export const useSignInWithGoogle = () => {
const workspaceInviteHash = useParams().workspaceInviteHash;
@ -15,12 +16,14 @@ export const useSignInWithGoogle = () => {
} as BillingCheckoutSession;
const { signInWithGoogle } = useAuth();
return {
signInWithGoogle: () =>
signInWithGoogle: ({ action }: { action: SocialSSOSignInUpActionType }) =>
signInWithGoogle({
workspaceInviteHash,
workspacePersonalInviteToken,
billingCheckoutSession,
action,
}),
};
};

View File

@ -3,6 +3,7 @@ import { useParams, useSearchParams } from 'react-router-dom';
import { useAuth } from '@/auth/hooks/useAuth';
import { billingCheckoutSessionState } from '@/auth/states/billingCheckoutSessionState';
import { useRecoilValue } from 'recoil';
import { SocialSSOSignInUpActionType } from '@/auth/types/socialSSOSignInUp.type';
export const useSignInWithMicrosoft = () => {
const workspaceInviteHash = useParams().workspaceInviteHash;
@ -13,11 +14,16 @@ export const useSignInWithMicrosoft = () => {
const { signInWithMicrosoft } = useAuth();
return {
signInWithMicrosoft: () =>
signInWithMicrosoft: ({
action,
}: {
action: SocialSSOSignInUpActionType;
}) =>
signInWithMicrosoft({
workspaceInviteHash,
workspacePersonalInviteToken,
billingCheckoutSession,
action,
}),
};
};

View File

@ -0,0 +1,37 @@
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { AppPath } from '@/types/AppPath';
import { useSignUpInNewWorkspaceMutation } from '~/generated/graphql';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
export const useSignUpInNewWorkspace = () => {
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const { enqueueSnackBar } = useSnackBar();
const [signUpInNewWorkspaceMutation] = useSignUpInNewWorkspaceMutation();
const createWorkspace = () => {
signUpInNewWorkspaceMutation({
onCompleted: async (data) => {
return await redirectToWorkspaceDomain(
getWorkspaceUrl(data.signUpInNewWorkspace.workspace.workspaceUrls),
AppPath.Verify,
{
loginToken: data.signUpInNewWorkspace.loginToken.token,
},
'_blank',
);
},
onError: (error: Error) => {
enqueueSnackBar(error.message, {
variant: SnackBarVariant.Error,
});
},
});
};
return {
createWorkspace,
};
};

View File

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

View File

@ -0,0 +1,10 @@
import { createState } from 'twenty-ui/utilities';
import { AvailableWorkspaces } from '~/generated/graphql';
export const availableWorkspacesState = createState<AvailableWorkspaces>({
key: 'availableWorkspacesState',
defaultValue: {
availableWorkspacesForSignIn: [],
availableWorkspacesForSignUp: [],
},
});

View File

@ -1,12 +0,0 @@
import { Workspace } from '~/generated/graphql';
import { createState } from 'twenty-ui/utilities';
export type Workspaces = Pick<
Workspace,
'id' | 'logo' | 'displayName' | 'workspaceUrls'
>[];
export const workspacesState = createState<Workspaces>({
key: 'workspacesState',
defaultValue: [],
});

View File

@ -0,0 +1,4 @@
export type SocialSSOSignInUpActionType =
| 'create-new-workspace'
| 'list-available-workspaces'
| 'join-workspace';

View File

@ -0,0 +1,70 @@
import { AvailableWorkspaces, AvailableWorkspace } from '~/generated/graphql';
import { AppPath } from '@/types/AppPath';
import { isDefined } from 'twenty-shared/utils';
import { generatePath } from 'react-router-dom';
export const countAvailableWorkspaces = ({
availableWorkspacesForSignIn,
availableWorkspacesForSignUp,
}: AvailableWorkspaces): number => {
return (
availableWorkspacesForSignIn.length + availableWorkspacesForSignUp.length
);
};
export const getFirstAvailableWorkspaces = ({
availableWorkspacesForSignIn,
availableWorkspacesForSignUp,
}: AvailableWorkspaces): AvailableWorkspace => {
return availableWorkspacesForSignIn[0] ?? availableWorkspacesForSignUp[0];
};
const getAvailableWorkspacePathname = (
availableWorkspace: AvailableWorkspace,
) => {
if (isDefined(availableWorkspace.loginToken)) {
return AppPath.Verify;
}
if (
isDefined(availableWorkspace.personalInviteToken) &&
isDefined(availableWorkspace.inviteHash)
) {
return generatePath(AppPath.Invite, {
workspaceInviteHash: availableWorkspace.inviteHash,
});
}
return AppPath.SignInUp;
};
const getAvailableWorkspaceSearchParams = (
availableWorkspace: AvailableWorkspace,
defaultSearchParams: Record<string, string> = {},
) => {
const searchParams: Record<string, string> = defaultSearchParams;
if (isDefined(availableWorkspace.loginToken)) {
searchParams.loginToken = availableWorkspace.loginToken;
return searchParams;
}
if (isDefined(availableWorkspace.personalInviteToken)) {
searchParams.inviteToken = availableWorkspace.personalInviteToken;
}
return searchParams;
};
export const getAvailableWorkspacePathAndSearchParams = (
availableWorkspace: AvailableWorkspace,
defaultSearchParams: Record<string, string> = {},
): { pathname: string; searchParams: Record<string, string> } => {
return {
pathname: getAvailableWorkspacePathname(availableWorkspace),
searchParams: getAvailableWorkspaceSearchParams(
availableWorkspace,
defaultSearchParams,
),
};
};

View File

@ -0,0 +1,43 @@
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { captchaTokenState } from '@/captcha/states/captchaTokenState';
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
describe('useReadCaptchaToken', () => {
it('should return undefined when no token exists', async () => {
const { result } = renderHook(() => useReadCaptchaToken(), {
wrapper: RecoilRoot,
});
await act(async () => {
const token = await result.current.readCaptchaToken();
expect(token).toBeUndefined();
});
});
it('should return the token when it exists', async () => {
const { result } = renderHook(
() => {
const hook = useReadCaptchaToken();
return hook;
},
{
wrapper: ({ children }) => (
<RecoilRoot
initializeState={({ set }) => {
set(captchaTokenState, 'test-token');
}}
>
{children}
</RecoilRoot>
),
},
);
await act(async () => {
const token = await result.current.readCaptchaToken();
expect(token).toBe('test-token');
});
});
});

View File

@ -0,0 +1,66 @@
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import * as ReactRouterDom from 'react-router-dom';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
}));
describe('useRequestFreshCaptchaToken', () => {
beforeEach(() => {
// Mock useLocation to return a path that requires captcha
(ReactRouterDom.useLocation as jest.Mock).mockReturnValue({
pathname: '/sign-in',
});
// Mock window.grecaptcha
window.grecaptcha = {
execute: jest.fn().mockImplementation(() => {
return Promise.resolve('google-recaptcha-token');
}),
};
// Mock window.turnstile
window.turnstile = {
render: jest.fn().mockReturnValue('turnstile-widget-id'),
execute: jest.fn().mockImplementation((widgetId, options) => {
return options?.callback('turnstile-token');
}),
};
});
afterEach(() => {
jest.clearAllMocks();
delete window.grecaptcha;
delete window.turnstile;
});
it('should not request a token if captcha is not required for the path', async () => {
const { result } = renderHook(() => useRequestFreshCaptchaToken(), {
wrapper: RecoilRoot,
});
await act(async () => {
await result.current.requestFreshCaptchaToken();
});
expect(window.grecaptcha.execute).not.toHaveBeenCalled();
expect(window.turnstile.execute).not.toHaveBeenCalled();
});
it('should not request a token if captcha provider is not defined', async () => {
const { result } = renderHook(() => useRequestFreshCaptchaToken(), {
wrapper: RecoilRoot,
});
await act(async () => {
await result.current.requestFreshCaptchaToken();
});
expect(window.grecaptcha.execute).not.toHaveBeenCalled();
expect(window.turnstile.execute).not.toHaveBeenCalled();
});
});

View File

@ -18,8 +18,9 @@ export const useIsCurrentLocationOnAWorkspace = () => {
throw new Error('frontDomain and defaultSubdomain are required');
}
const isOnAWorkspace =
isMultiWorkspaceEnabled && window.location.hostname !== defaultDomain;
const isOnAWorkspace = !isMultiWorkspaceEnabled
? true
: window.location.hostname !== defaultDomain;
return {
isOnAWorkspace,

View File

@ -150,8 +150,7 @@ export const queries = {
hasValidEnterpriseKey
customDomain
workspaceUrls {
subdomainUrl
customUrl
...WorkspaceUrlsFragment
}
featureFlags {
id
@ -179,8 +178,7 @@ export const queries = {
subdomain
customDomain
workspaceUrls {
subdomainUrl
customUrl
...WorkspaceUrlsFragment
}
}
}

View File

@ -14,6 +14,7 @@ import { currentUserState } from '@/auth/states/currentUserState';
import { billingState } from '@/client-config/states/billingState';
import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState';
import { useSettingsPermissionMap } from '@/settings/roles/hooks/useSettingsPermissionMap';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
const mockCurrentUser = {
id: 'fake-user-id',
@ -41,7 +42,11 @@ const initializeState = ({ set }: MutableSnapshot) => {
const Wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider>
<RecoilRoot initializeState={initializeState}>
<MemoryRouter>{children}</MemoryRouter>
<MemoryRouter>
<SnackBarProviderScope snackBarManagerScopeId="test-scope-id">
{children}
</SnackBarProviderScope>
</MemoryRouter>
</RecoilRoot>
</MockedProvider>
);

View File

@ -2,7 +2,6 @@ import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/consta
import { useAuth } from '@/auth/hooks/useAuth';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { Workspaces, workspacesState } from '@/auth/states/workspaces';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { AppPath } from '@/types/AppPath';
@ -37,9 +36,14 @@ import {
MenuItemSelectAvatar,
UndecoratedLink,
} from 'twenty-ui/navigation';
import { useSignUpInNewWorkspaceMutation } from '~/generated/graphql';
import {
useSignUpInNewWorkspaceMutation,
AvailableWorkspace,
} from '~/generated/graphql';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
import { countAvailableWorkspaces } from '@/auth/utils/availableWorkspacesUtils';
const StyledDescription = styled.div`
color: ${({ theme }) => theme.font.color.light};
@ -50,7 +54,9 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const { t } = useLingui();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const workspaces = useRecoilValue(workspacesState);
const availableWorkspaces = useRecoilValue(availableWorkspacesState);
const availableWorkspacesCount =
countAvailableWorkspaces(availableWorkspaces);
const { buildWorkspaceUrl } = useBuildWorkspaceUrl();
const { closeDropdown } = useDropdown(MULTI_WORKSPACE_DROPDOWN_ID);
const { signOut } = useAuth();
@ -63,8 +69,10 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
multiWorkspaceDropdownState,
);
const handleChange = async (workspace: Workspaces[0]) => {
redirectToWorkspaceDomain(getWorkspaceUrl(workspace.workspaceUrls));
const handleChange = async (availableWorkspace: AvailableWorkspace) => {
redirectToWorkspaceDomain(
getWorkspaceUrl(availableWorkspace.workspaceUrls),
);
};
const createWorkspace = () => {
@ -127,36 +135,41 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
>
{currentWorkspace?.displayName}
</DropdownMenuHeader>
{workspaces.length > 1 && (
{availableWorkspacesCount > 1 && (
<>
<DropdownMenuItemsContainer>
{workspaces
{[
...availableWorkspaces.availableWorkspacesForSignIn,
...availableWorkspaces.availableWorkspacesForSignUp,
]
.filter(({ id }) => id !== currentWorkspace?.id)
.slice(0, 3)
.map((workspace) => (
.map((availableWorkspace) => (
<UndecoratedLink
key={workspace.id}
key={availableWorkspace.id}
to={buildWorkspaceUrl(
getWorkspaceUrl(workspace.workspaceUrls),
getWorkspaceUrl(availableWorkspace.workspaceUrls),
)}
onClick={(event) => {
event?.preventDefault();
handleChange(workspace);
handleChange(availableWorkspace);
}}
>
<MenuItemSelectAvatar
text={workspace.displayName ?? '(No name)'}
text={availableWorkspace.displayName ?? '(No name)'}
avatar={
<Avatar
placeholder={workspace.displayName || ''}
avatarUrl={workspace.logo ?? DEFAULT_WORKSPACE_LOGO}
placeholder={availableWorkspace.displayName || ''}
avatarUrl={
availableWorkspace.logo ?? DEFAULT_WORKSPACE_LOGO
}
/>
}
selected={false}
/>
</UndecoratedLink>
))}
{workspaces.length > 4 && (
{availableWorkspacesCount > 4 && (
<MenuItem
LeftIcon={IconSwitchHorizontal}
text={t`Other workspaces`}

View File

@ -1,32 +1,23 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { Workspaces, workspacesState } from '@/auth/states/workspaces';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { multiWorkspaceDropdownState } from '@/ui/navigation/navigation-drawer/states/multiWorkspaceDropdownState';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { Avatar, IconChevronLeft } from 'twenty-ui/display';
import { MenuItemSelectAvatar, UndecoratedLink } from 'twenty-ui/navigation';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { IconChevronLeft } from 'twenty-ui/display';
import { WorkspacesForSignIn } from './components/WorkspacesForSignIn';
import { WorkspacesForSignUp } from './components/WorkspacesForSignUp';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
export const MultiWorkspaceDropdownWorkspacesListComponents = () => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const workspaces = useRecoilValue(workspacesState);
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const { buildWorkspaceUrl } = useBuildWorkspaceUrl();
const { t } = useLingui();
const handleChange = async (workspace: Workspaces[0]) => {
await redirectToWorkspaceDomain(getWorkspaceUrl(workspace.workspaceUrls));
};
const availableWorkspaces = useRecoilValue(availableWorkspacesState);
const setMultiWorkspaceDropdownState = useSetRecoilState(
multiWorkspaceDropdownState,
);
@ -52,37 +43,10 @@ export const MultiWorkspaceDropdownWorkspacesListComponents = () => {
}}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
{workspaces
.filter(
(workspace) =>
workspace.id !== currentWorkspace?.id &&
workspace.displayName
?.toLowerCase()
.includes(searchValue.toLowerCase()),
)
.map((workspace) => (
<UndecoratedLink
key={workspace.id}
to={buildWorkspaceUrl(getWorkspaceUrl(workspace.workspaceUrls))}
onClick={(event) => {
event?.preventDefault();
handleChange(workspace);
}}
>
<MenuItemSelectAvatar
text={workspace.displayName ?? '(No name)'}
avatar={
<Avatar
placeholder={workspace.displayName || ''}
avatarUrl={workspace.logo ?? DEFAULT_WORKSPACE_LOGO}
/>
}
selected={currentWorkspace?.id === workspace.id}
/>
</UndecoratedLink>
))}
</DropdownMenuItemsContainer>
<WorkspacesForSignIn searchValue={searchValue} />
{availableWorkspaces.availableWorkspacesForSignUp.length > 0 && (
<WorkspacesForSignUp searchValue={searchValue} />
)}
</DropdownContent>
);
};

View File

@ -0,0 +1,58 @@
import { Avatar } from 'twenty-ui/display';
import { MenuItemSelectAvatar, UndecoratedLink } from 'twenty-ui/navigation';
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { AvailableWorkspace } from '~/generated/graphql';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { getAvailableWorkspacePathAndSearchParams } from '@/auth/utils/availableWorkspacesUtils';
import React from 'react';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
export const AvailableWorkspaceItem = ({
availableWorkspace,
isSelected,
}: {
availableWorkspace: AvailableWorkspace;
isSelected: boolean;
}) => {
const { buildWorkspaceUrl } = useBuildWorkspaceUrl();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const { pathname, searchParams } =
getAvailableWorkspacePathAndSearchParams(availableWorkspace);
const handleChange = async () => {
await redirectToWorkspaceDomain(
getWorkspaceUrl(availableWorkspace.workspaceUrls),
pathname,
searchParams,
);
};
return (
<UndecoratedLink
key={availableWorkspace.id}
to={buildWorkspaceUrl(
getWorkspaceUrl(availableWorkspace.workspaceUrls),
pathname,
searchParams,
)}
onClick={(event) => {
event.preventDefault();
handleChange();
}}
>
<MenuItemSelectAvatar
text={availableWorkspace.displayName ?? '(No name)'}
avatar={
<Avatar
placeholder={availableWorkspace.displayName || ''}
avatarUrl={availableWorkspace.logo ?? DEFAULT_WORKSPACE_LOGO}
/>
}
selected={isSelected}
/>
</UndecoratedLink>
);
};

View File

@ -0,0 +1,39 @@
import { useLingui } from '@lingui/react/macro';
import { StyledDropdownMenuSubheader } from '@/ui/layout/dropdown/components/StyledDropdownMenuSubheader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useFilteredAvailableWorkspaces } from '@/ui/navigation/navigation-drawer/hooks/useFilteredAvailableWorkspaces';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRecoilValue } from 'recoil';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
import { AvailableWorkspaceItem } from '@/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/components/AvailableWorkspaceItem';
export const WorkspacesForSignIn = ({
searchValue,
}: {
searchValue: string;
}) => {
const { t } = useLingui();
const availableWorkspaces = useRecoilValue(availableWorkspacesState);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const { searchAvailableWorkspaces } = useFilteredAvailableWorkspaces();
return (
<>
<StyledDropdownMenuSubheader>{t`Member of`}</StyledDropdownMenuSubheader>
<DropdownMenuItemsContainer>
{searchAvailableWorkspaces(
searchValue,
availableWorkspaces.availableWorkspacesForSignIn,
).map((availableWorkspace) => (
<AvailableWorkspaceItem
key={availableWorkspace.id}
availableWorkspace={availableWorkspace}
isSelected={currentWorkspace?.id === availableWorkspace.id}
/>
))}
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -0,0 +1,39 @@
import { useLingui } from '@lingui/react/macro';
import { StyledDropdownMenuSubheader } from '@/ui/layout/dropdown/components/StyledDropdownMenuSubheader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useFilteredAvailableWorkspaces } from '@/ui/navigation/navigation-drawer/hooks/useFilteredAvailableWorkspaces';
import { AvailableWorkspaceItem } from '@/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/components/AvailableWorkspaceItem';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRecoilValue } from 'recoil';
export const WorkspacesForSignUp = ({
searchValue,
}: {
searchValue: string;
}) => {
const { t } = useLingui();
const availableWorkspaces = useRecoilValue(availableWorkspacesState);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const { searchAvailableWorkspaces } = useFilteredAvailableWorkspaces();
return (
<>
<StyledDropdownMenuSubheader>{t`Invitations`}</StyledDropdownMenuSubheader>
<DropdownMenuItemsContainer scrollable={false}>
{searchAvailableWorkspaces(
searchValue,
availableWorkspaces.availableWorkspacesForSignUp,
).map((availableWorkspace) => (
<AvailableWorkspaceItem
key={availableWorkspace.id}
availableWorkspace={availableWorkspace}
isSelected={currentWorkspace?.id === availableWorkspace.id}
/>
))}
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -0,0 +1,25 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRecoilValue } from 'recoil';
import { AvailableWorkspace } from '~/generated/graphql';
export const useFilteredAvailableWorkspaces = () => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const searchAvailableWorkspaces = (
searchValue: string,
availableWorkspaces: Array<AvailableWorkspace>,
) => {
return availableWorkspaces.filter(
(availableWorkspace) =>
currentWorkspace?.id &&
availableWorkspace.id !== currentWorkspace.id &&
availableWorkspace.displayName
?.toLowerCase()
.includes(searchValue.toLowerCase()),
);
};
return {
searchAvailableWorkspaces,
};
};

View File

@ -8,7 +8,6 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState';
import { workspacesState } from '@/auth/states/workspaces';
import { DateFormat } from '@/localization/constants/DateFormat';
import { TimeFormat } from '@/localization/constants/TimeFormat';
import { dateTimeFormatState } from '@/localization/states/dateTimeFormatState';
@ -30,6 +29,7 @@ import { useGetCurrentUserQuery } from '~/generated/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
export const UserProviderEffect = () => {
const location = useLocation();
@ -40,7 +40,7 @@ export const UserProviderEffect = () => {
const setCurrentUser = useSetRecoilState(currentUserState);
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const setCurrentUserWorkspace = useSetRecoilState(currentUserWorkspaceState);
const setWorkspaces = useSetRecoilState(workspacesState);
const setAvailableWorkspaces = useSetRecoilState(availableWorkspacesState);
const setDateTimeFormat = useSetRecoilState(dateTimeFormatState);
const isLoggedIn = useIsLogged();
@ -102,7 +102,7 @@ export const UserProviderEffect = () => {
workspaceMember,
workspaceMembers,
deletedWorkspaceMembers,
workspaces: userWorkspaces,
availableWorkspaces,
} = queryData.currentUser;
const affectDefaultValuesOnEmptyWorkspaceMemberFields = (
@ -153,12 +153,8 @@ export const UserProviderEffect = () => {
setCurrentWorkspaceMembersWithDeleted(deletedWorkspaceMembers);
}
if (isDefined(userWorkspaces)) {
const workspaces = userWorkspaces
.map(({ workspace }) => workspace)
.filter(isDefined);
setWorkspaces(workspaces);
if (isDefined(availableWorkspaces)) {
setAvailableWorkspaces(availableWorkspaces);
}
}, [
queryLoading,
@ -166,9 +162,9 @@ export const UserProviderEffect = () => {
setCurrentUser,
setCurrentUserWorkspace,
setCurrentWorkspaceMembers,
setAvailableWorkspaces,
setCurrentWorkspace,
setCurrentWorkspaceMember,
setWorkspaces,
setIsCurrentUserLoaded,
setDateTimeFormat,
setCurrentWorkspaceMembersWithDeleted,

View File

@ -3,6 +3,7 @@ import { ROLE_FRAGMENT } from '@/settings/roles/graphql/fragments/roleFragment';
import { DELETED_WORKSPACE_MEMBER_QUERY_FRAGMENT } from '@/workspace-member/graphql/fragments/deletedWorkspaceMemberQueryFragment';
import { WORKSPACE_MEMBER_QUERY_FRAGMENT } from '@/workspace-member/graphql/fragments/workspaceMemberQueryFragment';
import { gql } from '@apollo/client';
import { AVAILABLE_WORKSPACES_FOR_AUTH_FRAGMENT } from '@/auth/graphql/fragments/authFragments';
export const USER_QUERY_FRAGMENT = gql`
${ROLE_FRAGMENT}
@ -48,8 +49,7 @@ export const USER_QUERY_FRAGMENT = gql`
customDomain
isCustomDomainEnabled
workspaceUrls {
subdomainUrl
customUrl
...WorkspaceUrlsFragment
}
featureFlags {
key
@ -86,22 +86,13 @@ export const USER_QUERY_FRAGMENT = gql`
...RoleFragment
}
}
workspaces {
workspace {
id
logo
displayName
subdomain
customDomain
workspaceUrls {
subdomainUrl
customUrl
}
}
availableWorkspaces {
...AvailableWorkspacesFragment
}
userVars
}
${AVAILABLE_WORKSPACES_FOR_AUTH_FRAGMENT}
${WORKSPACE_MEMBER_QUERY_FRAGMENT}
${DELETED_WORKSPACE_MEMBER_QUERY_FRAGMENT}
`;

View File

@ -0,0 +1,8 @@
import { gql } from '@apollo/client';
export const WORKSPACE_URLS_FRAGMENT = gql`
fragment WorkspaceUrlsFragment on WorkspaceUrls {
subdomainUrl
customUrl
}
`;

View File

@ -124,7 +124,7 @@ export const PasswordReset = () => {
const [updatePasswordViaToken, { loading: isUpdatingPassword }] =
useUpdatePasswordViaResetTokenMutation();
const { signInWithCredentials } = useAuth();
const { signInWithCredentialsInWorkspace } = useAuth();
const { readCaptchaToken } = useReadCaptchaToken();
const onSubmit = async (formData: Form) => {
@ -153,7 +153,11 @@ export const PasswordReset = () => {
const token = await readCaptchaToken();
await signInWithCredentials(email || '', formData.newPassword, token);
await signInWithCredentialsInWorkspace(
email || '',
formData.newPassword,
token,
);
navigate(AppPath.Index);
} catch (err) {
logError(err);

View File

@ -1,17 +1,20 @@
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 {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
import { useRecoilValue } from 'recoil';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { Logo } from '@/auth/components/Logo';
import { Title } from '@/auth/components/Title';
import { EmailVerificationSent } from '@/auth/sign-in-up/components/EmailVerificationSent';
import { FooterNote } from '@/auth/sign-in-up/components/FooterNote';
import { SignInUpGlobalScopeForm } from '@/auth/sign-in-up/components/SignInUpGlobalScopeForm';
import { SignInUpSSOIdentityProviderSelection } from '@/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection';
import { SignInUpSSOIdentityProviderSelection } from '@/auth/sign-in-up/components/internal/SignInUpSSOIdentityProviderSelection';
import { SignInUpWorkspaceScopeForm } from '@/auth/sign-in-up/components/SignInUpWorkspaceScopeForm';
import { SignInUpWorkspaceScopeFormEffect } from '@/auth/sign-in-up/components/SignInUpWorkspaceScopeFormEffect';
import { SignInUpWorkspaceScopeFormEffect } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceScopeFormEffect';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useGetPublicWorkspaceDataByDomain } from '@/domain-manager/hooks/useGetPublicWorkspaceDataByDomain';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
@ -26,17 +29,20 @@ import { useSearchParams } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { AnimatedEaseIn } from 'twenty-ui/utilities';
import { PublicWorkspaceDataOutput } from '~/generated/graphql';
import { SignInUpGlobalScopeFormEffect } from '@/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect';
const StandardContent = ({
workspacePublicData,
signInUpForm,
signInUpStep,
title,
onClickOnLogo,
}: {
workspacePublicData: PublicWorkspaceDataOutput | null;
signInUpForm: JSX.Element | null;
signInUpStep: SignInUpStep;
title: string;
onClickOnLogo: () => void;
}) => {
return (
<Modal.Content isVerticalCentered isHorizontalCentered>
@ -44,6 +50,7 @@ const StandardContent = ({
<Logo
secondaryLogo={workspacePublicData?.logo}
placeholder={workspacePublicData?.displayName}
onClick={onClickOnLogo}
/>
</AnimatedEaseIn>
<Title animate>{title}</Title>
@ -55,6 +62,7 @@ const StandardContent = ({
export const SignInUp = () => {
const { t } = useLingui();
const setSignInUpStep = useSetRecoilState(signInUpStepState);
const { form } = useSignInUpForm();
const { signInUpStep } = useSignInUp(form);
@ -67,10 +75,20 @@ export const SignInUp = () => {
useWorkspaceFromInviteHash();
const [searchParams] = useSearchParams();
const onClickOnLogo = () => {
setSignInUpStep(SignInUpStep.Init);
};
const title = useMemo(() => {
if (isDefined(workspaceInviteHash)) {
return `Join ${workspaceFromInviteHash?.displayName ?? ''} team`;
}
if (signInUpStep === SignInUpStep.WorkspaceSelection) {
return t`Choose a Workspace`;
}
const workspaceName = !isDefined(workspacePublicData?.displayName)
? DEFAULT_WORKSPACE_NAME
: workspacePublicData?.displayName === ''
@ -79,31 +97,32 @@ export const SignInUp = () => {
return t`Welcome to ${workspaceName}`;
}, [
workspaceFromInviteHash?.displayName,
workspaceInviteHash,
signInUpStep,
workspacePublicData?.displayName,
t,
workspaceFromInviteHash?.displayName,
]);
const signInUpForm = useMemo(() => {
if (loading) return null;
if (isDefaultDomain && isMultiWorkspaceEnabled) {
return <SignInUpGlobalScopeForm />;
return (
<>
<SignInUpGlobalScopeFormEffect />
<SignInUpGlobalScopeForm />
</>
);
}
if (
(!isMultiWorkspaceEnabled ||
(isMultiWorkspaceEnabled && isOnAWorkspace)) &&
isOnAWorkspace &&
signInUpStep === SignInUpStep.SSOIdentityProviderSelection
) {
return <SignInUpSSOIdentityProviderSelection />;
}
if (
isDefined(workspacePublicData) &&
(!isMultiWorkspaceEnabled || isOnAWorkspace)
) {
if (isDefined(workspacePublicData) && isOnAWorkspace) {
return (
<>
<SignInUpWorkspaceScopeFormEffect />
@ -112,7 +131,12 @@ export const SignInUp = () => {
);
}
return <SignInUpGlobalScopeForm />;
return (
<>
<SignInUpGlobalScopeFormEffect />
<SignInUpGlobalScopeForm />
</>
);
}, [
isDefaultDomain,
isMultiWorkspaceEnabled,
@ -136,6 +160,7 @@ export const SignInUp = () => {
signInUpForm={signInUpForm}
signInUpStep={signInUpStep}
title={title}
onClickOnLogo={onClickOnLogo}
/>
);
};

View File

@ -1,19 +1,19 @@
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsPath } from '@/types/SettingsPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInput } from '@/ui/input/components/TextInput';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { zodResolver } from '@hookform/resolvers/zod';
import { Controller, useForm } from 'react-hook-form';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { Trans, useLingui } from '@lingui/react/macro';
import { z } from 'zod';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useCreateApprovedAccessDomainMutation } from '~/generated/graphql';
import { Controller, useForm } from 'react-hook-form';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { TextInput } from '@/ui/input/components/TextInput';
import { z } from 'zod';
import { useCreateApprovedAccessDomainMutation } from '~/generated/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
export const SettingsSecurityApprovedAccessDomain = () => {
const navigate = useNavigateSettings();
@ -62,7 +62,7 @@ export const SettingsSecurityApprovedAccessDomain = () => {
},
},
onCompleted: () => {
enqueueSnackBar(t`Domain added successfully.`, {
enqueueSnackBar(t`Please check your email for a verification link.`, {
variant: SnackBarVariant.Success,
});
navigate(SettingsPath.Security);

View File

@ -19,6 +19,7 @@ import { mockedRemoteTables } from '~/testing/mock-data/remote-tables';
import { mockedUserData } from '~/testing/mock-data/users';
import { mockedViewsData } from '~/testing/mock-data/views';
import { mockWorkspaceMembers } from '~/testing/mock-data/workspace-members';
import { mockedPublicWorkspaceDataBySubdomain } from '~/testing/mock-data/publicWorkspaceDataBySubdomain';
import { GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN } from '@/auth/graphql/queries/getPublicWorkspaceDataByDomain';
import { GET_ROLES } from '@/settings/roles/graphql/queries/getRolesQuery';
@ -90,22 +91,8 @@ export const graphqlMocks = {
() => {
return HttpResponse.json({
data: {
getPublicWorkspaceDataByDomain: {
id: 'id',
logo: 'logo',
displayName: 'displayName',
workspaceUrls: {
customUrl: undefined,
subdomainUrl: 'https://twenty.com',
},
authProviders: {
google: true,
microsoft: false,
password: true,
magicLink: false,
sso: [],
},
},
getPublicWorkspaceDataByDomain:
mockedPublicWorkspaceDataBySubdomain,
},
});
},

View File

@ -7,6 +7,7 @@ export const mockedPublicWorkspaceDataBySubdomain: GetPublicWorkspaceDataByDomai
logo: 'workspace-logo/original/c88deb49-7636-4560-918d-08c3265ffb20.49?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3b3Jrc3BhY2VJZCI6Ijk4NzAzMjNlLTIyYzMtNGQxNC05YjdmLTViZGM4NGY3ZDZlZSIsImlhdCI6MTczNjU0MDU0MywiZXhwIjoxNzM2NjI2OTQzfQ.C8cnHu09VGseRbQAMM4nhiO6z4TLG03ntFTvxm53-xg',
displayName: 'Twenty Eng',
workspaceUrls: {
__typename: 'WorkspaceUrls',
customUrl: 'https://twenty-eng.com',
subdomainUrl: 'https://custom.twenty.com',
},

View File

@ -25,6 +25,7 @@ type MockedUser = Pick<
| 'supportUserHash'
| 'onboardingStatus'
| 'userVars'
| 'availableWorkspaces'
> & {
workspaceMember: WorkspaceMember | null;
locale: string;
@ -132,6 +133,10 @@ export const mockedUserData: MockedUser = {
workspaces: [{ workspace: mockCurrentWorkspace }],
workspaceMembers: [mockedWorkspaceMemberData],
onboardingStatus: OnboardingStatus.COMPLETED,
availableWorkspaces: {
availableWorkspacesForSignIn: [],
availableWorkspacesForSignUp: [],
},
userVars: {},
};

View File

@ -22,4 +22,4 @@ MESSAGING_PROVIDER_GMAIL_CALLBACK_URL=http://localhost:3000/auth/google-gmail/ge
AUTH_MICROSOFT_CALLBACK_URL=http://localhost:3000/auth/microsoft/redirect
AUTH_MICROSOFT_APIS_CALLBACK_URL=http://localhost:3000/auth/microsoft-apis/get-access-token
CLICKHOUSE_URL=http://default:clickhousePassword@localhost:8123/twenty
CLICKHOUSE_URL=http://default:clickhousePassword@localhost:8123/twenty

View File

@ -1,15 +1,17 @@
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
declare module 'express-serve-static-core' {
interface Request {
user?: User | null;
apiKey?: ApiKeyWorkspaceEntity | null;
workspace: Workspace;
workspaceId: string;
workspace?: Workspace;
workspaceId?: string;
workspaceMetadataVersion?: number;
workspaceMemberId?: string;
userWorkspaceId?: string;
authProvider?: AuthProviderEnum | null;
}
}

View File

@ -22,6 +22,7 @@ declare global {
const MEMBER_ACCESS_TOKEN: string;
const GUEST_ACCESS_TOKEN: string;
const API_KEY_ACCESS_TOKEN: string;
const WORKSPACE_AGNOSTIC_TOKEN: string;
}
export {};

View File

@ -37,6 +37,7 @@ import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role
import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
export type GraphqlQueryResolverExecutionArgs<Input extends ResolverArgs> = {
args: Input;
@ -83,11 +84,15 @@ export abstract class GraphqlQueryBaseResolverService<
try {
const { authContext, objectMetadataItemWithFieldMaps } = options;
const workspace = authContext.workspace;
workspaceValidator.assertIsDefinedOrThrow(workspace);
await this.validate(args, options);
const workspaceDataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId: authContext.workspace.id,
workspaceId: workspace.id,
shouldFailIfMetadataNotFound: false,
});
@ -124,7 +129,7 @@ export abstract class GraphqlQueryBaseResolverService<
const roleId = await this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId: authContext.userWorkspaceId,
workspaceId: authContext.workspace.id,
workspaceId: workspace.id,
});
const executedByApiKey = isDefined(authContext.apiKey);
@ -169,7 +174,7 @@ export abstract class GraphqlQueryBaseResolverService<
const resultWithGetters = await this.queryResultGettersFactory.create(
results,
objectMetadataItemWithFieldMaps,
authContext.workspace.id,
workspace.id,
options.objectMetadataMaps,
);
@ -191,6 +196,10 @@ export abstract class GraphqlQueryBaseResolverService<
) {
const { authContext, objectMetadataItemWithFieldMaps } = options;
const workspace = authContext.workspace;
workspaceValidator.assertIsDefinedOrThrow(workspace);
if (
Object.keys(OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS).includes(
objectMetadataItemWithFieldMaps.nameSingular,
@ -206,7 +215,7 @@ export abstract class GraphqlQueryBaseResolverService<
await this.permissionsService.userHasWorkspaceSettingPermission({
userWorkspaceId: authContext.userWorkspaceId,
setting: permissionRequired,
workspaceId: authContext.workspace.id,
workspaceId: workspace.id,
isExecutedByApiKey: isDefined(authContext.apiKey),
});
@ -231,11 +240,15 @@ export abstract class GraphqlQueryBaseResolverService<
const requiredPermission =
this.getRequiredPermissionForMethod(operationName);
const workspace = options.authContext.workspace;
workspaceValidator.assertIsDefinedOrThrow(workspace);
const userHasPermission =
await this.permissionsService.userHasObjectRecordsPermission({
userWorkspaceId: options.authContext.userWorkspaceId,
requiredPermission,
workspaceId: options.authContext.workspace.id,
workspaceId: workspace.id,
isExecutedByApiKey: isDefined(options.authContext.apiKey),
objectMetadataId,
});

View File

@ -7,6 +7,7 @@ import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { objectRecordChangedValues } from 'src/engine/core-modules/event-emitter/utils/object-record-changed-values';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
@Injectable()
export class ApiEventEmitterService {
@ -33,7 +34,7 @@ export class ApiEventEmitterService {
after: record,
},
})),
workspaceId: authContext.workspace.id,
workspaceId: authContext.workspace?.id,
});
}
@ -50,6 +51,10 @@ export class ApiEventEmitterService {
authContext: AuthContext;
objectMetadataItem: ObjectMetadataInterface;
}): void {
const workspace = authContext.workspace;
workspaceValidator.assertIsDefinedOrThrow(workspace);
const mappedExistingRecords = existingRecords.reduce(
(acc, { id, ...record }) => ({
...acc,
@ -84,7 +89,7 @@ export class ApiEventEmitterService {
},
};
}),
workspaceId: authContext.workspace.id,
workspaceId: workspace.id,
});
}
@ -97,6 +102,10 @@ export class ApiEventEmitterService {
authContext: AuthContext;
objectMetadataItem: ObjectMetadataInterface;
}): void {
const workspace = authContext.workspace;
workspaceValidator.assertIsDefinedOrThrow(workspace);
this.workspaceEventEmitter.emitDatabaseBatchEvent({
objectMetadataNameSingular: objectMetadataItem.nameSingular,
action: DatabaseEventAction.DELETED,
@ -111,7 +120,7 @@ export class ApiEventEmitterService {
},
};
}),
workspaceId: authContext.workspace.id,
workspaceId: workspace.id,
});
}
@ -124,6 +133,10 @@ export class ApiEventEmitterService {
authContext: AuthContext;
objectMetadataItem: ObjectMetadataInterface;
}): void {
const workspace = authContext.workspace;
workspaceValidator.assertIsDefinedOrThrow(workspace);
this.workspaceEventEmitter.emitDatabaseBatchEvent({
objectMetadataNameSingular: objectMetadataItem.nameSingular,
action: DatabaseEventAction.RESTORED,
@ -138,7 +151,7 @@ export class ApiEventEmitterService {
},
};
}),
workspaceId: authContext.workspace.id,
workspaceId: workspace.id,
});
}
@ -151,6 +164,10 @@ export class ApiEventEmitterService {
authContext: AuthContext;
objectMetadataItem: ObjectMetadataInterface;
}): void {
const workspace = authContext.workspace;
workspaceValidator.assertIsDefinedOrThrow(workspace);
this.workspaceEventEmitter.emitDatabaseBatchEvent({
objectMetadataNameSingular: objectMetadataItem.nameSingular,
action: DatabaseEventAction.DESTROYED,
@ -165,7 +182,7 @@ export class ApiEventEmitterService {
},
};
}),
workspaceId: authContext.workspace.id,
workspaceId: workspace.id,
});
}
}

View File

@ -23,6 +23,7 @@ import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metada
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
type ArgPositionBackfillInput = {
argIndex?: number;
@ -171,7 +172,10 @@ export class QueryRunnerArgsFactory {
return Promise.resolve({});
}
const workspaceId = options.authContext.workspace.id;
const workspace = options.authContext.workspace;
workspaceValidator.assertIsDefinedOrThrow(workspace);
let isFieldPositionPresent = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -192,7 +196,7 @@ export class QueryRunnerArgsFactory {
const newValue = await this.recordPositionService.buildRecordPosition(
{
value,
workspaceId,
workspaceId: workspace.id,
objectMetadata: {
isCustom: options.objectMetadataItemWithFieldMaps.isCustom,
nameSingular:
@ -234,7 +238,7 @@ export class QueryRunnerArgsFactory {
'position',
await this.recordPositionService.buildRecordPosition({
value: 'first',
workspaceId,
workspaceId: workspace.id,
objectMetadata: {
isCustom: options.objectMetadataItemWithFieldMaps.isCustom,
nameSingular:

View File

@ -22,6 +22,7 @@ import { WorkspaceQueryHookKey } from 'src/engine/api/graphql/workspace-query-ru
import { WorkspaceQueryHookStorage } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/storage/workspace-query-hook.storage';
import { WorkspaceQueryHookType } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type';
import { WorkspaceQueryHookMetadataAccessor } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook-metadata.accessor';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
@Injectable()
export class WorkspaceQueryHookExplorer implements OnModuleInit {
@ -89,6 +90,10 @@ export class WorkspaceQueryHookExplorer implements OnModuleInit {
): Promise<ReturnType<WorkspacePreQueryHookInstance['execute']>> {
const methodName = 'execute';
const workspace = executeParams?.[0].workspace;
workspaceValidator.assertIsDefinedOrThrow(workspace);
if (isRequestScoped) {
const contextId = createContextId();
@ -96,7 +101,7 @@ export class WorkspaceQueryHookExplorer implements OnModuleInit {
this.moduleRef.registerRequestByContextId(
{
req: {
workspaceId: executeParams?.[0].workspace.id,
workspaceId: workspace.id,
},
},
contextId,
@ -152,6 +157,10 @@ export class WorkspaceQueryHookExplorer implements OnModuleInit {
): Promise<ReturnType<WorkspacePostQueryHookInstance['execute']>> {
const methodName = 'execute';
const workspace = executeParams?.[0].workspace;
workspaceValidator.assertIsDefinedOrThrow(workspace);
const transformedPayload = this.transformPayload(executeParams[2]);
if (isRequestScoped) {
@ -161,7 +170,7 @@ export class WorkspaceQueryHookExplorer implements OnModuleInit {
this.moduleRef.registerRequestByContextId(
{
req: {
workspaceId: executeParams?.[0].workspace.id,
workspaceId: workspace.id,
userWorkspaceId: executeParams?.[0].userWorkspaceId,
apiKey: executeParams?.[0].apiKey,
workspaceMemberId: executeParams?.[0].workspaceMemberId,

View File

@ -18,6 +18,7 @@ import { getObjectMetadataMapItemByNamePlural } from 'src/engine/metadata-module
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
@Injectable()
export class CoreQueryBuilderFactory {
@ -42,6 +43,8 @@ export class CoreQueryBuilderFactory {
const { workspace } =
await this.accessTokenService.validateTokenByRequest(request);
workspaceValidator.assertIsDefinedOrThrow(workspace);
const currentCacheVersion =
await this.workspaceCacheStorageService.getMetadataVersion(workspace.id);

View File

@ -11,6 +11,7 @@ import { ActorMetadata } from 'src/engine/metadata-modules/field-metadata/compos
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type CreateInput = Record<string, any>;
@ -30,6 +31,10 @@ export class CreatedByFromAuthContextService {
objectMetadataNameSingular: string,
authContext: AuthContext,
): Promise<CreateInput[]> {
const workspace = authContext.workspace;
workspaceValidator.assertIsDefinedOrThrow(workspace);
// TODO: Once all objects have it, we can remove this check
const createdByFieldMetadata = await this.fieldMetadataRepository.findOne({
where: {
@ -37,7 +42,7 @@ export class CreatedByFromAuthContextService {
nameSingular: objectMetadataNameSingular,
},
name: 'createdBy',
workspaceId: authContext.workspace.id,
workspaceId: workspace.id,
},
});
@ -78,6 +83,8 @@ export class CreatedByFromAuthContextService {
): Promise<ActorMetadata> {
const { workspace, workspaceMemberId, user, apiKey } = authContext;
workspaceValidator.assertIsDefinedOrThrow(workspace);
// TODO: remove that code once we have the workspace member id in all tokens
if (isDefined(workspaceMemberId) && isDefined(user)) {
return buildCreatedByFromFullNameMetadata({

View File

@ -26,7 +26,6 @@ export enum AppTokenType {
AuthorizationCode = 'AUTHORIZATION_CODE',
PasswordResetToken = 'PASSWORD_RESET_TOKEN',
InvitationToken = 'INVITATION_TOKEN',
OIDCCodeVerifier = 'OIDC_CODE_VERIFIER',
EmailVerificationToken = 'EMAIL_VERIFICATION_TOKEN',
}

View File

@ -203,4 +203,20 @@ export class ApprovedAccessDomainService {
},
});
}
async findValidatedApprovedAccessDomainWithWorkspacesAndSSOIdentityProvidersDomain(
domain: string,
) {
return await this.approvedAccessDomainRepository.find({
relations: [
'workspace',
'workspace.workspaceSSOIdentityProviders',
'workspace.approvedAccessDomains',
],
where: {
domain,
isValidated: true,
},
});
}
}

View File

@ -26,4 +26,5 @@ export enum AuthExceptionCode {
GOOGLE_API_AUTH_DISABLED = 'GOOGLE_API_AUTH_DISABLED',
MICROSOFT_API_AUTH_DISABLED = 'MICROSOFT_API_AUTH_DISABLED',
MISSING_ENVIRONMENT_VARIABLE = 'MISSING_ENVIRONMENT_VARIABLE',
INVALID_JWT_TOKEN_TYPE = 'INVALID_JWT_TOKEN_TYPE',
}

View File

@ -13,6 +13,8 @@ import { PermissionsService } from 'src/engine/metadata-modules/permissions/perm
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { AuthResolver } from './auth.resolver';
@ -45,6 +47,10 @@ describe('AuthResolver', () => {
provide: AuthService,
useValue: {},
},
{
provide: RefreshTokenService,
useValue: {},
},
{
provide: UserService,
useValue: {},
@ -81,6 +87,10 @@ describe('AuthResolver', () => {
provide: LoginTokenService,
useValue: {},
},
{
provide: WorkspaceAgnosticTokenService,
useValue: {},
},
{
provide: TransientTokenService,
useValue: {},

View File

@ -28,7 +28,6 @@ import {
import { GetAuthorizationUrlForSSOInput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.input';
import { GetAuthorizationUrlForSSOOutput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.output';
import { GetLoginTokenFromEmailVerificationTokenInput } from 'src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.input';
import { GetLoginTokenFromEmailVerificationTokenOutput } from 'src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.output';
import { SignUpOutput } from 'src/engine/core-modules/auth/dto/sign-up.output';
import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
@ -55,14 +54,21 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { GetLoginTokenFromEmailVerificationTokenOutput } from 'src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.output';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { AvailableWorkspacesAndAccessTokensOutput } from 'src/engine/core-modules/auth/dto/available-workspaces-and-access-tokens.output';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { AuthProvider } from 'src/engine/decorators/auth/auth-provider.decorator';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input';
import { GetLoginTokenFromCredentialsInput } from './dto/get-login-token-from-credentials.input';
import { UserCredentialsInput } from './dto/user-credentials.input';
import { LoginToken } from './dto/login-token.entity';
import { SignUpInput } from './dto/sign-up.input';
import { ApiKeyToken, AuthTokens } from './dto/token.entity';
import { UserExistsOutput } from './dto/user-exists.entity';
import { CheckUserExistsInput } from './dto/user-exists.input';
import { CheckUserExistOutput } from './dto/user-exists.entity';
import { EmailAndCaptchaInput } from './dto/user-exists.input';
import { WorkspaceInviteHashValid } from './dto/workspace-invite-hash-valid.entity';
import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input';
import { AuthService } from './services/auth.service';
@ -85,6 +91,8 @@ export class AuthResolver {
private apiKeyService: ApiKeyService,
private resetPasswordService: ResetPasswordService,
private loginTokenService: LoginTokenService,
private workspaceAgnosticTokenService: WorkspaceAgnosticTokenService,
private refreshTokenService: RefreshTokenService,
private signInUpService: SignInUpService,
private transientTokenService: TransientTokenService,
private emailVerificationService: EmailVerificationService,
@ -96,10 +104,10 @@ export class AuthResolver {
) {}
@UseGuards(CaptchaGuard, PublicEndpointGuard)
@Query(() => UserExistsOutput)
@Query(() => CheckUserExistOutput)
async checkUserExists(
@Args() checkUserExistsInput: CheckUserExistsInput,
): Promise<typeof UserExistsOutput> {
@Args() checkUserExistsInput: EmailAndCaptchaInput,
): Promise<CheckUserExistOutput> {
return await this.authService.checkUserExists(
checkUserExistsInput.email.toLowerCase(),
);
@ -136,11 +144,11 @@ export class AuthResolver {
);
}
@UseGuards(CaptchaGuard, PublicEndpointGuard)
@Mutation(() => LoginToken)
@UseGuards(CaptchaGuard, PublicEndpointGuard)
async getLoginTokenFromCredentials(
@Args()
getLoginTokenFromCredentialsInput: GetLoginTokenFromCredentialsInput,
getLoginTokenFromCredentialsInput: UserCredentialsInput,
@Args('origin') origin: string,
): Promise<LoginToken> {
const workspace =
@ -156,7 +164,7 @@ export class AuthResolver {
),
);
const user = await this.authService.getLoginTokenFromCredentials(
const user = await this.authService.validateLoginWithPassword(
getLoginTokenFromCredentialsInput,
workspace,
);
@ -164,17 +172,58 @@ export class AuthResolver {
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
workspace.id,
// email validation is active only for password flow
AuthProviderEnum.Password,
);
return { loginToken };
}
@Mutation(() => AvailableWorkspacesAndAccessTokensOutput)
@UseGuards(CaptchaGuard, PublicEndpointGuard)
async signIn(
@Args()
userCredentials: UserCredentialsInput,
): Promise<AvailableWorkspacesAndAccessTokensOutput> {
const user =
await this.authService.validateLoginWithPassword(userCredentials);
const availableWorkspaces =
await this.userWorkspaceService.findAvailableWorkspacesByEmail(
user.email,
);
return {
availableWorkspaces:
await this.userWorkspaceService.setLoginTokenToAvailableWorkspacesWhenAuthProviderMatch(
availableWorkspaces,
user,
AuthProviderEnum.Password,
),
tokens: {
accessToken:
await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken(
{
userId: user.id,
authProvider: AuthProviderEnum.Password,
},
),
refreshToken: await this.refreshTokenService.generateRefreshToken({
userId: user.id,
authProvider: AuthProviderEnum.Password,
targetedTokenType: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
}),
},
};
}
@Mutation(() => GetLoginTokenFromEmailVerificationTokenOutput)
@UseGuards(PublicEndpointGuard)
async getLoginTokenFromEmailVerificationToken(
@Args()
getLoginTokenFromEmailVerificationTokenInput: GetLoginTokenFromEmailVerificationTokenInput,
@Args('origin') origin: string,
@AuthProvider() authProvider: AuthProviderEnum,
) {
const appToken =
await this.emailVerificationTokenService.validateEmailVerificationTokenOrThrow(
@ -195,6 +244,7 @@ export class AuthResolver {
const loginToken = await this.loginTokenService.generateLoginToken(
appToken.user.email,
workspace.id,
authProvider,
);
const workspaceUrls = this.domainManagerService.getWorkspaceUrls(workspace);
@ -202,12 +252,59 @@ export class AuthResolver {
return { loginToken, workspaceUrls };
}
@Mutation(() => AvailableWorkspacesAndAccessTokensOutput)
@UseGuards(CaptchaGuard, PublicEndpointGuard)
async signUp(
@Args() signUpInput: UserCredentialsInput,
): Promise<AvailableWorkspacesAndAccessTokensOutput> {
const user = await this.signInUpService.signUpWithoutWorkspace(
{
email: signUpInput.email,
},
{
provider: AuthProviderEnum.Password,
password: signUpInput.password,
},
);
const availableWorkspaces =
await this.userWorkspaceService.findAvailableWorkspacesByEmail(
user.email,
);
return {
availableWorkspaces:
await this.userWorkspaceService.setLoginTokenToAvailableWorkspacesWhenAuthProviderMatch(
availableWorkspaces,
user,
AuthProviderEnum.Password,
),
tokens: {
accessToken:
await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken(
{
userId: user.id,
authProvider: AuthProviderEnum.Password,
},
),
refreshToken: await this.refreshTokenService.generateRefreshToken({
userId: user.id,
authProvider: AuthProviderEnum.Password,
targetedTokenType: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
}),
},
};
}
@Mutation(() => SignUpOutput)
async signUp(@Args() signUpInput: SignUpInput): Promise<SignUpOutput> {
@UseGuards(CaptchaGuard, PublicEndpointGuard)
async signUpInWorkspace(
@Args() signUpInput: SignUpInput,
@AuthProvider() authProvider: AuthProviderEnum,
): Promise<SignUpOutput> {
const currentWorkspace = await this.authService.findWorkspaceForSignInUp({
workspaceInviteHash: signUpInput.workspaceInviteHash,
authProvider: 'password',
authProvider: AuthProviderEnum.Password,
workspaceId: signUpInput.workspaceId,
});
@ -246,7 +343,7 @@ export class AuthResolver {
workspace: currentWorkspace,
invitation,
authParams: {
provider: 'password',
provider: AuthProviderEnum.Password,
password: signUpInput.password,
},
});
@ -262,6 +359,7 @@ export class AuthResolver {
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
workspace.id,
authProvider,
);
return {
@ -277,6 +375,7 @@ export class AuthResolver {
@UseGuards(UserAuthGuard)
async signUpInNewWorkspace(
@AuthUser() currentUser: User,
@AuthProvider() authProvider: AuthProviderEnum,
): Promise<SignUpOutput> {
const { user, workspace } = await this.signInUpService.signUpOnNewWorkspace(
{ type: 'existingUser', existingUser: currentUser },
@ -285,6 +384,7 @@ export class AuthResolver {
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
workspace.id,
authProvider,
);
return {
@ -320,11 +420,11 @@ export class AuthResolver {
return;
}
const transientToken =
await this.transientTokenService.generateTransientToken(
workspaceMember.id,
user.id,
workspace.id,
);
await this.transientTokenService.generateTransientToken({
workspaceId: workspace.id,
userId: user.id,
workspaceMemberId: workspaceMember.id,
});
return { transientToken };
}
@ -335,6 +435,14 @@ export class AuthResolver {
@Args() getAuthTokensFromLoginTokenInput: GetAuthTokensFromLoginTokenInput,
@Args('origin') origin: string,
): Promise<AuthTokens> {
const {
sub: email,
workspaceId,
authProvider,
} = await this.loginTokenService.verifyLoginToken(
getAuthTokensFromLoginTokenInput.loginToken,
);
const workspace =
await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
origin,
@ -342,11 +450,6 @@ export class AuthResolver {
workspaceValidator.assertIsDefinedOrThrow(workspace);
const { sub: email, workspaceId } =
await this.loginTokenService.verifyLoginToken(
getAuthTokensFromLoginTokenInput.loginToken,
);
if (workspaceId !== workspace.id) {
throw new AuthException(
'Token is not valid for this workspace',
@ -354,7 +457,7 @@ export class AuthResolver {
);
}
return await this.authService.verify(email, workspace.id);
return await this.authService.verify(email, workspace.id, authProvider);
}
@Mutation(() => AuthorizeApp)

View File

@ -6,10 +6,8 @@ import {
UseFilters,
UseGuards,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Response } from 'express';
import { Repository } from 'typeorm';
import { AuthOAuthExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-oauth-exception.filter';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
@ -17,23 +15,13 @@ import { GoogleOauthGuard } from 'src/engine/core-modules/auth/guards/google-oau
import { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/google-provider-enabled.guard';
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 { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('auth/google')
@UseFilters(AuthRestApiExceptionFilter)
export class GoogleAuthController {
constructor(
private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService,
private readonly guardRedirectService: GuardRedirectService,
private readonly domainManagerService: DomainManagerService,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
) {}
constructor(private readonly authService: AuthService) {}
@Get()
@UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard, PublicEndpointGuard)
@ -46,90 +34,11 @@ export class GoogleAuthController {
@UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard, PublicEndpointGuard)
@UseFilters(AuthOAuthExceptionFilter)
async googleAuthRedirect(@Req() req: GoogleRequest, @Res() res: Response) {
const {
firstName,
lastName,
email: rawEmail,
picture,
workspaceInviteHash,
workspaceId,
billingCheckoutSessionState,
locale,
} = req.user;
const email = rawEmail.toLowerCase();
const currentWorkspace = await this.authService.findWorkspaceForSignInUp({
workspaceId,
workspaceInviteHash,
email,
authProvider: 'google',
});
try {
const invitation =
currentWorkspace && email
? await this.authService.findInvitationForSignInUp({
currentWorkspace,
email,
})
: undefined;
const existingUser = await this.userRepository.findOne({
where: { email },
});
const { userData } = this.authService.formatUserDataPayload(
{
firstName,
lastName,
email,
picture,
locale,
},
existingUser,
);
await this.authService.checkAccessForSignIn({
userData,
invitation,
workspaceInviteHash,
workspace: currentWorkspace,
});
const { user, workspace } = await this.authService.signInUp({
invitation,
workspace: currentWorkspace,
userData,
authParams: {
provider: 'google',
},
billingCheckoutSessionState,
});
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
workspace.id,
);
return res.redirect(
this.authService.computeRedirectURI({
loginToken: loginToken.token,
workspace,
billingCheckoutSessionState,
}),
);
} catch (error) {
return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({
error,
workspace:
this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
currentWorkspace,
),
pathname: '/verify',
}),
);
}
return res.redirect(
await this.authService.signInUpWithSocialSSO(
req.user,
AuthProviderEnum.Google,
),
);
}
}

View File

@ -6,33 +6,21 @@ import {
UseFilters,
UseGuards,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Response } from 'express';
import { Repository } from 'typeorm';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { MicrosoftOAuthGuard } from 'src/engine/core-modules/auth/guards/microsoft-oauth.guard';
import { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/microsoft-provider-enabled.guard';
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 { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('auth/microsoft')
@UseFilters(AuthRestApiExceptionFilter)
export class MicrosoftAuthController {
constructor(
private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService,
private readonly guardRedirectService: GuardRedirectService,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
private readonly domainManagerService: DomainManagerService,
) {}
constructor(private readonly authService: AuthService) {}
@Get()
@UseGuards(
@ -55,90 +43,11 @@ export class MicrosoftAuthController {
@Req() req: MicrosoftRequest,
@Res() res: Response,
) {
const {
firstName,
lastName,
email: rawEmail,
picture,
workspaceInviteHash,
workspaceId,
billingCheckoutSessionState,
locale,
} = req.user;
const email = rawEmail.toLowerCase();
const currentWorkspace = await this.authService.findWorkspaceForSignInUp({
workspaceId,
workspaceInviteHash,
email,
authProvider: 'microsoft',
});
try {
const invitation =
currentWorkspace && email
? await this.authService.findInvitationForSignInUp({
currentWorkspace,
email,
})
: undefined;
const existingUser = await this.userRepository.findOne({
where: { email },
});
const { userData } = this.authService.formatUserDataPayload(
{
firstName,
lastName,
email,
picture,
locale,
},
existingUser,
);
await this.authService.checkAccessForSignIn({
userData,
invitation,
workspaceInviteHash,
workspace: currentWorkspace,
});
const { user, workspace } = await this.authService.signInUp({
invitation,
workspace: currentWorkspace,
userData,
authParams: {
provider: 'microsoft',
},
billingCheckoutSessionState,
});
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
workspace.id,
);
return res.redirect(
this.authService.computeRedirectURI({
loginToken: loginToken.token,
workspace,
billingCheckoutSessionState,
}),
);
} catch (error) {
return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({
error,
workspace:
this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
currentWorkspace,
),
pathname: '/verify',
}),
);
}
return res.redirect(
await this.authService.signInUpWithSocialSSO(
req.user,
AuthProviderEnum.Microsoft,
),
);
}
}

View File

@ -39,6 +39,7 @@ import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
@Controller('auth')
export class SSOAuthController {
@ -136,7 +137,7 @@ export class SSOAuthController {
workspaceId: workspaceIdentityProvider.workspaceId,
workspaceInviteHash: req.user.workspaceInviteHash,
email: req.user.email,
authProvider: 'sso',
authProvider: AuthProviderEnum.SSO,
});
workspaceValidator.assertIsDefinedOrThrow(
@ -206,7 +207,7 @@ export class SSOAuthController {
workspace: currentWorkspace,
invitation,
authParams: {
provider: 'sso',
provider: AuthProviderEnum.SSO,
},
});
@ -215,6 +216,7 @@ export class SSOAuthController {
loginToken: await this.loginTokenService.generateLoginToken(
user.email,
workspace.id,
AuthProviderEnum.SSO,
),
};
}

View File

@ -0,0 +1,14 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { AvailableWorkspaces } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
import { AuthTokenPair } from './token.entity';
@ObjectType()
export class AvailableWorkspacesAndAccessTokensOutput {
@Field(() => AuthTokenPair)
tokens: AuthTokenPair;
@Field(() => AvailableWorkspaces)
availableWorkspaces: AvailableWorkspaces;
}

View File

@ -28,13 +28,22 @@ class SSOConnection {
}
@ObjectType()
export class AvailableWorkspaceOutput {
export class AvailableWorkspace {
@Field(() => String)
id: string;
@Field(() => String, { nullable: true })
displayName?: string;
@Field(() => String, { nullable: true })
loginToken?: string;
@Field(() => String, { nullable: true })
personalInviteToken?: string;
@Field(() => String, { nullable: true })
inviteHash?: string;
@Field(() => WorkspaceUrls)
workspaceUrls: WorkspaceUrls;
@ -44,3 +53,12 @@ export class AvailableWorkspaceOutput {
@Field(() => [SSOConnection])
sso: SSOConnection[];
}
@ObjectType()
export class AvailableWorkspaces {
@Field(() => [AvailableWorkspace])
availableWorkspacesForSignIn: Array<AvailableWorkspace>;
@Field(() => [AvailableWorkspace])
availableWorkspacesForSignUp: Array<AvailableWorkspace>;
}

View File

@ -0,0 +1,37 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { APP_LOCALES } from 'twenty-shared/translations';
@ArgsType()
export class CreateUserAndWorkspaceInput {
@Field(() => String)
@IsNotEmpty()
@IsEmail()
email: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
firstName?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
lastName?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
picture?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
locale?: keyof typeof APP_LOCALES;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
captchaToken?: string;
}

View File

@ -41,3 +41,9 @@ export class PasswordResetToken {
@Field(() => String)
workspaceId: string;
}
@ObjectType()
export class WorkspaceAgnosticToken {
@Field(() => AuthToken)
token: AuthToken;
}

View File

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

View File

@ -1,33 +1,13 @@
import { Field, ObjectType, createUnionType } from '@nestjs/graphql';
import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class UserExists {
export class CheckUserExistOutput {
@Field(() => Boolean)
exists: true;
exists: boolean;
@Field(() => [AvailableWorkspaceOutput])
availableWorkspaces: Array<AvailableWorkspaceOutput>;
@Field(() => Number)
availableWorkspacesCount: number;
@Field(() => Boolean)
isEmailVerified: boolean;
}
@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;
},
});

View File

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

View File

@ -28,6 +28,7 @@ export class AuthGraphqlApiExceptionFilter implements ExceptionFilter {
case AuthExceptionCode.GOOGLE_API_AUTH_DISABLED:
case AuthExceptionCode.MICROSOFT_API_AUTH_DISABLED:
case AuthExceptionCode.MISSING_ENVIRONMENT_VARIABLE:
case AuthExceptionCode.INVALID_JWT_TOKEN_TYPE:
throw new ForbiddenError(exception.message);
case AuthExceptionCode.EMAIL_NOT_VERIFIED:
case AuthExceptionCode.INVALID_DATA:

View File

@ -2,14 +2,11 @@ import { Injectable } from '@nestjs/common';
import { ApiKeyToken } from 'src/engine/core-modules/auth/dto/token.entity';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
@Injectable()
export class ApiKeyService {
constructor(
private readonly jwtWrapperService: JwtWrapperService,
private readonly twentyConfigService: TwentyConfigService,
) {}
constructor(private readonly jwtWrapperService: JwtWrapperService) {}
async generateApiKeyToken(
workspaceId: string,
@ -19,13 +16,8 @@ export class ApiKeyService {
if (!apiKeyId) {
return;
}
const jwtPayload = {
sub: workspaceId,
type: 'API_KEY',
workspaceId,
};
const secret = this.jwtWrapperService.generateAppSecret(
'ACCESS',
JwtTokenTypeEnum.ACCESS,
workspaceId,
);
let expiresIn: string | number;
@ -37,11 +29,18 @@ export class ApiKeyService {
} else {
expiresIn = '100y';
}
const token = this.jwtWrapperService.sign(jwtPayload, {
secret,
expiresIn,
jwtid: apiKeyId,
});
const token = this.jwtWrapperService.sign(
{
sub: workspaceId,
type: JwtTokenTypeEnum.API_KEY,
workspaceId,
},
{
secret,
expiresIn,
jwtid: apiKeyId,
},
);
return { token };
}

View File

@ -4,10 +4,11 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class AuthSsoService {
constructor(
@InjectRepository(Workspace, 'core')
@ -15,18 +16,16 @@ export class AuthSsoService {
private readonly twentyConfigService: TwentyConfigService,
) {}
private getAuthProviderColumnNameByProvider(
authProvider: WorkspaceAuthProvider,
) {
if (authProvider === 'google') {
private getAuthProviderColumnNameByProvider(authProvider: AuthProviderEnum) {
if (authProvider === AuthProviderEnum.Google) {
return 'isGoogleAuthEnabled';
}
if (authProvider === 'microsoft') {
if (authProvider === AuthProviderEnum.Microsoft) {
return 'isMicrosoftAuthEnabled';
}
if (authProvider === 'password') {
if (authProvider === AuthProviderEnum.Password) {
return 'isPasswordAuthEnabled';
}
@ -34,10 +33,7 @@ export class AuthSsoService {
}
async findWorkspaceFromWorkspaceIdOrAuthProvider(
{
authProvider,
email,
}: { authProvider: WorkspaceAuthProvider; email: string },
{ authProvider, email }: { authProvider: AuthProviderEnum; email: string },
workspaceId?: string,
) {
if (

View File

@ -6,6 +6,7 @@ import { Repository } from 'typeorm';
import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
describe('AuthSsoService', () => {
let authSsoService: AuthSsoService;
@ -49,7 +50,7 @@ describe('AuthSsoService', () => {
const result =
await authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider(
{ authProvider: 'google', email: 'test@example.com' },
{ authProvider: AuthProviderEnum.Google, email: 'test@example.com' },
workspaceId,
);
@ -63,7 +64,7 @@ describe('AuthSsoService', () => {
});
it('should return a workspace from authProvider and email when multi-workspace mode is enabled', async () => {
const authProvider = 'google';
const authProvider = AuthProviderEnum.Google;
const email = 'test@example.com';
const mockWorkspace = { id: 'workspace-id-456' } as Workspace;
@ -102,7 +103,7 @@ describe('AuthSsoService', () => {
const result =
await authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({
authProvider: 'google',
authProvider: AuthProviderEnum.Google,
email: 'notfound@example.com',
});

View File

@ -14,7 +14,6 @@ import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-u
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { ExistingUserOrNewUser } from 'src/engine/core-modules/auth/types/signInUp.type';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
@ -22,26 +21,26 @@ import { UserService } from 'src/engine/core-modules/user/services/user.service'
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { AuthService } from './auth.service';
jest.mock('bcrypt');
const UserFindOneMock = jest.fn();
const UserWorkspacefindOneMock = jest.fn();
const userWorkspaceServiceCheckUserWorkspaceExistsMock = jest.fn();
const workspaceInvitationGetOneWorkspaceInvitationMock = jest.fn();
const workspaceInvitationValidatePersonalInvitationMock = jest.fn();
const userWorkspaceAddUserToWorkspaceMock = jest.fn();
const twentyConfigServiceGetMock = jest.fn();
describe('AuthService', () => {
let service: AuthService;
let userService: UserService;
let workspaceRepository: Repository<Workspace>;
let userRepository: Repository<User>;
let authSsoService: AuthSsoService;
let userWorkspaceService: UserWorkspaceService;
let workspaceInvitationService: WorkspaceInvitationService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@ -56,7 +55,7 @@ describe('AuthService', () => {
{
provide: getRepositoryToken(User, 'core'),
useValue: {
findOne: UserFindOneMock,
findOne: jest.fn(),
},
},
{
@ -70,6 +69,22 @@ describe('AuthService', () => {
}),
},
},
{
provide: LoginTokenService,
useValue: {},
},
{
provide: DomainManagerService,
useValue: {},
},
{
provide: WorkspaceAgnosticTokenService,
useValue: {},
},
{
provide: GuardRedirectService,
useValue: {},
},
{
provide: SignInUpService,
useValue: {},
@ -80,10 +95,6 @@ describe('AuthService', () => {
get: twentyConfigServiceGetMock,
},
},
{
provide: DomainManagerService,
useValue: {},
},
{
provide: EmailService,
useValue: {},
@ -99,10 +110,9 @@ describe('AuthService', () => {
{
provide: UserWorkspaceService,
useValue: {
checkUserWorkspaceExists:
userWorkspaceServiceCheckUserWorkspaceExistsMock,
addUserToWorkspaceIfUserNotInWorkspace:
userWorkspaceAddUserToWorkspaceMock,
checkUserWorkspaceExists: jest.fn(),
addUserToWorkspaceIfUserNotInWorkspace: jest.fn(),
findAvailableWorkspacesByEmail: jest.fn(),
},
},
{
@ -114,10 +124,8 @@ describe('AuthService', () => {
{
provide: WorkspaceInvitationService,
useValue: {
getOneWorkspaceInvitation:
workspaceInvitationGetOneWorkspaceInvitationMock,
validatePersonalInvitation:
workspaceInvitationValidatePersonalInvitationMock,
getOneWorkspaceInvitation: jest.fn(),
validatePersonalInvitation: jest.fn(),
},
},
{
@ -131,10 +139,18 @@ describe('AuthService', () => {
service = module.get<AuthService>(AuthService);
userService = module.get<UserService>(UserService);
workspaceInvitationService = module.get<WorkspaceInvitationService>(
WorkspaceInvitationService,
);
authSsoService = module.get<AuthSsoService>(AuthSsoService);
userWorkspaceService =
module.get<UserWorkspaceService>(UserWorkspaceService);
workspaceRepository = module.get<Repository<Workspace>>(
getRepositoryToken(Workspace, 'core'),
);
userRepository = module.get<Repository<User>>(
getRepositoryToken(User, 'core'),
);
});
beforeEach(() => {
@ -155,17 +171,17 @@ describe('AuthService', () => {
(bcrypt.compare as jest.Mock).mockReturnValueOnce(true);
UserFindOneMock.mockReturnValueOnce({
jest.spyOn(userRepository, 'findOne').mockReturnValueOnce({
email: user.email,
passwordHash: 'passwordHash',
captchaToken: user.captchaToken,
});
} as unknown as Promise<User>);
UserWorkspacefindOneMock.mockReturnValueOnce({});
jest
.spyOn(userWorkspaceService, 'checkUserWorkspaceExists')
.mockReturnValueOnce({} as any);
userWorkspaceServiceCheckUserWorkspaceExistsMock.mockReturnValueOnce({});
const response = await service.getLoginTokenFromCredentials(
const response = await service.validateLoginWithPassword(
{
email: 'email',
password: 'password',
@ -188,20 +204,32 @@ describe('AuthService', () => {
captchaToken: 'captchaToken',
};
UserFindOneMock.mockReturnValueOnce({
email: user.email,
passwordHash: 'passwordHash',
captchaToken: user.captchaToken,
});
const UserFindOneSpy = jest
.spyOn(userRepository, 'findOne')
.mockReturnValueOnce({
email: user.email,
passwordHash: 'passwordHash',
captchaToken: user.captchaToken,
} as unknown as Promise<User>);
(bcrypt.compare as jest.Mock).mockReturnValueOnce(true);
userWorkspaceServiceCheckUserWorkspaceExistsMock.mockReturnValueOnce(false);
jest
.spyOn(userWorkspaceService, 'checkUserWorkspaceExists')
.mockReturnValueOnce(null as any);
workspaceInvitationGetOneWorkspaceInvitationMock.mockReturnValueOnce({});
workspaceInvitationValidatePersonalInvitationMock.mockReturnValueOnce({});
userWorkspaceAddUserToWorkspaceMock.mockReturnValueOnce({});
const getOneWorkspaceInvitationSpy = jest
.spyOn(workspaceInvitationService, 'getOneWorkspaceInvitation')
.mockReturnValueOnce({} as any);
const response = await service.getLoginTokenFromCredentials(
const workspaceInvitationValidatePersonalInvitationSpy = jest
.spyOn(workspaceInvitationService, 'validatePersonalInvitation')
.mockReturnValueOnce({} as any);
const addUserToWorkspaceIfUserNotInWorkspaceSpy = jest
.spyOn(userWorkspaceService, 'addUserToWorkspaceIfUserNotInWorkspace')
.mockReturnValueOnce({} as any);
const response = await service.validateLoginWithPassword(
{
email: 'email',
password: 'password',
@ -218,14 +246,12 @@ describe('AuthService', () => {
captchaToken: user.captchaToken,
});
expect(getOneWorkspaceInvitationSpy).toHaveBeenCalledTimes(1);
expect(
workspaceInvitationGetOneWorkspaceInvitationMock,
workspaceInvitationValidatePersonalInvitationSpy,
).toHaveBeenCalledTimes(1);
expect(
workspaceInvitationValidatePersonalInvitationMock,
).toHaveBeenCalledTimes(1);
expect(userWorkspaceAddUserToWorkspaceMock).toHaveBeenCalledTimes(1);
expect(UserFindOneMock).toHaveBeenCalledTimes(1);
expect(addUserToWorkspaceIfUserNotInWorkspaceSpy).toHaveBeenCalledTimes(1);
expect(UserFindOneSpy).toHaveBeenCalledTimes(1);
});
describe('checkAccessForSignIn', () => {
@ -418,7 +444,7 @@ describe('AuthService', () => {
);
const result = await service.findWorkspaceForSignInUp({
authProvider: 'password',
authProvider: AuthProviderEnum.Password,
workspaceId: 'workspaceId',
});
@ -438,7 +464,7 @@ describe('AuthService', () => {
);
const result = await service.findWorkspaceForSignInUp({
authProvider: 'password',
authProvider: AuthProviderEnum.Password,
workspaceId: 'workspaceId',
workspaceInviteHash: 'workspaceInviteHash',
});
@ -459,7 +485,7 @@ describe('AuthService', () => {
);
const result = await service.findWorkspaceForSignInUp({
authProvider: 'password',
authProvider: AuthProviderEnum.Password,
workspaceId: 'workspaceId',
workspaceInviteHash: 'workspaceInviteHash',
});
@ -476,7 +502,7 @@ describe('AuthService', () => {
.mockResolvedValue({} as Workspace);
const result = await service.findWorkspaceForSignInUp({
authProvider: 'google',
authProvider: AuthProviderEnum.Google,
workspaceId: 'workspaceId',
email: 'email',
});
@ -493,7 +519,7 @@ describe('AuthService', () => {
.mockResolvedValue({} as Workspace);
const result = await service.findWorkspaceForSignInUp({
authProvider: 'sso',
authProvider: AuthProviderEnum.SSO,
workspaceId: 'workspaceId',
email: 'email',
});

View File

@ -30,13 +30,9 @@ import {
} from 'src/engine/core-modules/auth/auth.util';
import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.entity';
import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
import { GetLoginTokenFromCredentialsInput } from 'src/engine/core-modules/auth/dto/get-login-token-from-credentials.input';
import { UserCredentialsInput } from 'src/engine/core-modules/auth/dto/user-credentials.input';
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity';
import { UpdatePassword } from 'src/engine/core-modules/auth/dto/update-password.entity';
import {
UserExists,
UserNotExists,
} from 'src/engine/core-modules/auth/dto/user-exists.entity';
import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity';
import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
@ -57,17 +53,27 @@ import { UserService } from 'src/engine/core-modules/user/services/user.service'
import { User } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { CheckUserExistOutput } from 'src/engine/core-modules/auth/dto/user-exists.entity';
import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy';
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 { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class AuthService {
constructor(
private readonly accessTokenService: AccessTokenService,
private readonly workspaceAgnosticTokenService: WorkspaceAgnosticTokenService,
private readonly domainManagerService: DomainManagerService,
private readonly refreshTokenService: RefreshTokenService,
private readonly loginTokenService: LoginTokenService,
private readonly guardRedirectService: GuardRedirectService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly workspaceInvitationService: WorkspaceInvitationService,
private readonly authSsoService: AuthSsoService,
@ -121,11 +127,11 @@ export class AuthService {
);
}
async getLoginTokenFromCredentials(
input: GetLoginTokenFromCredentialsInput,
targetWorkspace: Workspace,
async validateLoginWithPassword(
input: UserCredentialsInput,
targetWorkspace?: Workspace,
) {
if (!targetWorkspace.isPasswordAuthEnabled) {
if (targetWorkspace && !targetWorkspace.isPasswordAuthEnabled) {
throw new AuthException(
'Email/Password auth is not enabled for this workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
@ -146,7 +152,9 @@ export class AuthService {
);
}
await this.checkAccessAndUseInvitationOrThrow(targetWorkspace, user);
if (targetWorkspace) {
await this.checkAccessAndUseInvitationOrThrow(targetWorkspace, user);
}
if (!user.passwordHash) {
throw new AuthException(
@ -182,7 +190,7 @@ export class AuthService {
userData: ExistingUserOrNewUser['userData'],
authParams: Extract<
AuthProviderWithPasswordType['authParams'],
{ provider: 'password' }
{ provider: AuthProviderEnum.Password }
>,
) {
if (userData.type === 'newUser') {
@ -203,7 +211,7 @@ export class AuthService {
authParams: AuthProviderWithPasswordType['authParams'],
workspace: Workspace | undefined | null,
) {
if (authParams.provider === 'password') {
if (authParams.provider === AuthProviderEnum.Password) {
await this.validatePassword(userData, authParams);
}
@ -248,7 +256,11 @@ export class AuthService {
});
}
async verify(email: string, workspaceId: string): Promise<AuthTokens> {
async verify(
email: string,
workspaceId: string,
authProvider: AuthProviderEnum,
): Promise<AuthTokens> {
if (!email) {
throw new AuthException(
'Email is required',
@ -268,14 +280,17 @@ export class AuthService {
// passwordHash is hidden for security reasons
user.passwordHash = '';
const accessToken = await this.accessTokenService.generateAccessToken(
user.id,
const accessToken = await this.accessTokenService.generateAccessToken({
userId: user.id,
workspaceId,
);
const refreshToken = await this.refreshTokenService.generateRefreshToken(
user.id,
authProvider,
});
const refreshToken = await this.refreshTokenService.generateRefreshToken({
userId: user.id,
workspaceId,
);
authProvider,
targetedTokenType: JwtTokenTypeEnum.ACCESS,
});
return {
tokens: {
@ -285,21 +300,25 @@ export class AuthService {
};
}
async checkUserExists(email: string): Promise<UserExists | UserNotExists> {
async countAvailableWorkspacesByEmail(email: string): Promise<number> {
return Object.values(
await this.userWorkspaceService.findAvailableWorkspacesByEmail(email),
).flat(2).length;
}
async checkUserExists(email: string): Promise<CheckUserExistOutput> {
const user = await this.userRepository.findOneBy({
email,
});
if (userValidator.isDefined(user)) {
return {
exists: true,
availableWorkspaces:
await this.userWorkspaceService.findAvailableWorkspacesByEmail(email),
isEmailVerified: user.isEmailVerified,
};
}
const isUserExist = userValidator.isDefined(user);
return { exists: false };
return {
exists: isUserExist,
availableWorkspacesCount:
await this.countAvailableWorkspacesByEmail(email),
isEmailVerified: isUserExist ? user.isEmailVerified : false,
};
}
async checkWorkspaceInviteHashIsValid(
@ -533,10 +552,10 @@ export class AuthService {
workspaceInviteHash?: string;
} & (
| {
authProvider: Exclude<WorkspaceAuthProvider, 'password'>;
authProvider: Exclude<AuthProviderEnum, AuthProviderEnum.Password>;
email: string;
}
| { authProvider: Extract<WorkspaceAuthProvider, 'password'> }
| { authProvider: Extract<AuthProviderEnum, AuthProviderEnum.Password> }
),
) {
if (params.workspaceInviteHash) {
@ -550,7 +569,7 @@ export class AuthService {
);
}
if (params.authProvider !== 'password') {
if (params.authProvider !== AuthProviderEnum.Password) {
return (
(await this.authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider(
{
@ -649,4 +668,142 @@ export class AuthService {
);
}
}
async signInUpWithSocialSSO(
{
firstName,
lastName,
email: rawEmail,
picture,
workspaceInviteHash,
workspaceId,
billingCheckoutSessionState,
action,
locale,
}: MicrosoftRequest['user'] | GoogleRequest['user'],
authProvider: AuthProviderEnum.Google | AuthProviderEnum.Microsoft,
): Promise<string> {
const email = rawEmail.toLowerCase();
const availableWorkspacesCount =
action === 'list-available-workspaces'
? await this.countAvailableWorkspacesByEmail(email)
: 0;
const existingUser = await this.userRepository.findOne({
where: { email },
});
if (
!workspaceId &&
!workspaceInviteHash &&
action === 'list-available-workspaces' &&
availableWorkspacesCount !== 0
) {
const user =
existingUser ??
(await this.signInUpService.signUpWithoutWorkspace(
{
firstName,
lastName,
email,
picture,
},
{
provider: authProvider,
},
));
const url = this.domainManagerService.buildBaseUrl({
pathname: '/welcome',
searchParams: {
tokenPair: JSON.stringify({
accessToken:
await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken(
{
userId: user.id,
authProvider,
},
),
refreshToken: await this.refreshTokenService.generateRefreshToken({
userId: user.id,
authProvider,
targetedTokenType: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
}),
}),
},
});
return url.toString();
}
const currentWorkspace =
action === 'create-new-workspace'
? undefined
: await this.findWorkspaceForSignInUp({
workspaceId,
workspaceInviteHash,
email,
authProvider,
});
try {
const invitation =
currentWorkspace && email
? await this.findInvitationForSignInUp({
currentWorkspace,
email,
})
: undefined;
const { userData } = this.formatUserDataPayload(
{
firstName,
lastName,
email,
picture,
locale,
},
existingUser,
);
await this.checkAccessForSignIn({
userData,
invitation,
workspaceInviteHash,
workspace: currentWorkspace,
});
const { user, workspace } = await this.signInUp({
invitation,
workspace: currentWorkspace,
userData,
authParams: {
provider: AuthProviderEnum.Google,
},
billingCheckoutSessionState,
});
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
workspace.id,
authProvider,
);
return this.computeRedirectURI({
loginToken: loginToken.token,
workspace,
billingCheckoutSessionState,
});
} catch (error) {
return this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({
error,
workspace:
this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
currentWorkspace,
),
pathname: '/verify',
});
}
}
}

View File

@ -5,6 +5,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Repository } from 'typeorm';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import {
AuthException,
@ -28,6 +29,7 @@ import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
jest.mock('src/utils/image', () => {
return {
@ -96,6 +98,10 @@ describe('SignInUpService', () => {
provide: HttpService,
useValue: {},
},
{
provide: LoginTokenService,
useValue: {},
},
{
provide: TwentyConfigService,
useValue: {
@ -155,7 +161,10 @@ describe('SignInUpService', () => {
id: 'workspaceId',
activationStatus: WorkspaceActivationStatus.ACTIVE,
} as Workspace,
authParams: { provider: 'password', password: 'validPassword' },
authParams: {
provider: AuthProviderEnum.Password,
password: 'validPassword',
},
userData: {
type: 'existingUser',
existingUser: { email: 'test@example.com' } as User,
@ -206,7 +215,10 @@ describe('SignInUpService', () => {
id: 'workspaceId',
activationStatus: WorkspaceActivationStatus.ACTIVE,
} as Workspace,
authParams: { provider: 'password', password: 'validPassword' },
authParams: {
provider: AuthProviderEnum.Password,
password: 'validPassword',
},
userData: {
type: 'existingUser',
existingUser: { email: 'test@example.com' } as User,
@ -230,7 +242,10 @@ describe('SignInUpService', () => {
const params: SignInUpBaseParams &
ExistingUserOrPartialUserWithPicture &
AuthProviderWithPasswordType = {
authParams: { provider: 'password', password: 'validPassword' },
authParams: {
provider: AuthProviderEnum.Password,
password: 'validPassword',
},
userData: {
type: 'newUserWithPicture',
newUserWithPicture: {
@ -283,7 +298,10 @@ describe('SignInUpService', () => {
id: 'workspaceId',
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
} as Workspace,
authParams: { provider: 'password', password: 'validPassword' },
authParams: {
provider: AuthProviderEnum.Password,
password: 'validPassword',
},
userData: {
type: 'existingUser',
existingUser: { email: 'test@example.com' } as User,
@ -315,7 +333,10 @@ describe('SignInUpService', () => {
id: 'workspaceId',
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
} as Workspace,
authParams: { provider: 'password', password: 'validPassword' },
authParams: {
provider: AuthProviderEnum.Password,
password: 'validPassword',
},
userData: {
type: 'existingUser',
existingUser: { email: 'test@example.com' } as User,
@ -340,7 +361,10 @@ describe('SignInUpService', () => {
ExistingUserOrPartialUserWithPicture &
AuthProviderWithPasswordType = {
workspace: null,
authParams: { provider: 'password', password: 'validPassword' },
authParams: {
provider: AuthProviderEnum.Password,
password: 'validPassword',
},
userData: {
type: 'existingUser',
existingUser: { email: 'existinguser@example.com' } as User,

View File

@ -25,8 +25,6 @@ import {
SignInUpNewUserPayload,
} from 'src/engine/core-modules/auth/types/signInUp.type';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
@ -34,9 +32,10 @@ import { UserService } from 'src/engine/core-modules/user/services/user.service'
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
import { isWorkEmail } from 'src/utils/is-work-email';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
@ -46,16 +45,14 @@ export class SignInUpService {
private readonly userRepository: Repository<User>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
private readonly fileUploadService: FileUploadService,
private readonly workspaceInvitationService: WorkspaceInvitationService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly onboardingService: OnboardingService,
private readonly loginTokenService: LoginTokenService,
private readonly httpService: HttpService,
private readonly twentyConfigService: TwentyConfigService,
private readonly domainManagerService: DomainManagerService,
private readonly userService: UserService,
private readonly userRoleService: UserRoleService,
private readonly featureFlagService: FeatureFlagService,
) {}
async computeParamsForNewUser(
@ -72,7 +69,7 @@ export class SignInUpService {
);
}
if (authParams.provider === 'password') {
if (authParams.provider === AuthProviderEnum.Password) {
newUserParams.passwordHash = await this.generateHash(authParams.password);
}
@ -293,11 +290,27 @@ export class SignInUpService {
return await this.userRepository.save(userCreated);
}
private async setDefaultImpersonateAndAccessFullAdminPanel() {
if (!this.twentyConfigService.get('IS_MULTIWORKSPACE_ENABLED')) {
const workspacesCount = await this.workspaceRepository.count();
// let the creation of the first workspace
if (workspacesCount > 0) {
throw new AuthException(
'New workspace setup is disabled',
AuthExceptionCode.SIGNUP_DISABLED,
);
}
return { canImpersonate: true, canAccessFullAdminPanel: true };
}
return { canImpersonate: false, canAccessFullAdminPanel: false };
}
async signUpOnNewWorkspace(
userData: ExistingUserOrPartialUserWithPicture['userData'],
) {
let canImpersonate = false;
let canAccessFullAdminPanel = false;
const email =
userData.type === 'newUserWithPicture'
? userData.newUserWithPicture.email
@ -310,21 +323,8 @@ export class SignInUpService {
);
}
if (!this.twentyConfigService.get('IS_MULTIWORKSPACE_ENABLED')) {
const workspacesCount = await this.workspaceRepository.count();
// if the workspace doesn't exist it means it's the first user of the workspace
canImpersonate = true;
canAccessFullAdminPanel = true;
// let the creation of the first workspace
if (workspacesCount > 0) {
throw new AuthException(
'New workspace setup is disabled',
AuthExceptionCode.SIGNUP_DISABLED,
);
}
}
const { canImpersonate, canAccessFullAdminPanel } =
await this.setDefaultImpersonateAndAccessFullAdminPanel();
const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(email)}`;
const isLogoUrlValid = async () => {
@ -380,4 +380,14 @@ export class SignInUpService {
return { user, workspace };
}
async signUpWithoutWorkspace(
newUserParams: SignInUpNewUserPayload,
authParams: AuthProviderWithPasswordType['authParams'],
) {
return this.saveNewUser(
await this.computeParamsForNewUser(newUserParams, authParams),
await this.setDefaultImpersonateAndAccessFullAdminPanel(),
);
}
}

View File

@ -6,6 +6,7 @@ import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { APP_LOCALES } from 'twenty-shared/translations';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { SocialSSOSignInUpActionType } from 'src/engine/core-modules/auth/types/signInUp.type';
export type GoogleRequest = Omit<
Request,
@ -19,6 +20,7 @@ export type GoogleRequest = Omit<
locale?: keyof typeof APP_LOCALES | null;
workspaceInviteHash?: string;
workspacePersonalInviteToken?: string;
action: SocialSSOSignInUpActionType;
workspaceId?: string;
billingCheckoutSessionState?: string;
};
@ -45,6 +47,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
workspaceId: req.params.workspaceId,
billingCheckoutSessionState: req.query.billingCheckoutSessionState,
workspacePersonalInviteToken: req.query.workspacePersonalInviteToken,
action: req.query.action,
}),
};
@ -53,8 +56,8 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
async validate(
request: GoogleRequest,
accessToken: string,
refreshToken: string,
_accessToken: string,
_refreshToken: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
profile: any,
done: VerifyCallback,
@ -74,6 +77,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
workspacePersonalInviteToken: state.workspacePersonalInviteToken,
workspaceId: state.workspaceId,
billingCheckoutSessionState: state.billingCheckoutSessionState,
action: state.action,
locale: state.locale,
};

View File

@ -155,12 +155,12 @@ describe('JwtAuthStrategy', () => {
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('User not found', expect.any(String)),
new AuthException('UserWorkspace not found', expect.any(String)),
);
try {
await strategy.validate(payload as JwtPayload);
} catch (e) {
expect(e.code).toBe(AuthExceptionCode.USER_NOT_FOUND);
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
}
});

View File

@ -10,8 +10,12 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import {
AccessTokenJwtPayload,
ApiKeyTokenJwtPayload,
AuthContext,
FileTokenJwtPayload,
JwtPayload,
WorkspaceAgnosticTokenJwtPayload,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@ -19,6 +23,9 @@ import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
import { userWorkspaceValidator } from 'src/engine/core-modules/user-workspace/user-workspace.validate';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
@Injectable()
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
@ -36,13 +43,20 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
// @ts-expect-error legacy noImplicitAny
const secretOrKeyProviderFunction = async (_request, rawJwtToken, done) => {
try {
const decodedToken = jwtWrapperService.decode(
rawJwtToken,
) as JwtPayload;
const workspaceId = decodedToken.workspaceId;
const decodedToken = jwtWrapperService.decode<
| FileTokenJwtPayload
| AccessTokenJwtPayload
| WorkspaceAgnosticTokenJwtPayload
>(rawJwtToken);
const appSecretBody =
decodedToken.type === 'WORKSPACE_AGNOSTIC'
? decodedToken.userId
: decodedToken.workspaceId;
const secret = jwtWrapperService.generateAppSecret(
'ACCESS',
workspaceId,
decodedToken.type,
appSecretBody,
);
done(null, secret);
@ -58,19 +72,20 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
});
}
private async validateAPIKey(payload: JwtPayload): Promise<AuthContext> {
let apiKey: ApiKeyWorkspaceEntity | null = null;
private async validateAPIKey(
payload: ApiKeyTokenJwtPayload,
): Promise<AuthContext> {
const workspace = await this.workspaceRepository.findOneBy({
id: payload['sub'],
id: payload.sub,
});
if (!workspace) {
throw new AuthException(
workspaceValidator.assertIsDefinedOrThrow(
workspace,
new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
),
);
const apiKeyRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ApiKeyWorkspaceEntity>(
@ -78,7 +93,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
'apiKey',
);
apiKey = await apiKeyRepository.findOne({
const apiKey = await apiKeyRepository.findOne({
where: {
id: payload.jti,
},
@ -91,13 +106,15 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
);
}
return { apiKey, workspace };
return { apiKey, workspace, workspaceMemberId: payload.workspaceMemberId };
}
private async validateAccessToken(payload: JwtPayload): Promise<AuthContext> {
private async validateAccessToken(
payload: AccessTokenJwtPayload,
): Promise<AuthContext> {
let user: User | null = null;
const workspace = await this.workspaceRepository.findOneBy({
id: payload['workspaceId'],
id: payload.workspaceId,
});
if (!workspace) {
@ -107,16 +124,19 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
);
}
user = await this.userRepository.findOne({
where: { id: payload.sub },
});
if (!user) {
const userId = payload.sub ?? payload.userId;
if (!userId) {
throw new AuthException(
'User not found',
AuthExceptionCode.USER_NOT_FOUND,
);
}
user = await this.userRepository.findOne({
where: { id: userId },
});
if (!payload.userWorkspaceId) {
throw new AuthException(
'UserWorkspace not found',
@ -130,27 +150,62 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
},
});
if (!userWorkspace) {
throw new AuthException(
userWorkspaceValidator.assertIsDefinedOrThrow(
userWorkspace,
new AuthException(
'UserWorkspace not found',
AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
);
}
),
);
return { user, workspace, userWorkspaceId: userWorkspace.id };
return {
user,
workspace,
authProvider: payload.authProvider,
userWorkspaceId: userWorkspace.id,
workspaceMemberId: payload.workspaceMemberId,
};
}
private async validateWorkspaceAgnosticToken(
payload: WorkspaceAgnosticTokenJwtPayload,
) {
const user = await this.userRepository.findOne({
where: { id: payload.sub },
});
userValidator.assertIsDefinedOrThrow(
user,
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
);
return { user, authProvider: payload.authProvider };
}
private isLegacyApiKeyPayload(
payload: JwtPayload,
): payload is ApiKeyTokenJwtPayload {
return !payload.type && !('workspaceId' in payload);
}
async validate(payload: JwtPayload): Promise<AuthContext> {
const workspaceMemberId = payload.workspaceMemberId;
if (!payload.type && !payload.workspaceId) {
return { ...(await this.validateAPIKey(payload)), workspaceMemberId };
// Support legacy api keys
if (payload.type === 'API_KEY' || this.isLegacyApiKeyPayload(payload)) {
return await this.validateAPIKey(payload);
}
if (payload.type === 'API_KEY') {
return { ...(await this.validateAPIKey(payload)), workspaceMemberId };
if (payload.type === 'WORKSPACE_AGNOSTIC') {
return await this.validateWorkspaceAgnosticToken(payload);
}
return { ...(await this.validateAccessToken(payload)), workspaceMemberId };
// `!payload.type` is here to support legacy token
if (payload.type === 'ACCESS' || !payload.type) {
return await this.validateAccessToken(payload);
}
throw new AuthException(
'Invalid token',
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
);
}
}

View File

@ -10,6 +10,7 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { SocialSSOSignInUpActionType } from 'src/engine/core-modules/auth/types/signInUp.type';
export type MicrosoftRequest = Omit<
Request,
@ -25,6 +26,7 @@ export type MicrosoftRequest = Omit<
workspacePersonalInviteToken?: string;
workspaceId?: string;
billingCheckoutSessionState?: string;
action: SocialSSOSignInUpActionType;
};
};
@ -50,6 +52,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
locale: req.query.locale,
billingCheckoutSessionState: req.query.billingCheckoutSessionState,
workspacePersonalInviteToken: req.query.workspacePersonalInviteToken,
action: req.query.action,
}),
};
@ -58,8 +61,8 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
async validate(
request: MicrosoftRequest,
accessToken: string,
refreshToken: string,
_accessToken: string,
_refreshToken: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
profile: any,
done: VerifyCallback,
@ -90,6 +93,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
workspaceId: state.workspaceId,
billingCheckoutSessionState: state.billingCheckoutSessionState,
locale: state.locale,
action: state.action,
};
done(null, user);

View File

@ -36,7 +36,7 @@ describe('AccessTokenService', () => {
provide: JwtWrapperService,
useValue: {
sign: jest.fn(),
verifyWorkspaceToken: jest.fn(),
verifyJwtToken: jest.fn(),
decode: jest.fn(),
generateAppSecret: jest.fn(),
extractJwtFromRequest: jest.fn(),
@ -138,7 +138,7 @@ describe('AccessTokenService', () => {
} as any);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
const result = await service.generateAccessToken(userId, workspaceId);
const result = await service.generateAccessToken({ userId, workspaceId });
expect(result).toEqual({
token: mockToken,
@ -159,7 +159,10 @@ describe('AccessTokenService', () => {
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
await expect(
service.generateAccessToken('non-existent-user', 'workspace-id'),
service.generateAccessToken({
userId: 'non-existent-user',
workspaceId: 'workspace-id',
}),
).rejects.toThrow(AuthException);
});
});
@ -184,7 +187,7 @@ describe('AccessTokenService', () => {
.spyOn(jwtWrapperService, 'extractJwtFromRequest')
.mockReturnValue(() => mockToken);
jest
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
.spyOn(jwtWrapperService, 'verifyJwtToken')
.mockResolvedValue(undefined);
jest
.spyOn(jwtWrapperService, 'decode')
@ -196,7 +199,7 @@ describe('AccessTokenService', () => {
const result = await service.validateTokenByRequest(mockRequest);
expect(result).toEqual(mockAuthContext);
expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith(
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(
mockToken,
'ACCESS',
);

View File

@ -14,8 +14,9 @@ import {
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
import {
AccessTokenJwtPayload,
AuthContext,
JwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@ -26,6 +27,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { userWorkspaceValidator } from 'src/engine/core-modules/user-workspace/user-workspace.validate';
@Injectable()
export class AccessTokenService {
@ -42,10 +44,14 @@ export class AccessTokenService {
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
) {}
async generateAccessToken(
userId: string,
workspaceId: string,
): Promise<AuthToken> {
async generateAccessToken({
userId,
workspaceId,
authProvider,
}: Omit<
AccessTokenJwtPayload,
'type' | 'workspaceMemberId' | 'userWorkspaceId' | 'sub'
>): Promise<AuthToken> {
const expiresIn = this.twentyConfigService.get('ACCESS_TOKEN_EXPIRES_IN');
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
@ -99,16 +105,24 @@ export class AccessTokenService {
},
});
const jwtPayload: JwtPayload = {
userWorkspaceValidator.assertIsDefinedOrThrow(userWorkspace);
const jwtPayload: AccessTokenJwtPayload = {
sub: user.id,
userId: user.id,
workspaceId,
workspaceMemberId: tokenWorkspaceMemberId,
userWorkspaceId: userWorkspace?.id,
userWorkspaceId: userWorkspace.id,
type: JwtTokenTypeEnum.ACCESS,
authProvider,
};
return {
token: this.jwtWrapperService.sign(jwtPayload, {
secret: this.jwtWrapperService.generateAppSecret('ACCESS', workspaceId),
secret: this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.ACCESS,
workspaceId,
),
expiresIn,
}),
expiresAt,
@ -116,14 +130,27 @@ export class AccessTokenService {
}
async validateToken(token: string): Promise<AuthContext> {
await this.jwtWrapperService.verifyWorkspaceToken(token, 'ACCESS');
await this.jwtWrapperService.verifyJwtToken(token, JwtTokenTypeEnum.ACCESS);
const decoded = await this.jwtWrapperService.decode(token);
const decoded = this.jwtWrapperService.decode<AccessTokenJwtPayload>(token);
const { user, apiKey, workspace, workspaceMemberId, userWorkspaceId } =
await this.jwtStrategy.validate(decoded as JwtPayload);
const {
user,
apiKey,
workspace,
workspaceMemberId,
userWorkspaceId,
authProvider,
} = await this.jwtStrategy.validate(decoded);
return { user, apiKey, workspace, workspaceMemberId, userWorkspaceId };
return {
user,
apiKey,
workspace,
workspaceMemberId,
userWorkspaceId,
authProvider,
};
}
async validateTokenByRequest(request: Request): Promise<AuthContext> {

View File

@ -19,7 +19,7 @@ describe('LoginTokenService', () => {
useValue: {
generateAppSecret: jest.fn(),
sign: jest.fn(),
verifyWorkspaceToken: jest.fn(),
verifyJwtToken: jest.fn(),
decode: jest.fn(),
},
},
@ -69,7 +69,7 @@ describe('LoginTokenService', () => {
'LOGIN_TOKEN_EXPIRES_IN',
);
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
{ sub: email, workspaceId },
{ sub: email, workspaceId, type: 'LOGIN' },
{ secret: mockSecret, expiresIn: mockExpiresIn },
);
});
@ -81,7 +81,7 @@ describe('LoginTokenService', () => {
const mockEmail = 'test@example.com';
jest
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
.spyOn(jwtWrapperService, 'verifyJwtToken')
.mockResolvedValue(undefined);
jest
.spyOn(jwtWrapperService, 'decode')
@ -90,7 +90,7 @@ describe('LoginTokenService', () => {
const result = await service.verifyLoginToken(mockToken);
expect(result).toEqual({ sub: mockEmail });
expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith(
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(
mockToken,
'LOGIN',
);
@ -103,7 +103,7 @@ describe('LoginTokenService', () => {
const mockToken = 'invalid-token';
jest
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
.spyOn(jwtWrapperService, 'verifyJwtToken')
.mockRejectedValue(new Error('Invalid token'));
await expect(service.verifyLoginToken(mockToken)).rejects.toThrow();

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