Compare commits

...
Author SHA1 Message Date
claude[bot]andFélix Malfait 2272e4f247 Remove unrelated SettingsAdminWorkspaceChatThread from this PR
This file was added in the base branch but is unrelated to the
impersonation refactoring. It should be submitted in a separate PR.

Co-authored-by: Félix Malfait <FelixMalfait@users.noreply.github.com>
2026-04-12 12:28:16 +00:00
Claude f33973bc24 Add try-catch around JSON.parse in stopImpersonating
If sessionStorage contains corrupted data, fall back to signOut()
instead of leaving the user stuck in the impersonated session.

https://claude.ai/code/session_01SdHcodsLnF7R6Zg8fUtKj8
2026-04-12 12:22:58 +00:00
Claude 777a031fd2 Refactor impersonation to use token restoration instead of session destruction
Replace useImpersonationAuth with useImpersonationSession that stores the
admin's original tokens in sessionStorage before swapping to the impersonated
user's session. "Stop Impersonating" now restores the original tokens and
navigates back instead of signing out completely.

- Same-workspace impersonation (admin panel + Members page): in-place token
  swap with Apollo cache reset, restorable via "Stop Impersonating"
- Cross-workspace impersonation (admin panel): still opens new tab via redirect
- Stop Impersonating: restores original session without re-login, falls back
  to window.close() or signOut() for cross-workspace tabs

https://claude.ai/code/session_01SdHcodsLnF7R6Zg8fUtKj8
2026-04-12 12:20:06 +00:00
Claude 06d8719e07 Fix lint errors in SettingsAdminWorkspaceChatThread
- Merge duplicate 'twenty-ui/layout' imports (Card and Section)
- Use explicit null check instead of truthiness for toolName filter

https://claude.ai/code/session_01SdHcodsLnF7R6Zg8fUtKj8
2026-04-12 12:19:13 +00:00
Claude 91b0937315 Fix CI lint/typecheck failures and admin panel UI issues
- Remove undefined useNavigate reference in SettingsAdminWorkspaceDetail
  (was crashing at runtime since useNavigate was not imported)
- Replace onCompleted callback with useEffect for Apollo useQuery
  compatibility, fix DeepPartialObject type mismatch
- Replace onClick+navigate with to prop on TableRow in SettingsAdminAI
  for accessible link behavior (cmd+click to open in new tab)
- Right-align Workspace column in Recent Users table
- Fix prettier formatting across changed files

https://claude.ai/code/session_01SdHcodsLnF7R6Zg8fUtKj8
2026-04-12 06:27:48 +00:00
Félix MalfaitandClaude Opus 4.6 c30d4ea8f5 Fix admin-panel test missing repository mocks and lint issues
- Add WorkspaceEntity, UserWorkspaceEntity, FeatureFlagEntity,
  AgentChatThreadEntity, AgentMessageEntity repository mocks to
  admin-panel.service.spec.ts
- Replace navigate() with TableRow `to` prop in workspace detail chats tab
- Fix prettier formatting in agent-chat.service.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 21:35:47 +02:00
Félix MalfaitandGitHub c5c77bed7d Merge branch 'main' into claude/recursing-turing 2026-04-11 21:30:07 +02:00
Félix MalfaitandClaude Opus 4.6 00e6f55c8d Evolve admin panel: dashboard tables, user detail page, members tab, chat fixes
- Add recent users and top workspaces tables to admin panel General tab with fuzzy search
- Add user detail page with user info, workspace tabs, and impersonate button
- Add members tab on workspace detail page with impersonate per user
- Move feature flags to dedicated tab on workspace detail page
- Add creation date and activation status to user/workspace info cards
- Fix chat thread viewer to show assistant messages (query by threadId only)
- Use LazyMarkdownRenderer for assistant messages in chat thread viewer
- Fix root cause of assistant messages having null workspaceId by using
  Repository.insert() instead of .create()/.save() to bypass TypeORM
  @ManyToOne/@JoinColumn column resolution issue

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 16:07:57 +02:00
Félix MalfaitandGitHub bbf875d21d Merge branch 'main' into claude/recursing-turing 2026-04-11 11:25:14 +02:00
Félix MalfaitandClaude Opus 4.6 99da104d94 Add admin panel workspace detail page with chat thread viewer
Introduce a workspace detail page accessible from the AI usage table
in the admin panel. The page has two tabs: Info (reusing existing
SettingsAdminWorkspaceContent) and Chats (listing AI chat threads,
gated by workspace allowImpersonation flag). Chat threads are clickable
to view the full conversation in a read-only message view.

Backend: new GraphQL queries (workspaceLookupAdminPanel,
getAdminWorkspaceChatThreads, getAdminChatThreadMessages) with
business logic in AdminPanelService and security checks via
assertWorkspaceAllowsImpersonation helper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 10:53:54 +02:00
32 changed files with 1755 additions and 387 deletions
@@ -70,6 +70,27 @@ export type AdminAiModels = {
models: Array<AdminAiModelConfig>;
};
export type AdminChatMessage = {
__typename?: 'AdminChatMessage';
createdAt: Scalars['DateTime'];
id: Scalars['UUID'];
parts: Array<AdminChatMessagePart>;
role: Scalars['String'];
};
export type AdminChatMessagePart = {
__typename?: 'AdminChatMessagePart';
textContent?: Maybe<Scalars['String']>;
toolName?: Maybe<Scalars['String']>;
type: Scalars['String'];
};
export type AdminChatThreadMessages = {
__typename?: 'AdminChatThreadMessages';
messages: Array<AdminChatMessage>;
thread: AdminWorkspaceChatThread;
};
export type AdminPanelHealthServiceData = {
__typename?: 'AdminPanelHealthServiceData';
description: Scalars['String'];
@@ -86,6 +107,25 @@ export enum AdminPanelHealthServiceStatus {
OUTAGE = 'OUTAGE'
}
export type AdminPanelRecentUser = {
__typename?: 'AdminPanelRecentUser';
createdAt: Scalars['DateTime'];
email: Scalars['String'];
firstName?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
lastName?: Maybe<Scalars['String']>;
workspaceId?: Maybe<Scalars['UUID']>;
workspaceName?: Maybe<Scalars['String']>;
};
export type AdminPanelTopWorkspace = {
__typename?: 'AdminPanelTopWorkspace';
id: Scalars['UUID'];
name: Scalars['String'];
subdomain: Scalars['String'];
totalUsers: Scalars['Int'];
};
export type AdminPanelWorkerQueueHealth = {
__typename?: 'AdminPanelWorkerQueueHealth';
id: Scalars['String'];
@@ -93,6 +133,17 @@ export type AdminPanelWorkerQueueHealth = {
status: AdminPanelHealthServiceStatus;
};
export type AdminWorkspaceChatThread = {
__typename?: 'AdminWorkspaceChatThread';
conversationSize: Scalars['Int'];
createdAt: Scalars['DateTime'];
id: Scalars['UUID'];
title?: Maybe<Scalars['String']>;
totalInputTokens: Scalars['Int'];
totalOutputTokens: Scalars['Int'];
updatedAt: Scalars['DateTime'];
};
export type Agent = {
__typename?: 'Agent';
applicationId?: Maybe<Scalars['UUID']>;
@@ -4314,6 +4365,8 @@ export type PublicWorkspaceData = {
export type Query = {
__typename?: 'Query';
adminPanelRecentUsers: Array<AdminPanelRecentUser>;
adminPanelTopWorkspaces: Array<AdminPanelTopWorkspace>;
agentTurns: Array<AgentTurn>;
apiKey?: Maybe<ApiKey>;
apiKeys: Array<ApiKey>;
@@ -4362,6 +4415,8 @@ export type Query = {
getAddressDetails: PlaceDetailsResult;
getAdminAiModels: AdminAiModels;
getAdminAiUsageByWorkspace: Array<UsageBreakdownItem>;
getAdminChatThreadMessages: AdminChatThreadMessages;
getAdminWorkspaceChatThreads: Array<AdminWorkspaceChatThread>;
getAiProviders: Scalars['JSON'];
getApprovedAccessDomains: Array<ApprovedAccessDomain>;
getAutoCompleteAddress: Array<AutocompleteResult>;
@@ -4427,6 +4482,17 @@ export type Query = {
versionInfo: VersionInfo;
webhook?: Maybe<Webhook>;
webhooks: Array<Webhook>;
workspaceLookupAdminPanel: UserLookup;
};
export type QueryAdminPanelRecentUsersArgs = {
searchTerm?: InputMaybe<Scalars['String']>;
};
export type QueryAdminPanelTopWorkspacesArgs = {
searchTerm?: InputMaybe<Scalars['String']>;
};
@@ -4592,6 +4658,17 @@ export type QueryGetAdminAiUsageByWorkspaceArgs = {
};
export type QueryGetAdminChatThreadMessagesArgs = {
threadId: Scalars['String'];
workspaceId: Scalars['String'];
};
export type QueryGetAdminWorkspaceChatThreadsArgs = {
workspaceId: Scalars['String'];
};
export type QueryGetAutoCompleteAddressArgs = {
address: Scalars['String'];
country?: InputMaybe<Scalars['String']>;
@@ -4827,6 +4904,11 @@ export type QueryWebhookArgs = {
id: Scalars['UUID'];
};
export type QueryWorkspaceLookupAdminPanelArgs = {
workspaceId: Scalars['String'];
};
export type QueueJob = {
__typename?: 'QueueJob';
attemptsMade: Scalars['Float'];
@@ -5850,6 +5932,7 @@ export type User = {
export type UserInfo = {
__typename?: 'UserInfo';
createdAt: Scalars['DateTime'];
email: Scalars['String'];
firstName?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
@@ -6258,7 +6341,9 @@ export enum WorkspaceActivationStatus {
export type WorkspaceInfo = {
__typename?: 'WorkspaceInfo';
activationStatus: WorkspaceActivationStatus;
allowImpersonation: Scalars['Boolean'];
createdAt: Scalars['DateTime'];
featureFlags: Array<FeatureFlag>;
id: Scalars['UUID'];
logo?: Maybe<Scalars['String']>;
@@ -7381,13 +7466,49 @@ export type UserLookupAdminPanelMutationVariables = Exact<{
}>;
export type UserLookupAdminPanelMutation = { __typename?: 'Mutation', userLookupAdminPanel: { __typename?: 'UserLookup', user: { __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }, workspaces: Array<{ __typename?: 'WorkspaceInfo', id: string, name: string, logo?: string | null, totalUsers: number, allowImpersonation: boolean, workspaceUrls: { __typename?: 'WorkspaceUrls', customUrl?: string | null, subdomainUrl: string }, users: Array<{ __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }>, featureFlags: Array<{ __typename?: 'FeatureFlag', key: FeatureFlagKey, value: boolean }> }> } };
export type UserLookupAdminPanelMutation = { __typename?: 'Mutation', userLookupAdminPanel: { __typename?: 'UserLookup', user: { __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null, createdAt: string }, workspaces: Array<{ __typename?: 'WorkspaceInfo', id: string, name: string, logo?: string | null, totalUsers: number, activationStatus: WorkspaceActivationStatus, createdAt: string, allowImpersonation: boolean, workspaceUrls: { __typename?: 'WorkspaceUrls', customUrl?: string | null, subdomainUrl: string }, users: Array<{ __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }>, featureFlags: Array<{ __typename?: 'FeatureFlag', key: FeatureFlagKey, value: boolean }> }> } };
export type AdminPanelRecentUsersQueryVariables = Exact<{
searchTerm?: InputMaybe<Scalars['String']>;
}>;
export type AdminPanelRecentUsersQuery = { __typename?: 'Query', adminPanelRecentUsers: Array<{ __typename?: 'AdminPanelRecentUser', id: string, email: string, firstName?: string | null, lastName?: string | null, createdAt: string, workspaceName?: string | null, workspaceId?: string | null }> };
export type AdminPanelTopWorkspacesQueryVariables = Exact<{
searchTerm?: InputMaybe<Scalars['String']>;
}>;
export type AdminPanelTopWorkspacesQuery = { __typename?: 'Query', adminPanelTopWorkspaces: Array<{ __typename?: 'AdminPanelTopWorkspace', id: string, name: string, totalUsers: number, subdomain: string }> };
export type GetAdminChatThreadMessagesQueryVariables = Exact<{
workspaceId: Scalars['String'];
threadId: Scalars['String'];
}>;
export type GetAdminChatThreadMessagesQuery = { __typename?: 'Query', getAdminChatThreadMessages: { __typename?: 'AdminChatThreadMessages', thread: { __typename?: 'AdminWorkspaceChatThread', id: string, title?: string | null, totalInputTokens: number, totalOutputTokens: number, conversationSize: number, createdAt: string, updatedAt: string }, messages: Array<{ __typename?: 'AdminChatMessage', id: string, role: string, createdAt: string, parts: Array<{ __typename?: 'AdminChatMessagePart', type: string, textContent?: string | null, toolName?: string | null }> }> } };
export type GetAdminWorkspaceChatThreadsQueryVariables = Exact<{
workspaceId: Scalars['String'];
}>;
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 GetVersionInfoQueryVariables = Exact<{ [key: string]: never; }>;
export type GetVersionInfoQuery = { __typename?: 'Query', versionInfo: { __typename?: 'VersionInfo', currentVersion?: string | null, latestVersion: string } };
export type WorkspaceLookupAdminPanelQueryVariables = Exact<{
workspaceId: Scalars['String'];
}>;
export type WorkspaceLookupAdminPanelQuery = { __typename?: 'Query', workspaceLookupAdminPanel: { __typename?: 'UserLookup', user: { __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null, createdAt: string }, workspaces: Array<{ __typename?: 'WorkspaceInfo', id: string, name: string, allowImpersonation: boolean, logo?: string | null, totalUsers: number, activationStatus: WorkspaceActivationStatus, createdAt: string, workspaceUrls: { __typename?: 'WorkspaceUrls', customUrl?: string | null, subdomainUrl: string }, users: Array<{ __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }>, featureFlags: Array<{ __typename?: 'FeatureFlag', key: FeatureFlagKey, value: boolean }> }> } };
export type DeleteJobsMutationVariables = Exact<{
queueName: Scalars['String'];
jobIds: Array<Scalars['String']> | Scalars['String'];
@@ -8562,8 +8683,13 @@ export const UpdateDatabaseConfigVariableDocument = {"kind":"Document","definiti
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 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 UserLookupAdminPanelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UserLookupAdminPanel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userIdentifier"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userLookupAdminPanel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"userIdentifier"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userIdentifier"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"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":"workspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"totalUsers"}},{"kind":"Field","name":{"kind":"Name","value":"allowImpersonation"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customUrl"}},{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"users"},"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":"featureFlags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}}]}}]}}]}}]} as unknown as DocumentNode<UserLookupAdminPanelMutation, UserLookupAdminPanelMutationVariables>;
export const UserLookupAdminPanelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UserLookupAdminPanel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userIdentifier"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userLookupAdminPanel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"userIdentifier"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userIdentifier"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"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":"createdAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"totalUsers"}},{"kind":"Field","name":{"kind":"Name","value":"activationStatus"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"allowImpersonation"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customUrl"}},{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"users"},"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":"featureFlags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}}]}}]}}]}}]} as unknown as DocumentNode<UserLookupAdminPanelMutation, UserLookupAdminPanelMutationVariables>;
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":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceName"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}}]}}]}}]} 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":"name"}},{"kind":"Field","name":{"kind":"Name","value":"totalUsers"}},{"kind":"Field","name":{"kind":"Name","value":"subdomain"}}]}}]}}]} as unknown as DocumentNode<AdminPanelTopWorkspacesQuery, AdminPanelTopWorkspacesQueryVariables>;
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":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminChatThreadMessages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"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":"String"}}}}],"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 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 WorkspaceLookupAdminPanelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"WorkspaceLookupAdminPanel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceLookupAdminPanel"},"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":"user"},"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":"createdAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"allowImpersonation"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"totalUsers"}},{"kind":"Field","name":{"kind":"Name","value":"activationStatus"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customUrl"}},{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"users"},"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":"featureFlags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}}]}}]}}]}}]} as unknown as DocumentNode<WorkspaceLookupAdminPanelQuery, WorkspaceLookupAdminPanelQueryVariables>;
export const DeleteJobsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteJobs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"queueName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"jobIds"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteJobs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"queueName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"queueName"}}},{"kind":"Argument","name":{"kind":"Name","value":"jobIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"jobIds"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deletedCount"}},{"kind":"Field","name":{"kind":"Name","value":"results"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"jobId"}},{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]}}]} as unknown as DocumentNode<DeleteJobsMutation, DeleteJobsMutationVariables>;
export const RetryJobsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RetryJobs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"queueName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"jobIds"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"retryJobs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"queueName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"queueName"}}},{"kind":"Argument","name":{"kind":"Name","value":"jobIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"jobIds"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"retriedCount"}},{"kind":"Field","name":{"kind":"Name","value":"results"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"jobId"}},{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]}}]} as unknown as DocumentNode<RetryJobsMutation, RetryJobsMutationVariables>;
export const GetIndicatorHealthStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetIndicatorHealthStatus"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"indicatorId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"HealthIndicatorId"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getIndicatorHealthStatus"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"indicatorId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"indicatorId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"details"}},{"kind":"Field","name":{"kind":"Name","value":"queues"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"queueName"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]}}]} as unknown as DocumentNode<GetIndicatorHealthStatusQuery, GetIndicatorHealthStatusQueryVariables>;
@@ -425,6 +425,30 @@ const SettingsAdminNewAiModel = lazy(() =>
),
);
const SettingsAdminUserDetail = lazy(() =>
import('~/pages/settings/admin-panel/SettingsAdminUserDetail').then(
(module) => ({
default: module.SettingsAdminUserDetail,
}),
),
);
const SettingsAdminWorkspaceDetail = lazy(() =>
import('~/pages/settings/admin-panel/SettingsAdminWorkspaceDetail').then(
(module) => ({
default: module.SettingsAdminWorkspaceDetail,
}),
),
);
const SettingsAdminWorkspaceChatThread = lazy(() =>
import('~/pages/settings/admin-panel/SettingsAdminWorkspaceChatThread').then(
(module) => ({
default: module.SettingsAdminWorkspaceChatThread,
}),
),
);
const SettingsUpdates = lazy(() =>
import('~/pages/settings/updates/SettingsUpdates').then((module) => ({
default: module.SettingsUpdates,
@@ -770,6 +794,18 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.AdminPanelAiProviderDetail}
element={<SettingsAdminAiProviderDetail />}
/>
<Route
path={SettingsPath.AdminPanelUserDetail}
element={<SettingsAdminUserDetail />}
/>
<Route
path={SettingsPath.AdminPanelWorkspaceDetail}
element={<SettingsAdminWorkspaceDetail />}
/>
<Route
path={SettingsPath.AdminPanelWorkspaceChatThread}
element={<SettingsAdminWorkspaceChatThread />}
/>
</>
)}
@@ -0,0 +1,95 @@
import { useApolloClient } from '@apollo/client/react';
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStore } from 'jotai';
import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState';
import { useAuth } from '@/auth/hooks/useAuth';
import { tokenPairState } from '@/auth/states/tokenPairState';
import { useClearSseClient } from '@/sse-db-event/hooks/useClearSseClient';
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
import { type AuthTokenPair } from '~/generated-metadata/graphql';
const IMPERSONATION_SESSION_KEY = 'impersonation_original_session';
type StoredImpersonationSession = {
tokenPair: AuthTokenPair;
returnPath: string;
};
export const useImpersonationSession = () => {
const store = useStore();
const client = useApolloClient();
const navigate = useNavigate();
const { getAuthTokensFromLoginToken, signOut } = useAuth();
const { clearSseClient } = useClearSseClient();
const { loadCurrentUser } = useLoadCurrentUser();
const startImpersonating = useCallback(
async (loginToken: string, returnPath?: string) => {
const currentTokenPair = store.get(tokenPairState.atom);
if (currentTokenPair) {
const session: StoredImpersonationSession = {
tokenPair: currentTokenPair,
returnPath: returnPath ?? window.location.pathname,
};
sessionStorage.setItem(
IMPERSONATION_SESSION_KEY,
JSON.stringify(session),
);
}
clearSseClient();
await client.clearStore();
store.set(isAppEffectRedirectEnabledState.atom, false);
await getAuthTokensFromLoginToken(loginToken);
store.set(isAppEffectRedirectEnabledState.atom, true);
},
[store, client, clearSseClient, getAuthTokensFromLoginToken],
);
const stopImpersonating = useCallback(async () => {
const raw = sessionStorage.getItem(IMPERSONATION_SESSION_KEY);
if (!raw) {
// No stored session — likely a cross-workspace tab opened via redirect.
// Try closing the tab (works when opened via window.open or target=_blank).
window.close();
// If window.close() was blocked by the browser, fall back to sign out.
await signOut();
return;
}
let session: StoredImpersonationSession;
try {
session = JSON.parse(raw);
} catch {
sessionStorage.removeItem(IMPERSONATION_SESSION_KEY);
await signOut();
return;
}
sessionStorage.removeItem(IMPERSONATION_SESSION_KEY);
clearSseClient();
await client.clearStore();
store.set(isAppEffectRedirectEnabledState.atom, false);
store.set(tokenPairState.atom, session.tokenPair);
await loadCurrentUser();
store.set(isAppEffectRedirectEnabledState.atom, true);
navigate(session.returnPath);
}, [store, client, clearSseClient, loadCurrentUser, signOut, navigate]);
const hasStoredSession = useCallback(() => {
return sessionStorage.getItem(IMPERSONATION_SESSION_KEY) !== null;
}, []);
return { startImpersonating, stopImpersonating, hasStoredSession };
};
@@ -1,4 +1,4 @@
import { useAuth } from '@/auth/hooks/useAuth';
import { useImpersonationSession } from '@/auth/hooks/useImpersonationSession';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { isImpersonatingState } from '@/auth/states/isImpersonatingState';
import { InformationBanner } from '@/information-banner/components/InformationBanner';
@@ -11,7 +11,7 @@ export const InformationBannerIsImpersonating = () => {
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const isImpersonating = useAtomStateValue(isImpersonatingState);
const { signOut } = useAuth();
const { stopImpersonating } = useImpersonationSession();
if (!isDefined(currentWorkspaceMember) || !isImpersonating) {
return null;
@@ -25,7 +25,7 @@ export const InformationBannerIsImpersonating = () => {
message={t`Logged in as ${impersonatedUser}`}
buttonTitle={t`Stop impersonating`}
buttonIcon={IconLogout}
buttonOnClick={signOut}
buttonOnClick={stopImpersonating}
/>
);
};
@@ -2,6 +2,8 @@ import { useMemo, useState } from 'react';
import { useMutation, useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { Tag } from 'twenty-ui/components';
import { H2Title, IconBolt, IconLock, IconRobot } from 'twenty-ui/display';
import { Card, Section } from 'twenty-ui/layout';
@@ -319,6 +321,9 @@ export const SettingsAdminAI = () => {
<TableRow
key={item.key}
gridTemplateColumns={USAGE_TABLE_GRID_TEMPLATE_COLUMNS}
to={getSettingsPath(SettingsPath.AdminPanelWorkspaceDetail, {
workspaceId: item.key,
})}
>
<TableCell color={themeCssVariables.font.color.primary}>
{item.label ?? item.key}
@@ -1,134 +1,73 @@
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
import { SettingsAdminWorkspaceContent } from '@/settings/admin-panel/components/SettingsAdminWorkspaceContent';
import { userLookupResultState } from '@/settings/admin-panel/states/userLookupResultState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsAdminVersionContainer } from '@/settings/admin-panel/components/SettingsAdminVersionContainer';
import { ADMIN_PANEL_RECENT_USERS } from '@/settings/admin-panel/graphql/queries/adminPanelRecentUsers';
import { ADMIN_PANEL_TOP_WORKSPACES } from '@/settings/admin-panel/graphql/queries/adminPanelTopWorkspaces';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
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 { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useState } from 'react';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useMutation } from '@apollo/client/react';
import { UserLookupAdminPanelDocument } from '~/generated-metadata/graphql';
import { useDebounce } from 'use-debounce';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { currentUserState } from '@/auth/states/currentUserState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import { SettingsAdminVersionContainer } from '@/settings/admin-panel/components/SettingsAdminVersionContainer';
import { SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminUserLookupWorkspaceTabsId';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { getImageAbsoluteURI, isDefined } from 'twenty-shared/utils';
import {
H2Title,
IconId,
IconMail,
IconSearch,
IconUser,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
gap: ${themeCssVariables.spacing[2]};
const StyledEmptyState = styled.div`
color: ${themeCssVariables.font.color.tertiary};
padding: ${themeCssVariables.spacing[4]} 0;
`;
export const SettingsAdminGeneral = () => {
const [userIdentifier, setUserIdentifier] = useState('');
const { enqueueErrorSnackBar } = useSnackBar();
const [userSearchTerm, setUserSearchTerm] = useState('');
const [debouncedUserSearchTerm] = useDebounce(userSearchTerm, 300);
const [activeTabId, setActiveTabId] = useAtomComponentState(
activeTabIdComponentState,
SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID,
);
const [userLookupResult, setUserLookupResult] = useAtomState(
userLookupResultState,
);
const [isUserLookupLoading, setIsUserLookupLoading] = useState(false);
const [userLookup] = useMutation(UserLookupAdminPanelDocument);
const [workspaceSearchTerm, setWorkspaceSearchTerm] = useState('');
const [debouncedWorkspaceSearchTerm] = useDebounce(workspaceSearchTerm, 300);
const currentUser = useAtomStateValue(currentUserState);
const canAccessFullAdminPanel = currentUser?.canAccessFullAdminPanel;
const canImpersonate = currentUser?.canImpersonate;
const canManageFeatureFlags = useAtomStateValue(canManageFeatureFlagsState);
const handleSearch = async () => {
setActiveTabId('');
setIsUserLookupLoading(true);
setUserLookupResult(null);
const { data: recentUsersData, loading: isLoadingUsers } = useQuery<{
adminPanelRecentUsers: {
id: string;
email: string;
firstName?: string | null;
lastName?: string | null;
createdAt: string;
workspaceName?: string | null;
workspaceId?: string | null;
}[];
}>(ADMIN_PANEL_RECENT_USERS, {
variables: { searchTerm: debouncedUserSearchTerm },
skip: !canImpersonate,
});
const response = await userLookup({
variables: { userIdentifier },
onCompleted: (data) => {
setIsUserLookupLoading(false);
if (isDefined(data?.userLookupAdminPanel)) {
setUserLookupResult(data.userLookupAdminPanel);
}
},
onError: (error) => {
setIsUserLookupLoading(false);
enqueueErrorSnackBar({
apolloError: error,
});
},
});
const { data: topWorkspacesData, loading: isLoadingWorkspaces } = useQuery<{
adminPanelTopWorkspaces: {
id: string;
name: string;
totalUsers: number;
subdomain: string;
}[];
}>(ADMIN_PANEL_TOP_WORKSPACES, {
variables: { searchTerm: debouncedWorkspaceSearchTerm },
skip: !canImpersonate,
});
const result = response.data?.userLookupAdminPanel;
if (isDefined(result?.workspaces) && result.workspaces.length > 0) {
setActiveTabId(result.workspaces[0].id);
}
};
const activeWorkspace = userLookupResult?.workspaces.find(
(workspace) => workspace.id === activeTabId,
);
const tabs =
userLookupResult?.workspaces.map((workspace) => ({
id: workspace.id,
title: workspace.name,
logo:
getImageAbsoluteURI({
imageUrl: isNonEmptyString(workspace.logo)
? workspace.logo
: DEFAULT_WORKSPACE_LOGO,
baseUrl: REACT_APP_SERVER_BASE_URL,
}) ?? '',
})) ?? [];
const userFullName = `${userLookupResult?.user.firstName || ''} ${
userLookupResult?.user.lastName || ''
}`.trim();
const userInfoItems = [
{
Icon: IconUser,
label: t`Name`,
value: userFullName,
},
{
Icon: IconMail,
label: t`Email`,
value: userLookupResult?.user.email,
},
{
Icon: IconId,
label: t`ID`,
value: userLookupResult?.user.id,
},
];
const recentUsers = recentUsersData?.adminPanelRecentUsers ?? [];
const topWorkspaces = topWorkspacesData?.adminPanelTopWorkspaces ?? [];
return (
<>
@@ -143,63 +82,105 @@ export const SettingsAdminGeneral = () => {
)}
{canImpersonate && (
<Section>
<H2Title
title={
canManageFeatureFlags
? t`Feature Flags & Impersonation`
: t`User Impersonation`
}
description={
canManageFeatureFlags
? t`Look up users and manage their workspace feature flags or impersonate them.`
: t`Look up users to impersonate them.`
}
/>
<StyledContainer>
<SettingsTextInput
instanceId="admin-user-lookup"
value={userIdentifier}
onChange={setUserIdentifier}
onInputEnter={handleSearch}
placeholder={t`Enter user ID or email address`}
fullWidth
disabled={isUserLookupLoading}
/>
<Button
Icon={IconSearch}
variant="primary"
accent="blue"
title={t`Search`}
onClick={handleSearch}
disabled={!userIdentifier.trim() || isUserLookupLoading}
/>
</StyledContainer>
</Section>
)}
{isDefined(userLookupResult) && (
<>
<Section>
<H2Title title={t`User Info`} description={t`About this user`} />
<SettingsTableCard
items={userInfoItems}
rounded
gridAutoColumns="1fr 4fr"
<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
/>
{recentUsers.length === 0 ? (
<StyledEmptyState>
{isLoadingUsers
? t`Loading...`
: t`No users found matching your search criteria.`}
</StyledEmptyState>
) : (
<Table>
<TableBody>
<TableRow gridTemplateColumns="1fr 2fr 1fr">
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`Email`}</TableHeader>
<TableHeader align="right">{t`Workspace`}</TableHeader>
</TableRow>
{recentUsers.map((user) => (
<TableRow
key={user.id}
gridTemplateColumns="1fr 2fr 1fr"
to={getSettingsPath(SettingsPath.AdminPanelUserDetail, {
userId: user.id,
})}
>
<TableCell color={themeCssVariables.font.color.primary}>
{`${user.firstName || ''} ${user.lastName || ''}`.trim() ||
'\u2014'}
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell align="right">
{user.workspaceName || '\u2014'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Section>
<Section>
<H2Title
title={t`Workspaces`}
description={t`All workspaces this user is a member of`}
title={t`Top Workspaces`}
description={t`Top 10 workspaces by number of users`}
/>
<TabList
tabs={tabs}
behaveAsLinks={false}
componentInstanceId={SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID}
<SettingsTextInput
instanceId="admin-panel-workspace-search"
value={workspaceSearchTerm}
onChange={setWorkspaceSearchTerm}
placeholder={t`Search by workspace name, subdomain, or ID...`}
fullWidth
/>
<SettingsAdminWorkspaceContent activeWorkspace={activeWorkspace} />
{topWorkspaces.length === 0 ? (
<StyledEmptyState>
{isLoadingWorkspaces
? t`Loading...`
: t`No workspaces found matching your search criteria.`}
</StyledEmptyState>
) : (
<Table>
<TableBody>
<TableRow gridTemplateColumns="2fr 1fr">
<TableHeader>{t`Workspace`}</TableHeader>
<TableHeader align="right">{t`Users`}</TableHeader>
</TableRow>
{topWorkspaces.map((workspace) => (
<TableRow
key={workspace.id}
gridTemplateColumns="2fr 1fr"
to={getSettingsPath(
SettingsPath.AdminPanelWorkspaceDetail,
{ workspaceId: workspace.id },
)}
>
<TableCell color={themeCssVariables.font.color.primary}>
{workspace.name || '\u2014'}
</TableCell>
<TableCell align="right">
{workspace.totalUsers}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Section>
</>
)}
@@ -1,46 +1,29 @@
import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import { useFeatureFlagState } from '@/settings/admin-panel/hooks/useFeatureFlagState';
import { useImpersonationAuth } from '@/settings/admin-panel/hooks/useImpersonationAuth';
import { useImpersonationRedirect } from '@/settings/admin-panel/hooks/useImpersonationRedirect';
import { userLookupResultState } from '@/settings/admin-panel/states/userLookupResultState';
import { type WorkspaceInfo } from '@/settings/admin-panel/types/WorkspaceInfo';
import { getWorkspaceSchemaName } from '@/settings/admin-panel/utils/getWorkspaceSchemaName';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
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 { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMutation } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { getImageAbsoluteURI, isDefined } from 'twenty-shared/utils';
import { AvatarOrIcon, Chip } from 'twenty-ui/components';
import { SettingsPath } from 'twenty-shared/types';
import {
getImageAbsoluteURI,
getSettingsPath,
isDefined,
} from 'twenty-shared/utils';
import { AvatarOrIcon, LinkChip } from 'twenty-ui/components';
import {
H2Title,
IconEyeShare,
IconCalendar,
IconHome,
IconId,
IconLink,
IconStatusChange,
IconUser,
} from 'twenty-ui/display';
import { Button, Toggle } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import {
type FeatureFlagKey,
ImpersonateDocument,
UpdateWorkspaceFeatureFlagDocument,
} from '~/generated-metadata/graphql';
type SettingsAdminWorkspaceContentProps = {
activeWorkspace: WorkspaceInfo | undefined;
@@ -53,93 +36,11 @@ const StyledContainer = styled.div`
margin-top: ${themeCssVariables.spacing[6]};
`;
const StyledButtonContainer = styled.div`
margin-top: ${themeCssVariables.spacing[3]};
`;
export const SettingsAdminWorkspaceContent = ({
activeWorkspace,
}: SettingsAdminWorkspaceContentProps) => {
const canManageFeatureFlags = useAtomStateValue(canManageFeatureFlagsState);
const { enqueueErrorSnackBar } = useSnackBar();
const [currentUser] = useAtomState(currentUserState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const [updateFeatureFlag] = useMutation(UpdateWorkspaceFeatureFlagDocument);
const [isImpersonateLoading, setIsImpersonationLoading] = useState(false);
const { executeImpersonationAuth } = useImpersonationAuth();
const { executeImpersonationRedirect } = useImpersonationRedirect();
const [impersonate] = useMutation(ImpersonateDocument);
const { updateFeatureFlagState } = useFeatureFlagState();
const userLookupResult = useAtomStateValue(userLookupResultState);
const { t } = useLingui();
const handleImpersonate = async (workspaceId: string) => {
if (!userLookupResult?.user.id) {
enqueueErrorSnackBar({ message: t`Please search for a user first` });
return;
}
setIsImpersonationLoading(true);
await impersonate({
variables: { userId: userLookupResult.user.id, workspaceId },
onCompleted: async (data) => {
const { loginToken, workspace } = data.impersonate;
const isCurrentWorkspace = workspace.id === currentWorkspace?.id;
if (isCurrentWorkspace) {
await executeImpersonationAuth(loginToken.token);
return;
}
return executeImpersonationRedirect(
workspace.workspaceUrls,
loginToken.token,
'_blank',
);
},
onError: (error) => {
const errorMessage = error.message;
enqueueErrorSnackBar({
message: t`Failed to impersonate user. ${errorMessage}`,
});
},
}).finally(() => {
setIsImpersonationLoading(false);
});
};
const handleFeatureFlagUpdate = async (
workspaceId: string,
featureFlag: FeatureFlagKey,
value: boolean,
) => {
const previousValue = userLookupResult?.workspaces
.find((workspace) => workspace.id === workspaceId)
?.featureFlags.find((flag) => flag.key === featureFlag)?.value;
updateFeatureFlagState(workspaceId, featureFlag, value);
await updateFeatureFlag({
variables: {
workspaceId,
featureFlag,
value,
},
onError: (error) => {
if (isDefined(previousValue)) {
updateFeatureFlagState(workspaceId, featureFlag, previousValue);
}
const errorMessage = error.message;
enqueueErrorSnackBar({
message: t`Failed to update feature flag. ${errorMessage}`,
});
},
});
};
const getWorkspaceUrl = (workspaceUrls: WorkspaceInfo['workspaceUrls']) => {
return workspaceUrls.customUrl ?? workspaceUrls.subdomainUrl;
};
@@ -148,10 +49,13 @@ export const SettingsAdminWorkspaceContent = ({
{
Icon: IconHome,
label: t`Name`,
value: (
<Chip
value: activeWorkspace?.id ? (
<LinkChip
label={activeWorkspace?.name ?? ''}
emptyLabel={t`Untitled`}
to={getSettingsPath(SettingsPath.AdminPanelWorkspaceDetail, {
workspaceId: activeWorkspace.id,
})}
leftComponent={
<AvatarOrIcon
avatarUrl={
@@ -165,6 +69,8 @@ export const SettingsAdminWorkspaceContent = ({
/>
}
/>
) : (
(activeWorkspace?.name ?? '')
),
},
{
@@ -191,6 +97,18 @@ export const SettingsAdminWorkspaceContent = ({
label: t`Members`,
value: activeWorkspace?.totalUsers,
},
{
Icon: IconStatusChange,
label: t`Status`,
value: activeWorkspace?.activationStatus,
},
{
Icon: IconCalendar,
label: t`Created`,
value: activeWorkspace?.createdAt
? new Date(activeWorkspace.createdAt).toLocaleDateString()
: '',
},
];
if (!activeWorkspace) return null;
@@ -206,62 +124,7 @@ export const SettingsAdminWorkspaceContent = ({
items={workspaceInfoItems}
gridAutoColumns="1fr 4fr"
/>
<StyledButtonContainer>
{currentUser?.canImpersonate && (
<Button
Icon={IconEyeShare}
variant="primary"
accent="default"
title={
activeWorkspace.allowImpersonation === false
? t`Impersonation is disabled for this workspace`
: t`Impersonate`
}
onClick={() => handleImpersonate(activeWorkspace.id)}
disabled={
isImpersonateLoading ||
activeWorkspace.allowImpersonation === false
}
dataTestId="impersonate-button"
/>
)}
</StyledButtonContainer>
</Section>
{canManageFeatureFlags && (
<Table>
<TableBody>
<TableRow
gridAutoColumns="1fr 100px"
mobileGridAutoColumns="1fr 80px"
>
<TableHeader>{t`Feature Flag`}</TableHeader>
<TableHeader align="right">{t`Status`}</TableHeader>
</TableRow>
{activeWorkspace.featureFlags.map((flag) => (
<TableRow
gridAutoColumns="1fr 100px"
mobileGridAutoColumns="1fr 80px"
key={flag.key}
>
<TableCell>{flag.key}</TableCell>
<TableCell align="right">
<Toggle
value={flag.value}
onChange={(newValue) =>
handleFeatureFlagUpdate(
activeWorkspace.id,
flag.key,
newValue,
)
}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</StyledContainer>
);
};
@@ -8,12 +8,15 @@ export const USER_LOOKUP_ADMIN_PANEL = gql`
email
firstName
lastName
createdAt
}
workspaces {
id
name
logo
totalUsers
activationStatus
createdAt
allowImpersonation
workspaceUrls {
customUrl
@@ -0,0 +1,15 @@
import { gql } from '@apollo/client';
export const ADMIN_PANEL_RECENT_USERS = gql`
query AdminPanelRecentUsers($searchTerm: String) {
adminPanelRecentUsers(searchTerm: $searchTerm) {
id
email
firstName
lastName
createdAt
workspaceName
workspaceId
}
}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const ADMIN_PANEL_TOP_WORKSPACES = gql`
query AdminPanelTopWorkspaces($searchTerm: String) {
adminPanelTopWorkspaces(searchTerm: $searchTerm) {
id
name
totalUsers
subdomain
}
}
`;
@@ -0,0 +1,27 @@
import { gql } from '@apollo/client';
export const GET_ADMIN_CHAT_THREAD_MESSAGES = gql`
query GetAdminChatThreadMessages($workspaceId: String!, $threadId: String!) {
getAdminChatThreadMessages(workspaceId: $workspaceId, threadId: $threadId) {
thread {
id
title
totalInputTokens
totalOutputTokens
conversationSize
createdAt
updatedAt
}
messages {
id
role
parts {
type
textContent
toolName
}
createdAt
}
}
}
`;
@@ -0,0 +1,15 @@
import { gql } from '@apollo/client';
export const GET_ADMIN_WORKSPACE_CHAT_THREADS = gql`
query GetAdminWorkspaceChatThreads($workspaceId: String!) {
getAdminWorkspaceChatThreads(workspaceId: $workspaceId) {
id
title
totalInputTokens
totalOutputTokens
conversationSize
createdAt
updatedAt
}
}
`;
@@ -0,0 +1,38 @@
import { gql } from '@apollo/client';
export const WORKSPACE_LOOKUP_ADMIN_PANEL = gql`
query WorkspaceLookupAdminPanel($workspaceId: String!) {
workspaceLookupAdminPanel(workspaceId: $workspaceId) {
user {
id
email
firstName
lastName
createdAt
}
workspaces {
id
name
allowImpersonation
logo
totalUsers
activationStatus
createdAt
workspaceUrls {
customUrl
subdomainUrl
}
users {
id
email
firstName
lastName
}
featureFlags {
key
value
}
}
}
}
`;
@@ -1,21 +0,0 @@
import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState';
import { useAuth } from '@/auth/hooks/useAuth';
import { useClearSseClient } from '@/sse-db-event/hooks/useClearSseClient';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const useImpersonationAuth = () => {
const { getAuthTokensFromLoginToken } = useAuth();
const { clearSseClient } = useClearSseClient();
const setIsAppEffectRedirectEnabled = useSetAtomState(
isAppEffectRedirectEnabledState,
);
const executeImpersonationAuth = async (loginToken: string) => {
setIsAppEffectRedirectEnabled(false);
clearSseClient();
await getAuthTokensFromLoginToken(loginToken);
setIsAppEffectRedirectEnabled(true);
};
return { executeImpersonationAuth };
};
@@ -0,0 +1,9 @@
export type AdminChatThread = {
id: string;
title: string | null;
totalInputTokens: number;
totalOutputTokens: number;
conversationSize: number;
createdAt: string;
updatedAt: string;
};
@@ -6,6 +6,7 @@ export type UserLookup = {
email: string;
firstName?: string | null;
lastName?: string | null;
createdAt: string;
};
workspaces: WorkspaceInfo[];
};
@@ -6,6 +6,8 @@ export type WorkspaceInfo = {
name: string;
logo?: string | null;
totalUsers: number;
activationStatus: string;
createdAt: string;
workspaceUrls: WorkspaceUrls;
users: {
id: string;
@@ -0,0 +1,250 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useMutation } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { SettingsPath } from 'twenty-shared/types';
import {
getImageAbsoluteURI,
getSettingsPath,
isDefined,
} from 'twenty-shared/utils';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { SettingsAdminWorkspaceContent } from '@/settings/admin-panel/components/SettingsAdminWorkspaceContent';
import { SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminUserLookupWorkspaceTabsId';
import { useImpersonationSession } from '@/auth/hooks/useImpersonationSession';
import { useImpersonationRedirect } from '@/settings/admin-panel/hooks/useImpersonationRedirect';
import { userLookupResultState } from '@/settings/admin-panel/states/userLookupResultState';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
H2Title,
IconCalendar,
IconEyeShare,
IconId,
IconMail,
IconUser,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import {
ImpersonateDocument,
UserLookupAdminPanelDocument,
} from '~/generated-metadata/graphql';
const StyledButtonContainer = styled.div`
margin-top: ${themeCssVariables.spacing[3]};
`;
export const SettingsAdminUserDetail = () => {
const { userId } = useParams<{ userId: string }>();
const [activeTabId, setActiveTabId] = useAtomComponentState(
activeTabIdComponentState,
SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID,
);
const [userLookupResult, setUserLookupResult] = useAtomState(
userLookupResultState,
);
const [userLookup, { loading: isLoading }] = useMutation(
UserLookupAdminPanelDocument,
);
const currentUser = useAtomStateValue(currentUserState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const { enqueueErrorSnackBar } = useSnackBar();
const { startImpersonating } = useImpersonationSession();
const { executeImpersonationRedirect } = useImpersonationRedirect();
const [impersonate] = useMutation(ImpersonateDocument);
const [isImpersonateLoading, setIsImpersonateLoading] = useState(false);
useEffect(() => {
if (!userId) {
return;
}
userLookup({
variables: { userIdentifier: userId },
onCompleted: (data) => {
if (isDefined(data?.userLookupAdminPanel)) {
setUserLookupResult(data.userLookupAdminPanel);
if (data.userLookupAdminPanel.workspaces.length > 0 && !activeTabId) {
setActiveTabId(data.userLookupAdminPanel.workspaces[0].id);
}
}
},
});
}, [userId, userLookup, setUserLookupResult, setActiveTabId, activeTabId]);
const handleImpersonate = async (workspaceId: string) => {
if (!userLookupResult?.user.id) {
return;
}
setIsImpersonateLoading(true);
await impersonate({
variables: { userId: userLookupResult.user.id, workspaceId },
onCompleted: async (data) => {
const { loginToken, workspace } = data.impersonate;
const isCurrentWorkspace = workspace.id === currentWorkspace?.id;
if (isCurrentWorkspace) {
await startImpersonating(loginToken.token);
return;
}
return executeImpersonationRedirect(
workspace.workspaceUrls,
loginToken.token,
'_blank',
);
},
onError: (error) => {
enqueueErrorSnackBar({
message: `Failed to impersonate user. ${error.message}`,
});
},
}).finally(() => {
setIsImpersonateLoading(false);
});
};
const userFullName = `${userLookupResult?.user.firstName || ''} ${
userLookupResult?.user.lastName || ''
}`.trim();
const activeWorkspace = userLookupResult?.workspaces.find(
(workspace) => workspace.id === activeTabId,
);
const tabs =
userLookupResult?.workspaces.map((workspace) => ({
id: workspace.id,
title: workspace.name,
logo:
getImageAbsoluteURI({
imageUrl: isNonEmptyString(workspace.logo)
? workspace.logo
: DEFAULT_WORKSPACE_LOGO,
baseUrl: REACT_APP_SERVER_BASE_URL,
}) ?? '',
})) ?? [];
const userInfoItems = [
{
Icon: IconUser,
label: t`Name`,
value: userFullName,
},
{
Icon: IconMail,
label: t`Email`,
value: userLookupResult?.user.email,
},
{
Icon: IconId,
label: t`ID`,
value: userLookupResult?.user.id,
},
{
Icon: IconCalendar,
label: t`Created`,
value: userLookupResult?.user.createdAt
? new Date(userLookupResult.user.createdAt).toLocaleDateString()
: '',
},
];
const displayName = userFullName || userId || '';
if (isLoading && !userLookupResult) {
return <SettingsSkeletonLoader />;
}
return (
<SubMenuTopBarContainer
links={[
{
children: t`Other`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Admin Panel - General`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: displayName,
},
]}
>
<SettingsPageContainer>
{isDefined(userLookupResult) && (
<>
<Section>
<H2Title title={t`User Info`} description={t`About this user`} />
<SettingsTableCard
items={userInfoItems}
rounded
gridAutoColumns="1fr 4fr"
/>
</Section>
<Section>
<H2Title
title={t`Workspaces`}
description={t`All workspaces this user is a member of`}
/>
<TabList
tabs={tabs}
behaveAsLinks={false}
componentInstanceId={
SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID
}
/>
<SettingsAdminWorkspaceContent
activeWorkspace={activeWorkspace}
/>
{currentUser?.canImpersonate && activeWorkspace && (
<StyledButtonContainer>
<Button
Icon={IconEyeShare}
variant="primary"
accent="default"
title={
activeWorkspace.allowImpersonation === false
? t`Impersonation is disabled for this workspace`
: t`Impersonate`
}
onClick={() => handleImpersonate(activeWorkspace.id)}
disabled={
isImpersonateLoading ||
activeWorkspace.allowImpersonation === false
}
/>
</StyledButtonContainer>
)}
</Section>
</>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -0,0 +1,404 @@
import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useMutation, useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
import { AI_ADMIN_PATH } from '@/settings/admin-panel/ai/constants/AiAdminPath';
import { SettingsAdminWorkspaceContent } from '@/settings/admin-panel/components/SettingsAdminWorkspaceContent';
import { GET_ADMIN_WORKSPACE_CHAT_THREADS } from '@/settings/admin-panel/graphql/queries/getAdminWorkspaceChatThreads';
import { WORKSPACE_LOOKUP_ADMIN_PANEL } from '@/settings/admin-panel/graphql/queries/workspaceLookupAdminPanel';
import { useFeatureFlagState } from '@/settings/admin-panel/hooks/useFeatureFlagState';
import { useImpersonationSession } from '@/auth/hooks/useImpersonationSession';
import { useImpersonationRedirect } from '@/settings/admin-panel/hooks/useImpersonationRedirect';
import { userLookupResultState } from '@/settings/admin-panel/states/userLookupResultState';
import { type AdminChatThread } from '@/settings/admin-panel/types/AdminChatThread';
import { type UserLookup } from '@/settings/admin-panel/types/UserLookup';
import { type WorkspaceInfo } from '@/settings/admin-panel/types/WorkspaceInfo';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
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 { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
H2Title,
IconEyeShare,
IconFlag,
IconMessage,
IconSettings2,
IconUsers,
} from 'twenty-ui/display';
import { Button, Toggle } from 'twenty-ui/input';
import { Card, Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
type FeatureFlagKey,
ImpersonateDocument,
UpdateWorkspaceFeatureFlagDocument,
} from '~/generated-metadata/graphql';
const WORKSPACE_DETAIL_TABS_ID = 'settings-admin-workspace-detail-tabs';
const WORKSPACE_DETAIL_TAB_IDS = {
INFO: 'info',
MEMBERS: 'members',
FEATURE_FLAGS: 'feature-flags',
CHATS: 'chats',
};
export const SettingsAdminWorkspaceDetail = () => {
const { workspaceId } = useParams<{ workspaceId: string }>();
const [activeTabId] = useAtomComponentState(
activeTabIdComponentState,
WORKSPACE_DETAIL_TABS_ID,
);
const currentUser = useAtomStateValue(currentUserState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const canManageFeatureFlags = useAtomStateValue(canManageFeatureFlagsState);
const { enqueueErrorSnackBar } = useSnackBar();
const [updateFeatureFlag] = useMutation(UpdateWorkspaceFeatureFlagDocument);
const { updateFeatureFlagState } = useFeatureFlagState();
const { startImpersonating } = useImpersonationSession();
const { executeImpersonationRedirect } = useImpersonationRedirect();
const [impersonate] = useMutation(ImpersonateDocument);
const [impersonatingUserId, setImpersonatingUserId] = useState<string | null>(
null,
);
const [, setUserLookupResult] = useAtomState(userLookupResultState);
const userLookupResult = useAtomStateValue(userLookupResultState);
const { data: workspaceData, loading: isLoadingWorkspace } = useQuery<{
workspaceLookupAdminPanel: UserLookup;
}>(WORKSPACE_LOOKUP_ADMIN_PANEL, {
variables: { workspaceId },
skip: !workspaceId,
});
useEffect(() => {
if (isDefined(workspaceData?.workspaceLookupAdminPanel)) {
setUserLookupResult(workspaceData.workspaceLookupAdminPanel);
}
}, [workspaceData, setUserLookupResult]);
const workspace = workspaceData?.workspaceLookupAdminPanel
?.workspaces?.[0] as WorkspaceInfo | undefined;
const effectiveTabId = activeTabId || WORKSPACE_DETAIL_TAB_IDS.INFO;
const { data: threadsData, loading: isLoadingThreads } = useQuery<{
getAdminWorkspaceChatThreads: AdminChatThread[];
}>(GET_ADMIN_WORKSPACE_CHAT_THREADS, {
variables: { workspaceId },
skip:
!workspaceId ||
!workspace?.allowImpersonation ||
effectiveTabId !== WORKSPACE_DETAIL_TAB_IDS.CHATS,
});
const threads = threadsData?.getAdminWorkspaceChatThreads ?? [];
const handleImpersonate = async (userId: string) => {
if (!workspaceId) return;
setImpersonatingUserId(userId);
await impersonate({
variables: { userId, workspaceId },
onCompleted: async (data) => {
const { loginToken, workspace: impersonatedWorkspace } =
data.impersonate;
const isCurrentWorkspace =
impersonatedWorkspace.id === currentWorkspace?.id;
if (isCurrentWorkspace) {
await startImpersonating(loginToken.token);
return;
}
return executeImpersonationRedirect(
impersonatedWorkspace.workspaceUrls,
loginToken.token,
'_blank',
);
},
onError: (error) => {
enqueueErrorSnackBar({
message: `Failed to impersonate user. ${error.message}`,
});
},
}).finally(() => {
setImpersonatingUserId(null);
});
};
const handleFeatureFlagUpdate = async (
featureFlag: FeatureFlagKey,
value: boolean,
) => {
if (!workspaceId) return;
const previousValue = userLookupResult?.workspaces
.find((ws) => ws.id === workspaceId)
?.featureFlags.find((flag) => flag.key === featureFlag)?.value;
updateFeatureFlagState(workspaceId, featureFlag, value);
await updateFeatureFlag({
variables: {
workspaceId,
featureFlag,
value,
},
onError: (error) => {
if (isDefined(previousValue)) {
updateFeatureFlagState(workspaceId, featureFlag, previousValue);
}
enqueueErrorSnackBar({
message: `Failed to update feature flag. ${error.message}`,
});
},
});
};
const tabs = useMemo(() => {
const result = [
{
id: WORKSPACE_DETAIL_TAB_IDS.INFO,
title: t`Info`,
Icon: IconSettings2,
},
];
if (currentUser?.canImpersonate) {
result.push({
id: WORKSPACE_DETAIL_TAB_IDS.MEMBERS,
title: t`Members`,
Icon: IconUsers,
});
}
if (canManageFeatureFlags) {
result.push({
id: WORKSPACE_DETAIL_TAB_IDS.FEATURE_FLAGS,
title: t`Feature Flags`,
Icon: IconFlag,
});
}
if (workspace?.allowImpersonation) {
result.push({
id: WORKSPACE_DETAIL_TAB_IDS.CHATS,
title: t`Chats`,
Icon: IconMessage,
});
}
return result;
}, [
workspace?.allowImpersonation,
canManageFeatureFlags,
currentUser?.canImpersonate,
]);
const workspaceName = workspace?.name || workspaceId || '';
if (isLoadingWorkspace) {
return <SettingsSkeletonLoader />;
}
return (
<SubMenuTopBarContainer
links={[
{
children: t`Other`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Admin Panel`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`AI`,
href: AI_ADMIN_PATH,
},
{
children: workspaceName,
},
]}
>
<SettingsPageContainer>
<TabList
tabs={tabs}
behaveAsLinks={false}
componentInstanceId={WORKSPACE_DETAIL_TABS_ID}
/>
{effectiveTabId === WORKSPACE_DETAIL_TAB_IDS.INFO && workspace && (
<SettingsAdminWorkspaceContent activeWorkspace={workspace} />
)}
{effectiveTabId === WORKSPACE_DETAIL_TAB_IDS.MEMBERS && workspace && (
<Section>
<H2Title title={t`Members`} description={t`Workspace members`} />
<Table>
<TableBody>
<TableRow gridTemplateColumns="1fr 2fr 100px">
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`Email`}</TableHeader>
<TableHeader align="right">{t`Actions`}</TableHeader>
</TableRow>
{workspace.users?.map((user) => {
const userId = user.id;
if (!isDefined(userId)) return null;
return (
<TableRow
key={userId}
gridTemplateColumns="1fr 2fr 100px"
to={getSettingsPath(SettingsPath.AdminPanelUserDetail, {
userId,
})}
>
<TableCell color={themeCssVariables.font.color.primary}>
{`${user.firstName || ''} ${user.lastName || ''}`.trim() ||
'\u2014'}
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell align="right">
{workspace.allowImpersonation && (
<Button
Icon={IconEyeShare}
variant="secondary"
size="small"
title={t`Impersonate`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleImpersonate(userId);
}}
disabled={impersonatingUserId === userId}
/>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Section>
)}
{effectiveTabId === WORKSPACE_DETAIL_TAB_IDS.FEATURE_FLAGS &&
workspace && (
<Section>
<H2Title
title={t`Feature Flags`}
description={t`Manage feature flags for this workspace`}
/>
<Table>
<TableBody>
<TableRow
gridAutoColumns="1fr 100px"
mobileGridAutoColumns="1fr 80px"
>
<TableHeader>{t`Feature Flag`}</TableHeader>
<TableHeader align="right">{t`Status`}</TableHeader>
</TableRow>
{workspace.featureFlags?.map((flag) => (
<TableRow
gridAutoColumns="1fr 100px"
mobileGridAutoColumns="1fr 80px"
key={flag.key}
>
<TableCell>{flag.key}</TableCell>
<TableCell align="right">
{isDefined(flag.key) && (
<Toggle
value={flag.value}
onChange={(newValue) =>
handleFeatureFlagUpdate(flag.key!, newValue)
}
/>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Section>
)}
{effectiveTabId === WORKSPACE_DETAIL_TAB_IDS.CHATS && (
<Section>
<H2Title
title={t`Chat Sessions`}
description={t`AI chat threads for this workspace`}
/>
{threads.length === 0 ? (
<Card rounded>
<TableRow gridTemplateColumns="1fr">
<TableCell
color={themeCssVariables.font.color.tertiary}
align="center"
>
{isLoadingThreads
? t`Loading...`
: t`No chat threads found.`}
</TableCell>
</TableRow>
</Card>
) : (
<Table>
<TableRow gridTemplateColumns="1fr 120px 120px">
<TableHeader>{t`Title`}</TableHeader>
<TableHeader align="right">{t`Messages`}</TableHeader>
<TableHeader align="right">{t`Updated`}</TableHeader>
</TableRow>
{threads.map((thread) => (
<TableRow
key={thread.id}
gridTemplateColumns="1fr 120px 120px"
to={getSettingsPath(
SettingsPath.AdminPanelWorkspaceChatThread,
{
workspaceId: workspaceId ?? '',
threadId: thread.id,
},
)}
>
<TableCell color={themeCssVariables.font.color.primary}>
{thread.title || t`Untitled`}
</TableCell>
<TableCell align="right">
{thread.conversationSize}
</TableCell>
<TableCell align="right">
{new Date(thread.updatedAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</Table>
)}
</Section>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -3,7 +3,7 @@ import { useDebouncedCallback } from 'use-debounce';
import { CoreObjectNameSingular, SettingsPath } from 'twenty-shared/types';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useImpersonationAuth } from '@/settings/admin-panel/hooks/useImpersonationAuth';
import { useImpersonationSession } from '@/auth/hooks/useImpersonationSession';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsRolesQueryEffect } from '@/settings/roles/components/SettingsRolesQueryEffect';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
@@ -50,7 +50,7 @@ export const SettingsWorkspaceMember = () => {
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const { openModal, closeModal } = useModal();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const { executeImpersonationAuth } = useImpersonationAuth();
const { startImpersonating } = useImpersonationSession();
const [impersonate] = useMutation(ImpersonateDocument);
const isImpersonating = useAtomStateValue(isImpersonatingState);
const canImpersonate =
@@ -150,7 +150,7 @@ export const SettingsWorkspaceMember = () => {
},
onCompleted: async (data) => {
const { loginToken } = data.impersonate;
await executeImpersonationAuth(loginToken.token);
await startImpersonating(loginToken.token);
},
onError: () => {
enqueueErrorSnackBar({
@@ -5,10 +5,15 @@ import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-pan
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { AgentMessageEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
const UserFindOneMock = jest.fn();
const LoginTokenServiceGenerateLoginTokenMock = jest.fn();
@@ -53,6 +58,26 @@ describe('AdminPanelService', () => {
findOne: UserFindOneMock,
},
},
{
provide: getRepositoryToken(WorkspaceEntity),
useValue: {},
},
{
provide: getRepositoryToken(UserWorkspaceEntity),
useValue: {},
},
{
provide: getRepositoryToken(FeatureFlagEntity),
useValue: {},
},
{
provide: getRepositoryToken(AgentChatThreadEntity),
useValue: {},
},
{
provide: getRepositoryToken(AgentMessageEntity),
useValue: {},
},
{
provide: LoginTokenService,
useValue: {
@@ -24,15 +24,26 @@ import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-cl
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 { UsageModule } from 'src/engine/core-modules/usage/usage.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { AgentMessageEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
import { KeyValuePairModule } from 'src/engine/core-modules/key-value-pair/key-value-pair.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
TypeOrmModule.forFeature([UserEntity, WorkspaceEntity]),
TypeOrmModule.forFeature([
UserEntity,
WorkspaceEntity,
UserWorkspaceEntity,
FeatureFlagEntity,
AgentChatThreadEntity,
AgentMessageEntity,
]),
AuthModule,
FileModule,
WorkspaceDomainsModule,
@@ -11,6 +11,10 @@ import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/adm
import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service';
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
import { AdminPanelRecentUserDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-recent-user.dto';
import { AdminPanelTopWorkspaceDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-top-workspace.dto';
import { AdminWorkspaceChatThreadDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-workspace-chat-thread.dto';
import { AdminChatThreadMessagesDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-chat-thread-messages.dto';
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 { AdminAIModelsDTO } from 'src/engine/core-modules/client-config/client-config.entity';
@@ -98,6 +102,32 @@ export class AdminPanelResolver {
return await this.adminService.userLookup(userLookupInput.userIdentifier);
}
@UseGuards(ServerLevelImpersonateGuard)
@Query(() => [AdminPanelRecentUserDTO])
async adminPanelRecentUsers(
@Args('searchTerm', {
type: () => String,
nullable: true,
defaultValue: '',
})
searchTerm: string,
): Promise<AdminPanelRecentUserDTO[]> {
return this.adminService.getRecentUsers(searchTerm);
}
@UseGuards(ServerLevelImpersonateGuard)
@Query(() => [AdminPanelTopWorkspaceDTO])
async adminPanelTopWorkspaces(
@Args('searchTerm', {
type: () => String,
nullable: true,
defaultValue: '',
})
searchTerm: string,
): Promise<AdminPanelTopWorkspaceDTO[]> {
return this.adminService.getTopWorkspaces(searchTerm);
}
@UseGuards(AdminPanelGuard)
@Mutation(() => Boolean)
async updateWorkspaceFeatureFlag(
@@ -614,4 +644,29 @@ export class AdminPanelResolver {
return true;
}
@UseGuards(AdminPanelGuard)
@Query(() => UserLookup)
async workspaceLookupAdminPanel(
@Args('workspaceId', { type: () => String }) workspaceId: string,
): Promise<UserLookup> {
return this.adminService.workspaceLookup(workspaceId);
}
@UseGuards(AdminPanelGuard)
@Query(() => [AdminWorkspaceChatThreadDTO])
async getAdminWorkspaceChatThreads(
@Args('workspaceId', { type: () => String }) workspaceId: string,
): Promise<AdminWorkspaceChatThreadDTO[]> {
return this.adminService.getWorkspaceChatThreads(workspaceId);
}
@UseGuards(AdminPanelGuard)
@Query(() => AdminChatThreadMessagesDTO)
async getAdminChatThreadMessages(
@Args('workspaceId', { type: () => String }) workspaceId: string,
@Args('threadId', { type: () => String }) threadId: string,
): Promise<AdminChatThreadMessagesDTO> {
return this.adminService.getChatThreadMessages(workspaceId, threadId);
}
}
@@ -8,6 +8,10 @@ import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import * as z from 'zod';
import { type AdminChatMessageDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-chat-message.dto';
import { type AdminPanelRecentUserDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-recent-user.dto';
import { type AdminPanelTopWorkspaceDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-top-workspace.dto';
import { type AdminWorkspaceChatThreadDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-workspace-chat-thread.dto';
import { type ConfigVariableDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variable.dto';
import { type ConfigVariablesGroupDataDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variables-group.dto';
import { type ConfigVariablesDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variables.dto';
@@ -18,15 +22,20 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { type FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { type ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { CONFIG_VARIABLES_GROUP_METADATA } from 'src/engine/core-modules/twenty-config/constants/config-variables-group-metadata';
import { type ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { AgentMessageEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
@Injectable()
export class AdminPanelService {
@@ -37,6 +46,16 @@ export class AdminPanelService {
private readonly secureHttpClientService: SecureHttpClientService,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
@InjectRepository(FeatureFlagEntity)
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
@InjectRepository(AgentChatThreadEntity)
private readonly agentChatThreadRepository: Repository<AgentChatThreadEntity>,
@InjectRepository(AgentMessageEntity)
private readonly agentMessageRepository: Repository<AgentMessageEntity>,
) {}
async userLookup(userIdentifier: string): Promise<UserLookup> {
@@ -76,11 +95,14 @@ export class AdminPanelService {
email: targetUser.email,
firstName: targetUser.firstName,
lastName: targetUser.lastName,
createdAt: targetUser.createdAt,
},
workspaces: targetUser.userWorkspaces.map((userWorkspace) => ({
id: userWorkspace.workspace.id,
name: userWorkspace.workspace.displayName ?? '',
totalUsers: userWorkspace.workspace.workspaceUsers.length,
activationStatus: userWorkspace.workspace.activationStatus,
createdAt: userWorkspace.workspace.createdAt,
logo: isDefined(userWorkspace.workspace.logoFileId)
? this.fileUrlService.signFileByIdUrl({
fileId: userWorkspace.workspace.logoFileId,
@@ -101,6 +123,7 @@ export class AdminPanelService {
email: workspaceUser.user.email,
firstName: workspaceUser.user.firstName,
lastName: workspaceUser.user.lastName,
createdAt: workspaceUser.user.createdAt,
})),
featureFlags: allFeatureFlagKeys.map((key) => ({
key,
@@ -113,6 +136,253 @@ export class AdminPanelService {
};
}
async workspaceLookup(workspaceId: string): Promise<UserLookup> {
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
});
if (!workspace) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.INVALID_INPUT,
{
userFriendlyMessage: msg`Workspace not found. Please check the ID.`,
},
);
}
const [workspaceUsers, featureFlags] = await Promise.all([
this.userWorkspaceRepository.find({
where: { workspaceId },
relations: { user: true },
}),
this.featureFlagRepository.find({
where: { workspaceId },
}),
]);
const allFeatureFlagKeys = Object.values(FeatureFlagKey);
const workspaceInfo = {
id: workspace.id,
name: workspace.displayName ?? '',
totalUsers: workspaceUsers.length,
activationStatus: workspace.activationStatus,
createdAt: workspace.createdAt,
logo: isDefined(workspace.logoFileId)
? this.fileUrlService.signFileByIdUrl({
fileId: workspace.logoFileId,
workspaceId: workspace.id,
fileFolder: FileFolder.CorePicture,
})
: undefined,
allowImpersonation: workspace.allowImpersonation,
workspaceUrls: this.workspaceDomainsService.getWorkspaceUrls({
subdomain: workspace.subdomain,
customDomain: workspace.customDomain,
isCustomDomainEnabled: workspace.isCustomDomainEnabled,
}),
users: workspaceUsers
.filter((wu) => isDefined(wu.user))
.map((wu) => ({
id: wu.user.id,
email: wu.user.email,
firstName: wu.user.firstName,
lastName: wu.user.lastName,
createdAt: wu.user.createdAt,
})),
featureFlags: allFeatureFlagKeys.map((key) => ({
key,
value: featureFlags.find((flag) => flag.key === key)?.value ?? false,
})) as FeatureFlagEntity[],
};
const firstUser = workspaceUsers.find((wu) => isDefined(wu.user))?.user;
return {
user: {
id: firstUser?.id ?? '',
email: firstUser?.email ?? '',
firstName: firstUser?.firstName,
lastName: firstUser?.lastName,
createdAt: firstUser?.createdAt ?? new Date(),
},
workspaces: [workspaceInfo],
};
}
async getRecentUsers(
searchTerm?: string,
): Promise<AdminPanelRecentUserDTO[]> {
let whereClause = 'u."deletedAt" IS NULL';
const params: unknown[] = [];
if (searchTerm && searchTerm.trim().length > 0) {
const term = `%${searchTerm.trim()}%`;
whereClause += ` AND (u.email ILIKE $1 OR CONCAT(u."firstName", ' ', u."lastName") ILIKE $1 OR u.id::text ILIKE $1)`;
params.push(term);
}
const results = await this.userRepository.manager.query(
`SELECT u.id, u.email, u."firstName", u."lastName", u."createdAt",
w."displayName" AS "workspaceName", w.id AS "workspaceId"
FROM core."user" u
LEFT JOIN core."userWorkspace" uw ON uw."userId" = u.id AND uw."deletedAt" IS NULL
LEFT JOIN core.workspace w ON w.id = uw."workspaceId" AND w."deletedAt" IS NULL
WHERE ${whereClause}
ORDER BY u."createdAt" DESC
LIMIT 10`,
params,
);
return results.map(
(row: {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
createdAt: Date;
workspaceName: string | null;
workspaceId: string | null;
}) => ({
id: row.id,
email: row.email,
firstName: row.firstName || null,
lastName: row.lastName || null,
createdAt: row.createdAt,
workspaceName: row.workspaceName ?? null,
workspaceId: row.workspaceId ?? null,
}),
);
}
async getTopWorkspaces(
searchTerm?: string,
): Promise<AdminPanelTopWorkspaceDTO[]> {
let whereClause = 'w."deletedAt" IS NULL';
const params: unknown[] = [];
if (searchTerm && searchTerm.trim().length > 0) {
const term = `%${searchTerm.trim()}%`;
whereClause += ` AND (w."displayName" ILIKE $1 OR w.subdomain ILIKE $1 OR w.id::text ILIKE $1)`;
params.push(term);
}
const results = await this.workspaceRepository.manager.query(
`SELECT w.id, w."displayName" AS name, w.subdomain, COUNT(uw.id)::int AS "totalUsers"
FROM core.workspace w
LEFT JOIN core."userWorkspace" uw ON uw."workspaceId" = w.id AND uw."deletedAt" IS NULL
WHERE ${whereClause}
GROUP BY w.id
ORDER BY "totalUsers" DESC
LIMIT 10`,
params,
);
return results.map(
(row: {
id: string;
name: string;
subdomain: string;
totalUsers: number;
}) => ({
id: row.id,
name: row.name ?? '',
subdomain: row.subdomain ?? '',
totalUsers: row.totalUsers,
}),
);
}
private async assertWorkspaceAllowsImpersonation(
workspaceId: string,
): Promise<void> {
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
select: { id: true, allowImpersonation: true },
});
if (!workspace) {
throw new UserInputError('Workspace not found');
}
if (!workspace.allowImpersonation) {
throw new UserInputError('This workspace has not enabled support access');
}
}
async getWorkspaceChatThreads(
workspaceId: string,
): Promise<AdminWorkspaceChatThreadDTO[]> {
await this.assertWorkspaceAllowsImpersonation(workspaceId);
const threads = await this.agentChatThreadRepository.find({
where: { workspaceId },
order: { updatedAt: 'DESC' },
take: 100,
});
return threads.map((thread) => ({
id: thread.id,
title: thread.title,
totalInputTokens: thread.totalInputTokens,
totalOutputTokens: thread.totalOutputTokens,
conversationSize: thread.conversationSize,
createdAt: thread.createdAt,
updatedAt: thread.updatedAt,
}));
}
async getChatThreadMessages(
workspaceId: string,
threadId: string,
): Promise<{
thread: AdminWorkspaceChatThreadDTO;
messages: AdminChatMessageDTO[];
}> {
await this.assertWorkspaceAllowsImpersonation(workspaceId);
const thread = await this.agentChatThreadRepository.findOne({
where: { id: threadId, workspaceId },
});
if (!thread) {
throw new UserInputError('Thread not found in this workspace');
}
const messages = await this.agentMessageRepository.find({
where: { threadId },
relations: { parts: true },
order: { createdAt: 'ASC' },
});
return {
thread: {
id: thread.id,
title: thread.title,
totalInputTokens: thread.totalInputTokens,
totalOutputTokens: thread.totalOutputTokens,
conversationSize: thread.conversationSize,
createdAt: thread.createdAt,
updatedAt: thread.updatedAt,
},
messages: messages.map((message) => ({
id: message.id,
role: message.role,
parts: (message.parts ?? [])
.sort((a, b) => a.orderIndex - b.orderIndex)
.map((part) => ({
type: part.type,
textContent: part.textContent,
toolName: part.toolName,
})),
createdAt: message.createdAt,
})),
};
}
getConfigVariablesGrouped(): ConfigVariablesDTO {
const rawEnvVars = this.twentyConfigService.getAll();
const groupedData = new Map<ConfigVariablesGroup, ConfigVariableDTO[]>();
@@ -0,0 +1,30 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('AdminChatMessagePart')
export class AdminChatMessagePartDTO {
@Field(() => String)
type: string;
@Field(() => String, { nullable: true })
textContent: string | null;
@Field(() => String, { nullable: true })
toolName: string | null;
}
@ObjectType('AdminChatMessage')
export class AdminChatMessageDTO {
@Field(() => UUIDScalarType)
id: string;
@Field(() => String)
role: string;
@Field(() => [AdminChatMessagePartDTO])
parts: AdminChatMessagePartDTO[];
@Field(() => Date)
createdAt: Date;
}
@@ -0,0 +1,13 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { AdminChatMessageDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-chat-message.dto';
import { AdminWorkspaceChatThreadDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-workspace-chat-thread.dto';
@ObjectType('AdminChatThreadMessages')
export class AdminChatThreadMessagesDTO {
@Field(() => AdminWorkspaceChatThreadDTO)
thread: AdminWorkspaceChatThreadDTO;
@Field(() => [AdminChatMessageDTO])
messages: AdminChatMessageDTO[];
}
@@ -0,0 +1,27 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('AdminPanelRecentUser')
export class AdminPanelRecentUserDTO {
@Field(() => UUIDScalarType)
id: string;
@Field(() => String)
email: string;
@Field(() => String, { nullable: true })
firstName?: string | null;
@Field(() => String, { nullable: true })
lastName?: string | null;
@Field(() => Date)
createdAt: Date;
@Field(() => String, { nullable: true })
workspaceName?: string | null;
@Field(() => UUIDScalarType, { nullable: true })
workspaceId?: string | null;
}
@@ -0,0 +1,18 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('AdminPanelTopWorkspace')
export class AdminPanelTopWorkspaceDTO {
@Field(() => UUIDScalarType)
id: string;
@Field(() => String)
name: string;
@Field(() => Int)
totalUsers: number;
@Field(() => String)
subdomain: string;
}
@@ -0,0 +1,27 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('AdminWorkspaceChatThread')
export class AdminWorkspaceChatThreadDTO {
@Field(() => UUIDScalarType)
id: string;
@Field(() => String, { nullable: true })
title: string | null;
@Field(() => Int)
totalInputTokens: number;
@Field(() => Int)
totalOutputTokens: number;
@Field(() => Int)
conversationSize: number;
@Field(() => Date)
createdAt: Date;
@Field(() => Date)
updatedAt: Date;
}
@@ -1,5 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { FeatureFlagDTO } from 'src/engine/core-modules/feature-flag/dtos/feature-flag.dto';
import { WorkspaceUrlsDTO } from 'src/engine/core-modules/workspace/dtos/workspace-urls.dto';
@@ -17,6 +19,9 @@ class UserInfoDTO {
@Field(() => String, { nullable: true })
lastName?: string;
@Field(() => Date)
createdAt: Date;
}
@ObjectType('WorkspaceInfo')
@@ -36,6 +41,12 @@ class WorkspaceInfoDTO {
@Field(() => Number)
totalUsers: number;
@Field(() => WorkspaceActivationStatus)
activationStatus: WorkspaceActivationStatus;
@Field(() => Date)
createdAt: Date;
@Field(() => WorkspaceUrlsDTO)
workspaceUrls: WorkspaceUrlsDTO;
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { ExtendedUIMessage } from 'twenty-shared/ai';
import { In, Repository } from 'typeorm';
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import type { UIDataTypes, UIMessagePart, UITools } from 'ai';
@@ -122,18 +123,19 @@ export class AgentChatService {
let actualTurnId = turnId;
if (!actualTurnId) {
const turn = this.turnRepository.create({
const turnInsertResult = await this.turnRepository.insert({
threadId,
agentId: agentId ?? null,
workspaceId,
});
const savedTurn = await this.turnRepository.save(turn);
actualTurnId = savedTurn.id;
actualTurnId = turnInsertResult.identifiers[0].id as string;
}
const message = this.messageRepository.create({
// Use .insert() instead of .create() + .save() to bypass a TypeORM
// issue where @ManyToOne/@JoinColumn on the same column as @Column can
// cause the explicit workspaceId value to be dropped during .save().
const messageValues = {
...(id ? { id } : {}),
threadId,
turnId: actualTurnId,
@@ -141,21 +143,33 @@ export class AgentChatService {
agentId: agentId ?? null,
processedAt: new Date(),
workspaceId,
});
};
const savedMessage = await this.messageRepository.save(message);
const insertResult = await this.messageRepository.insert(messageValues);
const savedMessageId = (id ?? insertResult.identifiers[0].id) as string;
if (uiMessage.parts && uiMessage.parts.length > 0) {
const dbParts = mapUIMessagePartsToDBParts(
uiMessage.parts,
savedMessage.id,
savedMessageId,
workspaceId,
);
await this.messagePartRepository.save(dbParts);
await this.messagePartRepository.insert(
dbParts as QueryDeepPartialEntity<AgentMessagePartEntity>[],
);
}
return savedMessage;
return {
id: savedMessageId,
threadId,
turnId: actualTurnId,
role: uiMessage.role as AgentMessageRole,
agentId: agentId ?? null,
processedAt: new Date(),
workspaceId,
} as AgentMessageEntity;
}
async getMessagesForThread(threadId: string, userWorkspaceId: string) {
@@ -193,7 +207,7 @@ export class AgentChatService {
fileIds?: string[];
workspaceId: string;
}): Promise<AgentMessageEntity> {
const message = this.messageRepository.create({
const messageValues = {
...(id ? { id } : {}),
threadId,
turnId: null,
@@ -201,9 +215,11 @@ export class AgentChatService {
agentId: null,
status: AgentMessageStatus.QUEUED,
workspaceId,
});
};
const savedMessage = await this.messageRepository.save(message);
const insertResult = await this.messageRepository.insert(messageValues);
const savedMessageId = (id ?? insertResult.identifiers[0].id) as string;
const files =
fileIds && fileIds.length > 0
@@ -213,28 +229,29 @@ export class AgentChatService {
: [];
const parts = [
this.messagePartRepository.create({
messageId: savedMessage.id,
{
messageId: savedMessageId,
orderIndex: 0,
type: 'text',
textContent: text,
workspaceId,
}),
...files.map((file, index) =>
this.messagePartRepository.create({
messageId: savedMessage.id,
orderIndex: index + 1,
type: 'file',
fileId: file.id,
fileFilename: file.path.split('/').pop() ?? null,
workspaceId,
}),
),
},
...files.map((file, index) => ({
messageId: savedMessageId,
orderIndex: index + 1,
type: 'file',
fileId: file.id,
fileFilename: file.path.split('/').pop() ?? null,
workspaceId,
})),
];
await this.messagePartRepository.save(parts);
await this.messagePartRepository.insert(parts);
return savedMessage;
return {
id: savedMessageId,
...messageValues,
} as AgentMessageEntity;
}
async getQueuedMessages(threadId: string): Promise<AgentMessageEntity[]> {
@@ -270,30 +287,30 @@ export class AgentChatService {
threadId: string,
workspaceId: string,
): Promise<string | null> {
const turn = this.turnRepository.create({
const turnInsertResult = await this.turnRepository.insert({
threadId,
agentId: null,
workspaceId,
});
const savedTurn = await this.turnRepository.save(turn);
const savedTurnId = turnInsertResult.identifiers[0].id as string;
const result = await this.messageRepository.update(
{ id: messageId, threadId, status: AgentMessageStatus.QUEUED },
{
status: AgentMessageStatus.SENT,
processedAt: new Date(),
turnId: savedTurn.id,
turnId: savedTurnId,
},
);
if ((result.affected ?? 0) === 0) {
await this.turnRepository.delete(savedTurn.id);
await this.turnRepository.delete(savedTurnId);
return null;
}
return savedTurn.id;
return savedTurnId;
}
async generateTitleIfNeeded({
@@ -69,6 +69,9 @@ export enum SettingsPath {
AdminPanelNewAiProvider = 'admin-panel/ai/new-provider',
AdminPanelAiProviderDetail = 'admin-panel/ai/providers/:providerName',
AdminPanelNewAiModel = 'admin-panel/ai/providers/:providerName/new-model',
AdminPanelUserDetail = 'admin-panel/users/:userId',
AdminPanelWorkspaceDetail = 'admin-panel/workspaces/:workspaceId',
AdminPanelWorkspaceChatThread = 'admin-panel/workspaces/:workspaceId/threads/:threadId',
Roles = 'roles',
RoleCreate = 'roles/create',