diff --git a/packages/twenty-emails/src/emails/server-admin-access-changed.email.tsx b/packages/twenty-emails/src/emails/server-admin-access-changed.email.tsx new file mode 100644 index 0000000000..7334b1aac7 --- /dev/null +++ b/packages/twenty-emails/src/emails/server-admin-access-changed.email.tsx @@ -0,0 +1,78 @@ +import { Trans } from '@lingui/react'; +import { BaseEmail } from 'src/components/BaseEmail'; +import { MainText } from 'src/components/MainText'; +import { Title } from 'src/components/Title'; +import { createI18nInstance } from 'src/utils/i18n.utils'; +import { type APP_LOCALES } from 'twenty-shared/translations'; + +type ServerAdminAccessChangedEmailProps = { + actorName: string; + targetName: string; + targetEmail: string; + canAccessFullAdminPanel: boolean; + canImpersonate: boolean; + locale: keyof typeof APP_LOCALES; +}; + +export const ServerAdminAccessChangedEmail = ({ + actorName, + targetName, + targetEmail, + canAccessFullAdminPanel, + canImpersonate, + locale, +}: ServerAdminAccessChangedEmailProps) => { + const i18n = createI18nInstance(locale); + const enabledLabel = i18n._('Enabled'); + const disabledLabel = i18n._('Disabled'); + const fullAdminStatus = canAccessFullAdminPanel + ? enabledLabel + : disabledLabel; + const impersonateStatus = canImpersonate ? enabledLabel : disabledLabel; + + return ( + + + <MainText> + <Trans + id="serverAdminAccessChanged.summary" + message="{actorName} updated server administrator access for {targetName} ({targetEmail})." + values={{ actorName, targetName, targetEmail }} + /> + <br /> + <br /> + <Trans + id="serverAdminAccessChanged.fullAdmin" + message="Full admin panel access: {fullAdminStatus}" + values={{ fullAdminStatus }} + /> + <br /> + <Trans + id="serverAdminAccessChanged.impersonation" + message="Impersonation: {impersonateStatus}" + values={{ impersonateStatus }} + /> + <br /> + <br /> + <Trans + id="serverAdminAccessChanged.warning" + message="If you did not expect this change, review your server administrators immediately." + /> + <br /> + </MainText> + <br /> + <br /> + </BaseEmail> + ); +}; + +ServerAdminAccessChangedEmail.PreviewProps = { + actorName: 'John Doe', + targetName: 'Jane Smith', + targetEmail: 'jane.smith@example.com', + canAccessFullAdminPanel: true, + canImpersonate: false, + locale: 'en', +} as ServerAdminAccessChangedEmailProps; + +export default ServerAdminAccessChangedEmail; diff --git a/packages/twenty-emails/src/index.ts b/packages/twenty-emails/src/index.ts index afc26b303d..f6ce3e8a78 100644 --- a/packages/twenty-emails/src/index.ts +++ b/packages/twenty-emails/src/index.ts @@ -4,6 +4,7 @@ export * from './emails/password-reset-link.email'; export * from './emails/password-update-notify.email'; export * from './emails/send-email-verification-link.email'; export * from './emails/send-invite-link.email'; +export * from './emails/server-admin-access-changed.email'; export * from './emails/validate-approved-access-domain.email'; export * from './emails/warn-suspended-workspace.email'; export * from './utils/email-renderer/email-renderer'; diff --git a/packages/twenty-front/src/generated-admin/graphql.ts b/packages/twenty-front/src/generated-admin/graphql.ts index 0103da2015..d0af44f13b 100644 --- a/packages/twenty-front/src/generated-admin/graphql.ts +++ b/packages/twenty-front/src/generated-admin/graphql.ts @@ -403,6 +403,7 @@ export type Mutation = { setMaintenanceMode: Scalars['Boolean']; updateAdminApplicationRegistrationVariable: ApplicationRegistrationVariableDto; updateDatabaseConfigVariable: Scalars['Boolean']; + updateServerAdminAccess: ServerAdmin; updateWorkspaceFeatureFlag: Scalars['Boolean']; }; @@ -506,6 +507,14 @@ export type MutationUpdateDatabaseConfigVariableArgs = { }; +export type MutationUpdateServerAdminAccessArgs = { + canAccessFullAdminPanel?: InputMaybe<Scalars['Boolean']>; + canImpersonate?: InputMaybe<Scalars['Boolean']>; + otp?: InputMaybe<Scalars['String']>; + userId: Scalars['UUID']; +}; + + export type MutationUpdateWorkspaceFeatureFlagArgs = { featureFlag: Scalars['String']; value: Scalars['Boolean']; @@ -533,6 +542,7 @@ export type Query = { getModelsDevSuggestions: Array<ModelsDevModelSuggestion>; getQueueJobs: QueueJobsResponse; getQueueMetrics: QueueMetricsData; + getServerAdmins: Array<ServerAdmin>; getSigningKeys: SigningKeysAdminPanelDto; getSystemHealthStatus: SystemHealth; getUpgradeStatus: Array<WorkspaceUpgradeStatus>; @@ -695,6 +705,16 @@ export type RetryJobsResponse = { retriedCount: Scalars['Int']; }; +export type ServerAdmin = { + __typename?: 'ServerAdmin'; + canAccessFullAdminPanel: Scalars['Boolean']; + canImpersonate: Scalars['Boolean']; + email: Scalars['String']; + firstName: Scalars['String']; + id: Scalars['UUID']; + lastName: Scalars['String']; +}; + export type SigningKeyDto = { __typename?: 'SigningKeyDTO'; createdAt: Scalars['DateTime']; @@ -998,6 +1018,16 @@ export type GetDatabaseConfigVariableQuery = { __typename?: 'Query', getDatabase export type UserInfoFragmentFragment = { __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null, createdAt: string }; +export type UpdateServerAdminAccessMutationVariables = Exact<{ + userId: Scalars['UUID']; + canAccessFullAdminPanel?: InputMaybe<Scalars['Boolean']>; + canImpersonate?: InputMaybe<Scalars['Boolean']>; + otp?: InputMaybe<Scalars['String']>; +}>; + + +export type UpdateServerAdminAccessMutation = { __typename?: 'Mutation', updateServerAdminAccess: { __typename?: 'ServerAdmin', id: string, email: string, firstName: string, lastName: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean } }; + export type UpdateWorkspaceFeatureFlagMutationVariables = Exact<{ workspaceId: Scalars['UUID']; featureFlag: Scalars['String']; @@ -1042,6 +1072,11 @@ export type GetAdminWorkspaceChatThreadsQueryVariables = Exact<{ export type GetAdminWorkspaceChatThreadsQuery = { __typename?: 'Query', getAdminWorkspaceChatThreads: Array<{ __typename?: 'AdminWorkspaceChatThread', id: string, title?: string | null, totalInputTokens: number, totalOutputTokens: number, conversationSize: number, createdAt: string, updatedAt: string }> }; +export type GetServerAdminsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetServerAdminsQuery = { __typename?: 'Query', getServerAdmins: Array<{ __typename?: 'ServerAdmin', id: string, email: string, firstName: string, lastName: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean }> }; + export type GetUpgradeStatusQueryVariables = Exact<{ workspaceIds: Array<Scalars['UUID']> | Scalars['UUID']; }>; @@ -1188,12 +1223,14 @@ export const DeleteDatabaseConfigVariableDocument = {"kind":"Document","definiti export const UpdateDatabaseConfigVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateDatabaseConfigVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"value"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateDatabaseConfigVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}},{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"value"}}}]}]}}]} as unknown as DocumentNode<UpdateDatabaseConfigVariableMutation, UpdateDatabaseConfigVariableMutationVariables>; export const GetConfigVariablesGroupedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetConfigVariablesGrouped"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getConfigVariablesGrouped"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"groups"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isHiddenOnLoad"}},{"kind":"Field","name":{"kind":"Name","value":"variables"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"isSensitive"}},{"kind":"Field","name":{"kind":"Name","value":"isEnvOnly"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"options"}},{"kind":"Field","name":{"kind":"Name","value":"source"}}]}}]}}]}}]}}]} as unknown as DocumentNode<GetConfigVariablesGroupedQuery, GetConfigVariablesGroupedQueryVariables>; export const GetDatabaseConfigVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetDatabaseConfigVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getDatabaseConfigVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"isSensitive"}},{"kind":"Field","name":{"kind":"Name","value":"isEnvOnly"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"options"}},{"kind":"Field","name":{"kind":"Name","value":"source"}}]}}]}}]} as unknown as DocumentNode<GetDatabaseConfigVariableQuery, GetDatabaseConfigVariableQueryVariables>; +export const UpdateServerAdminAccessDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateServerAdminAccess"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"canAccessFullAdminPanel"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"canImpersonate"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"otp"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateServerAdminAccess"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}},{"kind":"Argument","name":{"kind":"Name","value":"canAccessFullAdminPanel"},"value":{"kind":"Variable","name":{"kind":"Name","value":"canAccessFullAdminPanel"}}},{"kind":"Argument","name":{"kind":"Name","value":"canImpersonate"},"value":{"kind":"Variable","name":{"kind":"Name","value":"canImpersonate"}}},{"kind":"Argument","name":{"kind":"Name","value":"otp"},"value":{"kind":"Variable","name":{"kind":"Name","value":"otp"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"canAccessFullAdminPanel"}},{"kind":"Field","name":{"kind":"Name","value":"canImpersonate"}}]}}]}}]} as unknown as DocumentNode<UpdateServerAdminAccessMutation, UpdateServerAdminAccessMutationVariables>; export const UpdateWorkspaceFeatureFlagDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkspaceFeatureFlag"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"featureFlag"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"value"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateWorkspaceFeatureFlag"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"featureFlag"},"value":{"kind":"Variable","name":{"kind":"Name","value":"featureFlag"}}},{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"value"}}}]}]}}]} as unknown as DocumentNode<UpdateWorkspaceFeatureFlagMutation, UpdateWorkspaceFeatureFlagMutationVariables>; export const AdminPanelRecentUsersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AdminPanelRecentUsers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"adminPanelRecentUsers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"searchTerm"},"value":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceName"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceLogo"}}]}}]}}]} as unknown as DocumentNode<AdminPanelRecentUsersQuery, AdminPanelRecentUsersQueryVariables>; export const AdminPanelTopWorkspacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AdminPanelTopWorkspaces"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"adminPanelTopWorkspaces"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"searchTerm"},"value":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"totalUsers"}},{"kind":"Field","name":{"kind":"Name","value":"subdomain"}}]}}]}}]} as unknown as DocumentNode<AdminPanelTopWorkspacesQuery, AdminPanelTopWorkspacesQueryVariables>; export const FindOneAdminApplicationRegistrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOneAdminApplicationRegistration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findOneAdminApplicationRegistration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindOneAdminApplicationRegistrationQuery, FindOneAdminApplicationRegistrationQueryVariables>; export const GetAdminChatThreadMessagesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminChatThreadMessages"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminChatThreadMessages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"thread"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"messages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"parts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"textContent"}},{"kind":"Field","name":{"kind":"Name","value":"toolName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode<GetAdminChatThreadMessagesQuery, GetAdminChatThreadMessagesQueryVariables>; export const GetAdminWorkspaceChatThreadsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminWorkspaceChatThreads"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminWorkspaceChatThreads"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<GetAdminWorkspaceChatThreadsQuery, GetAdminWorkspaceChatThreadsQueryVariables>; +export const GetServerAdminsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetServerAdmins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getServerAdmins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"canAccessFullAdminPanel"}},{"kind":"Field","name":{"kind":"Name","value":"canImpersonate"}}]}}]}}]} as unknown as DocumentNode<GetServerAdminsQuery, GetServerAdminsQueryVariables>; export const GetUpgradeStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUpgradeStatus"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceIds"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getUpgradeStatus"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceIds"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"inferredVersion"}},{"kind":"Field","name":{"kind":"Name","value":"health"}},{"kind":"Field","name":{"kind":"Name","value":"latestCommand"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"executedByVersion"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode<GetUpgradeStatusQuery, GetUpgradeStatusQueryVariables>; export const GetVersionInfoDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetVersionInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"versionInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentVersion"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}}]}}]}}]} as unknown as DocumentNode<GetVersionInfoQuery, GetVersionInfoQueryVariables>; export const WorkspaceBillingAdminPanelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"WorkspaceBillingAdminPanel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceBillingAdminPanel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripeCustomerId"}},{"kind":"Field","name":{"kind":"Name","value":"creditBalance"}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripeSubscriptionId"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodStart"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"trialStart"}},{"kind":"Field","name":{"kind":"Name","value":"trialEnd"}},{"kind":"Field","name":{"kind":"Name","value":"cancelAt"}},{"kind":"Field","name":{"kind":"Name","value":"canceledAt"}},{"kind":"Field","name":{"kind":"Name","value":"cancelAtPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"includedCredits"}}]}}]}}]}}]}}]} as unknown as DocumentNode<WorkspaceBillingAdminPanelQuery, WorkspaceBillingAdminPanelQueryVariables>; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminGeneral.tsx b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminGeneral.tsx index e8023c6093..ec65d252c5 100644 --- a/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminGeneral.tsx +++ b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminGeneral.tsx @@ -1,6 +1,7 @@ import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState'; import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient'; import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader'; +import { SettingsAdminServerAdmins } from '@/settings/admin-panel/components/SettingsAdminServerAdmins'; import { SettingsAdminVersionContainer } from '@/settings/admin-panel/components/SettingsAdminVersionContainer'; import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; import { Table } from '@/ui/layout/table/components/Table'; @@ -61,7 +62,7 @@ export const SettingsAdminGeneral = () => { { client: apolloAdminClient, variables: { searchTerm: debouncedUserSearchTerm }, - skip: !canImpersonate, + skip: !canImpersonate && !canAccessFullAdminPanel, }, ); @@ -80,184 +81,185 @@ export const SettingsAdminGeneral = () => { return ( <> {canAccessFullAdminPanel && ( + <> + <Section> + <H2Title + title={t`About`} + description={t`Version of the application`} + /> + <SettingsAdminVersionContainer /> + </Section> + <SettingsAdminServerAdmins /> + </> + )} + + {(canImpersonate || canAccessFullAdminPanel) && ( <Section> <H2Title - title={t`About`} - description={t`Version of the application`} + title={t`Recent Users`} + description={ + canManageFeatureFlags + ? t`Last 10 users created. Click to manage feature flags or impersonate.` + : t`Last 10 users created. Click to impersonate.` + } /> - <SettingsAdminVersionContainer /> + <SettingsTextInput + instanceId="admin-panel-user-search" + value={userSearchTerm} + onChange={setUserSearchTerm} + placeholder={t`Search by name, email, or user ID...`} + fullWidth + /> + {isLoadingUsers ? ( + <SettingsSectionSkeletonLoader /> + ) : recentUsers.length === 0 ? ( + <StyledEmptyState> + {t`No users found matching your search criteria.`} + </StyledEmptyState> + ) : ( + <Table> + <TableBody> + <TableRow + gridTemplateColumns={RECENT_USERS_GRID_TEMPLATE_COLUMNS} + > + <TableHeader>{t`Name`}</TableHeader> + <TableHeader>{t`Email`}</TableHeader> + <TableHeader>{t`Workspace`}</TableHeader> + <TableHeader /> + </TableRow> + {recentUsers.map((user) => ( + <TableRow + key={user.id} + gridTemplateColumns={RECENT_USERS_GRID_TEMPLATE_COLUMNS} + to={getSettingsPath(SettingsPath.AdminPanelUserDetail, { + userId: user.id, + })} + > + <TableCell + color={themeCssVariables.font.color.primary} + gap={themeCssVariables.spacing[2]} + overflow="hidden" + > + <Avatar + avatarUrl={user.avatarUrl} + placeholder={ + `${user.firstName || ''} ${user.lastName || ''}`.trim() || + user.email + } + placeholderColorSeed={user.id} + size="md" + type="rounded" + /> + <OverflowingTextWithTooltip + text={ + `${user.firstName || ''} ${user.lastName || ''}`.trim() || + '\u2014' + } + /> + </TableCell> + <TableCell>{user.email}</TableCell> + <TableCell + gap={themeCssVariables.spacing[2]} + overflow="hidden" + > + {user.workspaceId ? ( + <> + <Avatar + avatarUrl={user.workspaceLogo} + placeholder={user.workspaceName || ''} + placeholderColorSeed={user.workspaceId} + size="sm" + /> + <OverflowingTextWithTooltip + text={user.workspaceName || '\u2014'} + /> + </> + ) : ( + '\u2014' + )} + </TableCell> + <TableCell align="center"> + <IconChevronRight + size={theme.icon.size.md} + stroke={theme.icon.stroke.sm} + color={theme.font.color.tertiary} + /> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + )} </Section> )} {canImpersonate && ( - <> - <Section> - <H2Title - title={t`Recent Users`} - description={ - canManageFeatureFlags - ? t`Last 10 users created. Click to manage feature flags or impersonate.` - : t`Last 10 users created. Click to impersonate.` - } - /> - <SettingsTextInput - instanceId="admin-panel-user-search" - value={userSearchTerm} - onChange={setUserSearchTerm} - placeholder={t`Search by name, email, or user ID...`} - fullWidth - /> - {isLoadingUsers ? ( - <SettingsSectionSkeletonLoader /> - ) : recentUsers.length === 0 ? ( - <StyledEmptyState> - {t`No users found matching your search criteria.`} - </StyledEmptyState> - ) : ( - <Table> - <TableBody> - <TableRow - gridTemplateColumns={RECENT_USERS_GRID_TEMPLATE_COLUMNS} - > - <TableHeader>{t`Name`}</TableHeader> - <TableHeader>{t`Email`}</TableHeader> - <TableHeader>{t`Workspace`}</TableHeader> - <TableHeader /> - </TableRow> - {recentUsers.map((user) => ( - <TableRow - key={user.id} - gridTemplateColumns={RECENT_USERS_GRID_TEMPLATE_COLUMNS} - to={getSettingsPath(SettingsPath.AdminPanelUserDetail, { - userId: user.id, - })} - > - <TableCell - color={themeCssVariables.font.color.primary} - gap={themeCssVariables.spacing[2]} - overflow="hidden" - > - <Avatar - avatarUrl={user.avatarUrl} - placeholder={ - `${user.firstName || ''} ${user.lastName || ''}`.trim() || - user.email - } - placeholderColorSeed={user.id} - size="md" - type="rounded" - /> - <OverflowingTextWithTooltip - text={ - `${user.firstName || ''} ${user.lastName || ''}`.trim() || - '\u2014' - } - /> - </TableCell> - <TableCell>{user.email}</TableCell> - <TableCell - gap={themeCssVariables.spacing[2]} - overflow="hidden" - > - {user.workspaceId ? ( - <> - <Avatar - avatarUrl={user.workspaceLogo} - placeholder={user.workspaceName || ''} - placeholderColorSeed={user.workspaceId} - size="sm" - /> - <OverflowingTextWithTooltip - text={user.workspaceName || '\u2014'} - /> - </> - ) : ( - '\u2014' - )} - </TableCell> - <TableCell align="center"> - <IconChevronRight - size={theme.icon.size.md} - stroke={theme.icon.stroke.sm} - color={theme.font.color.tertiary} - /> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - )} - </Section> - - <Section> - <H2Title - title={t`Top Workspaces`} - description={t`Top 10 workspaces by number of users`} - /> - <SettingsTextInput - instanceId="admin-panel-workspace-search" - value={workspaceSearchTerm} - onChange={setWorkspaceSearchTerm} - placeholder={t`Search by workspace name, subdomain, or ID...`} - fullWidth - /> - {isLoadingWorkspaces ? ( - <SettingsSectionSkeletonLoader /> - ) : topWorkspaces.length === 0 ? ( - <StyledEmptyState> - {t`No workspaces found matching your search criteria.`} - </StyledEmptyState> - ) : ( - <Table> - <TableBody> + <Section> + <H2Title + title={t`Top Workspaces`} + description={t`Top 10 workspaces by number of users`} + /> + <SettingsTextInput + instanceId="admin-panel-workspace-search" + value={workspaceSearchTerm} + onChange={setWorkspaceSearchTerm} + placeholder={t`Search by workspace name, subdomain, or ID...`} + fullWidth + /> + {isLoadingWorkspaces ? ( + <SettingsSectionSkeletonLoader /> + ) : topWorkspaces.length === 0 ? ( + <StyledEmptyState> + {t`No workspaces found matching your search criteria.`} + </StyledEmptyState> + ) : ( + <Table> + <TableBody> + <TableRow + gridTemplateColumns={TOP_WORKSPACES_GRID_TEMPLATE_COLUMNS} + > + <TableHeader>{t`Workspace`}</TableHeader> + <TableHeader align="right">{t`Users`}</TableHeader> + <TableHeader /> + </TableRow> + {topWorkspaces.map((workspace) => ( <TableRow + key={workspace.id} gridTemplateColumns={TOP_WORKSPACES_GRID_TEMPLATE_COLUMNS} + to={getSettingsPath( + SettingsPath.AdminPanelWorkspaceDetail, + { workspaceId: workspace.id }, + )} > - <TableHeader>{t`Workspace`}</TableHeader> - <TableHeader align="right">{t`Users`}</TableHeader> - <TableHeader /> - </TableRow> - {topWorkspaces.map((workspace) => ( - <TableRow - key={workspace.id} - gridTemplateColumns={TOP_WORKSPACES_GRID_TEMPLATE_COLUMNS} - to={getSettingsPath( - SettingsPath.AdminPanelWorkspaceDetail, - { workspaceId: workspace.id }, - )} + <TableCell + color={themeCssVariables.font.color.primary} + gap={themeCssVariables.spacing[2]} + overflow="hidden" > - <TableCell - color={themeCssVariables.font.color.primary} - gap={themeCssVariables.spacing[2]} - overflow="hidden" - > - <Avatar - avatarUrl={workspace.logoUrl} - placeholder={workspace.name || ''} - placeholderColorSeed={workspace.id} - size="md" - /> - <OverflowingTextWithTooltip - text={workspace.name || '\u2014'} - /> - </TableCell> - <TableCell align="right"> - {workspace.totalUsers} - </TableCell> - <TableCell align="center"> - <IconChevronRight - size={theme.icon.size.md} - stroke={theme.icon.stroke.sm} - color={theme.font.color.tertiary} - /> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - )} - </Section> - </> + <Avatar + avatarUrl={workspace.logoUrl} + placeholder={workspace.name || ''} + placeholderColorSeed={workspace.id} + size="md" + /> + <OverflowingTextWithTooltip + text={workspace.name || '\u2014'} + /> + </TableCell> + <TableCell align="right">{workspace.totalUsers}</TableCell> + <TableCell align="center"> + <IconChevronRight + size={theme.icon.size.md} + stroke={theme.icon.stroke.sm} + color={theme.font.color.tertiary} + /> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + )} + </Section> )} </> ); diff --git a/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminServerAdminAccess.tsx b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminServerAdminAccess.tsx new file mode 100644 index 0000000000..57fa03c4a1 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminServerAdminAccess.tsx @@ -0,0 +1,270 @@ +import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient'; +import { TwoFactorAuthenticationVerificationCodeDash } from '@/settings/two-factor-authentication/components/TwoFactorAuthenticationVerificationCodeDash'; +import { TwoFactorAuthenticationVerificationCodeSlot } from '@/settings/two-factor-authentication/components/TwoFactorAuthenticationVerificationCodeSlot'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; +import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { CombinedGraphQLErrors } from '@apollo/client/errors'; +import { useMutation, useQuery } from '@apollo/client/react'; +import { styled } from '@linaria/react'; +import { t } from '@lingui/core/macro'; +import { OTPInput } from 'input-otp'; +import { useState } from 'react'; +import { IconDotsVertical, Status } from 'twenty-ui-deprecated/display'; +import { LightIconButton } from 'twenty-ui-deprecated/input'; +import { MenuItem } from 'twenty-ui-deprecated/navigation'; +import { themeCssVariables } from 'twenty-ui-deprecated/theme-constants'; +import { + GetServerAdminsDocument, + UpdateServerAdminAccessDocument, +} from '~/generated-admin/graphql'; + +type ServerAdminAccessUpdate = { + canAccessFullAdminPanel?: boolean; + canImpersonate?: boolean; +}; + +type PendingServerAdminChange = { + description: string; + isRevoking: boolean; + update: ServerAdminAccessUpdate; +}; + +const SERVER_ADMIN_ACCESS_CONFIRMATION_MODAL_ID = + 'server-admin-access-confirmation'; + +const StyledValue = styled.div` + align-items: center; + display: flex; + gap: ${themeCssVariables.spacing[1]}; +`; + +const StyledChips = styled.div` + align-items: center; + display: flex; + flex: 1; + gap: ${themeCssVariables.spacing[2]}; +`; + +const StyledNoAccess = styled.span` + color: ${themeCssVariables.font.color.tertiary}; + flex: 1; +`; + +const StyledConfirmationContent = styled.div` + align-items: center; + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing[4]}; +`; + +const StyledOTPContainer = styled.div` + display: flex; + gap: ${themeCssVariables.spacing[1]}; +`; + +export const SettingsAdminServerAdminAccess = ({ + userId, + userLabel, +}: { + userId: string; + userLabel: string; +}) => { + const dropdownId = `server-admin-access-${userId}`; + const apolloAdminClient = useApolloAdminClient(); + const { openModal } = useModal(); + const { closeDropdown } = useCloseDropdown(); + const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar(); + + const [pendingChange, setPendingChange] = + useState<PendingServerAdminChange | null>(null); + const [otp, setOtp] = useState(''); + + const { data, refetch } = useQuery(GetServerAdminsDocument, { + client: apolloAdminClient, + }); + + const [updateServerAdminAccess] = useMutation( + UpdateServerAdminAccessDocument, + { client: apolloAdminClient }, + ); + + const serverAdmins = data?.getServerAdmins ?? []; + const currentAccess = serverAdmins.find((admin) => admin.id === userId); + const canAccessFullAdminPanel = + currentAccess?.canAccessFullAdminPanel ?? false; + const canImpersonate = currentAccess?.canImpersonate ?? false; + const fullAdminCount = serverAdmins.filter( + (admin) => admin.canAccessFullAdminPanel, + ).length; + const isLastFullAdmin = canAccessFullAdminPanel && fullAdminCount <= 1; + const hasAnyAccess = canAccessFullAdminPanel || canImpersonate; + const hasFullAccess = canAccessFullAdminPanel && canImpersonate; + + const requestChange = (change: PendingServerAdminChange) => { + closeDropdown(dropdownId); + setOtp(''); + setPendingChange(change); + openModal(SERVER_ADMIN_ACCESS_CONFIRMATION_MODAL_ID); + }; + + const handleConfirm = async () => { + if (pendingChange === null) { + return; + } + + try { + await updateServerAdminAccess({ + variables: { + userId, + otp: otp.length > 0 ? otp : undefined, + ...pendingChange.update, + }, + }); + await refetch(); + enqueueSuccessSnackBar({ + message: t`Server administrator access updated.`, + }); + } catch (error) { + enqueueErrorSnackBar({ + ...(CombinedGraphQLErrors.is(error) + ? { apolloError: error } + : { message: t`Failed to update server administrator access.` }), + }); + } finally { + setOtp(''); + setPendingChange(null); + } + }; + + return ( + <> + <StyledValue> + {hasAnyAccess ? ( + <StyledChips> + {canAccessFullAdminPanel && ( + <Status color="green" text={t`Admin panel`} weight="medium" /> + )} + {canImpersonate && ( + <Status color="blue" text={t`Impersonation`} weight="medium" /> + )} + </StyledChips> + ) : ( + <StyledNoAccess>{t`No access`}</StyledNoAccess> + )} + <Dropdown + dropdownId={dropdownId} + dropdownPlacement="right-start" + clickableComponent={ + <LightIconButton Icon={IconDotsVertical} accent="tertiary" /> + } + dropdownComponents={ + <DropdownContent> + <DropdownMenuItemsContainer> + <MenuItem + text={ + canAccessFullAdminPanel + ? t`Revoke admin panel access` + : t`Grant admin panel access` + } + disabled={isLastFullAdmin} + onClick={() => + requestChange({ + description: t`full admin panel access`, + isRevoking: canAccessFullAdminPanel, + update: { + canAccessFullAdminPanel: !canAccessFullAdminPanel, + }, + }) + } + /> + <MenuItem + text={ + canImpersonate + ? t`Disable impersonation` + : t`Enable impersonation` + } + onClick={() => + requestChange({ + description: t`impersonation`, + isRevoking: canImpersonate, + update: { canImpersonate: !canImpersonate }, + }) + } + /> + {!hasFullAccess && ( + <MenuItem + text={t`Grant full access`} + onClick={() => + requestChange({ + description: t`full server access`, + isRevoking: false, + update: { + canAccessFullAdminPanel: true, + canImpersonate: true, + }, + }) + } + /> + )} + </DropdownMenuItemsContainer> + </DropdownContent> + } + /> + </StyledValue> + <ConfirmationModal + modalInstanceId={SERVER_ADMIN_ACCESS_CONFIRMATION_MODAL_ID} + title={pendingChange?.isRevoking ? t`Revoke access` : t`Grant access`} + confirmButtonAccent={pendingChange?.isRevoking ? 'danger' : 'blue'} + confirmButtonText={t`Confirm`} + onConfirmClick={handleConfirm} + onClose={() => { + setOtp(''); + setPendingChange(null); + }} + subtitle={ + <StyledConfirmationContent> + <div> + {pendingChange?.isRevoking + ? t`This will revoke ${pendingChange?.description ?? ''} for ${userLabel}.` + : t`This will grant ${pendingChange?.description ?? ''} to ${userLabel}.`} + </div> + <div>{t`Enter your two-factor authentication code to confirm.`}</div> + <OTPInput + maxLength={6} + value={otp} + onChange={setOtp} + render={({ slots }) => ( + <StyledOTPContainer> + {slots.slice(0, 3).map((slot, index) => ( + <TwoFactorAuthenticationVerificationCodeSlot + key={index} + char={slot.char} + placeholderChar={slot.placeholderChar} + isActive={slot.isActive} + hasFakeCaret={slot.hasFakeCaret} + /> + ))} + <TwoFactorAuthenticationVerificationCodeDash /> + {slots.slice(3).map((slot, index) => ( + <TwoFactorAuthenticationVerificationCodeSlot + key={index + 3} + char={slot.char} + placeholderChar={slot.placeholderChar} + isActive={slot.isActive} + hasFakeCaret={slot.hasFakeCaret} + /> + ))} + </StyledOTPContainer> + )} + /> + </StyledConfirmationContent> + } + /> + </> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminServerAdmins.tsx b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminServerAdmins.tsx new file mode 100644 index 0000000000..b0aeb82cae --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminServerAdmins.tsx @@ -0,0 +1,102 @@ +import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient'; +import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader'; +import { Table } from '@/ui/layout/table/components/Table'; +import { TableBody } from '@/ui/layout/table/components/TableBody'; +import { TableCell } from '@/ui/layout/table/components/TableCell'; +import { TableHeader } from '@/ui/layout/table/components/TableHeader'; +import { TableRow } from '@/ui/layout/table/components/TableRow'; +import { useQuery } from '@apollo/client/react'; +import { styled } from '@linaria/react'; +import { t } from '@lingui/core/macro'; +import { useContext } from 'react'; +import { SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath } from 'twenty-shared/utils'; +import { + H2Title, + IconChevronRight, + OverflowingTextWithTooltip, +} from 'twenty-ui-deprecated/display'; +import { Section } from 'twenty-ui-deprecated/layout'; +import { + ThemeContext, + themeCssVariables, +} from 'twenty-ui-deprecated/theme-constants'; +import { GetServerAdminsDocument } from '~/generated-admin/graphql'; + +const SERVER_ADMINS_GRID_TEMPLATE_COLUMNS = '2fr 1fr 1fr 36px'; + +const StyledEmptyState = styled.div` + color: ${themeCssVariables.font.color.tertiary}; + padding: ${themeCssVariables.spacing[4]} 0; +`; + +export const SettingsAdminServerAdmins = () => { + const { theme } = useContext(ThemeContext); + const apolloAdminClient = useApolloAdminClient(); + + const { data, loading, error } = useQuery(GetServerAdminsDocument, { + client: apolloAdminClient, + }); + + const serverAdmins = data?.getServerAdmins ?? []; + + return ( + <Section> + <H2Title + title={t`Administrators`} + description={t`Users with server-level access. Open a user to grant or revoke access; use the search below to find anyone.`} + /> + {loading ? ( + <SettingsSectionSkeletonLoader /> + ) : error ? ( + <StyledEmptyState>{t`Failed to load server administrators.`}</StyledEmptyState> + ) : serverAdmins.length === 0 ? ( + <StyledEmptyState>{t`No server administrators found.`}</StyledEmptyState> + ) : ( + <Table> + <TableBody> + <TableRow gridTemplateColumns={SERVER_ADMINS_GRID_TEMPLATE_COLUMNS}> + <TableHeader>{t`Administrator`}</TableHeader> + <TableHeader>{t`Admin panel`}</TableHeader> + <TableHeader>{t`Impersonation`}</TableHeader> + <TableHeader /> + </TableRow> + {serverAdmins.map((admin) => { + const adminLabel = + `${admin.firstName || ''} ${admin.lastName || ''}`.trim() || + admin.email; + + return ( + <TableRow + key={admin.id} + gridTemplateColumns={SERVER_ADMINS_GRID_TEMPLATE_COLUMNS} + to={getSettingsPath(SettingsPath.AdminPanelUserDetail, { + userId: admin.id, + })} + > + <TableCell + color={themeCssVariables.font.color.primary} + overflow="hidden" + > + <OverflowingTextWithTooltip text={adminLabel} /> + </TableCell> + <TableCell> + {admin.canAccessFullAdminPanel ? t`Yes` : '—'} + </TableCell> + <TableCell>{admin.canImpersonate ? t`Yes` : '—'}</TableCell> + <TableCell align="center"> + <IconChevronRight + size={theme.icon.size.md} + stroke={theme.icon.stroke.sm} + color={theme.font.color.tertiary} + /> + </TableCell> + </TableRow> + ); + })} + </TableBody> + </Table> + )} + </Section> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/graphql/mutations/updateServerAdminAccess.ts b/packages/twenty-front/src/modules/settings/admin-panel/graphql/mutations/updateServerAdminAccess.ts new file mode 100644 index 0000000000..35c3734e35 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/graphql/mutations/updateServerAdminAccess.ts @@ -0,0 +1,24 @@ +import { gql } from '@apollo/client'; + +export const UPDATE_SERVER_ADMIN_ACCESS = gql` + mutation UpdateServerAdminAccess( + $userId: UUID! + $canAccessFullAdminPanel: Boolean + $canImpersonate: Boolean + $otp: String + ) { + updateServerAdminAccess( + userId: $userId + canAccessFullAdminPanel: $canAccessFullAdminPanel + canImpersonate: $canImpersonate + otp: $otp + ) { + id + email + firstName + lastName + canAccessFullAdminPanel + canImpersonate + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/graphql/queries/getServerAdmins.ts b/packages/twenty-front/src/modules/settings/admin-panel/graphql/queries/getServerAdmins.ts new file mode 100644 index 0000000000..8ccc25505a --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/graphql/queries/getServerAdmins.ts @@ -0,0 +1,14 @@ +import { gql } from '@apollo/client'; + +export const GET_SERVER_ADMINS = gql` + query GetServerAdmins { + getServerAdmins { + id + email + firstName + lastName + canAccessFullAdminPanel + canImpersonate + } + } +`; diff --git a/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminUserDetail.tsx b/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminUserDetail.tsx index 67808efccb..ccd322b954 100644 --- a/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminUserDetail.tsx +++ b/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminUserDetail.tsx @@ -13,6 +13,7 @@ import { import { currentUserState } from '@/auth/states/currentUserState'; import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient'; +import { SettingsAdminServerAdminAccess } from '@/settings/admin-panel/components/SettingsAdminServerAdminAccess'; import { SettingsAdminWorkspaceContent } from '@/settings/admin-panel/components/SettingsAdminWorkspaceContent'; import { SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminUserLookupWorkspaceTabsId'; import { useHandleImpersonate } from '@/settings/admin-panel/hooks/useHandleImpersonate'; @@ -30,6 +31,7 @@ import { IconCalendar, IconEyeShare, IconId, + IconLock, IconMail, IconUser, } from 'twenty-ui-deprecated/display'; @@ -91,6 +93,8 @@ export const SettingsAdminUserDetail = () => { }) ?? '', })) ?? []; + const displayName = userFullName || userId || ''; + const userInfoItems = [ { Icon: IconUser, @@ -114,10 +118,22 @@ export const SettingsAdminUserDetail = () => { ? new Date(user.createdAt).toLocaleDateString() : '', }, + ...(currentUser?.canAccessFullAdminPanel && isDefined(userId) + ? [ + { + Icon: IconLock, + label: t`Server access`, + value: ( + <SettingsAdminServerAdminAccess + userId={userId} + userLabel={displayName} + /> + ), + }, + ] + : []), ]; - const displayName = userFullName || userId || ''; - if (isLoading) { return <SettingsSkeletonLoader />; } diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts index 7755ff25a5..4feb359792 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TerminusModule } from '@nestjs/terminus'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module'; import { AdminPanelApplicationRegistrationResolver } from 'src/engine/core-modules/admin-panel/admin-panel-application-registration.resolver'; import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service'; import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service'; @@ -15,6 +16,7 @@ import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/main import { AdminPanelBillingService } from 'src/engine/core-modules/admin-panel/services/admin-panel-billing.service'; import { AdminPanelChatService } from 'src/engine/core-modules/admin-panel/services/admin-panel-chat.service'; import { AdminPanelConfigService } from 'src/engine/core-modules/admin-panel/services/admin-panel-config.service'; +import { AdminPanelServerAdminService } from 'src/engine/core-modules/admin-panel/services/admin-panel-server-admin.service'; import { AdminPanelSigningKeyService } from 'src/engine/core-modules/admin-panel/services/admin-panel-signing-key.service'; import { AdminPanelStatisticsService } from 'src/engine/core-modules/admin-panel/services/admin-panel-statistics.service'; import { AdminPanelUserLookupService } from 'src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service'; @@ -25,6 +27,7 @@ import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity'; import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module'; +import { EventLogEmitterModule } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileModule } from 'src/engine/core-modules/file/file.module'; @@ -35,6 +38,7 @@ import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module'; import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module'; import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.module'; +import { TwoFactorAuthenticationModule } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.module'; import { UpgradeModule } from 'src/engine/core-modules/upgrade/upgrade.module'; import { UsageModule } from 'src/engine/core-modules/usage/usage.module'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; @@ -77,11 +81,15 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac UpgradeModule, UserModule, JwtModule, + CoreEntityCacheModule, + EventLogEmitterModule, + TwoFactorAuthenticationModule, ], providers: [ AdminPanelResolver, AdminPanelApplicationRegistrationResolver, AdminPanelUserLookupService, + AdminPanelServerAdminService, AdminPanelStatisticsService, AdminPanelBillingService, AdminPanelChatService, diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts index 87bcd3a53a..cd7a958e00 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts @@ -26,9 +26,11 @@ import { DeleteJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/ import { QueueJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/queue-jobs-response.dto'; import { RetryJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/retry-jobs-response.dto'; import { RevokeSigningKeyInput } from 'src/engine/core-modules/admin-panel/dtos/revoke-signing-key.input'; +import { ServerAdminDTO } from 'src/engine/core-modules/admin-panel/dtos/server-admin.dto'; import { SigningKeyDTO } from 'src/engine/core-modules/admin-panel/dtos/signing-key.dto'; import { SigningKeysAdminPanelDTO } from 'src/engine/core-modules/admin-panel/dtos/signing-keys-admin-panel.dto'; import { SystemHealthDTO } from 'src/engine/core-modules/admin-panel/dtos/system-health.dto'; +import { UpdateServerAdminAccessInput } from 'src/engine/core-modules/admin-panel/dtos/update-server-admin-access.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.dto'; import { UserLookupInput } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.input'; @@ -41,6 +43,7 @@ import { AdminPanelBillingService } from 'src/engine/core-modules/admin-panel/se import { AdminPanelChatService } from 'src/engine/core-modules/admin-panel/services/admin-panel-chat.service'; import { AdminPanelConfigService } from 'src/engine/core-modules/admin-panel/services/admin-panel-config.service'; import { AdminPanelSigningKeyService } from 'src/engine/core-modules/admin-panel/services/admin-panel-signing-key.service'; +import { AdminPanelServerAdminService } from 'src/engine/core-modules/admin-panel/services/admin-panel-server-admin.service'; import { AdminPanelStatisticsService } from 'src/engine/core-modules/admin-panel/services/admin-panel-statistics.service'; import { AdminPanelUserLookupService } from 'src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service'; import { AdminPanelVersionService } from 'src/engine/core-modules/admin-panel/services/admin-panel-version.service'; @@ -50,6 +53,7 @@ import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modu import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; +import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type'; import { AdminAiModelsDTO } from 'src/engine/core-modules/client-config/client-config.entity'; import { FeatureFlagException } from 'src/engine/core-modules/feature-flag/feature-flag.exception'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; @@ -60,10 +64,15 @@ import { type MessageQueue } from 'src/engine/core-modules/message-queue/message import { type ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables'; import { ConfigVariableGraphqlApiExceptionFilter } from 'src/engine/core-modules/twenty-config/filters/config-variable-graphql-api-exception.filter'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { TwoFactorAuthenticationExceptionFilter } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication-exception.filter'; import { UsageBreakdownItemDTO } from 'src/engine/core-modules/usage/dtos/usage-breakdown-item.dto'; import { UsageAnalyticsService } from 'src/engine/core-modules/usage/services/usage-analytics.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { AdminPanelGuard } from 'src/engine/guards/admin-panel-guard'; +import { AdminPanelOrImpersonateGuard } from 'src/engine/guards/admin-panel-or-impersonate.guard'; +import { NoImpersonationGuard } from 'src/engine/guards/no-impersonation.guard'; import { ServerLevelImpersonateGuard } from 'src/engine/guards/server-level-impersonate.guard'; import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; @@ -89,6 +98,7 @@ import { SetMaintenanceModeInput } from './dtos/set-maintenance-mode.input'; @AdminResolver() @UseFilters( AuthGraphqlApiExceptionFilter, + TwoFactorAuthenticationExceptionFilter, PreventNestToAutoLogGraphqlErrorsFilter, ConfigVariableGraphqlApiExceptionFilter, ) @@ -100,6 +110,7 @@ import { SetMaintenanceModeInput } from './dtos/set-maintenance-mode.input'; export class AdminPanelResolver { constructor( private readonly adminUserLookupService: AdminPanelUserLookupService, + private readonly adminServerAdminService: AdminPanelServerAdminService, private readonly adminStatisticsService: AdminPanelStatisticsService, private readonly adminBillingService: AdminPanelBillingService, private readonly adminChatService: AdminPanelChatService, @@ -123,7 +134,7 @@ export class AdminPanelResolver { private readonly workspaceRepository: Repository<WorkspaceEntity>, ) {} - @UseGuards(ServerLevelImpersonateGuard) + @UseGuards(AdminPanelOrImpersonateGuard) @Query(() => UserLookup) async userLookupAdminPanel( @Args() userLookupInput: UserLookupInput, @@ -133,7 +144,7 @@ export class AdminPanelResolver { ); } - @UseGuards(ServerLevelImpersonateGuard) + @UseGuards(AdminPanelOrImpersonateGuard) @Query(() => [AdminPanelRecentUserDTO]) async adminPanelRecentUsers( @Args('searchTerm', { @@ -159,6 +170,29 @@ export class AdminPanelResolver { return this.adminStatisticsService.getTopWorkspaces(searchTerm); } + @UseGuards(AdminPanelGuard, NoImpersonationGuard) + @Query(() => [ServerAdminDTO]) + async getServerAdmins(): Promise<ServerAdminDTO[]> { + return this.adminServerAdminService.getServerAdmins(); + } + + @UseGuards(AdminPanelGuard, NoImpersonationGuard) + @Mutation(() => ServerAdminDTO) + async updateServerAdminAccess( + @Args() input: UpdateServerAdminAccessInput, + @AuthUser() actor: AuthContextUser, + @AuthWorkspace() workspace: WorkspaceEntity, + ): Promise<ServerAdminDTO> { + return this.adminServerAdminService.updateServerAdminAccess({ + actor, + actorWorkspaceId: workspace.id, + targetUserId: input.userId, + canAccessFullAdminPanel: input.canAccessFullAdminPanel, + canImpersonate: input.canImpersonate, + otp: input.otp, + }); + } + @UseGuards(AdminPanelGuard) @Mutation(() => Boolean) async updateWorkspaceFeatureFlag( diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/server-admin.dto.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/server-admin.dto.ts new file mode 100644 index 0000000000..60d468fabd --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/server-admin.dto.ts @@ -0,0 +1,24 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@ObjectType('ServerAdmin') +export class ServerAdminDTO { + @Field(() => UUIDScalarType) + id: string; + + @Field() + email: string; + + @Field() + firstName: string; + + @Field() + lastName: string; + + @Field() + canAccessFullAdminPanel: boolean; + + @Field() + canImpersonate: boolean; +} diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/update-server-admin-access.input.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/update-server-admin-access.input.ts new file mode 100644 index 0000000000..e13d86f67d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/update-server-admin-access.input.ts @@ -0,0 +1,34 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { + IsBoolean, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@ArgsType() +export class UpdateServerAdminAccessInput { + @Field(() => UUIDScalarType) + @IsNotEmpty() + @IsUUID() + userId: string; + + @Field({ nullable: true }) + @IsOptional() + @IsBoolean() + canAccessFullAdminPanel?: boolean; + + @Field({ nullable: true }) + @IsOptional() + @IsBoolean() + canImpersonate?: boolean; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + otp?: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-server-admin.service.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-server-admin.service.ts new file mode 100644 index 0000000000..f9cbbdd240 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-server-admin.service.ts @@ -0,0 +1,319 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { msg } from '@lingui/core/macro'; +import { render } from '@react-email/render'; +import { isNonEmptyString } from '@sniptt/guards'; +import { ServerAdminAccessChangedEmail } from 'twenty-emails'; +import { SOURCE_LOCALE } from 'twenty-shared/translations'; +import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; + +import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service'; +import { type ServerAdminDTO } from 'src/engine/core-modules/admin-panel/dtos/server-admin.dto'; +import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { EmailService } from 'src/engine/core-modules/email/email.service'; +import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service'; +import { SERVER_ADMIN_ACCESS_CHANGED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/server-admin/server-admin-access-changed'; +import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; +import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { TwoFactorAuthenticationService } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.service'; +import { twoFactorAuthenticationMethodsValidator } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.validation'; +import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { UserEntity } from 'src/engine/core-modules/user/user.entity'; + +@Injectable() +export class AdminPanelServerAdminService { + private readonly logger = new Logger(AdminPanelServerAdminService.name); + + constructor( + @InjectRepository(UserEntity) + private readonly userRepository: Repository<UserEntity>, + @InjectRepository(UserWorkspaceEntity) + private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>, + private readonly coreEntityCacheService: CoreEntityCacheService, + private readonly twoFactorAuthenticationService: TwoFactorAuthenticationService, + private readonly emailService: EmailService, + private readonly i18nService: I18nService, + private readonly twentyConfigService: TwentyConfigService, + private readonly eventLogEmitterService: EventLogEmitterService, + ) {} + + async getServerAdmins(): Promise<ServerAdminDTO[]> { + const admins = await this.userRepository.find({ + where: [{ canAccessFullAdminPanel: true }, { canImpersonate: true }], + order: { firstName: 'ASC', lastName: 'ASC' }, + }); + + return admins.map((admin) => this.toServerAdminDTO(admin)); + } + + async updateServerAdminAccess({ + actor, + actorWorkspaceId, + targetUserId, + canAccessFullAdminPanel, + canImpersonate, + otp, + }: { + actor: AuthContextUser; + actorWorkspaceId: string; + targetUserId: string; + canAccessFullAdminPanel?: boolean; + canImpersonate?: boolean; + otp?: string; + }): Promise<ServerAdminDTO> { + if (!isDefined(canAccessFullAdminPanel) && !isDefined(canImpersonate)) { + throw new UserInputError('No administrator access change was provided.'); + } + + const targetUser = await this.userRepository.findOne({ + where: { id: targetUserId }, + }); + + if (!isDefined(targetUser)) { + throw new UserInputError('User not found.'); + } + + await this.assertFreshStepUpAuthentication({ + actorUserId: actor.id, + actorWorkspaceId, + otp, + }); + + const nextCanAccessFullAdminPanel = + canAccessFullAdminPanel ?? targetUser.canAccessFullAdminPanel; + const nextCanImpersonate = canImpersonate ?? targetUser.canImpersonate; + + const hasChange = + nextCanAccessFullAdminPanel !== targetUser.canAccessFullAdminPanel || + nextCanImpersonate !== targetUser.canImpersonate; + + if (!hasChange) { + return this.toServerAdminDTO(targetUser); + } + + const isRevokingFullAdmin = + targetUser.canAccessFullAdminPanel === true && + nextCanAccessFullAdminPanel === false; + + targetUser.canAccessFullAdminPanel = nextCanAccessFullAdminPanel; + targetUser.canImpersonate = nextCanImpersonate; + + await this.userRepository.manager.transaction(async (manager) => { + if (isRevokingFullAdmin) { + const lockedFullAdmins = await manager.find(UserEntity, { + where: { canAccessFullAdminPanel: true }, + lock: { mode: 'pessimistic_write' }, + }); + + const otherFullAdmins = lockedFullAdmins.filter( + (admin) => admin.id !== targetUserId, + ); + + if (otherFullAdmins.length === 0) { + throw new UserInputError( + 'You cannot revoke admin panel access from the last server administrator.', + { + userFriendlyMessage: msg`You cannot revoke admin panel access from the last server administrator.`, + }, + ); + } + } + + await manager.save(UserEntity, targetUser); + }); + + await this.coreEntityCacheService.invalidate('user', targetUserId); + + this.logger.log( + `Server admin access for user ${targetUserId} updated by ${actor.id}: ` + + `canAccessFullAdminPanel=${nextCanAccessFullAdminPanel}, canImpersonate=${nextCanImpersonate}`, + ); + + this.emitServerAdminAccessChangedEvent({ + actor, + actorWorkspaceId, + targetUser, + }); + + await this.notifyAdministrators({ actor, targetUser }); + + return this.toServerAdminDTO(targetUser); + } + + private async assertFreshStepUpAuthentication({ + actorUserId, + actorWorkspaceId, + otp, + }: { + actorUserId: string; + actorWorkspaceId: string; + otp?: string; + }): Promise<void> { + const isDevelopment = + this.twentyConfigService.get('NODE_ENV') === NodeEnvironment.DEVELOPMENT; + + if (isDevelopment) { + return; + } + + if (!isNonEmptyString(otp)) { + throw new UserInputError( + 'A two-factor authentication code is required to change server administrator access.', + { + userFriendlyMessage: msg`Enter your two-factor authentication code to manage server administrators.`, + }, + ); + } + + // Verify against the actor's current workspace only — checking the same code + // against every workspace they belong to would allow one OTP guess per + // workspace, weakening brute-force resistance. + const actorUserWorkspace = await this.userWorkspaceRepository.findOne({ + where: { userId: actorUserId, workspaceId: actorWorkspaceId }, + relations: ['twoFactorAuthenticationMethods'], + }); + + const hasVerifiedTwoFactor = + isDefined(actorUserWorkspace) && + twoFactorAuthenticationMethodsValidator.areDefined( + actorUserWorkspace.twoFactorAuthenticationMethods, + ) && + twoFactorAuthenticationMethodsValidator.areVerified( + actorUserWorkspace.twoFactorAuthenticationMethods, + ); + + if (!hasVerifiedTwoFactor) { + throw new UserInputError( + 'Enable two-factor authentication in your current workspace to manage server administrators.', + { + userFriendlyMessage: msg`Enable two-factor authentication in your current workspace to manage server administrators.`, + }, + ); + } + + // A wrong code throws INVALID_OTP, which the resolver's + // TwoFactorAuthenticationExceptionFilter maps to a user-friendly message. + await this.twoFactorAuthenticationService.verifyTwoFactorAuthenticationMethodForAuthenticatedUser( + actorUserId, + otp, + actorWorkspaceId, + ); + } + + private async notifyAdministrators({ + actor, + targetUser, + }: { + actor: AuthContextUser; + targetUser: UserEntity; + }): Promise<void> { + try { + const fullAdmins = await this.userRepository.find({ + where: { canAccessFullAdminPanel: true }, + }); + + const recipientsById = new Map<string, UserEntity>(); + + for (const fullAdmin of fullAdmins) { + recipientsById.set(fullAdmin.id, fullAdmin); + } + recipientsById.set(targetUser.id, targetUser); + + const actorName = `${actor.firstName} ${actor.lastName}`.trim(); + const targetName = + `${targetUser.firstName} ${targetUser.lastName}`.trim(); + const from = `${this.twentyConfigService.get('EMAIL_FROM_NAME')} <${this.twentyConfigService.get('EMAIL_FROM_ADDRESS')}>`; + + const recipientsByLocale = new Map<UserEntity['locale'], UserEntity[]>(); + + for (const recipient of recipientsById.values()) { + const locale = recipient.locale || SOURCE_LOCALE; + const localeRecipients = recipientsByLocale.get(locale) ?? []; + + localeRecipients.push(recipient); + recipientsByLocale.set(locale, localeRecipients); + } + + await Promise.allSettled( + Array.from(recipientsByLocale.entries()).map( + async ([locale, recipients]) => { + const emailTemplate = ServerAdminAccessChangedEmail({ + actorName, + targetName, + targetEmail: targetUser.email, + canAccessFullAdminPanel: targetUser.canAccessFullAdminPanel, + canImpersonate: targetUser.canImpersonate, + locale, + }); + const html = await render(emailTemplate, { pretty: true }); + const text = await render(emailTemplate, { plainText: true }); + + const i18n = this.i18nService.getI18nInstance(locale); + const subject = i18n._(msg`Server administrator access changed`); + + const sendResults = await Promise.allSettled( + recipients.map((recipient) => + this.emailService.send({ + from, + to: recipient.email, + subject, + text, + html, + }), + ), + ); + + const failedCount = sendResults.filter( + (result) => result.status === 'rejected', + ).length; + + if (failedCount > 0) { + this.logger.error( + `Failed to enqueue ${failedCount} server admin access notification email(s) for locale ${locale}`, + ); + } + }, + ), + ); + } catch (error) { + this.logger.error( + 'Failed to send server admin access change notifications', + error, + ); + } + } + + private emitServerAdminAccessChangedEvent({ + actor, + actorWorkspaceId, + targetUser, + }: { + actor: AuthContextUser; + actorWorkspaceId: string; + targetUser: UserEntity; + }): void { + void this.eventLogEmitterService + .createContext({ workspaceId: actorWorkspaceId, userId: actor.id }) + .insertWorkspaceEvent(SERVER_ADMIN_ACCESS_CHANGED_EVENT, { + targetUserId: targetUser.id, + canAccessFullAdminPanel: targetUser.canAccessFullAdminPanel, + canImpersonate: targetUser.canImpersonate, + message: `Server admin access for user ${targetUser.id} changed by ${actor.id}`, + }); + } + + private toServerAdminDTO(user: UserEntity): ServerAdminDTO { + return { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + canAccessFullAdminPanel: user.canAccessFullAdminPanel, + canImpersonate: user.canImpersonate, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/event-logs/emit/events.type.ts b/packages/twenty-server/src/engine/core-modules/event-logs/emit/events.type.ts index 744e868b42..448fd83a2b 100644 --- a/packages/twenty-server/src/engine/core-modules/event-logs/emit/events.type.ts +++ b/packages/twenty-server/src/engine/core-modules/event-logs/emit/events.type.ts @@ -30,6 +30,10 @@ import { type IMPERSONATION_EVENT, type ImpersonationTrackEvent, } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/impersonation/impersonation'; +import { + type SERVER_ADMIN_ACCESS_CHANGED_EVENT, + type ServerAdminAccessChangedTrackEvent, +} from 'src/engine/core-modules/event-logs/emit/events/workspace-event/server-admin/server-admin-access-changed'; import { type USER_SIGNUP_EVENT, type UserSignupTrackEvent, @@ -59,7 +63,8 @@ export type TrackEventName = | typeof OBJECT_RECORD_UPSERTED_EVENT | typeof USER_SIGNUP_EVENT | typeof WORKSPACE_CREATED_EVENT - | typeof PAYMENT_RECEIVED_EVENT; + | typeof PAYMENT_RECEIVED_EVENT + | typeof SERVER_ADMIN_ACCESS_CHANGED_EVENT; export interface TrackEvents { [CUSTOM_DOMAIN_ACTIVATED_EVENT]: CustomDomainActivatedTrackEvent; @@ -74,6 +79,7 @@ export interface TrackEvents { [OBJECT_RECORD_UPSERTED_EVENT]: ObjectRecordUpsertedTrackEvent; [WORKSPACE_CREATED_EVENT]: WorkspaceCreatedTrackEvent; [PAYMENT_RECEIVED_EVENT]: PaymentReceivedTrackEvent; + [SERVER_ADMIN_ACCESS_CHANGED_EVENT]: ServerAdminAccessChangedTrackEvent; } export type TrackEventProperties<T extends TrackEventName> = diff --git a/packages/twenty-server/src/engine/core-modules/event-logs/emit/events/workspace-event/server-admin/server-admin-access-changed.ts b/packages/twenty-server/src/engine/core-modules/event-logs/emit/events/workspace-event/server-admin/server-admin-access-changed.ts new file mode 100644 index 0000000000..db2bab15b3 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/event-logs/emit/events/workspace-event/server-admin/server-admin-access-changed.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +import { registerEvent } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/track'; + +export const SERVER_ADMIN_ACCESS_CHANGED_EVENT = + 'ServerAdminAccessChanged' as const; + +export const serverAdminAccessChangedSchema = z.strictObject({ + event: z.literal(SERVER_ADMIN_ACCESS_CHANGED_EVENT), + properties: z.strictObject({ + targetUserId: z.string(), + canAccessFullAdminPanel: z.boolean(), + canImpersonate: z.boolean(), + message: z.string().optional(), + }), +}); + +export type ServerAdminAccessChangedTrackEvent = z.infer< + typeof serverAdminAccessChangedSchema +>; + +registerEvent( + SERVER_ADMIN_ACCESS_CHANGED_EVENT, + serverAdminAccessChangedSchema, +); diff --git a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts index ede07ac790..e079de842a 100644 --- a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts @@ -446,9 +446,15 @@ export class UserService extends TypeOrmQueryService<UserEntity> { user.isEmailVerified = true; - return queryRunner + const savedUser = queryRunner ? await queryRunner.manager.save(UserEntity, user) : await this.userRepository.save(user); + + if (!queryRunner) { + await this.coreEntityCacheService.invalidate('user', userId); + } + + return savedUser; } async updateEmailFromVerificationToken(userId: string, email: string) { @@ -458,6 +464,8 @@ export class UserService extends TypeOrmQueryService<UserEntity> { const updatedUser = await this.userRepository.save(user); + await this.coreEntityCacheService.invalidate('user', user.id); + await this.enqueueWorkspaceMemberEmailUpdate({ userId: user.id, email, diff --git a/packages/twenty-server/src/engine/guards/admin-panel-or-impersonate.guard.ts b/packages/twenty-server/src/engine/guards/admin-panel-or-impersonate.guard.ts new file mode 100644 index 0000000000..141d859c5e --- /dev/null +++ b/packages/twenty-server/src/engine/guards/admin-panel-or-impersonate.guard.ts @@ -0,0 +1,17 @@ +import { type CanActivate, type ExecutionContext } from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; + +// Read-only admin-panel lookups (user/recent-users search) are available to +// full admins as well as impersonators: managing server-admin access requires +// finding users, and a full admin is the higher privilege. +export class AdminPanelOrImpersonateGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean | Promise<boolean> { + const ctx = GqlExecutionContext.create(context); + const request = ctx.getContext().req; + + return ( + request.user.canAccessFullAdminPanel === true || + request.user.canImpersonate === true + ); + } +}