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

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

View File

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

View File

@ -254,10 +254,10 @@ const SettingsAdmin = lazy(() =>
})),
);
const SettingsAdminFeatureFlags = lazy(() =>
import('~/pages/settings/admin-panel/SettingsAdminFeatureFlags').then(
const SettingsAdminContent = lazy(() =>
import('@/settings/admin-panel/components/SettingsAdminContent').then(
(module) => ({
default: module.SettingsAdminFeatureFlags,
default: module.SettingsAdminContent,
}),
),
);
@ -402,7 +402,7 @@ export const SettingsRoutes = ({
<Route path={SettingsPath.AdminPanel} element={<SettingsAdmin />} />
<Route
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 !
export const IMPERSONATE = gql`
mutation Impersonate($userId: String!) {
impersonate(userId: $userId) {
user {
...UserQueryFragment
mutation Impersonate($userId: String!, $workspaceId: String!) {
impersonate(userId: $userId, workspaceId: $workspaceId) {
workspace {
subdomain
id
}
tokens {
...AuthTokensFragment
loginToken {
...AuthTokenFragment
}
}
}

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import {
ChallengeDocument,
GetCurrentUserDocument,
SignUpDocument,
VerifyDocument,
} from '~/generated/graphql';
@ -8,6 +9,7 @@ export const queries = {
challenge: ChallengeDocument,
verify: VerifyDocument,
signup: SignUpDocument,
getCurrentUser: GetCurrentUserDocument,
};
export const email = 'test@test.com';
@ -22,6 +24,7 @@ export const variables = {
},
verify: { loginToken: token },
signup: {},
getCurrentUser: {},
};
export const results = {
@ -32,7 +35,14 @@ export const results = {
},
},
verify: {
user: {
tokens: {
accessToken: { token, expiresAt: 'expiresAt' },
refreshToken: { token, expiresAt: 'expiresAt' },
},
},
signUp: { loginToken: { token, expiresAt: 'expiresAt' } },
getCurrentUser: {
currentUser: {
id: 'id',
firstName: 'firstName',
lastName: 'lastName',
@ -49,7 +59,7 @@ export const results = {
avatarUrl: 'avatarUrl',
locale: 'locale',
},
defaultWorkspace: {
currentWorkspace: {
id: 'id',
displayName: 'displayName',
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 = [
@ -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 { MockedProvider } from '@apollo/client/testing';
import { expect } from '@storybook/test';
import { act, renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { ReactNode, act } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { iconsState } from 'twenty-ui';
@ -15,6 +14,7 @@ import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthPro
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { email, mocks, password, results, token } from '../__mocks__/useAuth';
import { renderHook } from '@testing-library/react';
const Wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider mocks={mocks} addTypename={false}>
@ -59,6 +59,7 @@ describe('useAuth', () => {
});
expect(mocks[1].result).toHaveBeenCalled();
expect(mocks[3].result).toHaveBeenCalled();
});
it('should handle credential sign-in', async () => {

View File

@ -4,6 +4,7 @@ import {
snapshot_UNSTABLE,
useGotoRecoilSnapshot,
useRecoilCallback,
useRecoilValue,
useSetRecoilState,
} from 'recoil';
import { iconsState } from 'twenty-ui';
@ -23,6 +24,7 @@ import { REACT_APP_SERVER_BASE_URL } from '~/config';
import {
useChallengeMutation,
useCheckUserExistsLazyQuery,
useGetCurrentUserLazyQuery,
useSignUpMutation,
useVerifyMutation,
} from '~/generated/graphql';
@ -48,6 +50,8 @@ import { useReadWorkspaceSubdomainFromCurrentLocation } from '@/domain-manager/h
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
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';
export const useAuth = () => {
@ -62,15 +66,19 @@ export const useAuth = () => {
const setCurrentWorkspaceMembers = useSetRecoilState(
currentWorkspaceMembersState,
);
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const setIsVerifyPendingState = useSetRecoilState(isVerifyPendingState);
const setWorkspaces = useSetRecoilState(workspacesState);
const { redirect } = useRedirect();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const [challenge] = useChallengeMutation();
const [signUp] = useSignUpMutation();
const [verify] = useVerifyMutation();
const [getCurrentUser] = useGetCurrentUserLazyQuery();
const { isOnAWorkspaceSubdomain } =
useIsCurrentLocationOnAWorkspaceSubdomain();
const { workspaceSubdomain } = useReadWorkspaceSubdomainFromCurrentLocation();
@ -165,6 +173,98 @@ export const useAuth = () => {
[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(
async (loginToken: string) => {
const verifyResult = await verify({
@ -181,74 +281,7 @@ export const useAuth = () => {
setTokenPair(verifyResult.data?.verify.tokens);
const user = verifyResult.data?.verify.user;
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);
const { user, workspaceMember, workspace } = await loadCurrentUser();
return {
user,
@ -257,19 +290,7 @@ export const useAuth = () => {
tokens: verifyResult.data?.verify.tokens,
};
},
[
verify,
setTokenPair,
setCurrentUser,
setCurrentWorkspace,
isOnAWorkspaceSubdomain,
setIsAppWaitingForFreshObjectMetadataState,
setCurrentWorkspaceMembers,
setCurrentWorkspaceMember,
setDateTimeFormat,
setLastAuthenticateWorkspaceDomain,
setWorkspaces,
],
[verify, setTokenPair, loadCurrentUser],
);
const handleCrendentialsSignIn = useCallback(
@ -328,6 +349,16 @@ export const useAuth = () => {
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(
signUpResult.data?.signUp.loginToken.token,
);
@ -336,7 +367,13 @@ export const useAuth = () => {
return { user, workspaceMember, workspace };
},
[setIsVerifyPendingState, signUp, handleVerify],
[
setIsVerifyPendingState,
signUp,
isMultiWorkspaceEnabled,
handleVerify,
redirectToWorkspaceDomain,
],
);
const buildRedirectUrl = useCallback(
@ -357,6 +394,7 @@ export const useAuth = () => {
params.workspacePersonalInviteToken,
);
}
if (isDefined(workspaceSubdomain)) {
url.searchParams.set('workspaceSubdomain', workspaceSubdomain);
}
@ -390,6 +428,8 @@ export const useAuth = () => {
challenge: handleChallenge,
verify: handleVerify,
loadCurrentUser,
checkUserExists: { checkUserExistsData, checkUserExistsQuery },
clearSession,
signOut: handleSignOut,

View File

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

View File

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

View File

@ -1,10 +1,6 @@
import { SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID } from '@/settings/admin-panel/constants/SettingsAdminFeatureFlagsTabs';
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 { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { Table } from '@/ui/layout/table/components/Table';
@ -22,11 +18,13 @@ import {
H1TitleFontColor,
H2Title,
IconSearch,
IconUser,
isDefined,
Section,
Toggle,
} from 'twenty-ui';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useImpersonate } from '@/settings/admin-panel/hooks/useImpersonate';
const StyledLinkContainer = styled.div`
margin-right: ${({ theme }) => theme.spacing(2)};
@ -66,8 +64,16 @@ const StyledContentContainer = styled.div`
padding: ${({ theme }) => theme.spacing(4)} 0;
`;
export const SettingsAdminFeatureFlags = () => {
export const SettingsAdminContent = () => {
const [userIdentifier, setUserIdentifier] = useState('');
const [userId, setUserId] = useState('');
const {
handleImpersonate,
isLoading: isImpersonateLoading,
error: impersonateError,
canImpersonate,
} = useImpersonate();
const { activeTabId, setActiveTabId } = useTabList(
SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID,
@ -86,6 +92,10 @@ export const SettingsAdminFeatureFlags = () => {
const result = await handleUserLookup(userIdentifier);
if (isDefined(result?.user?.id) && !error) {
setUserId(result.user.id.trim());
}
if (
isDefined(result?.workspaces) &&
result.workspaces.length > 0 &&
@ -126,6 +136,21 @@ export const SettingsAdminFeatureFlags = () => {
}`}
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>
<TableRow
gridAutoColumns="1fr 100px"
@ -162,82 +187,69 @@ export const SettingsAdminFeatureFlags = () => {
};
return (
<SubMenuTopBarContainer
title="Feature Flags"
links={[
{
children: 'Other',
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."
/>
<>
<Section>
<H2Title
title="Feature Flags & Impersonation"
description="Look up users and manage their workspace feature flags or impersonate it."
/>
<StyledContainer>
<StyledLinkContainer>
<TextInput
value={userIdentifier}
onChange={setUserIdentifier}
onInputEnter={handleSearch}
placeholder="Enter user ID or email address"
fullWidth
disabled={isLoading}
/>
</StyledLinkContainer>
<Button
Icon={IconSearch}
variant="primary"
accent="blue"
title="Search"
onClick={handleSearch}
disabled={!userIdentifier.trim() || isLoading}
<StyledContainer>
<StyledLinkContainer>
<TextInput
value={userIdentifier}
onChange={setUserIdentifier}
onInputEnter={handleSearch}
placeholder="Enter user ID or email address"
fullWidth
disabled={isLoading}
/>
</StyledContainer>
</StyledLinkContainer>
<Button
Icon={IconSearch}
variant="primary"
accent="blue"
title="Search"
onClick={handleSearch}
disabled={!userIdentifier.trim() || isLoading}
/>
</StyledContainer>
{error && <StyledErrorSection>{error}</StyledErrorSection>}
</Section>
{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>
{(error || impersonateError) && (
<StyledErrorSection>{error ?? impersonateError}</StyledErrorSection>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
</Section>
{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
logo
totalUsers
allowImpersonation
users {
id
email

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,16 +2,12 @@ import styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCallback } from 'react';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { H2Title, Loader, MainButton } from 'twenty-ui';
import { z } from 'zod';
import { SubTitle } from '@/auth/components/SubTitle';
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 { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
@ -22,9 +18,7 @@ import {
useActivateWorkspaceMutation,
} from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { AppPath } from '@/types/AppPath';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { useAuth } from '@/auth/hooks/useAuth';
const StyledContentContainer = styled.div`
width: 100%;
@ -50,12 +44,9 @@ type Form = z.infer<typeof validationSchema>;
export const CreateWorkspace = () => {
const { enqueueSnackBar } = useSnackBar();
const onboardingStatus = useOnboardingStatus();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const { loadCurrentUser } = useAuth();
const [activateWorkspace] = useActivateWorkspaceMutation();
const apolloMetadataClient = useApolloMetadataClient();
const setIsCurrentUserLoaded = useSetRecoilState(isCurrentUserLoadedState);
// Form
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)) {
throw result.errors ?? new Error('Unknown error');
}
await loadCurrentUser();
} catch (error: any) {
enqueueSnackBar(error?.message, {
variant: SnackBarVariant.Error,
});
}
},
[
activateWorkspace,
setIsCurrentUserLoaded,
isMultiWorkspaceEnabled,
apolloMetadataClient,
redirectToWorkspaceDomain,
enqueueSnackBar,
],
[activateWorkspace, enqueueSnackBar, loadCurrentUser],
);
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 { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useTheme } from '@emotion/react';
import { IconFlag, UndecoratedLink } from 'twenty-ui';
import { SettingsAdminContent } from '@/settings/admin-panel/components/SettingsAdminContent';
export const SettingsAdmin = () => {
const theme = useTheme();
return (
<SubMenuTopBarContainer
title="Server Admin Panel"
@ -21,18 +17,7 @@ export const SettingsAdmin = () => {
]}
>
<SettingsPageContainer>
<SettingsAdminImpersonateUsers />
<UndecoratedLink to={getSettingsPagePath(SettingsPath.FeatureFlags)}>
<SettingsCard
Icon={
<IconFlag
size={theme.icon.size.lg}
stroke={theme.icon.stroke.sm}
/>
}
title="Feature Flags"
/>
</UndecoratedLink>
<SettingsAdminContent />
</SettingsPageContainer>
</SubMenuTopBarContainer>
);

View File

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