feat(server): in-app server-level admin management (#19785) (#21321)

## Closes #19785

In-app management of **server-level admin rights**
(`canAccessFullAdminPanel`, `canImpersonate`) so self-hosters no longer
need raw SQL + a Redis flush + restart to grant access.

> **Draft** — feature complete; `/code-review` + `/security-review` run
and addressed.

### Background
`AdminPanelGuard` / `ServerLevelImpersonateGuard` read
`request.user.{canAccessFullAdminPanel,canImpersonate}`, hydrated each
request from `CoreEntityCacheService.get('user', …)` (local 30-min +
Redis no-TTL). The cache was only invalidated on soft-delete, so a raw
`UPDATE core."user"` never took effect. The **first** signup auto-gets
both flags; every subsequent admin previously needed raw SQL.

### UX
- **Admin Panel → General → Administrators**: a read-only overview of
every user with server-level access; each row links to that user's admin
page.
- **Find anyone** via the user search (Recent Users) — available to full
admins and impersonators — then open their **admin user page**.
- On the user page, an **"Administrator access"** card (gated on
`canAccessFullAdminPanel`) has two toggles — *Full admin panel access*
and *Impersonation* — that work for **any** user (a user with no access
shows both off). Mirrors how **Impersonate** already works (find user →
user page → act). Each change opens a confirm dialog with a **2FA code**
field; the last full admin's toggle is disabled.

### Backend / security
- **Cache fix** — invalidate the user entity cache on committed user
updates (not just soft-delete) so privilege changes propagate (~100 ms,
cluster-wide) with no restart.
- `getServerAdmins` query + `updateServerAdminAccess` mutation (any
`targetUserId`), gated on `canAccessFullAdminPanel`.
- `NoImpersonationGuard` on both — an impersonated full-admin session
can't be used to escalate an impersonator.
- Fresh **2FA TOTP step-up** (enrolled+verified method **and** a fresh
code; genuine 2FA errors surface; dev-skip on trusted `NODE_ENV`).
- **Last-admin lockout** in a transaction with a pessimistic row lock
(no TOCTOU).
- **Email-to-all-admins + affected user** (rendered once per locale),
structured log, audit event-log emit.
- **Authorization**: the read-only `userLookupAdminPanel` +
`adminPanelRecentUsers` lookups now accept `canAccessFullAdminPanel OR
canImpersonate` (new `AdminPanelOrImpersonateGuard`), so a full admin
without impersonate can still find users to manage.
Workspace/impersonation queries stay impersonate-gated.

### Reviews
- `/code-review` (max effort): 3 security findings
(impersonation-escalation sink, lockout TOCTOU, step-up accepting
PENDING 2FA) — **all fixed**. `/simplify`: applied. `/security-review`:
**no high/medium vulnerabilities**.

### Follow-ups (not in this PR)
- Unit tests for `AdminPanelServerAdminService` + a frontend test.
- Point the self-host troubleshooting docs at the new UI.
- OTP retry UX: `ConfirmationModal` closes on confirm, so a wrong code
needs a reopen (kept to reuse the existing modal; no new pattern).

### Notes for reviewers
- `generated-admin/graphql.ts` entries were hand-added to match codegen
output (admin codegen needs a running server); re-run `nx
graphql:generate twenty-front --configuration=admin` to confirm parity.
- First-admin bootstrap (first signup) is unchanged.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-06-10 06:50:25 +02:00
committed by GitHub
parent 6c65ae8257
commit ce2d77be2a
18 changed files with 1193 additions and 174 deletions
@@ -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 (
<BaseEmail locale={locale}>
<Title value={i18n._('Server administrator access changed')} />
<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;
+1
View File
@@ -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';
@@ -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>;
@@ -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>
)}
</>
);
@@ -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>
}
/>
</>
);
};
@@ -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>
);
};
@@ -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
}
}
`;
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const GET_SERVER_ADMINS = gql`
query GetServerAdmins {
getServerAdmins {
id
email
firstName
lastName
canAccessFullAdminPanel
canImpersonate
}
}
`;
@@ -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 />;
}
@@ -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,
@@ -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(
@@ -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;
}
@@ -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;
}
@@ -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,
};
}
}
@@ -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> =
@@ -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,
);
@@ -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,
@@ -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
);
}
}