## 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:
@@ -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;
|
||||
@@ -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>;
|
||||
|
||||
+170
-168
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
+270
@@ -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>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+102
@@ -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>
|
||||
);
|
||||
};
|
||||
+24
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+14
@@ -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;
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
+319
@@ -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> =
|
||||
|
||||
+25
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user