refacto(*): remove everything about default workspace (#9157)

## Summary
- [x] Remove defaultWorkspace in user
- [x] Remove all occurrence of defaultWorkspace and defaultWorkspaceId
- [x] Improve activate workspace flow
- [x] Improve security on social login
- [x] Add `ImpersonateGuard`
- [x] Allow to use impersonation with couple `User/Workspace`
- [x] Prevent unexpected reload on activate workspace
- [x] Scope login token with workspaceId 

Fix https://github.com/twentyhq/twenty/issues/9033#event-15714863042
This commit is contained in:
Antoine Moreaux
2024-12-24 12:47:41 +01:00
committed by GitHub
parent fe6948ba0b
commit cd2946b670
78 changed files with 1150 additions and 1244 deletions

View File

@ -1,5 +1,7 @@
module.exports = { module.exports = {
schema: (process.env.REACT_APP_SERVER_BASE_URL ?? 'http://localhost:3000') + '/metadata', schema:
(process.env.REACT_APP_SERVER_BASE_URL ?? 'http://localhost:3000') +
'/metadata',
documents: [ documents: [
'./src/modules/databases/graphql/**/*.ts', './src/modules/databases/graphql/**/*.ts',
'./src/modules/object-metadata/graphql/*.ts', './src/modules/object-metadata/graphql/*.ts',

View File

@ -1,5 +1,7 @@
module.exports = { module.exports = {
schema: (process.env.REACT_APP_SERVER_BASE_URL ?? 'http://localhost:3000') + '/graphql', schema:
(process.env.REACT_APP_SERVER_BASE_URL ?? 'http://localhost:3000') +
'/graphql',
documents: [ documents: [
'!./src/modules/databases/**', '!./src/modules/databases/**',
'!./src/modules/object-metadata/**', '!./src/modules/object-metadata/**',

View File

@ -176,6 +176,7 @@ export type ClientConfig = {
__typename?: 'ClientConfig'; __typename?: 'ClientConfig';
analyticsEnabled: Scalars['Boolean']['output']; analyticsEnabled: Scalars['Boolean']['output'];
api: ApiConfig; api: ApiConfig;
authProviders: AuthProviders;
billing: Billing; billing: Billing;
captcha: Captcha; captcha: Captcha;
chromeExtensionId?: Maybe<Scalars['String']['output']>; chromeExtensionId?: Maybe<Scalars['String']['output']>;
@ -358,13 +359,6 @@ export type EmailPasswordResetLink = {
success: Scalars['Boolean']['output']; success: Scalars['Boolean']['output'];
}; };
export type ExchangeAuthCode = {
__typename?: 'ExchangeAuthCode';
accessToken: AuthToken;
loginToken: AuthToken;
refreshToken: AuthToken;
};
export type ExecuteServerlessFunctionInput = { export type ExecuteServerlessFunctionInput = {
/** Id of the serverless function to execute */ /** Id of the serverless function to execute */
id: Scalars['UUID']['input']; id: Scalars['UUID']['input'];
@ -581,12 +575,11 @@ export type Mutation = {
editSSOIdentityProvider: EditSsoOutput; editSSOIdentityProvider: EditSsoOutput;
emailPasswordResetLink: EmailPasswordResetLink; emailPasswordResetLink: EmailPasswordResetLink;
enablePostgresProxy: PostgresCredentials; enablePostgresProxy: PostgresCredentials;
exchangeAuthorizationCode: ExchangeAuthCode;
executeOneServerlessFunction: ServerlessFunctionExecutionResult; executeOneServerlessFunction: ServerlessFunctionExecutionResult;
generateApiKeyToken: ApiKeyToken; generateApiKeyToken: ApiKeyToken;
generateTransientToken: TransientToken; generateTransientToken: TransientToken;
getAuthorizationUrl: GetAuthorizationUrlOutput; getAuthorizationUrl: GetAuthorizationUrlOutput;
impersonate: Verify; impersonate: AuthTokens;
publishServerlessFunction: ServerlessFunction; publishServerlessFunction: ServerlessFunction;
renewToken: AuthTokens; renewToken: AuthTokens;
resendWorkspaceInvitation: SendInvitationsOutput; resendWorkspaceInvitation: SendInvitationsOutput;
@ -613,7 +606,7 @@ export type Mutation = {
uploadProfilePicture: Scalars['String']['output']; uploadProfilePicture: Scalars['String']['output'];
uploadWorkspaceLogo: Scalars['String']['output']; uploadWorkspaceLogo: Scalars['String']['output'];
userLookupAdminPanel: UserLookup; userLookupAdminPanel: UserLookup;
verify: Verify; verify: AuthTokens;
}; };
@ -762,13 +755,6 @@ export type MutationEmailPasswordResetLinkArgs = {
}; };
export type MutationExchangeAuthorizationCodeArgs = {
authorizationCode: Scalars['String']['input'];
clientSecret?: InputMaybe<Scalars['String']['input']>;
codeVerifier?: InputMaybe<Scalars['String']['input']>;
};
export type MutationExecuteOneServerlessFunctionArgs = { export type MutationExecuteOneServerlessFunctionArgs = {
input: ExecuteServerlessFunctionInput; input: ExecuteServerlessFunctionInput;
}; };
@ -787,6 +773,7 @@ export type MutationGetAuthorizationUrlArgs = {
export type MutationImpersonateArgs = { export type MutationImpersonateArgs = {
userId: Scalars['String']['input']; userId: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
}; };
@ -1593,9 +1580,8 @@ export type User = {
analyticsTinybirdJwts?: Maybe<AnalyticsTinybirdJwtMap>; analyticsTinybirdJwts?: Maybe<AnalyticsTinybirdJwtMap>;
canImpersonate: Scalars['Boolean']['output']; canImpersonate: Scalars['Boolean']['output'];
createdAt: Scalars['DateTime']['output']; createdAt: Scalars['DateTime']['output'];
currentWorkspace?: Maybe<Workspace>;
defaultAvatarUrl?: Maybe<Scalars['String']['output']>; defaultAvatarUrl?: Maybe<Scalars['String']['output']>;
defaultWorkspace: Workspace;
defaultWorkspaceId: Scalars['String']['output'];
deletedAt?: Maybe<Scalars['DateTime']['output']>; deletedAt?: Maybe<Scalars['DateTime']['output']>;
disabled?: Maybe<Scalars['Boolean']['output']>; disabled?: Maybe<Scalars['Boolean']['output']>;
email: Scalars['String']['output']; email: Scalars['String']['output'];
@ -1681,12 +1667,6 @@ export type ValidatePasswordResetToken = {
id: Scalars['String']['output']; id: Scalars['String']['output'];
}; };
export type Verify = {
__typename?: 'Verify';
tokens: AuthTokenPair;
user: User;
};
export type WorkflowAction = { export type WorkflowAction = {
__typename?: 'WorkflowAction'; __typename?: 'WorkflowAction';
id: Scalars['UUID']['output']; id: Scalars['UUID']['output'];

View File

@ -25,12 +25,6 @@ export type ActivateWorkspaceInput = {
displayName?: InputMaybe<Scalars['String']>; displayName?: InputMaybe<Scalars['String']>;
}; };
export type ActivateWorkspaceOutput = {
__typename?: 'ActivateWorkspaceOutput';
loginToken: AuthToken;
workspace: Workspace;
};
export type Analytics = { export type Analytics = {
__typename?: 'Analytics'; __typename?: 'Analytics';
/** Boolean that confirms query was dispatched */ /** Boolean that confirms query was dispatched */
@ -260,13 +254,6 @@ export type EmailPasswordResetLink = {
success: Scalars['Boolean']; success: Scalars['Boolean'];
}; };
export type ExchangeAuthCode = {
__typename?: 'ExchangeAuthCode';
accessToken: AuthToken;
loginToken: AuthToken;
refreshToken: AuthToken;
};
export type ExecuteServerlessFunctionInput = { export type ExecuteServerlessFunctionInput = {
/** Id of the serverless function to execute */ /** Id of the serverless function to execute */
id: Scalars['UUID']; id: Scalars['UUID'];
@ -382,6 +369,12 @@ export enum IdentityProviderType {
Saml = 'SAML' Saml = 'SAML'
} }
export type ImpersonateOutput = {
__typename?: 'ImpersonateOutput';
loginToken: AuthToken;
workspace: WorkspaceSubdomainAndId;
};
export type IndexConnection = { export type IndexConnection = {
__typename?: 'IndexConnection'; __typename?: 'IndexConnection';
/** Array of edges. */ /** Array of edges. */
@ -445,7 +438,7 @@ export enum MessageChannelVisibility {
export type Mutation = { export type Mutation = {
__typename?: 'Mutation'; __typename?: 'Mutation';
activateWorkflowVersion: Scalars['Boolean']; activateWorkflowVersion: Scalars['Boolean'];
activateWorkspace: ActivateWorkspaceOutput; activateWorkspace: Workspace;
addUserToWorkspace: User; addUserToWorkspace: User;
addUserToWorkspaceByInviteToken: User; addUserToWorkspaceByInviteToken: User;
authorizeApp: AuthorizeApp; authorizeApp: AuthorizeApp;
@ -470,18 +463,17 @@ export type Mutation = {
editSSOIdentityProvider: EditSsoOutput; editSSOIdentityProvider: EditSsoOutput;
emailPasswordResetLink: EmailPasswordResetLink; emailPasswordResetLink: EmailPasswordResetLink;
enablePostgresProxy: PostgresCredentials; enablePostgresProxy: PostgresCredentials;
exchangeAuthorizationCode: ExchangeAuthCode;
executeOneServerlessFunction: ServerlessFunctionExecutionResult; executeOneServerlessFunction: ServerlessFunctionExecutionResult;
generateApiKeyToken: ApiKeyToken; generateApiKeyToken: ApiKeyToken;
generateTransientToken: TransientToken; generateTransientToken: TransientToken;
getAuthorizationUrl: GetAuthorizationUrlOutput; getAuthorizationUrl: GetAuthorizationUrlOutput;
impersonate: Verify; impersonate: ImpersonateOutput;
publishServerlessFunction: ServerlessFunction; publishServerlessFunction: ServerlessFunction;
renewToken: AuthTokens; renewToken: AuthTokens;
resendWorkspaceInvitation: SendInvitationsOutput; resendWorkspaceInvitation: SendInvitationsOutput;
runWorkflowVersion: WorkflowRun; runWorkflowVersion: WorkflowRun;
sendInvitations: SendInvitationsOutput; sendInvitations: SendInvitationsOutput;
signUp: LoginToken; signUp: SignUpOutput;
skipSyncEmailOnboardingStep: OnboardingStepSuccess; skipSyncEmailOnboardingStep: OnboardingStepSuccess;
switchWorkspace: PublicWorkspaceDataOutput; switchWorkspace: PublicWorkspaceDataOutput;
track: Analytics; track: Analytics;
@ -497,7 +489,7 @@ export type Mutation = {
uploadProfilePicture: Scalars['String']; uploadProfilePicture: Scalars['String'];
uploadWorkspaceLogo: Scalars['String']; uploadWorkspaceLogo: Scalars['String'];
userLookupAdminPanel: UserLookup; userLookupAdminPanel: UserLookup;
verify: Verify; verify: AuthTokens;
}; };
@ -606,13 +598,6 @@ export type MutationEmailPasswordResetLinkArgs = {
}; };
export type MutationExchangeAuthorizationCodeArgs = {
authorizationCode: Scalars['String'];
clientSecret?: InputMaybe<Scalars['String']>;
codeVerifier?: InputMaybe<Scalars['String']>;
};
export type MutationExecuteOneServerlessFunctionArgs = { export type MutationExecuteOneServerlessFunctionArgs = {
input: ExecuteServerlessFunctionInput; input: ExecuteServerlessFunctionInput;
}; };
@ -631,6 +616,7 @@ export type MutationGetAuthorizationUrlArgs = {
export type MutationImpersonateArgs = { export type MutationImpersonateArgs = {
userId: Scalars['String']; userId: Scalars['String'];
workspaceId: Scalars['String'];
}; };
@ -1121,6 +1107,12 @@ export type SetupSsoOutput = {
type: IdentityProviderType; type: IdentityProviderType;
}; };
export type SignUpOutput = {
__typename?: 'SignUpOutput';
loginToken: AuthToken;
workspace: WorkspaceSubdomainAndId;
};
/** Sort Directions */ /** Sort Directions */
export enum SortDirection { export enum SortDirection {
Asc = 'ASC', Asc = 'ASC',
@ -1302,9 +1294,8 @@ export type User = {
analyticsTinybirdJwts?: Maybe<AnalyticsTinybirdJwtMap>; analyticsTinybirdJwts?: Maybe<AnalyticsTinybirdJwtMap>;
canImpersonate: Scalars['Boolean']; canImpersonate: Scalars['Boolean'];
createdAt: Scalars['DateTime']; createdAt: Scalars['DateTime'];
currentWorkspace?: Maybe<Workspace>;
defaultAvatarUrl?: Maybe<Scalars['String']>; defaultAvatarUrl?: Maybe<Scalars['String']>;
defaultWorkspace: Workspace;
defaultWorkspaceId: Scalars['String'];
deletedAt?: Maybe<Scalars['DateTime']>; deletedAt?: Maybe<Scalars['DateTime']>;
disabled?: Maybe<Scalars['Boolean']>; disabled?: Maybe<Scalars['Boolean']>;
email: Scalars['String']; email: Scalars['String'];
@ -1333,7 +1324,6 @@ export type UserEdge = {
export type UserExists = { export type UserExists = {
__typename?: 'UserExists'; __typename?: 'UserExists';
availableWorkspaces: Array<AvailableWorkspaceOutput>; availableWorkspaces: Array<AvailableWorkspaceOutput>;
defaultWorkspaceId: Scalars['String'];
exists: Scalars['Boolean']; exists: Scalars['Boolean'];
}; };
@ -1381,12 +1371,6 @@ export type ValidatePasswordResetToken = {
id: Scalars['String']; id: Scalars['String'];
}; };
export type Verify = {
__typename?: 'Verify';
tokens: AuthTokenPair;
user: User;
};
export type WorkflowAction = { export type WorkflowAction = {
__typename?: 'WorkflowAction'; __typename?: 'WorkflowAction';
id: Scalars['UUID']; id: Scalars['UUID'];
@ -1471,6 +1455,7 @@ export type WorkspaceEdge = {
export type WorkspaceInfo = { export type WorkspaceInfo = {
__typename?: 'WorkspaceInfo'; __typename?: 'WorkspaceInfo';
allowImpersonation: Scalars['Boolean'];
featureFlags: Array<FeatureFlag>; featureFlags: Array<FeatureFlag>;
id: Scalars['String']; id: Scalars['String'];
logo?: Maybe<Scalars['String']>; logo?: Maybe<Scalars['String']>;
@ -1524,6 +1509,12 @@ export type WorkspaceNameAndId = {
id: Scalars['String']; id: Scalars['String'];
}; };
export type WorkspaceSubdomainAndId = {
__typename?: 'WorkspaceSubdomainAndId';
id: Scalars['String'];
subdomain: Scalars['String'];
};
export type BillingCustomer = { export type BillingCustomer = {
__typename?: 'billingCustomer'; __typename?: 'billingCustomer';
id: Scalars['UUID']; id: Scalars['UUID'];
@ -1877,10 +1868,11 @@ export type GetAuthorizationUrlMutation = { __typename?: 'Mutation', getAuthoriz
export type ImpersonateMutationVariables = Exact<{ export type ImpersonateMutationVariables = Exact<{
userId: Scalars['String']; userId: Scalars['String'];
workspaceId: Scalars['String'];
}>; }>;
export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null, subdomain: string } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'ImpersonateOutput', workspace: { __typename?: 'WorkspaceSubdomainAndId', subdomain: string, id: string }, loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } };
export type RenewTokenMutationVariables = Exact<{ export type RenewTokenMutationVariables = Exact<{
appToken: Scalars['String']; appToken: Scalars['String'];
@ -1898,7 +1890,7 @@ export type SignUpMutationVariables = Exact<{
}>; }>;
export type SignUpMutation = { __typename?: 'Mutation', signUp: { __typename?: 'LoginToken', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } }; export type SignUpMutation = { __typename?: 'Mutation', signUp: { __typename?: 'SignUpOutput', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, workspace: { __typename?: 'WorkspaceSubdomainAndId', id: string, subdomain: string } } };
export type SwitchWorkspaceMutationVariables = Exact<{ export type SwitchWorkspaceMutationVariables = Exact<{
workspaceId: Scalars['String']; workspaceId: Scalars['String'];
@ -1920,7 +1912,7 @@ export type VerifyMutationVariables = Exact<{
}>; }>;
export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null, subdomain: string } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'AuthTokens', tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
export type CheckUserExistsQueryVariables = Exact<{ export type CheckUserExistsQueryVariables = Exact<{
email: Scalars['String']; email: Scalars['String'];
@ -1928,7 +1920,7 @@ export type CheckUserExistsQueryVariables = Exact<{
}>; }>;
export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename: 'UserExists', exists: boolean, defaultWorkspaceId: string, availableWorkspaces: Array<{ __typename?: 'AvailableWorkspaceOutput', id: string, displayName?: string | null, subdomain: string, logo?: string | null, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } | { __typename: 'UserNotExists', exists: boolean } }; export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename: 'UserExists', exists: boolean, availableWorkspaces: Array<{ __typename?: 'AvailableWorkspaceOutput', id: string, displayName?: string | null, subdomain: string, logo?: string | null, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } | { __typename: 'UserNotExists', exists: boolean } };
export type GetPublicWorkspaceDataBySubdomainQueryVariables = Exact<{ [key: string]: never; }>; export type GetPublicWorkspaceDataBySubdomainQueryVariables = Exact<{ [key: string]: never; }>;
@ -1993,7 +1985,7 @@ export type UserLookupAdminPanelMutationVariables = Exact<{
}>; }>;
export type UserLookupAdminPanelMutation = { __typename?: 'Mutation', userLookupAdminPanel: { __typename?: 'UserLookup', user: { __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }, workspaces: Array<{ __typename?: 'WorkspaceInfo', id: string, name: string, logo?: string | null, totalUsers: number, users: Array<{ __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }>, featureFlags: Array<{ __typename?: 'FeatureFlag', key: string, value: boolean }> }> } }; export type UserLookupAdminPanelMutation = { __typename?: 'Mutation', userLookupAdminPanel: { __typename?: 'UserLookup', user: { __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }, workspaces: Array<{ __typename?: 'WorkspaceInfo', id: string, name: string, logo?: string | null, totalUsers: number, allowImpersonation: boolean, users: Array<{ __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }>, featureFlags: Array<{ __typename?: 'FeatureFlag', key: string, value: boolean }> }> } };
export type CreateOidcIdentityProviderMutationVariables = Exact<{ export type CreateOidcIdentityProviderMutationVariables = Exact<{
input: SetupOidcSsoInput; input: SetupOidcSsoInput;
@ -2028,7 +2020,7 @@ export type ListSsoIdentityProvidersByWorkspaceIdQueryVariables = Exact<{ [key:
export type ListSsoIdentityProvidersByWorkspaceIdQuery = { __typename?: 'Query', listSSOIdentityProvidersByWorkspaceId: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> }; export type ListSsoIdentityProvidersByWorkspaceIdQuery = { __typename?: 'Query', listSSOIdentityProvidersByWorkspaceId: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> };
export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null, subdomain: string } | null }> }; export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null, subdomain: string } | null }> };
export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>; export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>;
@ -2045,7 +2037,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf
export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null, subdomain: string } | null }> } }; export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null, subdomain: string } | null }> } };
export type ActivateWorkflowVersionMutationVariables = Exact<{ export type ActivateWorkflowVersionMutationVariables = Exact<{
workflowVersionId: Scalars['String']; workflowVersionId: Scalars['String'];
@ -2143,7 +2135,7 @@ export type ActivateWorkspaceMutationVariables = Exact<{
}>; }>;
export type ActivateWorkspaceMutation = { __typename?: 'Mutation', activateWorkspace: { __typename?: 'ActivateWorkspaceOutput', workspace: { __typename?: 'Workspace', id: any, subdomain: string }, loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } }; export type ActivateWorkspaceMutation = { __typename?: 'Mutation', activateWorkspace: { __typename?: 'Workspace', id: any, subdomain: string } };
export type DeleteCurrentWorkspaceMutationVariables = Exact<{ [key: string]: never; }>; export type DeleteCurrentWorkspaceMutationVariables = Exact<{ [key: string]: never; }>;
@ -2308,7 +2300,7 @@ export const UserQueryFragmentFragmentDoc = gql`
workspaceMembers { workspaceMembers {
...WorkspaceMemberQueryFragment ...WorkspaceMemberQueryFragment
} }
defaultWorkspace { currentWorkspace {
id id
displayName displayName
logo logo
@ -2823,18 +2815,18 @@ export type GetAuthorizationUrlMutationHookResult = ReturnType<typeof useGetAuth
export type GetAuthorizationUrlMutationResult = Apollo.MutationResult<GetAuthorizationUrlMutation>; export type GetAuthorizationUrlMutationResult = Apollo.MutationResult<GetAuthorizationUrlMutation>;
export type GetAuthorizationUrlMutationOptions = Apollo.BaseMutationOptions<GetAuthorizationUrlMutation, GetAuthorizationUrlMutationVariables>; export type GetAuthorizationUrlMutationOptions = Apollo.BaseMutationOptions<GetAuthorizationUrlMutation, GetAuthorizationUrlMutationVariables>;
export const ImpersonateDocument = gql` export const ImpersonateDocument = gql`
mutation Impersonate($userId: String!) { mutation Impersonate($userId: String!, $workspaceId: String!) {
impersonate(userId: $userId) { impersonate(userId: $userId, workspaceId: $workspaceId) {
user { workspace {
...UserQueryFragment subdomain
id
} }
tokens { loginToken {
...AuthTokensFragment ...AuthTokenFragment
} }
} }
} }
${UserQueryFragmentFragmentDoc} ${AuthTokenFragmentFragmentDoc}`;
${AuthTokensFragmentFragmentDoc}`;
export type ImpersonateMutationFn = Apollo.MutationFunction<ImpersonateMutation, ImpersonateMutationVariables>; export type ImpersonateMutationFn = Apollo.MutationFunction<ImpersonateMutation, ImpersonateMutationVariables>;
/** /**
@ -2851,6 +2843,7 @@ export type ImpersonateMutationFn = Apollo.MutationFunction<ImpersonateMutation,
* const [impersonateMutation, { data, loading, error }] = useImpersonateMutation({ * const [impersonateMutation, { data, loading, error }] = useImpersonateMutation({
* variables: { * variables: {
* userId: // value for 'userId' * userId: // value for 'userId'
* workspaceId: // value for 'workspaceId'
* }, * },
* }); * });
*/ */
@ -2908,6 +2901,10 @@ export const SignUpDocument = gql`
loginToken { loginToken {
...AuthTokenFragment ...AuthTokenFragment
} }
workspace {
id
subdomain
}
} }
} }
${AuthTokenFragmentFragmentDoc}`; ${AuthTokenFragmentFragmentDoc}`;
@ -3028,16 +3025,12 @@ export type UpdatePasswordViaResetTokenMutationOptions = Apollo.BaseMutationOpti
export const VerifyDocument = gql` export const VerifyDocument = gql`
mutation Verify($loginToken: String!) { mutation Verify($loginToken: String!) {
verify(loginToken: $loginToken) { verify(loginToken: $loginToken) {
user {
...UserQueryFragment
}
tokens { tokens {
...AuthTokensFragment ...AuthTokensFragment
} }
} }
} }
${UserQueryFragmentFragmentDoc} ${AuthTokensFragmentFragmentDoc}`;
${AuthTokensFragmentFragmentDoc}`;
export type VerifyMutationFn = Apollo.MutationFunction<VerifyMutation, VerifyMutationVariables>; export type VerifyMutationFn = Apollo.MutationFunction<VerifyMutation, VerifyMutationVariables>;
/** /**
@ -3070,7 +3063,6 @@ export const CheckUserExistsDocument = gql`
__typename __typename
... on UserExists { ... on UserExists {
exists exists
defaultWorkspaceId
availableWorkspaces { availableWorkspaces {
id id
displayName displayName
@ -3507,6 +3499,7 @@ export const UserLookupAdminPanelDocument = gql`
name name
logo logo
totalUsers totalUsers
allowImpersonation
users { users {
id id
email email
@ -4281,16 +4274,11 @@ export type AddUserToWorkspaceByInviteTokenMutationOptions = Apollo.BaseMutation
export const ActivateWorkspaceDocument = gql` export const ActivateWorkspaceDocument = gql`
mutation ActivateWorkspace($input: ActivateWorkspaceInput!) { mutation ActivateWorkspace($input: ActivateWorkspaceInput!) {
activateWorkspace(data: $input) { activateWorkspace(data: $input) {
workspace { id
id subdomain
subdomain
}
loginToken {
...AuthTokenFragment
}
} }
} }
${AuthTokenFragmentFragmentDoc}`; `;
export type ActivateWorkspaceMutationFn = Apollo.MutationFunction<ActivateWorkspaceMutation, ActivateWorkspaceMutationVariables>; export type ActivateWorkspaceMutationFn = Apollo.MutationFunction<ActivateWorkspaceMutation, ActivateWorkspaceMutationVariables>;
/** /**

View File

@ -254,10 +254,10 @@ const SettingsAdmin = lazy(() =>
})), })),
); );
const SettingsAdminFeatureFlags = lazy(() => const SettingsAdminContent = lazy(() =>
import('~/pages/settings/admin-panel/SettingsAdminFeatureFlags').then( import('@/settings/admin-panel/components/SettingsAdminContent').then(
(module) => ({ (module) => ({
default: module.SettingsAdminFeatureFlags, default: module.SettingsAdminContent,
}), }),
), ),
); );
@ -402,7 +402,7 @@ export const SettingsRoutes = ({
<Route path={SettingsPath.AdminPanel} element={<SettingsAdmin />} /> <Route path={SettingsPath.AdminPanel} element={<SettingsAdmin />} />
<Route <Route
path={SettingsPath.FeatureFlags} path={SettingsPath.FeatureFlags}
element={<SettingsAdminFeatureFlags />} element={<SettingsAdminContent />}
/> />
</> </>
)} )}

View File

@ -2,13 +2,14 @@ import { gql } from '@apollo/client';
// TODO: Fragments should be used instead of duplicating the user fields ! // TODO: Fragments should be used instead of duplicating the user fields !
export const IMPERSONATE = gql` export const IMPERSONATE = gql`
mutation Impersonate($userId: String!) { mutation Impersonate($userId: String!, $workspaceId: String!) {
impersonate(userId: $userId) { impersonate(userId: $userId, workspaceId: $workspaceId) {
user { workspace {
...UserQueryFragment subdomain
id
} }
tokens { loginToken {
...AuthTokensFragment ...AuthTokenFragment
} }
} }
} }

View File

@ -18,6 +18,10 @@ export const SIGN_UP = gql`
loginToken { loginToken {
...AuthTokenFragment ...AuthTokenFragment
} }
workspace {
id
subdomain
}
} }
} }
`; `;

View File

@ -3,9 +3,6 @@ import { gql } from '@apollo/client';
export const VERIFY = gql` export const VERIFY = gql`
mutation Verify($loginToken: String!) { mutation Verify($loginToken: String!) {
verify(loginToken: $loginToken) { verify(loginToken: $loginToken) {
user {
...UserQueryFragment
}
tokens { tokens {
...AuthTokensFragment ...AuthTokensFragment
} }

View File

@ -6,7 +6,6 @@ export const CHECK_USER_EXISTS = gql`
__typename __typename
... on UserExists { ... on UserExists {
exists exists
defaultWorkspaceId
availableWorkspaces { availableWorkspaces {
id id
displayName displayName

View File

@ -1,5 +1,6 @@
import { import {
ChallengeDocument, ChallengeDocument,
GetCurrentUserDocument,
SignUpDocument, SignUpDocument,
VerifyDocument, VerifyDocument,
} from '~/generated/graphql'; } from '~/generated/graphql';
@ -8,6 +9,7 @@ export const queries = {
challenge: ChallengeDocument, challenge: ChallengeDocument,
verify: VerifyDocument, verify: VerifyDocument,
signup: SignUpDocument, signup: SignUpDocument,
getCurrentUser: GetCurrentUserDocument,
}; };
export const email = 'test@test.com'; export const email = 'test@test.com';
@ -22,6 +24,7 @@ export const variables = {
}, },
verify: { loginToken: token }, verify: { loginToken: token },
signup: {}, signup: {},
getCurrentUser: {},
}; };
export const results = { export const results = {
@ -32,7 +35,14 @@ export const results = {
}, },
}, },
verify: { verify: {
user: { tokens: {
accessToken: { token, expiresAt: 'expiresAt' },
refreshToken: { token, expiresAt: 'expiresAt' },
},
},
signUp: { loginToken: { token, expiresAt: 'expiresAt' } },
getCurrentUser: {
currentUser: {
id: 'id', id: 'id',
firstName: 'firstName', firstName: 'firstName',
lastName: 'lastName', lastName: 'lastName',
@ -49,7 +59,7 @@ export const results = {
avatarUrl: 'avatarUrl', avatarUrl: 'avatarUrl',
locale: 'locale', locale: 'locale',
}, },
defaultWorkspace: { currentWorkspace: {
id: 'id', id: 'id',
displayName: 'displayName', displayName: 'displayName',
logo: 'logo', logo: 'logo',
@ -65,13 +75,7 @@ export const results = {
}, },
}, },
}, },
tokens: {
accessToken: { token, expiresAt: 'expiresAt' },
refreshToken: { token, expiresAt: 'expiresAt' },
},
signup: {},
}, },
signUp: { loginToken: { token, expiresAt: 'expiresAt' } },
}; };
export const mocks = [ export const mocks = [
@ -108,4 +112,13 @@ export const mocks = [
}, },
})), })),
}, },
{
request: {
query: queries.getCurrentUser,
variables: variables.getCurrentUser,
},
result: jest.fn(() => ({
data: results.getCurrentUser,
})),
},
]; ];

View File

@ -1,8 +1,7 @@
import { useApolloClient } from '@apollo/client'; import { useApolloClient } from '@apollo/client';
import { MockedProvider } from '@apollo/client/testing'; import { MockedProvider } from '@apollo/client/testing';
import { expect } from '@storybook/test'; import { expect } from '@storybook/test';
import { act, renderHook } from '@testing-library/react'; import { ReactNode, act } from 'react';
import { ReactNode } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil'; import { RecoilRoot, useRecoilValue } from 'recoil';
import { iconsState } from 'twenty-ui'; import { iconsState } from 'twenty-ui';
@ -15,6 +14,7 @@ import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthPro
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { email, mocks, password, results, token } from '../__mocks__/useAuth'; import { email, mocks, password, results, token } from '../__mocks__/useAuth';
import { renderHook } from '@testing-library/react';
const Wrapper = ({ children }: { children: ReactNode }) => ( const Wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider mocks={mocks} addTypename={false}> <MockedProvider mocks={mocks} addTypename={false}>
@ -59,6 +59,7 @@ describe('useAuth', () => {
}); });
expect(mocks[1].result).toHaveBeenCalled(); expect(mocks[1].result).toHaveBeenCalled();
expect(mocks[3].result).toHaveBeenCalled();
}); });
it('should handle credential sign-in', async () => { it('should handle credential sign-in', async () => {

View File

@ -4,6 +4,7 @@ import {
snapshot_UNSTABLE, snapshot_UNSTABLE,
useGotoRecoilSnapshot, useGotoRecoilSnapshot,
useRecoilCallback, useRecoilCallback,
useRecoilValue,
useSetRecoilState, useSetRecoilState,
} from 'recoil'; } from 'recoil';
import { iconsState } from 'twenty-ui'; import { iconsState } from 'twenty-ui';
@ -23,6 +24,7 @@ import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { import {
useChallengeMutation, useChallengeMutation,
useCheckUserExistsLazyQuery, useCheckUserExistsLazyQuery,
useGetCurrentUserLazyQuery,
useSignUpMutation, useSignUpMutation,
useVerifyMutation, useVerifyMutation,
} from '~/generated/graphql'; } from '~/generated/graphql';
@ -48,6 +50,8 @@ import { useReadWorkspaceSubdomainFromCurrentLocation } from '@/domain-manager/h
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState'; import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState'; import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { AppPath } from '@/types/AppPath';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { useRedirect } from '@/domain-manager/hooks/useRedirect'; import { useRedirect } from '@/domain-manager/hooks/useRedirect';
export const useAuth = () => { export const useAuth = () => {
@ -62,15 +66,19 @@ export const useAuth = () => {
const setCurrentWorkspaceMembers = useSetRecoilState( const setCurrentWorkspaceMembers = useSetRecoilState(
currentWorkspaceMembersState, currentWorkspaceMembersState,
); );
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState); const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const setIsVerifyPendingState = useSetRecoilState(isVerifyPendingState); const setIsVerifyPendingState = useSetRecoilState(isVerifyPendingState);
const setWorkspaces = useSetRecoilState(workspacesState); const setWorkspaces = useSetRecoilState(workspacesState);
const { redirect } = useRedirect(); const { redirect } = useRedirect();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const [challenge] = useChallengeMutation(); const [challenge] = useChallengeMutation();
const [signUp] = useSignUpMutation(); const [signUp] = useSignUpMutation();
const [verify] = useVerifyMutation(); const [verify] = useVerifyMutation();
const [getCurrentUser] = useGetCurrentUserLazyQuery();
const { isOnAWorkspaceSubdomain } = const { isOnAWorkspaceSubdomain } =
useIsCurrentLocationOnAWorkspaceSubdomain(); useIsCurrentLocationOnAWorkspaceSubdomain();
const { workspaceSubdomain } = useReadWorkspaceSubdomainFromCurrentLocation(); const { workspaceSubdomain } = useReadWorkspaceSubdomainFromCurrentLocation();
@ -165,6 +173,98 @@ export const useAuth = () => {
[challenge], [challenge],
); );
const loadCurrentUser = useCallback(async () => {
const currentUserResult = await getCurrentUser();
const user = currentUserResult.data?.currentUser;
if (!user) {
throw new Error('No current user result');
}
let workspaceMember = null;
setCurrentUser(user);
if (isDefined(user.workspaceMembers)) {
const workspaceMembers = user.workspaceMembers.map((workspaceMember) => ({
...workspaceMember,
colorScheme: workspaceMember.colorScheme as ColorScheme,
locale: workspaceMember.locale ?? 'en',
}));
setCurrentWorkspaceMembers(workspaceMembers);
}
if (isDefined(user.workspaceMember)) {
workspaceMember = {
...user.workspaceMember,
colorScheme: user.workspaceMember?.colorScheme as ColorScheme,
locale: user.workspaceMember?.locale ?? 'en',
};
setCurrentWorkspaceMember(workspaceMember);
// TODO: factorize with UserProviderEffect
setDateTimeFormat({
timeZone:
workspaceMember.timeZone && workspaceMember.timeZone !== 'system'
? workspaceMember.timeZone
: detectTimeZone(),
dateFormat: isDefined(user.workspaceMember.dateFormat)
? getDateFormatFromWorkspaceDateFormat(
user.workspaceMember.dateFormat,
)
: DateFormat[detectDateFormat()],
timeFormat: isDefined(user.workspaceMember.timeFormat)
? getTimeFormatFromWorkspaceTimeFormat(
user.workspaceMember.timeFormat,
)
: TimeFormat[detectTimeFormat()],
});
}
const workspace = user.currentWorkspace ?? null;
setCurrentWorkspace(workspace);
if (isDefined(workspace) && isOnAWorkspaceSubdomain) {
setLastAuthenticateWorkspaceDomain({
workspaceId: workspace.id,
subdomain: workspace.subdomain,
});
}
if (isDefined(user.workspaces)) {
const validWorkspaces = user.workspaces
.filter(
({ workspace }) => workspace !== null && workspace !== undefined,
)
.map((validWorkspace) => validWorkspace.workspace)
.filter(isDefined);
setWorkspaces(validWorkspaces);
}
setIsAppWaitingForFreshObjectMetadataState(true);
return {
user,
workspaceMember,
workspace,
};
}, [
getCurrentUser,
isOnAWorkspaceSubdomain,
setCurrentUser,
setCurrentWorkspace,
setCurrentWorkspaceMember,
setCurrentWorkspaceMembers,
setDateTimeFormat,
setIsAppWaitingForFreshObjectMetadataState,
setLastAuthenticateWorkspaceDomain,
setWorkspaces,
]);
const handleVerify = useCallback( const handleVerify = useCallback(
async (loginToken: string) => { async (loginToken: string) => {
const verifyResult = await verify({ const verifyResult = await verify({
@ -181,74 +281,7 @@ export const useAuth = () => {
setTokenPair(verifyResult.data?.verify.tokens); setTokenPair(verifyResult.data?.verify.tokens);
const user = verifyResult.data?.verify.user; const { user, workspaceMember, workspace } = await loadCurrentUser();
let workspaceMember = null;
setCurrentUser(user);
if (isDefined(user.workspaceMembers)) {
const workspaceMembers = user.workspaceMembers.map(
(workspaceMember) => ({
...workspaceMember,
colorScheme: workspaceMember.colorScheme as ColorScheme,
locale: workspaceMember.locale ?? 'en',
}),
);
setCurrentWorkspaceMembers(workspaceMembers);
}
if (isDefined(user.workspaceMember)) {
workspaceMember = {
...user.workspaceMember,
colorScheme: user.workspaceMember?.colorScheme as ColorScheme,
locale: user.workspaceMember?.locale ?? 'en',
};
setCurrentWorkspaceMember(workspaceMember);
// TODO: factorize with UserProviderEffect
setDateTimeFormat({
timeZone:
workspaceMember.timeZone && workspaceMember.timeZone !== 'system'
? workspaceMember.timeZone
: detectTimeZone(),
dateFormat: isDefined(user.workspaceMember.dateFormat)
? getDateFormatFromWorkspaceDateFormat(
user.workspaceMember.dateFormat,
)
: DateFormat[detectDateFormat()],
timeFormat: isDefined(user.workspaceMember.timeFormat)
? getTimeFormatFromWorkspaceTimeFormat(
user.workspaceMember.timeFormat,
)
: TimeFormat[detectTimeFormat()],
});
}
const workspace = user.defaultWorkspace ?? null;
setCurrentWorkspace(workspace);
if (isDefined(workspace) && isOnAWorkspaceSubdomain) {
setLastAuthenticateWorkspaceDomain({
workspaceId: workspace.id,
subdomain: workspace.subdomain,
});
}
if (isDefined(verifyResult.data?.verify.user.workspaces)) {
const validWorkspaces = verifyResult.data?.verify.user.workspaces
.filter(
({ workspace }) => workspace !== null && workspace !== undefined,
)
.map((validWorkspace) => validWorkspace.workspace)
.filter(isDefined);
setWorkspaces(validWorkspaces);
}
setIsAppWaitingForFreshObjectMetadataState(true);
return { return {
user, user,
@ -257,19 +290,7 @@ export const useAuth = () => {
tokens: verifyResult.data?.verify.tokens, tokens: verifyResult.data?.verify.tokens,
}; };
}, },
[ [verify, setTokenPair, loadCurrentUser],
verify,
setTokenPair,
setCurrentUser,
setCurrentWorkspace,
isOnAWorkspaceSubdomain,
setIsAppWaitingForFreshObjectMetadataState,
setCurrentWorkspaceMembers,
setCurrentWorkspaceMember,
setDateTimeFormat,
setLastAuthenticateWorkspaceDomain,
setWorkspaces,
],
); );
const handleCrendentialsSignIn = useCallback( const handleCrendentialsSignIn = useCallback(
@ -328,6 +349,16 @@ export const useAuth = () => {
throw new Error('No login token'); throw new Error('No login token');
} }
if (isMultiWorkspaceEnabled) {
return redirectToWorkspaceDomain(
signUpResult.data.signUp.workspace.subdomain,
AppPath.Verify,
{
loginToken: signUpResult.data.signUp.loginToken.token,
},
);
}
const { user, workspace, workspaceMember } = await handleVerify( const { user, workspace, workspaceMember } = await handleVerify(
signUpResult.data?.signUp.loginToken.token, signUpResult.data?.signUp.loginToken.token,
); );
@ -336,7 +367,13 @@ export const useAuth = () => {
return { user, workspaceMember, workspace }; return { user, workspaceMember, workspace };
}, },
[setIsVerifyPendingState, signUp, handleVerify], [
setIsVerifyPendingState,
signUp,
isMultiWorkspaceEnabled,
handleVerify,
redirectToWorkspaceDomain,
],
); );
const buildRedirectUrl = useCallback( const buildRedirectUrl = useCallback(
@ -357,6 +394,7 @@ export const useAuth = () => {
params.workspacePersonalInviteToken, params.workspacePersonalInviteToken,
); );
} }
if (isDefined(workspaceSubdomain)) { if (isDefined(workspaceSubdomain)) {
url.searchParams.set('workspaceSubdomain', workspaceSubdomain); url.searchParams.set('workspaceSubdomain', workspaceSubdomain);
} }
@ -390,6 +428,8 @@ export const useAuth = () => {
challenge: handleChallenge, challenge: handleChallenge,
verify: handleVerify, verify: handleVerify,
loadCurrentUser,
checkUserExists: { checkUserExistsData, checkUserExistsQuery }, checkUserExists: { checkUserExistsData, checkUserExistsQuery },
clearSession, clearSession,
signOut: handleSignOut, signOut: handleSignOut,

View File

@ -86,10 +86,7 @@ export const SignInUpGlobalScopeForm = () => {
const response = data.checkUserExists; const response = data.checkUserExists;
if (response.__typename === 'UserExists') { if (response.__typename === 'UserExists') {
if (response.availableWorkspaces.length >= 1) { if (response.availableWorkspaces.length >= 1) {
const workspace = const workspace = response.availableWorkspaces[0];
response.availableWorkspaces.find(
(workspace) => workspace.id === response.defaultWorkspaceId,
) ?? response.availableWorkspaces[0];
return redirectToWorkspaceDomain(workspace.subdomain, pathname, { return redirectToWorkspaceDomain(workspace.subdomain, pathname, {
email: form.getValues('email'), email: form.getValues('email'),
}); });

View File

@ -134,7 +134,7 @@ export const queries = {
workspaceMembers { workspaceMembers {
...WorkspaceMemberQueryFragment ...WorkspaceMemberQueryFragment
} }
defaultWorkspace { currentWorkspace {
id id
displayName displayName
logo logo
@ -281,7 +281,7 @@ export const responseData = {
timeFormat: '24', timeFormat: '24',
}, },
workspaceMembers: [], workspaceMembers: [],
defaultWorkspace: { currentWorkspace: {
id: 'test-workspace-id', id: 'test-workspace-id',
displayName: 'Test Workspace', displayName: 'Test Workspace',
logo: null, logo: null,

View File

@ -1,10 +1,6 @@
import { SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID } from '@/settings/admin-panel/constants/SettingsAdminFeatureFlagsTabs'; import { SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID } from '@/settings/admin-panel/constants/SettingsAdminFeatureFlagsTabs';
import { useFeatureFlagsManagement } from '@/settings/admin-panel/hooks/useFeatureFlagsManagement'; import { useFeatureFlagsManagement } from '@/settings/admin-panel/hooks/useFeatureFlagsManagement';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { TextInput } from '@/ui/input/components/TextInput'; import { TextInput } from '@/ui/input/components/TextInput';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab/components/TabList'; import { TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList'; import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { Table } from '@/ui/layout/table/components/Table'; import { Table } from '@/ui/layout/table/components/Table';
@ -22,11 +18,13 @@ import {
H1TitleFontColor, H1TitleFontColor,
H2Title, H2Title,
IconSearch, IconSearch,
IconUser,
isDefined, isDefined,
Section, Section,
Toggle, Toggle,
} from 'twenty-ui'; } from 'twenty-ui';
import { REACT_APP_SERVER_BASE_URL } from '~/config'; import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useImpersonate } from '@/settings/admin-panel/hooks/useImpersonate';
const StyledLinkContainer = styled.div` const StyledLinkContainer = styled.div`
margin-right: ${({ theme }) => theme.spacing(2)}; margin-right: ${({ theme }) => theme.spacing(2)};
@ -66,8 +64,16 @@ const StyledContentContainer = styled.div`
padding: ${({ theme }) => theme.spacing(4)} 0; padding: ${({ theme }) => theme.spacing(4)} 0;
`; `;
export const SettingsAdminFeatureFlags = () => { export const SettingsAdminContent = () => {
const [userIdentifier, setUserIdentifier] = useState(''); const [userIdentifier, setUserIdentifier] = useState('');
const [userId, setUserId] = useState('');
const {
handleImpersonate,
isLoading: isImpersonateLoading,
error: impersonateError,
canImpersonate,
} = useImpersonate();
const { activeTabId, setActiveTabId } = useTabList( const { activeTabId, setActiveTabId } = useTabList(
SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID, SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID,
@ -86,6 +92,10 @@ export const SettingsAdminFeatureFlags = () => {
const result = await handleUserLookup(userIdentifier); const result = await handleUserLookup(userIdentifier);
if (isDefined(result?.user?.id) && !error) {
setUserId(result.user.id.trim());
}
if ( if (
isDefined(result?.workspaces) && isDefined(result?.workspaces) &&
result.workspaces.length > 0 && result.workspaces.length > 0 &&
@ -126,6 +136,21 @@ export const SettingsAdminFeatureFlags = () => {
}`} }`}
description={'Total Users'} description={'Total Users'}
/> />
{canImpersonate && (
<Button
Icon={IconUser}
variant="primary"
accent="blue"
title={'Impersonate'}
onClick={() => handleImpersonate(userId, activeWorkspace.id)}
disabled={
isImpersonateLoading ||
activeWorkspace.allowImpersonation === false
}
dataTestId="impersonate-button"
/>
)}
<StyledTable> <StyledTable>
<TableRow <TableRow
gridAutoColumns="1fr 100px" gridAutoColumns="1fr 100px"
@ -162,82 +187,69 @@ export const SettingsAdminFeatureFlags = () => {
}; };
return ( return (
<SubMenuTopBarContainer <>
title="Feature Flags" <Section>
links={[ <H2Title
{ title="Feature Flags & Impersonation"
children: 'Other', description="Look up users and manage their workspace feature flags or impersonate it."
href: getSettingsPagePath(SettingsPath.AdminPanel), />
},
{
children: 'Server Admin Panel',
href: getSettingsPagePath(SettingsPath.AdminPanel),
},
{ children: 'Feature Flags' },
]}
>
<SettingsPageContainer>
<Section>
<H2Title
title="Feature Flags Management"
description="Look up users and manage their workspace feature flags."
/>
<StyledContainer> <StyledContainer>
<StyledLinkContainer> <StyledLinkContainer>
<TextInput <TextInput
value={userIdentifier} value={userIdentifier}
onChange={setUserIdentifier} onChange={setUserIdentifier}
onInputEnter={handleSearch} onInputEnter={handleSearch}
placeholder="Enter user ID or email address" placeholder="Enter user ID or email address"
fullWidth fullWidth
disabled={isLoading} disabled={isLoading}
/>
</StyledLinkContainer>
<Button
Icon={IconSearch}
variant="primary"
accent="blue"
title="Search"
onClick={handleSearch}
disabled={!userIdentifier.trim() || isLoading}
/> />
</StyledContainer> </StyledLinkContainer>
<Button
Icon={IconSearch}
variant="primary"
accent="blue"
title="Search"
onClick={handleSearch}
disabled={!userIdentifier.trim() || isLoading}
/>
</StyledContainer>
{error && <StyledErrorSection>{error}</StyledErrorSection>} {(error || impersonateError) && (
</Section> <StyledErrorSection>{error ?? impersonateError}</StyledErrorSection>
{shouldShowUserData && (
<Section>
<StyledUserInfo>
<H1Title title="User Info" fontColor={H1TitleFontColor.Primary} />
<H2Title
title={`${userLookupResult.user.firstName || ''} ${
userLookupResult.user.lastName || ''
}`.trim()}
description="User Name"
/>
<H2Title
title={userLookupResult.user.email}
description="User Email"
/>
<H2Title title={userLookupResult.user.id} description="User ID" />
</StyledUserInfo>
<H1Title title="Workspaces" fontColor={H1TitleFontColor.Primary} />
<StyledTabListContainer>
<TabList
tabs={tabs}
tabListInstanceId={SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID}
behaveAsLinks={false}
/>
</StyledTabListContainer>
<StyledContentContainer>
{renderWorkspaceContent()}
</StyledContentContainer>
</Section>
)} )}
</SettingsPageContainer> </Section>
</SubMenuTopBarContainer>
{shouldShowUserData && (
<Section>
<StyledUserInfo>
<H1Title title="User Info" fontColor={H1TitleFontColor.Primary} />
<H2Title
title={`${userLookupResult.user.firstName || ''} ${
userLookupResult.user.lastName || ''
}`.trim()}
description="User Name"
/>
<H2Title
title={userLookupResult.user.email}
description="User Email"
/>
<H2Title title={userLookupResult.user.id} description="User ID" />
</StyledUserInfo>
<H1Title title="Workspaces" fontColor={H1TitleFontColor.Primary} />
<StyledTabListContainer>
<TabList
tabs={tabs}
tabListInstanceId={SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID}
behaveAsLinks={false}
/>
</StyledTabListContainer>
<StyledContentContainer>
{renderWorkspaceContent()}
</StyledContentContainer>
</Section>
)}
</>
); );
}; };

View File

@ -1,67 +0,0 @@
import { useImpersonate } from '@/settings/admin-panel/hooks/useImpersonate';
import { TextInput } from '@/ui/input/components/TextInput';
import styled from '@emotion/styled';
import { useState } from 'react';
import { Button, H2Title, IconUser, Section } from 'twenty-ui';
const StyledLinkContainer = styled.div`
margin-right: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
`;
const StyledErrorSection = styled.div`
color: ${({ theme }) => theme.font.color.danger};
margin-top: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsAdminImpersonateUsers = () => {
const [userId, setUserId] = useState('');
const { handleImpersonate, isLoading, error, canImpersonate } =
useImpersonate();
if (!canImpersonate) {
return (
<Section>
<H2Title
title="Impersonate"
description="You don't have permission to impersonate other users. Please contact your administrator if you need this access."
/>
</Section>
);
}
return (
<Section>
<H2Title title="Impersonate" description="Impersonate a user." />
<StyledContainer>
<StyledLinkContainer>
<TextInput
value={userId}
onChange={setUserId}
placeholder="Enter user ID or email address"
fullWidth
disabled={isLoading}
dataTestId="impersonate-input"
onInputEnter={() => handleImpersonate(userId)}
/>
</StyledLinkContainer>
<Button
Icon={IconUser}
variant="primary"
accent="blue"
title={'Impersonate'}
onClick={() => handleImpersonate(userId)}
disabled={!userId.trim() || isLoading}
dataTestId="impersonate-button"
/>
</StyledContainer>
{error && <StyledErrorSection>{error}</StyledErrorSection>}
</Section>
);
};

View File

@ -14,6 +14,7 @@ export const USER_LOOKUP_ADMIN_PANEL = gql`
name name
logo logo
totalUsers totalUsers
allowImpersonation
users { users {
id id
email email

View File

@ -1,24 +1,21 @@
import { useAuth } from '@/auth/hooks/useAuth';
import { currentUserState } from '@/auth/states/currentUserState'; import { currentUserState } from '@/auth/states/currentUserState';
import { tokenPairState } from '@/auth/states/tokenPairState';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { useState } from 'react'; import { useState } from 'react';
import { useRecoilState, useSetRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import { useImpersonateMutation } from '~/generated/graphql'; import { useImpersonateMutation } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { useRedirect } from '@/domain-manager/hooks/useRedirect'; import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
export const useImpersonate = () => { export const useImpersonate = () => {
const { clearSession } = useAuth(); const [currentUser] = useRecoilState(currentUserState);
const { redirect } = useRedirect();
const [currentUser, setCurrentUser] = useRecoilState(currentUserState);
const setTokenPair = useSetRecoilState(tokenPairState);
const [impersonate] = useImpersonateMutation(); const [impersonate] = useImpersonateMutation();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const handleImpersonate = async (userId: string) => { const handleImpersonate = async (userId: string, workspaceId: string) => {
if (!userId.trim()) { if (!userId.trim()) {
setError('Please enter a user ID'); setError('Please enter a user ID');
return; return;
@ -29,7 +26,7 @@ export const useImpersonate = () => {
try { try {
const impersonateResult = await impersonate({ const impersonateResult = await impersonate({
variables: { userId }, variables: { userId, workspaceId },
}); });
if (isDefined(impersonateResult.errors)) { if (isDefined(impersonateResult.errors)) {
@ -40,11 +37,11 @@ export const useImpersonate = () => {
throw new Error('No impersonate result'); throw new Error('No impersonate result');
} }
const { user, tokens } = impersonateResult.data.impersonate; const { loginToken, workspace } = impersonateResult.data.impersonate;
await clearSession();
setCurrentUser(user); return redirectToWorkspaceDomain(workspace.subdomain, AppPath.Verify, {
setTokenPair(tokens); loginToken: loginToken.token,
redirect(AppPath.Index); });
} catch (error) { } catch (error) {
setError('Failed to impersonate user. Please try again.'); setError('Failed to impersonate user. Please try again.');
setIsLoading(false); setIsLoading(false);

View File

@ -12,4 +12,5 @@ export type WorkspaceInfo = {
lastName?: string | null; lastName?: string | null;
}[]; }[];
featureFlags: FeatureFlag[]; featureFlags: FeatureFlag[];
allowImpersonation: boolean;
}; };

View File

@ -52,7 +52,10 @@ export const UserProviderEffect = () => {
if (!isDefined(queryData?.currentUser)) return; if (!isDefined(queryData?.currentUser)) return;
setCurrentUser(queryData.currentUser); setCurrentUser(queryData.currentUser);
setCurrentWorkspace(queryData.currentUser.defaultWorkspace);
if (isDefined(queryData.currentUser.currentWorkspace)) {
setCurrentWorkspace(queryData.currentUser.currentWorkspace);
}
const { const {
workspaceMember, workspaceMember,

View File

@ -24,7 +24,7 @@ export const USER_QUERY_FRAGMENT = gql`
workspaceMembers { workspaceMembers {
...WorkspaceMemberQueryFragment ...WorkspaceMemberQueryFragment
} }
defaultWorkspace { currentWorkspace {
id id
displayName displayName
logo logo

View File

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

View File

@ -2,16 +2,12 @@ import styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { Controller, SubmitHandler, useForm } from 'react-hook-form'; import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum'; import { Key } from 'ts-key-enum';
import { H2Title, Loader, MainButton } from 'twenty-ui'; import { H2Title, Loader, MainButton } from 'twenty-ui';
import { z } from 'zod'; import { z } from 'zod';
import { SubTitle } from '@/auth/components/SubTitle'; import { SubTitle } from '@/auth/components/SubTitle';
import { Title } from '@/auth/components/Title'; import { Title } from '@/auth/components/Title';
import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadingState';
import { FIND_MANY_OBJECT_METADATA_ITEMS } from '@/object-metadata/graphql/queries';
import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient';
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus'; import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
import { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader'; import { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
@ -22,9 +18,7 @@ import {
useActivateWorkspaceMutation, useActivateWorkspaceMutation,
} from '~/generated/graphql'; } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { useAuth } from '@/auth/hooks/useAuth';
import { AppPath } from '@/types/AppPath';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
const StyledContentContainer = styled.div` const StyledContentContainer = styled.div`
width: 100%; width: 100%;
@ -50,12 +44,9 @@ type Form = z.infer<typeof validationSchema>;
export const CreateWorkspace = () => { export const CreateWorkspace = () => {
const { enqueueSnackBar } = useSnackBar(); const { enqueueSnackBar } = useSnackBar();
const onboardingStatus = useOnboardingStatus(); const onboardingStatus = useOnboardingStatus();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const { loadCurrentUser } = useAuth();
const [activateWorkspace] = useActivateWorkspaceMutation(); const [activateWorkspace] = useActivateWorkspaceMutation();
const apolloMetadataClient = useApolloMetadataClient();
const setIsCurrentUserLoaded = useSetRecoilState(isCurrentUserLoadedState);
// Form // Form
const { const {
@ -81,39 +72,17 @@ export const CreateWorkspace = () => {
}, },
}); });
setIsCurrentUserLoaded(false);
if (isDefined(result.data) && isMultiWorkspaceEnabled) {
return redirectToWorkspaceDomain(
result.data.activateWorkspace.workspace.subdomain,
AppPath.Verify,
{
loginToken: result.data.activateWorkspace.loginToken.token,
},
);
}
await apolloMetadataClient?.refetchQueries({
include: [FIND_MANY_OBJECT_METADATA_ITEMS],
});
if (isDefined(result.errors)) { if (isDefined(result.errors)) {
throw result.errors ?? new Error('Unknown error'); throw result.errors ?? new Error('Unknown error');
} }
await loadCurrentUser();
} catch (error: any) { } catch (error: any) {
enqueueSnackBar(error?.message, { enqueueSnackBar(error?.message, {
variant: SnackBarVariant.Error, variant: SnackBarVariant.Error,
}); });
} }
}, },
[ [activateWorkspace, enqueueSnackBar, loadCurrentUser],
activateWorkspace,
setIsCurrentUserLoaded,
isMultiWorkspaceEnabled,
apolloMetadataClient,
redirectToWorkspaceDomain,
enqueueSnackBar,
],
); );
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {

View File

@ -1,14 +1,10 @@
import { SettingsAdminImpersonateUsers } from '@/settings/admin-panel/components/SettingsAdminImpersonateUsers';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useTheme } from '@emotion/react'; import { SettingsAdminContent } from '@/settings/admin-panel/components/SettingsAdminContent';
import { IconFlag, UndecoratedLink } from 'twenty-ui';
export const SettingsAdmin = () => { export const SettingsAdmin = () => {
const theme = useTheme();
return ( return (
<SubMenuTopBarContainer <SubMenuTopBarContainer
title="Server Admin Panel" title="Server Admin Panel"
@ -21,18 +17,7 @@ export const SettingsAdmin = () => {
]} ]}
> >
<SettingsPageContainer> <SettingsPageContainer>
<SettingsAdminImpersonateUsers /> <SettingsAdminContent />
<UndecoratedLink to={getSettingsPagePath(SettingsPath.FeatureFlags)}>
<SettingsCard
Icon={
<IconFlag
size={theme.icon.size.lg}
stroke={theme.icon.stroke.sm}
/>
}
title="Feature Flags"
/>
</UndecoratedLink>
</SettingsPageContainer> </SettingsPageContainer>
</SubMenuTopBarContainer> </SubMenuTopBarContainer>
); );

View File

@ -25,7 +25,7 @@ type MockedUser = Pick<
> & { > & {
workspaceMember: WorkspaceMember | null; workspaceMember: WorkspaceMember | null;
locale: string; locale: string;
defaultWorkspace: Workspace; currentWorkspace: Workspace;
workspaces: Array<{ workspace: Workspace }>; workspaces: Array<{ workspace: Workspace }>;
workspaceMembers: WorkspaceMember[]; workspaceMembers: WorkspaceMember[];
}; };
@ -112,7 +112,7 @@ export const mockedUserData: MockedUser = {
supportUserHash: supportUserHash:
'a95afad9ff6f0b364e2a3fd3e246a1a852c22b6e55a3ca33745a86c201f9c10d', 'a95afad9ff6f0b364e2a3fd3e246a1a852c22b6e55a3ca33745a86c201f9c10d',
workspaceMember: mockedWorkspaceMemberData, workspaceMember: mockedWorkspaceMemberData,
defaultWorkspace: mockDefaultWorkspace, currentWorkspace: mockDefaultWorkspace,
locale: 'en', locale: 'en',
workspaces: [{ workspace: mockDefaultWorkspace }], workspaces: [{ workspace: mockDefaultWorkspace }],
workspaceMembers: [mockedWorkspaceMemberData], workspaceMembers: [mockedWorkspaceMemberData],
@ -134,7 +134,7 @@ export const mockedOnboardingUserData = (
supportUserHash: supportUserHash:
'4fb61d34ed3a4aeda2476d4b308b5162db9e1809b2b8277e6fdc6efc4a609254', '4fb61d34ed3a4aeda2476d4b308b5162db9e1809b2b8277e6fdc6efc4a609254',
workspaceMember: null, workspaceMember: null,
defaultWorkspace: mockDefaultWorkspace, currentWorkspace: mockDefaultWorkspace,
locale: 'en', locale: 'en',
workspaces: [{ workspace: mockDefaultWorkspace }], workspaces: [{ workspace: mockDefaultWorkspace }],
onboardingStatus: onboardingStatus || null, onboardingStatus: onboardingStatus || null,

View File

@ -24,7 +24,6 @@ export const seedUsers = async (
'lastName', 'lastName',
'email', 'email',
'passwordHash', 'passwordHash',
'defaultWorkspaceId',
]) ])
.orIgnore() .orIgnore()
.values([ .values([
@ -35,7 +34,6 @@ export const seedUsers = async (
email: 'noah@demo.dev', email: 'noah@demo.dev',
passwordHash: passwordHash:
'$2b$10$66d.6DuQExxnrfI9rMqOg.U1XIYpagr6Lv05uoWLYbYmtK0HDIvS6', // Applecar2025 '$2b$10$66d.6DuQExxnrfI9rMqOg.U1XIYpagr6Lv05uoWLYbYmtK0HDIvS6', // Applecar2025
defaultWorkspaceId: workspaceId,
}, },
{ {
id: DEMO_SEED_USER_IDS.HUGO, id: DEMO_SEED_USER_IDS.HUGO,
@ -44,7 +42,6 @@ export const seedUsers = async (
email: 'hugo@demo.dev', email: 'hugo@demo.dev',
passwordHash: passwordHash:
'$2b$10$66d.6DuQExxnrfI9rMqOg.U1XIYpagr6Lv05uoWLYbYmtK0HDIvS6', // Applecar2025 '$2b$10$66d.6DuQExxnrfI9rMqOg.U1XIYpagr6Lv05uoWLYbYmtK0HDIvS6', // Applecar2025
defaultWorkspaceId: workspaceId,
}, },
{ {
id: DEMO_SEED_USER_IDS.TIM, id: DEMO_SEED_USER_IDS.TIM,
@ -53,7 +50,6 @@ export const seedUsers = async (
email: 'tim@apple.dev', email: 'tim@apple.dev',
passwordHash: passwordHash:
'$2b$10$66d.6DuQExxnrfI9rMqOg.U1XIYpagr6Lv05uoWLYbYmtK0HDIvS6', // Applecar2025 '$2b$10$66d.6DuQExxnrfI9rMqOg.U1XIYpagr6Lv05uoWLYbYmtK0HDIvS6', // Applecar2025
defaultWorkspaceId: workspaceId,
}, },
]) ])
.execute(); .execute();

View File

@ -24,7 +24,6 @@ export const seedUsers = async (
'lastName', 'lastName',
'email', 'email',
'passwordHash', 'passwordHash',
'defaultWorkspaceId',
]) ])
.orIgnore() .orIgnore()
.values([ .values([
@ -35,7 +34,6 @@ export const seedUsers = async (
email: 'tim@apple.dev', email: 'tim@apple.dev',
passwordHash: passwordHash:
'$2b$10$66d.6DuQExxnrfI9rMqOg.U1XIYpagr6Lv05uoWLYbYmtK0HDIvS6', // Applecar2025 '$2b$10$66d.6DuQExxnrfI9rMqOg.U1XIYpagr6Lv05uoWLYbYmtK0HDIvS6', // Applecar2025
defaultWorkspaceId: workspaceId,
}, },
{ {
id: DEV_SEED_USER_IDS.JONY, id: DEV_SEED_USER_IDS.JONY,
@ -44,7 +42,6 @@ export const seedUsers = async (
email: 'jony.ive@apple.dev', email: 'jony.ive@apple.dev',
passwordHash: passwordHash:
'$2b$10$66d.6DuQExxnrfI9rMqOg.U1XIYpagr6Lv05uoWLYbYmtK0HDIvS6', // Applecar2025 '$2b$10$66d.6DuQExxnrfI9rMqOg.U1XIYpagr6Lv05uoWLYbYmtK0HDIvS6', // Applecar2025
defaultWorkspaceId: workspaceId,
}, },
{ {
id: DEV_SEED_USER_IDS.PHIL, id: DEV_SEED_USER_IDS.PHIL,
@ -53,7 +50,6 @@ export const seedUsers = async (
email: 'phil.schiler@apple.dev', email: 'phil.schiler@apple.dev',
passwordHash: passwordHash:
'$2b$10$66d.6DuQExxnrfI9rMqOg.U1XIYpagr6Lv05uoWLYbYmtK0HDIvS6', // Applecar2025 '$2b$10$66d.6DuQExxnrfI9rMqOg.U1XIYpagr6Lv05uoWLYbYmtK0HDIvS6', // Applecar2025
defaultWorkspaceId: workspaceId,
}, },
]) ])
.execute(); .execute();

View File

@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveDefaultWorkspaceId1734544295083
implements MigrationInterface
{
name = 'RemoveDefaultWorkspaceId1734544295083';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."user" DROP CONSTRAINT "FK_2ec910029395fa7655621c88908"`,
);
await queryRunner.query(
`ALTER TABLE "core"."user" DROP COLUMN "defaultWorkspaceId"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."user" ADD "defaultWorkspaceId" uuid NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."user" ADD CONSTRAINT "FK_2ec910029395fa7655621c88908" FOREIGN KEY ("defaultWorkspaceId") REFERENCES "core"."workspace"("id") ON DELETE RESTRICT ON UPDATE NO ACTION`,
);
}
}

View File

@ -6,49 +6,41 @@ import { ImpersonateInput } from 'src/engine/core-modules/admin-panel/dtos/imper
import { UpdateWorkspaceFeatureFlagInput } from 'src/engine/core-modules/admin-panel/dtos/update-workspace-feature-flag.input'; import { UpdateWorkspaceFeatureFlagInput } from 'src/engine/core-modules/admin-panel/dtos/update-workspace-feature-flag.input';
import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.entity'; import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.entity';
import { UserLookupInput } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.input'; import { UserLookupInput } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.input';
import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { User } from 'src/engine/core-modules/user/user.entity';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { ImpersonateGuard } from 'src/engine/guards/impersonate-guard';
import { ImpersonateOutput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.output';
@Resolver() @Resolver()
@UseFilters(AuthGraphqlApiExceptionFilter) @UseFilters(AuthGraphqlApiExceptionFilter)
export class AdminPanelResolver { export class AdminPanelResolver {
constructor(private adminService: AdminPanelService) {} constructor(private adminService: AdminPanelService) {}
@UseGuards(WorkspaceAuthGuard, UserAuthGuard) @UseGuards(WorkspaceAuthGuard, UserAuthGuard, ImpersonateGuard)
@Mutation(() => Verify) @Mutation(() => ImpersonateOutput)
async impersonate( async impersonate(
@Args() impersonateInput: ImpersonateInput, @Args() { workspaceId, userId }: ImpersonateInput,
@AuthUser() user: User, ): Promise<ImpersonateOutput> {
): Promise<Verify> { return await this.adminService.impersonate(userId, workspaceId);
return await this.adminService.impersonate(impersonateInput.userId, user);
} }
@UseGuards(WorkspaceAuthGuard, UserAuthGuard) @UseGuards(WorkspaceAuthGuard, UserAuthGuard, ImpersonateGuard)
@Mutation(() => UserLookup) @Mutation(() => UserLookup)
async userLookupAdminPanel( async userLookupAdminPanel(
@Args() userLookupInput: UserLookupInput, @Args() userLookupInput: UserLookupInput,
@AuthUser() user: User,
): Promise<UserLookup> { ): Promise<UserLookup> {
return await this.adminService.userLookup( return await this.adminService.userLookup(userLookupInput.userIdentifier);
userLookupInput.userIdentifier,
user,
);
} }
@UseGuards(WorkspaceAuthGuard, UserAuthGuard) @UseGuards(WorkspaceAuthGuard, UserAuthGuard, ImpersonateGuard)
@Mutation(() => Boolean) @Mutation(() => Boolean)
async updateWorkspaceFeatureFlag( async updateWorkspaceFeatureFlag(
@Args() updateFlagInput: UpdateWorkspaceFeatureFlagInput, @Args() updateFlagInput: UpdateWorkspaceFeatureFlagInput,
@AuthUser() user: User,
): Promise<boolean> { ): Promise<boolean> {
await this.adminService.updateWorkspaceFeatureFlags( await this.adminService.updateWorkspaceFeatureFlags(
updateFlagInput.workspaceId, updateFlagInput.workspaceId,
updateFlagInput.featureFlag, updateFlagInput.featureFlag,
user,
updateFlagInput.value, updateFlagInput.value,
); );

View File

@ -8,18 +8,18 @@ import {
AuthException, AuthException,
AuthExceptionCode, AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception'; } from 'src/engine/core-modules/auth/auth.exception';
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 { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
@Injectable() @Injectable()
export class AdminPanelService { export class AdminPanelService {
constructor( constructor(
private readonly accessTokenService: AccessTokenService, private readonly loginTokenService: LoginTokenService,
private readonly refreshTokenService: RefreshTokenService,
@InjectRepository(User, 'core') @InjectRepository(User, 'core')
private readonly userRepository: Repository<User>, private readonly userRepository: Repository<User>,
@InjectRepository(Workspace, 'core') @InjectRepository(Workspace, 'core')
@ -28,64 +28,48 @@ export class AdminPanelService {
private readonly featureFlagRepository: Repository<FeatureFlagEntity>, private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
) {} ) {}
async impersonate(userIdentifier: string, userImpersonating: User) { async impersonate(userId: string, workspaceId: string) {
if (!userImpersonating.canImpersonate) {
throw new AuthException(
'User cannot impersonate',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const isEmail = userIdentifier.includes('@');
const user = await this.userRepository.findOne({ const user = await this.userRepository.findOne({
where: isEmail ? { email: userIdentifier } : { id: userIdentifier }, where: {
relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'], id: userId,
workspaces: {
workspaceId,
workspace: {
allowImpersonation: true,
},
},
},
relations: ['workspaces', 'workspaces.workspace'],
}); });
if (!user) { userValidator.assertIsDefinedOrThrow(
throw new AuthException( user,
'User not found', new AuthException('User not found', AuthExceptionCode.INVALID_INPUT),
AuthExceptionCode.INVALID_INPUT, );
);
}
if (!user.defaultWorkspace.allowImpersonation) { workspaceValidator.assertIsDefinedOrThrow(
throw new AuthException( user.workspaces[0].workspace,
new AuthException(
'Impersonation not allowed', 'Impersonation not allowed',
AuthExceptionCode.FORBIDDEN_EXCEPTION, AuthExceptionCode.FORBIDDEN_EXCEPTION,
); ),
}
const accessToken = await this.accessTokenService.generateAccessToken(
user.id,
user.defaultWorkspaceId,
); );
const refreshToken = await this.refreshTokenService.generateRefreshToken(
user.id, const loginToken = await this.loginTokenService.generateLoginToken(
user.defaultWorkspaceId, user.email,
user.workspaces[0].workspace.id,
); );
return { return {
user, workspace: {
tokens: { id: user.workspaces[0].workspace.id,
accessToken, subdomain: user.workspaces[0].workspace.subdomain,
refreshToken,
}, },
loginToken,
}; };
} }
async userLookup( async userLookup(userIdentifier: string): Promise<UserLookup> {
userIdentifier: string,
userImpersonating: User,
): Promise<UserLookup> {
if (!userImpersonating.canImpersonate) {
throw new AuthException(
'User cannot access user info',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const isEmail = userIdentifier.includes('@'); const isEmail = userIdentifier.includes('@');
const targetUser = await this.userRepository.findOne({ const targetUser = await this.userRepository.findOne({
@ -99,12 +83,10 @@ export class AdminPanelService {
], ],
}); });
if (!targetUser) { userValidator.assertIsDefinedOrThrow(
throw new AuthException( targetUser,
'User not found', new AuthException('User not found', AuthExceptionCode.INVALID_INPUT),
AuthExceptionCode.INVALID_INPUT, );
);
}
const allFeatureFlagKeys = Object.values(FeatureFlagKey); const allFeatureFlagKeys = Object.values(FeatureFlagKey);
@ -120,6 +102,7 @@ export class AdminPanelService {
name: userWorkspace.workspace.displayName ?? '', name: userWorkspace.workspace.displayName ?? '',
totalUsers: userWorkspace.workspace.workspaceUsers.length, totalUsers: userWorkspace.workspace.workspaceUsers.length,
logo: userWorkspace.workspace.logo, logo: userWorkspace.workspace.logo,
allowImpersonation: userWorkspace.workspace.allowImpersonation,
users: userWorkspace.workspace.workspaceUsers.map((workspaceUser) => ({ users: userWorkspace.workspace.workspaceUsers.map((workspaceUser) => ({
id: workspaceUser.user.id, id: workspaceUser.user.id,
email: workspaceUser.user.email, email: workspaceUser.user.email,
@ -140,27 +123,17 @@ export class AdminPanelService {
async updateWorkspaceFeatureFlags( async updateWorkspaceFeatureFlags(
workspaceId: string, workspaceId: string,
featureFlag: FeatureFlagKey, featureFlag: FeatureFlagKey,
userImpersonating: User,
value: boolean, value: boolean,
) { ) {
if (!userImpersonating.canImpersonate) {
throw new AuthException(
'User cannot update feature flags',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const workspace = await this.workspaceRepository.findOne({ const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId }, where: { id: workspaceId },
relations: ['featureFlags'], relations: ['featureFlags'],
}); });
if (!workspace) { workspaceValidator.assertIsDefinedOrThrow(
throw new AuthException( workspace,
'Workspace not found', new AuthException('Workspace not found', AuthExceptionCode.INVALID_INPUT),
AuthExceptionCode.INVALID_INPUT, );
);
}
const existingFlag = workspace.featureFlags?.find( const existingFlag = workspace.featureFlags?.find(
(flag) => flag.key === featureFlag, (flag) => flag.key === featureFlag,

View File

@ -8,4 +8,9 @@ export class ImpersonateInput {
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
userId: string; userId: string;
@Field(() => String)
@IsNotEmpty()
@IsString()
workspaceId: string;
} }

View File

@ -0,0 +1,13 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
import { WorkspaceSubdomainAndId } from 'src/engine/core-modules/workspace/dtos/workspace-subdomain-id.dto';
@ObjectType()
export class ImpersonateOutput {
@Field(() => AuthToken)
loginToken: AuthToken;
@Field(() => WorkspaceSubdomainAndId)
workspace: WorkspaceSubdomainAndId;
}

View File

@ -25,6 +25,9 @@ class WorkspaceInfo {
@Field(() => String) @Field(() => String)
name: string; name: string;
@Field(() => Boolean)
allowImpersonation: boolean;
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
logo?: string; logo?: string;

View File

@ -14,7 +14,7 @@ import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-
import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service'; import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service';
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service'; import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
import { MicrosoftAPIsService } from 'src/engine/core-modules/auth/services/microsoft-apis.service'; import { MicrosoftAPIsService } from 'src/engine/core-modules/auth/services/microsoft-apis.service';
import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service'; // import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service';
import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service'; 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'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service'; import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service';
@ -103,7 +103,8 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
SwitchWorkspaceService, SwitchWorkspaceService,
TransientTokenService, TransientTokenService,
ApiKeyService, ApiKeyService,
OAuthService, // reenable when working on: https://github.com/twentyhq/twenty/issues/9143
// OAuthService,
], ],
exports: [AccessTokenService, LoginTokenService, RefreshTokenService], exports: [AccessTokenService, LoginTokenService, RefreshTokenService],
}) })

View File

@ -13,7 +13,7 @@ import { AuthResolver } from './auth.resolver';
import { ApiKeyService } from './services/api-key.service'; import { ApiKeyService } from './services/api-key.service';
import { AuthService } from './services/auth.service'; import { AuthService } from './services/auth.service';
import { OAuthService } from './services/oauth.service'; // import { OAuthService } from './services/oauth.service';
import { ResetPasswordService } from './services/reset-password.service'; import { ResetPasswordService } from './services/reset-password.service';
import { SwitchWorkspaceService } from './services/switch-workspace.service'; import { SwitchWorkspaceService } from './services/switch-workspace.service';
import { LoginTokenService } from './token/services/login-token.service'; import { LoginTokenService } from './token/services/login-token.service';
@ -80,10 +80,10 @@ describe('AuthResolver', () => {
provide: TransientTokenService, provide: TransientTokenService,
useValue: {}, useValue: {},
}, },
{ // {
provide: OAuthService, // provide: OAuthService,
useValue: {}, // useValue: {},
}, // },
], ],
}) })
.overrideGuard(CaptchaGuard) .overrideGuard(CaptchaGuard)

View File

@ -7,8 +7,6 @@ import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.ent
import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input'; import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-password-reset-link.entity'; import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-password-reset-link.entity';
import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.input'; import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.input';
import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity';
import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input';
import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity'; import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity';
import { TransientToken } from 'src/engine/core-modules/auth/dto/transient-token.entity'; import { TransientToken } from 'src/engine/core-modules/auth/dto/transient-token.entity';
import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/dto/update-password-via-reset-token.input'; import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/dto/update-password-via-reset-token.input';
@ -16,7 +14,7 @@ import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/val
import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.input'; import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.input';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service'; import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service';
import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service'; // import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service';
import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service'; import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service';
import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service'; import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
@ -31,7 +29,7 @@ import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorat
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { SwitchWorkspaceInput } from 'src/engine/core-modules/auth/dto/switch-workspace.input'; import { SwitchWorkspaceInput } from 'src/engine/core-modules/auth/dto/switch-workspace.input';
import { PublicWorkspaceDataOutput } from 'src/engine/core-modules/workspace/dtos/public-workspace-data.output'; import { PublicWorkspaceDataOutput } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
import { import {
AuthException, AuthException,
AuthExceptionCode, AuthExceptionCode,
@ -39,6 +37,8 @@ import {
import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator'; import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output'; import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { SignUpOutput } from 'src/engine/core-modules/auth/dto/sign-up.output';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { ChallengeInput } from './dto/challenge.input'; import { ChallengeInput } from './dto/challenge.input';
import { LoginToken } from './dto/login-token.entity'; import { LoginToken } from './dto/login-token.entity';
@ -46,7 +46,6 @@ import { SignUpInput } from './dto/sign-up.input';
import { ApiKeyToken, AuthTokens } from './dto/token.entity'; import { ApiKeyToken, AuthTokens } from './dto/token.entity';
import { UserExistsOutput } from './dto/user-exists.entity'; import { UserExistsOutput } from './dto/user-exists.entity';
import { CheckUserExistsInput } from './dto/user-exists.input'; import { CheckUserExistsInput } from './dto/user-exists.input';
import { Verify } from './dto/verify.entity';
import { VerifyInput } from './dto/verify.input'; import { VerifyInput } from './dto/verify.input';
import { WorkspaceInviteHashValid } from './dto/workspace-invite-hash-valid.entity'; import { WorkspaceInviteHashValid } from './dto/workspace-invite-hash-valid.entity';
import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input'; import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input';
@ -64,7 +63,7 @@ export class AuthResolver {
private loginTokenService: LoginTokenService, private loginTokenService: LoginTokenService,
private switchWorkspaceService: SwitchWorkspaceService, private switchWorkspaceService: SwitchWorkspaceService,
private transientTokenService: TransientTokenService, private transientTokenService: TransientTokenService,
private oauthService: OAuthService, // private oauthService: OAuthService,
private domainManagerService: DomainManagerService, private domainManagerService: DomainManagerService,
) {} ) {}
@ -101,7 +100,9 @@ export class AuthResolver {
@OriginHeader() origin: string, @OriginHeader() origin: string,
): Promise<LoginToken> { ): Promise<LoginToken> {
const workspace = const workspace =
await this.domainManagerService.getWorkspaceByOrigin(origin); await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
origin,
);
if (!workspace) { if (!workspace) {
throw new AuthException( throw new AuthException(
@ -112,18 +113,19 @@ export class AuthResolver {
const user = await this.authService.challenge(challengeInput, workspace); const user = await this.authService.challenge(challengeInput, workspace);
const loginToken = await this.loginTokenService.generateLoginToken( const loginToken = await this.loginTokenService.generateLoginToken(
user.email, user.email,
workspace.id,
); );
return { loginToken }; return { loginToken };
} }
@UseGuards(CaptchaGuard) @UseGuards(CaptchaGuard)
@Mutation(() => LoginToken) @Mutation(() => SignUpOutput)
async signUp( async signUp(
@Args() signUpInput: SignUpInput, @Args() signUpInput: SignUpInput,
@OriginHeader() origin: string, @OriginHeader() origin: string,
): Promise<LoginToken> { ): Promise<SignUpOutput> {
const user = await this.authService.signInUp({ const { user, workspace } = await this.authService.signInUp({
...signUpInput, ...signUpInput,
targetWorkspaceSubdomain: targetWorkspaceSubdomain:
this.domainManagerService.getWorkspaceSubdomainByOrigin(origin), this.domainManagerService.getWorkspaceSubdomainByOrigin(origin),
@ -133,19 +135,26 @@ export class AuthResolver {
const loginToken = await this.loginTokenService.generateLoginToken( const loginToken = await this.loginTokenService.generateLoginToken(
user.email, user.email,
workspace.id,
); );
return { loginToken }; return {
loginToken,
workspace: {
id: workspace.id,
subdomain: workspace.subdomain,
},
};
} }
@Mutation(() => ExchangeAuthCode) // @Mutation(() => ExchangeAuthCode)
async exchangeAuthorizationCode( // async exchangeAuthorizationCode(
@Args() exchangeAuthCodeInput: ExchangeAuthCodeInput, // @Args() exchangeAuthCodeInput: ExchangeAuthCodeInput,
) { // ) {
return await this.oauthService.verifyAuthorizationCode( // return await this.oauthService.verifyAuthorizationCode(
exchangeAuthCodeInput, // exchangeAuthCodeInput,
); // );
} // }
@Mutation(() => TransientToken) @Mutation(() => TransientToken)
@UseGuards(WorkspaceAuthGuard, UserAuthGuard) @UseGuards(WorkspaceAuthGuard, UserAuthGuard)
@ -165,25 +174,35 @@ export class AuthResolver {
await this.transientTokenService.generateTransientToken( await this.transientTokenService.generateTransientToken(
workspaceMember.id, workspaceMember.id,
user.id, user.id,
user.defaultWorkspaceId, workspace.id,
); );
return { transientToken }; return { transientToken };
} }
@Mutation(() => Verify) @Mutation(() => AuthTokens)
async verify( async verify(
@Args() verifyInput: VerifyInput, @Args() verifyInput: VerifyInput,
@OriginHeader() origin: string, @OriginHeader() origin: string,
): Promise<Verify> { ): Promise<AuthTokens> {
const workspace = const workspace =
await this.domainManagerService.getWorkspaceByOrigin(origin); await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
origin,
);
const { sub: email } = await this.loginTokenService.verifyLoginToken( workspaceValidator.assertIsDefinedOrThrow(workspace);
verifyInput.loginToken,
);
return await this.authService.verify(email, workspace?.id); const { sub: email, workspaceId } =
await this.loginTokenService.verifyLoginToken(verifyInput.loginToken);
if (workspaceId !== workspace.id) {
throw new AuthException(
'Token is not valid for this workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
return await this.authService.verify(email, workspace.id);
} }
@Mutation(() => AuthorizeApp) @Mutation(() => AuthorizeApp)
@ -191,10 +210,12 @@ export class AuthResolver {
async authorizeApp( async authorizeApp(
@Args() authorizeAppInput: AuthorizeAppInput, @Args() authorizeAppInput: AuthorizeAppInput,
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<AuthorizeApp> { ): Promise<AuthorizeApp> {
return await this.authService.generateAuthorizationCode( return await this.authService.generateAuthorizationCode(
authorizeAppInput, authorizeAppInput,
user, user,
workspace,
); );
} }

View File

@ -71,8 +71,9 @@ export class GoogleAuthController {
if ( if (
this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') && this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') &&
targetWorkspaceSubdomain === (targetWorkspaceSubdomain ===
this.environmentService.get('DEFAULT_SUBDOMAIN') this.environmentService.get('DEFAULT_SUBDOMAIN') ||
!targetWorkspaceSubdomain)
) { ) {
const workspaceWithGoogleAuthActive = const workspaceWithGoogleAuthActive =
await this.workspaceRepository.findOne({ await this.workspaceRepository.findOne({
@ -84,7 +85,7 @@ export class GoogleAuthController {
}, },
}, },
}, },
relations: ['userWorkspaces', 'userWorkspaces.user'], relations: ['workspaceUsers', 'workspaceUsers.user'],
}); });
if (workspaceWithGoogleAuthActive) { if (workspaceWithGoogleAuthActive) {
@ -93,16 +94,18 @@ export class GoogleAuthController {
} }
} }
const user = await this.authService.signInUp(signInUpParams); const { user, workspace } =
await this.authService.signInUp(signInUpParams);
const loginToken = await this.loginTokenService.generateLoginToken( const loginToken = await this.loginTokenService.generateLoginToken(
user.email, user.email,
workspace.id,
); );
return res.redirect( return res.redirect(
await this.authService.computeRedirectURI( this.authService.computeRedirectURI(
loginToken.token, loginToken.token,
user.defaultWorkspace.subdomain, workspace.subdomain,
), ),
); );
} catch (err) { } catch (err) {

View File

@ -6,8 +6,10 @@ import {
UseFilters, UseFilters,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Response } from 'express'; import { Response } from 'express';
import { Repository } from 'typeorm';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter'; 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 { MicrosoftOAuthGuard } from 'src/engine/core-modules/auth/guards/microsoft-oauth.guard';
@ -18,6 +20,7 @@ import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/l
import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Controller('auth/microsoft') @Controller('auth/microsoft')
@UseFilters(AuthRestApiExceptionFilter) @UseFilters(AuthRestApiExceptionFilter)
@ -27,6 +30,8 @@ export class MicrosoftAuthController {
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly domainManagerService: DomainManagerService, private readonly domainManagerService: DomainManagerService,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {} ) {}
@Get() @Get()
@ -43,43 +48,57 @@ export class MicrosoftAuthController {
@Res() res: Response, @Res() res: Response,
) { ) {
try { try {
const { const signInUpParams = req.user;
firstName,
lastName,
email,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
targetWorkspaceSubdomain,
} = req.user;
const user = await this.authService.signInUp({ if (
email, this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') &&
firstName, (signInUpParams.targetWorkspaceSubdomain ===
lastName, this.environmentService.get('DEFAULT_SUBDOMAIN') ||
picture, !signInUpParams.targetWorkspaceSubdomain)
workspaceInviteHash, ) {
workspacePersonalInviteToken, const workspaceWithGoogleAuthActive =
targetWorkspaceSubdomain, await this.workspaceRepository.findOne({
where: {
isMicrosoftAuthEnabled: true,
workspaceUsers: {
user: {
email: signInUpParams.email,
},
},
},
relations: ['userWorkspaces', 'userWorkspaces.user'],
});
if (workspaceWithGoogleAuthActive) {
signInUpParams.targetWorkspaceSubdomain =
workspaceWithGoogleAuthActive.subdomain;
}
}
const { user, workspace } = await this.authService.signInUp({
...signInUpParams,
fromSSO: true, fromSSO: true,
authProvider: 'microsoft', authProvider: 'microsoft',
}); });
const loginToken = await this.loginTokenService.generateLoginToken( const loginToken = await this.loginTokenService.generateLoginToken(
user.email, user.email,
workspace.id,
); );
return res.redirect( return res.redirect(
await this.authService.computeRedirectURI( this.authService.computeRedirectURI(
loginToken.token, loginToken.token,
user.defaultWorkspace.subdomain, workspace.subdomain,
), ),
); );
} catch (err) { } catch (err) {
if (err instanceof AuthException) { if (err instanceof AuthException) {
return res.redirect( return res.redirect(
this.domainManagerService.computeRedirectErrorUrl({ this.domainManagerService.computeRedirectErrorUrl({
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), subdomain:
req.user.targetWorkspaceSubdomain ??
this.environmentService.get('DEFAULT_SUBDOMAIN'),
errorMessage: err.message, errorMessage: err.message,
}), }),
); );

View File

@ -86,7 +86,7 @@ export class SSOAuthController {
); );
return res.redirect( return res.redirect(
await this.authService.computeRedirectURI( this.authService.computeRedirectURI(
loginToken.token, loginToken.token,
identityProvider.workspace.subdomain, identityProvider.workspace.subdomain,
), ),
@ -113,7 +113,7 @@ export class SSOAuthController {
); );
return res.redirect( return res.redirect(
await this.authService.computeRedirectURI( this.authService.computeRedirectURI(
loginToken.token, loginToken.token,
identityProvider.workspace.subdomain, identityProvider.workspace.subdomain,
), ),
@ -183,7 +183,10 @@ export class SSOAuthController {
return { return {
identityProvider, identityProvider,
loginToken: await this.loginTokenService.generateLoginToken(user.email), loginToken: await this.loginTokenService.generateLoginToken(
user.email,
identityProvider.workspace.id,
),
}; };
} }
} }

View File

@ -0,0 +1,14 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { WorkspaceSubdomainAndId } from 'src/engine/core-modules/workspace/dtos/workspace-subdomain-id.dto';
import { AuthToken } from './token.entity';
@ObjectType()
export class SignUpOutput {
@Field(() => AuthToken)
loginToken: AuthToken;
@Field(() => WorkspaceSubdomainAndId)
workspace: WorkspaceSubdomainAndId;
}

View File

@ -7,9 +7,6 @@ export class UserExists {
@Field(() => Boolean) @Field(() => Boolean)
exists: true; exists: true;
@Field(() => String)
defaultWorkspaceId: string;
@Field(() => [AvailableWorkspaceOutput]) @Field(() => [AvailableWorkspaceOutput])
availableWorkspaces: Array<AvailableWorkspaceOutput>; availableWorkspaces: Array<AvailableWorkspaceOutput>;
} }

View File

@ -1,11 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { User } from 'src/engine/core-modules/user/user.entity';
import { AuthTokens } from './token.entity';
@ObjectType()
export class Verify extends AuthTokens {
@Field(() => User)
user: DeepPartial<User>;
}

View File

@ -33,7 +33,6 @@ import {
UserExists, UserExists,
UserNotExists, UserNotExists,
} from 'src/engine/core-modules/auth/dto/user-exists.entity'; } from 'src/engine/core-modules/auth/dto/user-exists.entity';
import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity';
import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity'; import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
@ -42,12 +41,12 @@ import { DomainManagerService } from 'src/engine/core-modules/domain-manager/ser
import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate'; import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; 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 { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity';
@Injectable() @Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository // eslint-disable-next-line @nx/workspace-inject-workspace-repository
@ -57,7 +56,6 @@ export class AuthService {
private readonly domainManagerService: DomainManagerService, private readonly domainManagerService: DomainManagerService,
private readonly refreshTokenService: RefreshTokenService, private readonly refreshTokenService: RefreshTokenService,
private readonly userWorkspaceService: UserWorkspaceService, private readonly userWorkspaceService: UserWorkspaceService,
private readonly userService: UserService,
private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly workspaceInvitationService: WorkspaceInvitationService,
private readonly signInUpService: SignInUpService, private readonly signInUpService: SignInUpService,
@InjectRepository(Workspace, 'core') @InjectRepository(Workspace, 'core')
@ -188,7 +186,7 @@ export class AuthService {
}); });
} }
async verify(email: string, workspaceId?: string): Promise<Verify> { async verify(email: string, workspaceId: string): Promise<AuthTokens> {
if (!email) { if (!email) {
throw new AuthException( throw new AuthException(
'Email is required', 'Email is required',
@ -196,31 +194,8 @@ export class AuthService {
); );
} }
const userWithIdAndDefaultWorkspaceId = await this.userRepository.findOne({
select: ['defaultWorkspaceId', 'id'],
where: { email },
});
userValidator.assertIsDefinedOrThrow(
userWithIdAndDefaultWorkspaceId,
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
);
if (
workspaceId &&
userWithIdAndDefaultWorkspaceId.defaultWorkspaceId !== workspaceId
) {
await this.userService.saveDefaultWorkspaceIfUserHasAccessOrThrow(
userWithIdAndDefaultWorkspaceId.id,
workspaceId,
);
}
const user = await this.userRepository.findOne({ const user = await this.userRepository.findOne({
where: { where: { email },
email,
},
relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'],
}); });
userValidator.assertIsDefinedOrThrow( userValidator.assertIsDefinedOrThrow(
@ -233,15 +208,14 @@ export class AuthService {
const accessToken = await this.accessTokenService.generateAccessToken( const accessToken = await this.accessTokenService.generateAccessToken(
user.id, user.id,
user.defaultWorkspaceId, workspaceId,
); );
const refreshToken = await this.refreshTokenService.generateRefreshToken( const refreshToken = await this.refreshTokenService.generateRefreshToken(
user.id, user.id,
user.defaultWorkspaceId, workspaceId,
); );
return { return {
user,
tokens: { tokens: {
accessToken, accessToken,
refreshToken, refreshToken,
@ -257,7 +231,6 @@ export class AuthService {
if (userValidator.isDefined(user)) { if (userValidator.isDefined(user)) {
return { return {
exists: true, exists: true,
defaultWorkspaceId: user.defaultWorkspaceId,
availableWorkspaces: await this.findAvailableWorkspacesByEmail(email), availableWorkspaces: await this.findAvailableWorkspacesByEmail(email),
}; };
} }
@ -278,6 +251,7 @@ export class AuthService {
async generateAuthorizationCode( async generateAuthorizationCode(
authorizeAppInput: AuthorizeAppInput, authorizeAppInput: AuthorizeAppInput,
user: User, user: User,
workspace: Workspace,
): Promise<AuthorizeApp> { ): Promise<AuthorizeApp> {
// TODO: replace with db call to - third party app table // TODO: replace with db call to - third party app table
const apps = [ const apps = [
@ -329,14 +303,14 @@ export class AuthService {
value: codeChallenge, value: codeChallenge,
type: AppTokenType.CodeChallenge, type: AppTokenType.CodeChallenge,
userId: user.id, userId: user.id,
workspaceId: user.defaultWorkspaceId, workspaceId: workspace.id,
expiresAt, expiresAt,
}, },
{ {
value: authorizationCode, value: authorizationCode,
type: AppTokenType.AuthorizationCode, type: AppTokenType.AuthorizationCode,
userId: user.id, userId: user.id,
workspaceId: user.defaultWorkspaceId, workspaceId: workspace.id,
expiresAt, expiresAt,
}, },
]); ]);
@ -347,7 +321,7 @@ export class AuthService {
value: authorizationCode, value: authorizationCode,
type: AppTokenType.AuthorizationCode, type: AppTokenType.AuthorizationCode,
userId: user.id, userId: user.id,
workspaceId: user.defaultWorkspaceId, workspaceId: workspace.id,
expiresAt, expiresAt,
}); });
@ -439,7 +413,7 @@ export class AuthService {
return workspace; return workspace;
} }
async computeRedirectURI(loginToken: string, subdomain?: string) { computeRedirectURI(loginToken: string, subdomain?: string) {
const url = this.domainManagerService.buildWorkspaceURL({ const url = this.domainManagerService.buildWorkspaceURL({
subdomain, subdomain,
pathname: '/verify', pathname: '/verify',

View File

@ -1,155 +1,157 @@
import { Injectable } from '@nestjs/common'; // import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; // import { InjectRepository } from '@nestjs/typeorm';
//
import crypto from 'crypto'; // import crypto from 'crypto';
//
import { Repository } from 'typeorm'; // import { Repository } from 'typeorm';
//
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; // import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { // import {
AuthException, // AuthException,
AuthExceptionCode, // AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception'; // } from 'src/engine/core-modules/auth/auth.exception';
import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity'; // import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity';
import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input'; // import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; // import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; // import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; // import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { User } from 'src/engine/core-modules/user/user.entity'; // import { User } from 'src/engine/core-modules/user/user.entity';
// import { userValidator } from 'src/engine/core-modules/user/user.validate';
@Injectable() //
export class OAuthService { // @Injectable()
constructor( // export class OAuthService {
@InjectRepository(User, 'core') // constructor(
private readonly userRepository: Repository<User>, // @InjectRepository(User, 'core')
@InjectRepository(AppToken, 'core') // private readonly userRepository: Repository<User>,
private readonly appTokenRepository: Repository<AppToken>, // @InjectRepository(AppToken, 'core')
private readonly accessTokenService: AccessTokenService, // private readonly appTokenRepository: Repository<AppToken>,
private readonly refreshTokenService: RefreshTokenService, // private readonly accessTokenService: AccessTokenService,
private readonly loginTokenService: LoginTokenService, // private readonly refreshTokenService: RefreshTokenService,
) {} // private readonly loginTokenService: LoginTokenService,
// ) {}
async verifyAuthorizationCode( //
exchangeAuthCodeInput: ExchangeAuthCodeInput, // async verifyAuthorizationCode(
): Promise<ExchangeAuthCode> { // exchangeAuthCodeInput: ExchangeAuthCodeInput,
const { authorizationCode, codeVerifier } = exchangeAuthCodeInput; // ): Promise<ExchangeAuthCode> {
// const { authorizationCode, codeVerifier } = exchangeAuthCodeInput;
if (!authorizationCode) { //
throw new AuthException( // if (!authorizationCode) {
'Authorization code not found', // throw new AuthException(
AuthExceptionCode.INVALID_INPUT, // 'Authorization code not found',
); // AuthExceptionCode.INVALID_INPUT,
} // );
// }
let userId = ''; //
// let userId = '';
if (codeVerifier) { //
const authorizationCodeAppToken = await this.appTokenRepository.findOne({ // if (codeVerifier) {
where: { // const authorizationCodeAppToken = await this.appTokenRepository.findOne({
value: authorizationCode, // where: {
}, // value: authorizationCode,
}); // },
// });
if (!authorizationCodeAppToken) { //
throw new AuthException( // if (!authorizationCodeAppToken) {
'Authorization code does not exist', // throw new AuthException(
AuthExceptionCode.INVALID_INPUT, // 'Authorization code does not exist',
); // AuthExceptionCode.INVALID_INPUT,
} // );
// }
if (!(authorizationCodeAppToken.expiresAt.getTime() >= Date.now())) { //
throw new AuthException( // if (!(authorizationCodeAppToken.expiresAt.getTime() >= Date.now())) {
'Authorization code expired.', // throw new AuthException(
AuthExceptionCode.FORBIDDEN_EXCEPTION, // 'Authorization code expired.',
); // AuthExceptionCode.FORBIDDEN_EXCEPTION,
} // );
// }
const codeChallenge = crypto //
.createHash('sha256') // const codeChallenge = crypto
.update(codeVerifier) // .createHash('sha256')
.digest() // .update(codeVerifier)
.toString('base64') // .digest()
.replace(/\+/g, '-') // .toString('base64')
.replace(/\//g, '_') // .replace(/\+/g, '-')
.replace(/=/g, ''); // .replace(/\//g, '_')
// .replace(/=/g, '');
const codeChallengeAppToken = await this.appTokenRepository.findOne({ //
where: { // const codeChallengeAppToken = await this.appTokenRepository.findOne({
value: codeChallenge, // where: {
}, // value: codeChallenge,
}); // },
// });
if (!codeChallengeAppToken || !codeChallengeAppToken.userId) { //
throw new AuthException( // if (!codeChallengeAppToken || !codeChallengeAppToken.userId) {
'code verifier doesnt match the challenge', // throw new AuthException(
AuthExceptionCode.FORBIDDEN_EXCEPTION, // 'code verifier doesnt match the challenge',
); // AuthExceptionCode.FORBIDDEN_EXCEPTION,
} // );
// }
if (!(codeChallengeAppToken.expiresAt.getTime() >= Date.now())) { //
throw new AuthException( // if (!(codeChallengeAppToken.expiresAt.getTime() >= Date.now())) {
'code challenge expired.', // throw new AuthException(
AuthExceptionCode.FORBIDDEN_EXCEPTION, // 'code challenge expired.',
); // AuthExceptionCode.FORBIDDEN_EXCEPTION,
} // );
// }
if (codeChallengeAppToken.userId !== authorizationCodeAppToken.userId) { //
throw new AuthException( // if (codeChallengeAppToken.userId !== authorizationCodeAppToken.userId) {
'authorization code / code verifier was not created by same client', // throw new AuthException(
AuthExceptionCode.FORBIDDEN_EXCEPTION, // 'authorization code / code verifier was not created by same client',
); // AuthExceptionCode.FORBIDDEN_EXCEPTION,
} // );
// }
if (codeChallengeAppToken.revokedAt) { //
throw new AuthException( // if (codeChallengeAppToken.revokedAt) {
'Token has been revoked.', // throw new AuthException(
AuthExceptionCode.FORBIDDEN_EXCEPTION, // 'Token has been revoked.',
); // AuthExceptionCode.FORBIDDEN_EXCEPTION,
} // );
// }
await this.appTokenRepository.save({ //
id: codeChallengeAppToken.id, // await this.appTokenRepository.save({
revokedAt: new Date(), // id: codeChallengeAppToken.id,
}); // revokedAt: new Date(),
// });
userId = codeChallengeAppToken.userId; //
} // userId = codeChallengeAppToken.userId;
// }
const user = await this.userRepository.findOne({ //
where: { id: userId }, // const user = await this.userRepository.findOne({
relations: ['defaultWorkspace'], // where: { id: userId },
}); // relations: ['defaultWorkspace'],
// });
if (!user) { //
throw new AuthException( // userValidator.assertIsDefinedOrThrow(
'User who generated the token does not exist', // user,
AuthExceptionCode.INVALID_INPUT, // new AuthException(
); // 'User who generated the token does not exist',
} // AuthExceptionCode.INVALID_INPUT,
// ),
if (!user.defaultWorkspace) { // );
throw new AuthException( //
'User does not have a default workspace', // if (!user.defaultWorkspace) {
AuthExceptionCode.INVALID_DATA, // throw new AuthException(
); // 'User does not have a default workspace',
} // AuthExceptionCode.INVALID_DATA,
// );
const accessToken = await this.accessTokenService.generateAccessToken( // }
user.id, //
user.defaultWorkspaceId, // const accessToken = await this.accessTokenService.generateAccessToken(
); // user.id,
const refreshToken = await this.refreshTokenService.generateRefreshToken( // user.defaultWorkspaceId,
user.id, // );
user.defaultWorkspaceId, // const refreshToken = await this.refreshTokenService.generateRefreshToken(
); // user.id,
const loginToken = await this.loginTokenService.generateLoginToken( // user.defaultWorkspaceId,
user.email, // );
); // const loginToken = await this.loginTokenService.generateLoginToken(
// user.email,
return { // );
accessToken, //
refreshToken, // return {
loginToken, // accessToken,
}; // refreshToken,
} // loginToken,
} // };
// }
// }

View File

@ -15,11 +15,11 @@ import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/use
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { import {
Workspace, Workspace,
WorkspaceActivationStatus, WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity'; } from 'src/engine/core-modules/workspace/workspace.entity';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
jest.mock('bcrypt'); jest.mock('bcrypt');
@ -120,12 +120,15 @@ describe('SignInUpService', () => {
provide: DomainManagerService, provide: DomainManagerService,
useValue: { useValue: {
generateSubdomain: jest.fn().mockReturnValue('testSubDomain'), generateSubdomain: jest.fn().mockReturnValue('testSubDomain'),
getWorkspaceBySubdomainOrDefaultWorkspace: jest
.fn()
.mockReturnValue({}),
}, },
}, },
{ {
provide: UserService, provide: UserService,
useValue: { useValue: {
saveDefaultWorkspaceIfUserHasAccessOrThrow: jest.fn(), hasUserAccessToWorkspaceOrThrow: jest.fn(),
}, },
}, },
], ],
@ -148,7 +151,10 @@ describe('SignInUpService', () => {
const spy = jest const spy = jest
.spyOn(service, 'signUpOnNewWorkspace') .spyOn(service, 'signUpOnNewWorkspace')
.mockResolvedValueOnce({} as User); .mockResolvedValueOnce({ user: {}, workspace: {} } as {
user: User;
workspace: Workspace;
});
await service.signInUp({ await service.signInUp({
email: 'test@test.com', email: 'test@test.com',
@ -172,7 +178,6 @@ describe('SignInUpService', () => {
id: 'user-id', id: 'user-id',
email, email,
passwordHash: undefined, passwordHash: undefined,
defaultWorkspace: { id: 'workspace-id' },
}; };
UserFindOneMock.mockReturnValueOnce(existingUser); UserFindOneMock.mockReturnValueOnce(existingUser);
@ -189,7 +194,7 @@ describe('SignInUpService', () => {
targetWorkspaceSubdomain: 'testSubDomain', targetWorkspaceSubdomain: 'testSubDomain',
}); });
expect(result).toEqual(existingUser); expect(result).toEqual({ user: existingUser, workspace: {} });
}); });
it('signInUp - sso - new user - existing invitation', async () => { it('signInUp - sso - new user - existing invitation', async () => {
const email = 'newuser@test.com'; const email = 'newuser@test.com';
@ -248,7 +253,11 @@ describe('SignInUpService', () => {
id: 'user-id', id: 'user-id',
email, email,
passwordHash: undefined, passwordHash: undefined,
defaultWorkspace: { id: 'workspace-id' }, };
const workspace = {
id: workspaceId,
activationStatus: WorkspaceActivationStatus.ACTIVE,
}; };
UserFindOneMock.mockReturnValueOnce(existingUser); UserFindOneMock.mockReturnValueOnce(existingUser);
@ -259,10 +268,7 @@ describe('SignInUpService', () => {
); );
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ workspaceInvitationValidateInvitationMock.mockReturnValueOnce({
isValid: true, isValid: true,
workspace: { workspace,
id: workspaceId,
activationStatus: WorkspaceActivationStatus.ACTIVE,
},
}); });
workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce(
@ -277,7 +283,7 @@ describe('SignInUpService', () => {
targetWorkspaceSubdomain: 'testSubDomain', targetWorkspaceSubdomain: 'testSubDomain',
}); });
expect(result).toEqual(existingUser); expect(result).toEqual({ user: existingUser, workspace });
expect(userWorkspaceServiceAddUserToWorkspaceMock).toHaveBeenCalledTimes(1); expect(userWorkspaceServiceAddUserToWorkspaceMock).toHaveBeenCalledTimes(1);
expect( expect(
workspaceInvitationInvalidateWorkspaceInvitationMock, workspaceInvitationInvalidateWorkspaceInvitationMock,
@ -287,15 +293,16 @@ describe('SignInUpService', () => {
const email = 'newuser@test.com'; const email = 'newuser@test.com';
const workspaceId = 'workspace-id'; const workspaceId = 'workspace-id';
const workspacePersonalInviteToken = 'personal-token-value'; const workspacePersonalInviteToken = 'personal-token-value';
const workspace = {
id: workspaceId,
activationStatus: WorkspaceActivationStatus.ACTIVE,
};
UserFindOneMock.mockReturnValueOnce(null); UserFindOneMock.mockReturnValueOnce(null);
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ workspaceInvitationValidateInvitationMock.mockReturnValueOnce({
isValid: true, isValid: true,
workspace: { workspace,
id: workspaceId,
activationStatus: WorkspaceActivationStatus.ACTIVE,
},
}); });
workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce(
@ -345,7 +352,6 @@ describe('SignInUpService', () => {
id: 'user-id', id: 'user-id',
email, email,
passwordHash: undefined, passwordHash: undefined,
defaultWorkspace: { id: 'workspace-id' },
}; };
UserFindOneMock.mockReturnValueOnce(existingUser); UserFindOneMock.mockReturnValueOnce(existingUser);
@ -379,7 +385,6 @@ describe('SignInUpService', () => {
id: 'user-id', id: 'user-id',
email, email,
passwordHash: 'hash-of-validPassword123', passwordHash: 'hash-of-validPassword123',
defaultWorkspace: { id: 'workspace-id' },
}; };
UserFindOneMock.mockReturnValueOnce(existingUser); UserFindOneMock.mockReturnValueOnce(existingUser);

View File

@ -2,7 +2,6 @@ import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'class-validator';
import FileType from 'file-type'; import FileType from 'file-type';
import { TWENTY_ICONS_BASE_URL } from 'twenty-shared'; import { TWENTY_ICONS_BASE_URL } from 'twenty-shared';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@ -37,6 +36,7 @@ import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email'; import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
import { getImageBufferFromUrl } from 'src/utils/image'; import { getImageBufferFromUrl } from 'src/utils/image';
import { isWorkEmail } from 'src/utils/is-work-email'; import { isWorkEmail } from 'src/utils/is-work-email';
import { isDefined } from 'src/utils/is-defined';
export type SignInUpServiceInput = { export type SignInUpServiceInput = {
email: string; email: string;
@ -106,7 +106,6 @@ export class SignInUpService {
const existingUser = await this.userRepository.findOne({ const existingUser = await this.userRepository.findOne({
where: { email }, where: { email },
relations: ['defaultWorkspace'],
}); });
if (existingUser && !fromSSO) { if (existingUser && !fromSSO) {
@ -123,53 +122,22 @@ export class SignInUpService {
} }
} }
const maybeInvitation = const signInUpWithInvitationResult = await this.signInUpWithInvitation({
fromSSO && !workspacePersonalInviteToken && !workspaceInviteHash email,
? await this.workspaceInvitationService.findInvitationByWorkspaceSubdomainAndUserEmail( workspacePersonalInviteToken,
{ workspaceInviteHash,
subdomain: targetWorkspaceSubdomain, targetWorkspaceSubdomain,
email, fromSSO,
}, firstName,
) lastName,
: undefined; picture,
authProvider,
passwordHash,
existingUser,
});
if ( if (isDefined(signInUpWithInvitationResult)) {
workspacePersonalInviteToken || return signInUpWithInvitationResult;
workspaceInviteHash ||
maybeInvitation
) {
const invitationValidation =
workspacePersonalInviteToken || workspaceInviteHash || maybeInvitation
? await this.workspaceInvitationService.validateInvitation({
workspacePersonalInviteToken:
workspacePersonalInviteToken ?? maybeInvitation?.value,
workspaceInviteHash,
email,
})
: null;
if (
invitationValidation?.isValid === true &&
invitationValidation.workspace
) {
const updatedUser = await this.signInUpOnExistingWorkspace({
email,
passwordHash,
workspace: invitationValidation.workspace,
firstName,
lastName,
picture,
existingUser,
authProvider,
});
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
invitationValidation.workspace.id,
email,
);
return updatedUser;
}
} }
if (!existingUser) { if (!existingUser) {
@ -182,27 +150,111 @@ export class SignInUpService {
}); });
} }
if (targetWorkspaceSubdomain) { const workspace =
const workspace = await this.workspaceRepository.findOne({ await this.domainManagerService.getWorkspaceBySubdomainOrDefaultWorkspace(
where: { subdomain: targetWorkspaceSubdomain }, targetWorkspaceSubdomain,
select: ['id'], );
workspaceValidator.assertIsDefinedOrThrow(workspace);
return await this.validateSignIn({
user: existingUser,
workspace,
authProvider,
});
}
private async signInUpWithInvitation({
email,
workspacePersonalInviteToken,
workspaceInviteHash,
firstName,
lastName,
picture,
fromSSO,
targetWorkspaceSubdomain,
authProvider,
passwordHash,
existingUser,
}: {
email: string;
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
firstName: string;
lastName: string;
picture?: string | null;
authProvider?: WorkspaceAuthProvider;
passwordHash?: string;
existingUser: User | null;
fromSSO: boolean;
targetWorkspaceSubdomain?: string;
}) {
const maybeInvitation =
fromSSO && !workspacePersonalInviteToken && !workspaceInviteHash
? await this.workspaceInvitationService.findInvitationByWorkspaceSubdomainAndUserEmail(
{
subdomain: targetWorkspaceSubdomain,
email,
},
)
: undefined;
const invitationValidation =
workspacePersonalInviteToken || workspaceInviteHash || maybeInvitation
? await this.workspaceInvitationService.validateInvitation({
workspacePersonalInviteToken:
workspacePersonalInviteToken ?? maybeInvitation?.value,
workspaceInviteHash,
email,
})
: null;
if (
invitationValidation?.isValid === true &&
invitationValidation.workspace
) {
const updatedUser = await this.signInUpOnExistingWorkspace({
email,
passwordHash,
workspace: invitationValidation.workspace,
firstName,
lastName,
picture,
existingUser,
authProvider,
}); });
workspaceValidator.assertIsExist( await this.workspaceInvitationService.invalidateWorkspaceInvitation(
workspace, invitationValidation.workspace.id,
new AuthException( email,
'Workspace not found',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
); );
await this.userService.saveDefaultWorkspaceIfUserHasAccessOrThrow( return {
existingUser.id, user: updatedUser,
workspace.id, workspace: invitationValidation.workspace,
); };
}
}
private async validateSignIn({
user,
workspace,
authProvider,
}: {
user: User;
workspace: Workspace;
authProvider: SignInUpServiceInput['authProvider'];
}) {
if (authProvider) {
workspaceValidator.isAuthEnabledOrThrow(authProvider, workspace);
} }
return existingUser; await this.userService.hasUserAccessToWorkspaceOrThrow(
user.id,
workspace.id,
);
return { user, workspace };
} }
async signInUpOnExistingWorkspace({ async signInUpOnExistingWorkspace({
@ -227,7 +279,7 @@ export class SignInUpService {
const isNewUser = !isDefined(existingUser); const isNewUser = !isDefined(existingUser);
let user = existingUser; let user = existingUser;
workspaceValidator.assertIsExist( workspaceValidator.assertIsDefinedOrThrow(
workspace, workspace,
new AuthException( new AuthException(
'Workspace not found', 'Workspace not found',
@ -244,14 +296,7 @@ export class SignInUpService {
); );
if (authProvider) { if (authProvider) {
workspaceValidator.isAuthEnabledOrThrow( workspaceValidator.isAuthEnabledOrThrow(authProvider, workspace);
authProvider,
workspace,
new AuthException(
`${authProvider} auth is not enabled for this workspace`,
AuthExceptionCode.OAUTH_ACCESS_DENIED,
),
);
} }
if (isNewUser) { if (isNewUser) {
@ -264,7 +309,6 @@ export class SignInUpService {
defaultAvatarUrl: imagePath, defaultAvatarUrl: imagePath,
canImpersonate: false, canImpersonate: false,
passwordHash, passwordHash,
defaultWorkspace: workspace,
}); });
user = await this.userRepository.save(userToCreate); user = await this.userRepository.save(userToCreate);
@ -364,10 +408,7 @@ export class SignInUpService {
user.defaultAvatarUrl = await this.uploadPicture(picture, workspace.id); user.defaultAvatarUrl = await this.uploadPicture(picture, workspace.id);
const userCreated = this.userRepository.create({ const userCreated = this.userRepository.create(user);
...user,
defaultWorkspace: workspace,
});
const newUser = await this.userRepository.save(userCreated); const newUser = await this.userRepository.save(userCreated);
@ -383,7 +424,7 @@ export class SignInUpService {
value: true, value: true,
}); });
return newUser; return { user: newUser, workspace };
} }
async uploadPicture( async uploadPicture(

View File

@ -17,9 +17,6 @@ describe('SwitchWorkspaceService', () => {
let service: SwitchWorkspaceService; let service: SwitchWorkspaceService;
let userRepository: Repository<User>; let userRepository: Repository<User>;
let workspaceRepository: Repository<Workspace>; let workspaceRepository: Repository<Workspace>;
let userService: UserService;
let accessTokenService: AccessTokenService;
let refreshTokenService: RefreshTokenService;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
@ -45,18 +42,16 @@ describe('SwitchWorkspaceService', () => {
generateRefreshToken: jest.fn(), generateRefreshToken: jest.fn(),
}, },
}, },
{
provide: UserService,
useValue: {
saveDefaultWorkspaceIfUserHasAccessOrThrow: jest.fn(),
},
},
{ {
provide: EnvironmentService, provide: EnvironmentService,
useValue: { useValue: {
get: jest.fn(), get: jest.fn(),
}, },
}, },
{
provide: UserService,
useValue: {},
},
], ],
}).compile(); }).compile();
@ -67,9 +62,6 @@ describe('SwitchWorkspaceService', () => {
workspaceRepository = module.get<Repository<Workspace>>( workspaceRepository = module.get<Repository<Workspace>>(
getRepositoryToken(Workspace, 'core'), getRepositoryToken(Workspace, 'core'),
); );
accessTokenService = module.get<AccessTokenService>(AccessTokenService);
refreshTokenService = module.get<RefreshTokenService>(RefreshTokenService);
userService = module.get<UserService>(UserService);
}); });
it('should be defined', () => { it('should be defined', () => {
@ -191,44 +183,4 @@ describe('SwitchWorkspaceService', () => {
}); });
}); });
}); });
describe('generateSwitchWorkspaceToken', () => {
it('should generate and return auth tokens', async () => {
const mockUser = { id: 'user-id' };
const mockWorkspace = { id: 'workspace-id' };
const mockAccessToken = { token: 'access-token', expiresAt: new Date() };
const mockRefreshToken = 'refresh-token';
jest.spyOn(userRepository, 'save').mockResolvedValue({} as User);
jest
.spyOn(accessTokenService, 'generateAccessToken')
.mockResolvedValue(mockAccessToken);
jest
.spyOn(refreshTokenService, 'generateRefreshToken')
.mockResolvedValue(mockRefreshToken as any);
const result = await service.generateSwitchWorkspaceToken(
mockUser as User,
mockWorkspace as Workspace,
);
expect(result).toEqual({
tokens: {
accessToken: mockAccessToken,
refreshToken: mockRefreshToken,
},
});
expect(
userService.saveDefaultWorkspaceIfUserHasAccessOrThrow,
).toHaveBeenCalledWith(mockUser.id, mockWorkspace.id);
expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith(
mockUser.id,
mockWorkspace.id,
);
expect(refreshTokenService.generateRefreshToken).toHaveBeenCalledWith(
mockUser.id,
mockWorkspace.id,
);
});
});
}); });

View File

@ -7,50 +7,31 @@ import {
AuthException, AuthException,
AuthExceptionCode, AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception'; } from 'src/engine/core-modules/auth/auth.exception';
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity';
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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { AuthProviders } from 'src/engine/core-modules/workspace/dtos/public-workspace-data.output'; import { AuthProviders } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
import { getAuthProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/get-auth-providers-by-workspace.util'; import { getAuthProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/get-auth-providers-by-workspace.util';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
@Injectable() @Injectable()
export class SwitchWorkspaceService { export class SwitchWorkspaceService {
constructor( constructor(
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
@InjectRepository(Workspace, 'core') @InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>, private readonly workspaceRepository: Repository<Workspace>,
private readonly userService: UserService,
private readonly accessTokenService: AccessTokenService,
private readonly refreshTokenService: RefreshTokenService,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
) {} ) {}
async switchWorkspace(user: User, workspaceId: string) { async switchWorkspace(user: User, workspaceId: string) {
const userExists = await this.userRepository.findBy({ id: user.id });
if (!userExists) {
throw new AuthException(
'User not found',
AuthExceptionCode.INVALID_INPUT,
);
}
const workspace = await this.workspaceRepository.findOne({ const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId }, where: { id: workspaceId },
relations: ['workspaceUsers', 'workspaceSSOIdentityProviders'], relations: ['workspaceUsers', 'workspaceSSOIdentityProviders'],
}); });
if (!workspace) { workspaceValidator.assertIsDefinedOrThrow(
throw new AuthException( workspace,
'workspace doesnt exist', new AuthException('Workspace not found', AuthExceptionCode.INVALID_INPUT),
AuthExceptionCode.INVALID_INPUT, );
);
}
if ( if (
!workspace.workspaceUsers !workspace.workspaceUsers
@ -63,11 +44,6 @@ export class SwitchWorkspaceService {
); );
} }
await this.userRepository.save({
id: user.id,
defaultWorkspace: workspace,
});
const systemEnabledProviders: AuthProviders = { const systemEnabledProviders: AuthProviders = {
google: this.environmentService.get('AUTH_GOOGLE_ENABLED'), google: this.environmentService.get('AUTH_GOOGLE_ENABLED'),
magicLink: false, magicLink: false,
@ -87,30 +63,4 @@ export class SwitchWorkspaceService {
}), }),
}; };
} }
async generateSwitchWorkspaceToken(
user: User,
workspace: Workspace,
): Promise<AuthTokens> {
await this.userService.saveDefaultWorkspaceIfUserHasAccessOrThrow(
user.id,
workspace.id,
);
const token = await this.accessTokenService.generateAccessToken(
user.id,
workspace.id,
);
const refreshToken = await this.refreshTokenService.generateRefreshToken(
user.id,
workspace.id,
);
return {
tokens: {
accessToken: token,
refreshToken,
},
};
}
} }

View File

@ -12,7 +12,10 @@ import { EnvironmentService } from 'src/engine/core-modules/environment/environm
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { AccessTokenService } from './access-token.service'; import { AccessTokenService } from './access-token.service';
@ -22,6 +25,7 @@ describe('AccessTokenService', () => {
let jwtWrapperService: JwtWrapperService; let jwtWrapperService: JwtWrapperService;
let environmentService: EnvironmentService; let environmentService: EnvironmentService;
let userRepository: Repository<User>; let userRepository: Repository<User>;
let workspaceRepository: Repository<Workspace>;
let twentyORMGlobalManager: TwentyORMGlobalManager; let twentyORMGlobalManager: TwentyORMGlobalManager;
beforeEach(async () => { beforeEach(async () => {
@ -84,6 +88,9 @@ describe('AccessTokenService', () => {
userRepository = module.get<Repository<User>>( userRepository = module.get<Repository<User>>(
getRepositoryToken(User, 'core'), getRepositoryToken(User, 'core'),
); );
workspaceRepository = module.get<Repository<Workspace>>(
getRepositoryToken(Workspace, 'core'),
);
twentyORMGlobalManager = module.get<TwentyORMGlobalManager>( twentyORMGlobalManager = module.get<TwentyORMGlobalManager>(
TwentyORMGlobalManager, TwentyORMGlobalManager,
); );
@ -99,14 +106,19 @@ describe('AccessTokenService', () => {
const workspaceId = 'workspace-id'; const workspaceId = 'workspace-id';
const mockUser = { const mockUser = {
id: userId, id: userId,
defaultWorkspace: { id: workspaceId, activationStatus: 'ACTIVE' }, };
defaultWorkspaceId: workspaceId, const mockWorkspace = {
activationStatus: WorkspaceActivationStatus.ACTIVE,
id: workspaceId,
}; };
const mockWorkspaceMember = { id: 'workspace-member-id' }; const mockWorkspaceMember = { id: 'workspace-member-id' };
const mockToken = 'mock-token'; const mockToken = 'mock-token';
jest.spyOn(environmentService, 'get').mockReturnValue('1h'); jest.spyOn(environmentService, 'get').mockReturnValue('1h');
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as User); jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as User);
jest
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace as Workspace);
jest jest
.spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace') .spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace')
.mockResolvedValue({ .mockResolvedValue({

View File

@ -20,9 +20,14 @@ import {
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceActivationStatus } from 'src/engine/core-modules/workspace/workspace.entity'; import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; 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 { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
@Injectable() @Injectable()
export class AccessTokenService { export class AccessTokenService {
@ -32,6 +37,8 @@ export class AccessTokenService {
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
@InjectRepository(User, 'core') @InjectRepository(User, 'core')
private readonly userRepository: Repository<User>, private readonly userRepository: Repository<User>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {} ) {}
@ -45,33 +52,25 @@ export class AccessTokenService {
const user = await this.userRepository.findOne({ const user = await this.userRepository.findOne({
where: { id: userId }, where: { id: userId },
relations: ['defaultWorkspace'],
}); });
if (!user) { userValidator.assertIsDefinedOrThrow(
throw new AuthException( user,
'User is not found', new AuthException('User is not found', AuthExceptionCode.INVALID_INPUT),
AuthExceptionCode.INVALID_INPUT, );
);
}
if (!user.defaultWorkspace) {
throw new AuthException(
'User does not have a default workspace',
AuthExceptionCode.INVALID_DATA,
);
}
const tokenWorkspaceId = workspaceId ?? user.defaultWorkspaceId;
let tokenWorkspaceMemberId: string | undefined; let tokenWorkspaceMemberId: string | undefined;
if ( const workspace = await this.workspaceRepository.findOne({
user.defaultWorkspace.activationStatus === where: { id: workspaceId },
WorkspaceActivationStatus.ACTIVE });
) {
workspaceValidator.assertIsDefinedOrThrow(workspace);
if (workspace.activationStatus === WorkspaceActivationStatus.ACTIVE) {
const workspaceMemberRepository = const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>( await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
tokenWorkspaceId, workspaceId,
'workspaceMember', 'workspaceMember',
); );
@ -93,7 +92,7 @@ export class AccessTokenService {
const jwtPayload: JwtPayload = { const jwtPayload: JwtPayload = {
sub: user.id, sub: user.id,
workspaceId: workspaceId ? workspaceId : user.defaultWorkspaceId, workspaceId,
workspaceMemberId: tokenWorkspaceMemberId, workspaceMemberId: tokenWorkspaceMemberId,
}; };

View File

@ -47,6 +47,7 @@ describe('LoginTokenService', () => {
const mockSecret = 'mock-secret'; const mockSecret = 'mock-secret';
const mockExpiresIn = '1h'; const mockExpiresIn = '1h';
const mockToken = 'mock-token'; const mockToken = 'mock-token';
const workspaceId = 'workspace-id';
jest jest
.spyOn(jwtWrapperService, 'generateAppSecret') .spyOn(jwtWrapperService, 'generateAppSecret')
@ -54,18 +55,21 @@ describe('LoginTokenService', () => {
jest.spyOn(environmentService, 'get').mockReturnValue(mockExpiresIn); jest.spyOn(environmentService, 'get').mockReturnValue(mockExpiresIn);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
const result = await service.generateLoginToken(email); const result = await service.generateLoginToken(email, workspaceId);
expect(result).toEqual({ expect(result).toEqual({
token: mockToken, token: mockToken,
expiresAt: expect.any(Date), expiresAt: expect.any(Date),
}); });
expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith('LOGIN'); expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
'LOGIN',
workspaceId,
);
expect(environmentService.get).toHaveBeenCalledWith( expect(environmentService.get).toHaveBeenCalledWith(
'LOGIN_TOKEN_EXPIRES_IN', 'LOGIN_TOKEN_EXPIRES_IN',
); );
expect(jwtWrapperService.sign).toHaveBeenCalledWith( expect(jwtWrapperService.sign).toHaveBeenCalledWith(
{ sub: email }, { sub: email, workspaceId },
{ secret: mockSecret, expiresIn: mockExpiresIn }, { secret: mockSecret, expiresIn: mockExpiresIn },
); );
}); });

View File

@ -14,14 +14,21 @@ export class LoginTokenService {
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
) {} ) {}
async generateLoginToken(email: string): Promise<AuthToken> { async generateLoginToken(
const secret = this.jwtWrapperService.generateAppSecret('LOGIN'); email: string,
workspaceId: string,
): Promise<AuthToken> {
const secret = this.jwtWrapperService.generateAppSecret(
'LOGIN',
workspaceId,
);
const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN'); const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN');
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const jwtPayload = { const jwtPayload = {
sub: email, sub: email,
workspaceId,
}; };
return { return {
@ -33,7 +40,9 @@ export class LoginTokenService {
}; };
} }
async verifyLoginToken(loginToken: string): Promise<{ sub: string }> { async verifyLoginToken(
loginToken: string,
): Promise<{ sub: string; workspaceId: string }> {
await this.jwtWrapperService.verifyWorkspaceToken(loginToken, 'LOGIN'); await this.jwtWrapperService.verifyWorkspaceToken(loginToken, 'LOGIN');
return this.jwtWrapperService.decode(loginToken, { return this.jwtWrapperService.decode(loginToken, {

View File

@ -15,6 +15,7 @@ import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
@Module({ @Module({
imports: [ imports: [
@ -24,6 +25,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
DataSourceModule, DataSourceModule,
EmailModule, EmailModule,
WorkspaceSSOModule, WorkspaceSSOModule,
UserWorkspaceModule,
], ],
providers: [ providers: [
RenewTokenService, RenewTokenService,

View File

@ -40,11 +40,12 @@ export class BillingResolver {
@UseGuards(WorkspaceAuthGuard, UserAuthGuard) @UseGuards(WorkspaceAuthGuard, UserAuthGuard)
async billingPortalSession( async billingPortalSession(
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Args() { returnUrlPath }: BillingSessionInput, @Args() { returnUrlPath }: BillingSessionInput,
) { ) {
return { return {
url: await this.billingPortalWorkspaceService.computeBillingPortalSessionURLOrThrow( url: await this.billingPortalWorkspaceService.computeBillingPortalSessionURLOrThrow(
user.defaultWorkspaceId, workspace.id,
returnUrlPath, returnUrlPath,
), ),
}; };

View File

@ -45,12 +45,13 @@ export class BillingPortalWorkspaceService {
const stripeCustomerId = ( const stripeCustomerId = (
await this.billingSubscriptionRepository.findOneBy({ await this.billingSubscriptionRepository.findOneBy({
workspaceId: user.defaultWorkspaceId, workspaceId: workspace.id,
}) })
)?.stripeCustomerId; )?.stripeCustomerId;
const session = await this.stripeService.createCheckoutSession( const session = await this.stripeService.createCheckoutSession(
user, user,
workspace.id,
priceId, priceId,
quantity, quantity,
successUrl, successUrl,

View File

@ -84,6 +84,7 @@ export class StripeService {
async createCheckoutSession( async createCheckoutSession(
user: User, user: User,
workspaceId: string,
priceId: string, priceId: string,
quantity: number, quantity: number,
successUrl?: string, successUrl?: string,
@ -100,7 +101,7 @@ export class StripeService {
mode: 'subscription', mode: 'subscription',
subscription_data: { subscription_data: {
metadata: { metadata: {
workspaceId: user.defaultWorkspaceId, workspaceId,
}, },
trial_period_days: this.environmentService.get( trial_period_days: this.environmentService.get(
'BILLING_FREE_TRIAL_DURATION_IN_DAYS', 'BILLING_FREE_TRIAL_DURATION_IN_DAYS',

View File

@ -1,7 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql'; import { Field, ObjectType } from '@nestjs/graphql';
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces'; import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
import { AuthProviders } from 'src/engine/core-modules/workspace/dtos/public-workspace-data.output'; import { AuthProviders } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
@ObjectType() @ObjectType()
class Billing { class Billing {

View File

@ -0,0 +1,11 @@
import { CustomException } from 'src/utils/custom-exception';
export class DomainManagerException extends CustomException {
constructor(message: string, code: DomainManagerExceptionCode) {
super(message, code);
}
}
export enum DomainManagerExceptionCode {
SUBDOMAIN_REQUIRED = 'SUBDOMAIN_REQUIRED',
}

View File

@ -117,6 +117,14 @@ export class DomainManagerService {
return subdomain; return subdomain;
}; };
async getWorkspaceBySubdomainOrDefaultWorkspace(subdomain?: string) {
return subdomain
? await this.workspaceRepository.findOne({
where: { subdomain },
})
: await this.getDefaultWorkspace();
}
isDefaultSubdomain(subdomain: string) { isDefaultSubdomain(subdomain: string) {
return subdomain === this.environmentService.get('DEFAULT_SUBDOMAIN'); return subdomain === this.environmentService.get('DEFAULT_SUBDOMAIN');
} }
@ -137,7 +145,7 @@ export class DomainManagerService {
return url.toString(); return url.toString();
} }
async getDefaultWorkspace() { private async getDefaultWorkspace() {
if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) { if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) {
const workspaces = await this.workspaceRepository.find({ const workspaces = await this.workspaceRepository.find({
order: { order: {
@ -147,7 +155,6 @@ export class DomainManagerService {
}); });
if (workspaces.length > 1) { if (workspaces.length > 1) {
// TODO AMOREAUX: this logger is trigger twice and the second time the message is undefined for an unknown reason
Logger.warn( Logger.warn(
`In single-workspace mode, there should be only one workspace. Today there are ${workspaces.length} workspaces`, `In single-workspace mode, there should be only one workspace. Today there are ${workspaces.length} workspaces`,
); );
@ -161,7 +168,7 @@ export class DomainManagerService {
); );
} }
async getWorkspaceByOrigin(origin: string) { async getWorkspaceByOriginOrDefaultWorkspace(origin: string) {
try { try {
if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) { if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) {
return this.getDefaultWorkspace(); return this.getDefaultWorkspace();
@ -171,12 +178,10 @@ export class DomainManagerService {
if (!isDefined(subdomain)) return; if (!isDefined(subdomain)) return;
const workspace = await this.workspaceRepository.findOne({ return await this.workspaceRepository.findOne({
where: { subdomain }, where: { subdomain },
relations: ['workspaceSSOIdentityProviders'], relations: ['workspaceSSOIdentityProviders'],
}); });
return workspace;
} catch (e) { } catch (e) {
throw new WorkspaceException( throw new WorkspaceException(
'Workspace not found', 'Workspace not found',

View File

@ -4,7 +4,10 @@ import { BillingService } from 'src/engine/core-modules/billing/services/billing
import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum'; import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service'; import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceActivationStatus } from 'src/engine/core-modules/workspace/workspace.entity'; import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
export enum OnboardingStepKeys { export enum OnboardingStepKeys {
ONBOARDING_CONNECT_ACCOUNT_PENDING = 'ONBOARDING_CONNECT_ACCOUNT_PENDING', ONBOARDING_CONNECT_ACCOUNT_PENDING = 'ONBOARDING_CONNECT_ACCOUNT_PENDING',
@ -25,34 +28,33 @@ export class OnboardingService {
private readonly userVarsService: UserVarsService<OnboardingKeyValueTypeMap>, private readonly userVarsService: UserVarsService<OnboardingKeyValueTypeMap>,
) {} ) {}
private async isSubscriptionIncompleteOnboardingStatus(user: User) { private async isSubscriptionIncompleteOnboardingStatus(workspace: Workspace) {
const hasSubscription = const hasSubscription =
await this.billingService.hasWorkspaceActiveSubscriptionOrFreeAccessOrEntitlement( await this.billingService.hasWorkspaceActiveSubscriptionOrFreeAccessOrEntitlement(
user.defaultWorkspaceId, workspace.id,
); );
return !hasSubscription; return !hasSubscription;
} }
private isWorkspaceActivationPending(user: User) { private isWorkspaceActivationPending(workspace: Workspace) {
return ( return (
user.defaultWorkspace.activationStatus === workspace.activationStatus === WorkspaceActivationStatus.PENDING_CREATION
WorkspaceActivationStatus.PENDING_CREATION
); );
} }
async getOnboardingStatus(user: User) { async getOnboardingStatus(user: User, workspace: Workspace) {
if (await this.isSubscriptionIncompleteOnboardingStatus(user)) { if (await this.isSubscriptionIncompleteOnboardingStatus(workspace)) {
return OnboardingStatus.PLAN_REQUIRED; return OnboardingStatus.PLAN_REQUIRED;
} }
if (this.isWorkspaceActivationPending(user)) { if (this.isWorkspaceActivationPending(workspace)) {
return OnboardingStatus.WORKSPACE_ACTIVATION; return OnboardingStatus.WORKSPACE_ACTIVATION;
} }
const userVars = await this.userVarsService.getAll({ const userVars = await this.userVarsService.getAll({
userId: user.id, userId: user.id,
workspaceId: user.defaultWorkspaceId, workspaceId: workspace.id,
}); });
const isProfileCreationPending = const isProfileCreationPending =

View File

@ -110,18 +110,12 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
await this.createWorkspaceMember(workspace.id, user); await this.createWorkspaceMember(workspace.id, user);
} }
const savedUser = await this.userRepository.save({
id: user.id,
defaultWorkspace: workspace,
updatedAt: new Date().toISOString(),
});
await this.workspaceInvitationService.invalidateWorkspaceInvitation( await this.workspaceInvitationService.invalidateWorkspaceInvitation(
workspace.id, workspace.id,
user.email, user.email,
); );
return savedUser; return user;
} }
async addUserToWorkspaceByInviteToken(inviteToken: string, user: User) { async addUserToWorkspaceByInviteToken(inviteToken: string, user: User) {

View File

@ -72,18 +72,13 @@ export class UserService extends TypeOrmQueryService<User> {
return workspaceMemberRepository.find(); return workspaceMemberRepository.find();
} }
async deleteUser(userId: string): Promise<User> { private async deleteUserFromWorkspace({
const user = await this.userRepository.findOne({ userId,
where: { workspaceId,
id: userId, }: {
}, userId: string;
relations: ['defaultWorkspace'], workspaceId: string;
}); }) {
assert(user, 'User not found');
const workspaceId = user.defaultWorkspaceId;
const dataSourceMetadata = const dataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
workspaceId, workspaceId,
@ -103,8 +98,6 @@ export class UserService extends TypeOrmQueryService<User> {
if (workspaceMembers.length === 1) { if (workspaceMembers.length === 1) {
await this.workspaceService.deleteWorkspace(workspaceId); await this.workspaceService.deleteWorkspace(workspaceId);
return user;
} }
await workspaceDataSource?.query( await workspaceDataSource?.query(
@ -131,6 +124,19 @@ export class UserService extends TypeOrmQueryService<User> {
], ],
workspaceId, workspaceId,
}); });
}
async deleteUser(userId: string): Promise<User> {
const user = await this.userRepository.findOne({
where: {
id: userId,
},
relations: ['workspaces'],
});
userValidator.assertIsDefinedOrThrow(user);
await Promise.all(user.workspaces.map(this.deleteUserFromWorkspace));
return user; return user;
} }
@ -154,16 +160,4 @@ export class UserService extends TypeOrmQueryService<User> {
), ),
); );
} }
async saveDefaultWorkspaceIfUserHasAccessOrThrow(
userId: string,
workspaceId: string,
) {
await this.hasUserAccessToWorkspaceOrThrow(userId, workspaceId);
return await this.userRepository.save({
id: userId,
defaultWorkspaceId: workspaceId,
});
}
} }

View File

@ -6,7 +6,6 @@ import {
CreateDateColumn, CreateDateColumn,
Entity, Entity,
Index, Index,
ManyToOne,
OneToMany, OneToMany,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Relation, Relation,
@ -81,16 +80,6 @@ export class User {
@Column({ nullable: true, type: 'timestamptz' }) @Column({ nullable: true, type: 'timestamptz' })
deletedAt: Date; deletedAt: Date;
@Field(() => Workspace, { nullable: false })
@ManyToOne(() => Workspace, (workspace) => workspace.users, {
onDelete: 'RESTRICT',
})
defaultWorkspace: Relation<Workspace>;
@Field()
@Column()
defaultWorkspaceId: string;
@OneToMany(() => AppToken, (appToken) => appToken.user, { @OneToMany(() => AppToken, (appToken) => appToken.user, {
cascade: true, cascade: true,
}) })
@ -110,4 +99,7 @@ export class User {
@Field(() => OnboardingStatus, { nullable: true }) @Field(() => OnboardingStatus, { nullable: true })
onboardingStatus: OnboardingStatus; onboardingStatus: OnboardingStatus;
@Field(() => Workspace, { nullable: true })
currentWorkspace: Relation<Workspace>;
} }

View File

@ -0,0 +1,11 @@
import { CustomException } from 'src/utils/custom-exception';
export class UserException extends CustomException {
constructor(message: string, code: UserExceptionCode) {
super(message, code);
}
}
export enum UserExceptionCode {
USER_NOT_FOUND = 'USER_NOT_FOUND',
}

View File

@ -18,6 +18,7 @@ import { UserResolver } from 'src/engine/core-modules/user/user.resolver';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { userAutoResolverOpts } from './user.auto-resolver-opts'; import { userAutoResolverOpts } from './user.auto-resolver-opts';
@ -41,6 +42,7 @@ import { UserService } from './services/user.service';
TypeOrmModule.forFeature([KeyValuePair], 'core'), TypeOrmModule.forFeature([KeyValuePair], 'core'),
UserVarsModule, UserVarsModule,
AnalyticsModule, AnalyticsModule,
DomainManagerModule,
], ],
exports: [UserService], exports: [UserService],
providers: [UserService, UserResolver, TypeORMService], providers: [UserService, UserResolver, TypeORMService],

View File

@ -44,6 +44,9 @@ import {
AuthExceptionCode, AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception'; } from 'src/engine/core-modules/auth/auth.exception';
import { userValidator } from 'src/engine/core-modules/user/user.validate'; import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
const getHMACKey = (email?: string, key?: string | null) => { const getHMACKey = (email?: string, key?: string | null) => {
if (!email || !key) return null; if (!email || !key) return null;
@ -66,28 +69,31 @@ export class UserResolver {
private readonly userVarService: UserVarsService, private readonly userVarService: UserVarsService,
private readonly fileService: FileService, private readonly fileService: FileService,
private readonly analyticsService: AnalyticsService, private readonly analyticsService: AnalyticsService,
private readonly domainManagerService: DomainManagerService,
) {} ) {}
@Query(() => User) @Query(() => User)
async currentUser( async currentUser(
@AuthUser() { id: userId }: User, @AuthUser() { id: userId }: User,
@AuthWorkspace() { id: workspaceId }: Workspace, @OriginHeader() origin: string,
): Promise<User> { ): Promise<User> {
if ( const workspace =
this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') && await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
workspaceId origin,
) {
await this.userService.saveDefaultWorkspaceIfUserHasAccessOrThrow(
userId,
workspaceId,
); );
}
workspaceValidator.assertIsDefinedOrThrow(workspace);
await this.userService.hasUserAccessToWorkspaceOrThrow(
userId,
workspace.id,
);
const user = await this.userRepository.findOne({ const user = await this.userRepository.findOne({
where: { where: {
id: userId, id: userId,
}, },
relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'], relations: ['workspaces', 'workspaces.workspace'],
}); });
userValidator.assertIsDefinedOrThrow( userValidator.assertIsDefinedOrThrow(
@ -95,14 +101,17 @@ export class UserResolver {
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND), new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
); );
return user; return { ...user, currentWorkspace: workspace };
} }
@ResolveField(() => GraphQLJSONObject) @ResolveField(() => GraphQLJSONObject)
async userVars(@Parent() user: User): Promise<Record<string, any>> { async userVars(
@Parent() user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<Record<string, any>> {
const userVars = await this.userVarService.getAll({ const userVars = await this.userVarService.getAll({
userId: user.id, userId: user.id,
workspaceId: user.defaultWorkspaceId, workspaceId: workspace.id,
}); });
const userVarAllowList = [ const userVarAllowList = [
@ -127,13 +136,13 @@ export class UserResolver {
): Promise<WorkspaceMember | null> { ): Promise<WorkspaceMember | null> {
const workspaceMember = await this.userService.loadWorkspaceMember( const workspaceMember = await this.userService.loadWorkspaceMember(
user, user,
workspace ?? user.defaultWorkspace, workspace,
); );
if (workspaceMember && workspaceMember.avatarUrl) { if (workspaceMember && workspaceMember.avatarUrl) {
const avatarUrlToken = await this.fileService.encodeFileToken({ const avatarUrlToken = await this.fileService.encodeFileToken({
workspaceMemberId: workspaceMember.id, workspaceMemberId: workspaceMember.id,
workspaceId: user.defaultWorkspaceId, workspaceId: workspace.id,
}); });
workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`; workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`;
@ -146,16 +155,18 @@ export class UserResolver {
@ResolveField(() => [WorkspaceMember], { @ResolveField(() => [WorkspaceMember], {
nullable: true, nullable: true,
}) })
async workspaceMembers(@Parent() user: User): Promise<WorkspaceMember[]> { async workspaceMembers(
const workspaceMembers = await this.userService.loadWorkspaceMembers( @Parent() user: User,
user.defaultWorkspace, @AuthWorkspace() workspace: Workspace,
); ): Promise<WorkspaceMember[]> {
const workspaceMembers =
await this.userService.loadWorkspaceMembers(workspace);
for (const workspaceMember of workspaceMembers) { for (const workspaceMember of workspaceMembers) {
if (workspaceMember.avatarUrl) { if (workspaceMember.avatarUrl) {
const avatarUrlToken = await this.fileService.encodeFileToken({ const avatarUrlToken = await this.fileService.encodeFileToken({
workspaceMemberId: workspaceMember.id, workspaceMemberId: workspaceMember.id,
workspaceId: user.defaultWorkspaceId, workspaceId: workspace.id,
}); });
workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`; workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`;
@ -221,7 +232,17 @@ export class UserResolver {
} }
@ResolveField(() => OnboardingStatus) @ResolveField(() => OnboardingStatus)
async onboardingStatus(@Parent() user: User): Promise<OnboardingStatus> { async onboardingStatus(
return this.onboardingService.getOnboardingStatus(user); @Parent() user: User,
@OriginHeader() origin: string,
): Promise<OnboardingStatus> {
const workspace =
await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
origin,
);
workspaceValidator.assertIsDefinedOrThrow(workspace);
return this.onboardingService.getOnboardingStatus(user, workspace);
} }
} }

View File

@ -1,10 +1,17 @@
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { CustomException } from 'src/utils/custom-exception'; import { CustomException } from 'src/utils/custom-exception';
import { isDefined } from 'src/utils/is-defined'; import { isDefined } from 'src/utils/is-defined';
import {
UserException,
UserExceptionCode,
} from 'src/engine/core-modules/user/user.exception';
const assertIsDefinedOrThrow = ( const assertIsDefinedOrThrow = (
user: User | undefined | null, user: User | undefined | null,
exceptionToThrow: CustomException, exceptionToThrow: CustomException = new UserException(
'User not found',
UserExceptionCode.USER_NOT_FOUND,
),
): asserts user is User => { ): asserts user is User => {
if (!isDefined(user)) { if (!isDefined(user)) {
throw exceptionToThrow; throw exceptionToThrow;

View File

@ -142,11 +142,10 @@ export class WorkspaceInvitationService {
subdomain?: string; subdomain?: string;
email: string; email: string;
}) { }) {
const workspace = this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') const workspace =
? await this.workspaceRepository.findOneBy({ await this.domainManagerService.getWorkspaceBySubdomainOrDefaultWorkspace(
subdomain, subdomain,
}) );
: await this.domainManagerService.getDefaultWorkspace();
if (!workspace) return; if (!workspace) return;

View File

@ -1,13 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
@ObjectType()
export class ActivateWorkspaceOutput {
@Field(() => Workspace)
workspace: Workspace;
@Field(() => AuthToken)
loginToken: AuthToken;
}

View File

@ -0,0 +1,10 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class WorkspaceSubdomainAndId {
@Field()
subdomain: string;
@Field()
id: string;
}

View File

@ -51,7 +51,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
id: payload.id, id: payload.id,
}); });
workspaceValidator.assertIsExist( workspaceValidator.assertIsDefinedOrThrow(
workspace, workspace,
new WorkspaceException( new WorkspaceException(
'Workspace not found', 'Workspace not found',
@ -81,55 +81,46 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
}); });
} }
async activateWorkspace(user: User, data: ActivateWorkspaceInput) { async activateWorkspace(
user: User,
workspace: Workspace,
data: ActivateWorkspaceInput,
) {
if (!data.displayName || !data.displayName.length) { if (!data.displayName || !data.displayName.length) {
throw new BadRequestException("'displayName' not provided"); throw new BadRequestException("'displayName' not provided");
} }
const existingWorkspace = await this.workspaceRepository.findOneBy({
id: user.defaultWorkspaceId,
});
if (!existingWorkspace) {
throw new Error('Workspace not found');
}
if ( if (
existingWorkspace.activationStatus === workspace.activationStatus === WorkspaceActivationStatus.ONGOING_CREATION
WorkspaceActivationStatus.ONGOING_CREATION
) { ) {
throw new Error('Workspace is already being created'); throw new Error('Workspace is already being created');
} }
if ( if (
existingWorkspace.activationStatus !== workspace.activationStatus !== WorkspaceActivationStatus.PENDING_CREATION
WorkspaceActivationStatus.PENDING_CREATION
) { ) {
throw new Error('Workspace is not pending creation'); throw new Error('Workspace is not pending creation');
} }
await this.workspaceRepository.update(user.defaultWorkspaceId, { await this.workspaceRepository.update(workspace.id, {
activationStatus: WorkspaceActivationStatus.ONGOING_CREATION, activationStatus: WorkspaceActivationStatus.ONGOING_CREATION,
}); });
await this.featureFlagService.enableFeatureFlags( await this.featureFlagService.enableFeatureFlags(
DEFAULT_FEATURE_FLAGS, DEFAULT_FEATURE_FLAGS,
user.defaultWorkspaceId, workspace.id,
); );
await this.workspaceManagerService.init(user.defaultWorkspaceId); await this.workspaceManagerService.init(workspace.id);
await this.userWorkspaceService.createWorkspaceMember( await this.userWorkspaceService.createWorkspaceMember(workspace.id, user);
user.defaultWorkspaceId,
user,
);
await this.workspaceRepository.update(user.defaultWorkspaceId, { await this.workspaceRepository.update(workspace.id, {
displayName: data.displayName, displayName: data.displayName,
activationStatus: WorkspaceActivationStatus.ACTIVE, activationStatus: WorkspaceActivationStatus.ACTIVE,
}); });
return await this.workspaceRepository.findOneBy({ return await this.workspaceRepository.findOneBy({
id: user.defaultWorkspaceId, id: workspace.id,
}); });
} }
@ -170,41 +161,6 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
userId, userId,
workspaceId, workspaceId,
}); });
await this.reassignOrRemoveUserDefaultWorkspace(workspaceId, userId);
}
private async reassignOrRemoveUserDefaultWorkspace(
workspaceId: string,
userId: string,
) {
const userWorkspaces = await this.userWorkspaceRepository.find({
where: { userId: userId },
});
if (userWorkspaces.length === 0) {
await this.userRepository.delete({ id: userId });
return;
}
const user = await this.userRepository.findOne({
where: {
id: userId,
},
});
if (!user) {
throw new Error(`User ${userId} not found in workspace ${workspaceId}`);
}
if (user.defaultWorkspaceId === workspaceId) {
await this.userRepository.update(
{ id: userId },
{
defaultWorkspaceId: userWorkspaces[0].workspaceId,
},
);
}
} }
async isSubdomainAvailable(subdomain: string) { async isSubdomainAvailable(subdomain: string) {

View File

@ -1,4 +1,4 @@
import { AuthProviders } from 'src/engine/core-modules/workspace/dtos/public-workspace-data.output'; import { AuthProviders } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
export const getAuthProvidersByWorkspace = ({ export const getAuthProvidersByWorkspace = ({

View File

@ -21,7 +21,6 @@ import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-p
import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity'; import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
export enum WorkspaceActivationStatus { export enum WorkspaceActivationStatus {
ONGOING_CREATION = 'ONGOING_CREATION', ONGOING_CREATION = 'ONGOING_CREATION',
@ -89,9 +88,6 @@ export class Workspace {
}) })
keyValuePairs: Relation<KeyValuePair[]>; keyValuePairs: Relation<KeyValuePair[]>;
@OneToMany(() => User, (user) => user.defaultWorkspace)
users: Relation<User[]>;
@OneToMany(() => UserWorkspace, (userWorkspace) => userWorkspace.workspace, { @OneToMany(() => UserWorkspace, (userWorkspace) => userWorkspace.workspace, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })

View File

@ -22,11 +22,10 @@ import { FileService } from 'src/engine/core-modules/file/services/file.service'
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { ActivateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/activate-workspace-input'; import { ActivateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/activate-workspace-input';
import { ActivateWorkspaceOutput } from 'src/engine/core-modules/workspace/dtos/activate-workspace-output';
import { import {
AuthProviders, AuthProviders,
PublicWorkspaceDataOutput, PublicWorkspaceDataOutput,
} from 'src/engine/core-modules/workspace/dtos/public-workspace-data.output'; } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
import { UpdateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/update-workspace-input'; import { UpdateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/update-workspace-input';
import { getAuthProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/get-auth-providers-by-workspace.util'; import { getAuthProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/get-auth-providers-by-workspace.util';
import { workspaceGraphqlApiExceptionHandler } from 'src/engine/core-modules/workspace/utils/workspace-graphql-api-exception-handler.util'; import { workspaceGraphqlApiExceptionHandler } from 'src/engine/core-modules/workspace/utils/workspace-graphql-api-exception-handler.util';
@ -74,21 +73,21 @@ export class WorkspaceResolver {
return workspace; return workspace;
} }
@Mutation(() => ActivateWorkspaceOutput) @Mutation(() => Workspace)
@UseGuards(UserAuthGuard) @UseGuards(UserAuthGuard)
async activateWorkspace( async activateWorkspace(
@Args('data') data: ActivateWorkspaceInput, @Args('data') data: ActivateWorkspaceInput,
@AuthUser() user: User, @AuthUser() user: User,
@OriginHeader() origin: string,
) { ) {
const workspace = await this.workspaceService.activateWorkspace(user, data); const workspace =
const loginToken = await this.loginTokenService.generateLoginToken( await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
user.email, origin,
); );
return { workspaceValidator.assertIsDefinedOrThrow(workspace);
workspace,
loginToken, return await this.workspaceService.activateWorkspace(user, workspace, data);
};
} }
@Mutation(() => Workspace) @Mutation(() => Workspace)
@ -188,9 +187,11 @@ export class WorkspaceResolver {
@Query(() => PublicWorkspaceDataOutput) @Query(() => PublicWorkspaceDataOutput)
async getPublicWorkspaceDataBySubdomain(@OriginHeader() origin: string) { async getPublicWorkspaceDataBySubdomain(@OriginHeader() origin: string) {
const workspace = const workspace =
await this.domainManagerService.getWorkspaceByOrigin(origin); await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
origin,
);
workspaceValidator.assertIsExist( workspaceValidator.assertIsDefinedOrThrow(
workspace, workspace,
new WorkspaceException( new WorkspaceException(
'Workspace not found', 'Workspace not found',

View File

@ -3,12 +3,22 @@ import {
WorkspaceActivationStatus, WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity'; } from 'src/engine/core-modules/workspace/workspace.entity';
import { CustomException } from 'src/utils/custom-exception'; import { CustomException } from 'src/utils/custom-exception';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type'; import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type';
import {
WorkspaceException,
WorkspaceExceptionCode,
} from 'src/engine/core-modules/workspace/workspace.exception';
const assertIsExist = ( const assertIsDefinedOrThrow = (
workspace: Workspace | undefined | null, workspace: Workspace | undefined | null,
exceptionToThrow?: CustomException, exceptionToThrow: CustomException = new WorkspaceException(
'Workspace not found',
WorkspaceExceptionCode.WORKSPACE_NOT_FOUND,
),
): asserts workspace is Workspace => { ): asserts workspace is Workspace => {
if (!workspace) { if (!workspace) {
throw exceptionToThrow; throw exceptionToThrow;
@ -28,7 +38,10 @@ const assertIsActive = (
const isAuthEnabledOrThrow = ( const isAuthEnabledOrThrow = (
provider: WorkspaceAuthProvider, provider: WorkspaceAuthProvider,
workspace: Workspace, workspace: Workspace,
exceptionToThrowCustom: AuthException, exceptionToThrowCustom: AuthException = new AuthException(
`${provider} auth is not enabled for this workspace`,
AuthExceptionCode.OAUTH_ACCESS_DENIED,
),
) => { ) => {
if (provider === 'google' && workspace.isGoogleAuthEnabled) return true; if (provider === 'google' && workspace.isGoogleAuthEnabled) return true;
if (provider === 'microsoft' && workspace.isMicrosoftAuthEnabled) return true; if (provider === 'microsoft' && workspace.isMicrosoftAuthEnabled) return true;
@ -38,11 +51,11 @@ const isAuthEnabledOrThrow = (
}; };
export const workspaceValidator: { export const workspaceValidator: {
assertIsExist: typeof assertIsExist; assertIsDefinedOrThrow: typeof assertIsDefinedOrThrow;
assertIsActive: typeof assertIsActive; assertIsActive: typeof assertIsActive;
isAuthEnabledOrThrow: typeof isAuthEnabledOrThrow; isAuthEnabledOrThrow: typeof isAuthEnabledOrThrow;
} = { } = {
assertIsExist: assertIsExist, assertIsDefinedOrThrow: assertIsDefinedOrThrow,
assertIsActive: assertIsActive, assertIsActive: assertIsActive,
isAuthEnabledOrThrow: isAuthEnabledOrThrow, isAuthEnabledOrThrow: isAuthEnabledOrThrow,
}; };

View File

@ -0,0 +1,15 @@
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { Observable } from 'rxjs';
export class ImpersonateGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const ctx = GqlExecutionContext.create(context);
const request = ctx.getContext().req;
return request.user.canImpersonate === true;
}
}

View File

@ -1,33 +0,0 @@
import { isDefined } from 'src/utils/is-defined';
export const buildWorkspaceURL = (
baseUrl: string,
subdomain: string | null,
{
withPathname,
withSearchParams,
}: {
withPathname?: string;
withSearchParams?: Record<string, string | number>;
} = {},
) => {
const url = new URL(baseUrl);
if (subdomain && subdomain.length > 0) {
url.hostname = subdomain + '.' + url.hostname;
}
if (withPathname) {
url.pathname = withPathname;
}
if (withSearchParams) {
Object.entries(withSearchParams).forEach(([key, value]) => {
if (isDefined(value)) {
url.searchParams.set(key, value.toString());
}
});
}
return url;
};