Add installed workspaces view to application registration (#22359)

## After
<img width="895" height="344" alt="image"
src="https://github.com/user-attachments/assets/33591753-f248-45ce-b32d-cc1112f50579"
/>
<img width="889" height="425" alt="image"
src="https://github.com/user-attachments/assets/469ee228-9abb-486f-b2ec-9efb490bb2c8"
/>
<img width="766" height="343" alt="image"
src="https://github.com/user-attachments/assets/2d88444a-6d98-4f97-8e5d-109197cfad27"
/>


## Summary
Add a new "Installed workspaces" section to the application registration
settings page that displays all workspaces that have installed a given
application, with pagination support.

## Key Changes
- **Backend Service**: Added `getInstalledWorkspaces()` method to
`ApplicationRegistrationService` that queries installed applications
across workspaces with pagination support
- **Backend DTO**: Created
`ApplicationRegistrationInstalledWorkspacesDTO` and
`InstalledWorkspaceDTO` to structure the response with workspace details
(id, displayName, logo, version), total count, and hasMore flag
- **GraphQL Resolver**: Added
`findApplicationRegistrationInstalledWorkspaces` query resolver with
pagination (page parameter, default page size of 10) and proper
authorization guards
- **Frontend Component**: Created
`SettingsApplicationRegistrationInstalledWorkspaces` component that:
- Displays installed workspaces in a table with workspace logo, name,
and version
  - Shows initial 3 workspaces with "Show all" button to expand
- Implements pagination with "Show more" button to load additional pages
  - Handles empty state (returns null if no workspaces installed)
- **GraphQL Query**: Added
`FindApplicationRegistrationInstalledWorkspaces` query document for
frontend data fetching
- **Integration**: Integrated the new component into
`SettingsApplicationRegistrationGeneralTab`

## Implementation Details
- Pagination uses offset-based approach with configurable page size (10
workspaces per page)
- Query results are ordered by workspace displayName and id for
consistent ordering
- Soft-deleted applications and workspaces are excluded from the list
and counts
- Apollo Client's `fetchMore` with `updateQuery` merges paginated
results into the cache
- Component respects existing authorization (API_KEYS_AND_WEBHOOKS
permission required)
- Uses existing UI components (Table, Card, Avatar, Button) from
twenty-ui library
- Supports internationalization with Lingui

## Screenshots
The new "Installed workspaces" section on the app registration General
tab (admin app detail page), captured against a local instance with a
demo app installed in 14 workspaces. The three PNGs are committed under
`.github/assets/screenshots/installed-workspaces/` and render inline in
the **Files changed** tab of this PR:

- `1-first-3-show-all.png` — Collapsed: the first 3 installed workspaces
(avatar + name + installed version) with a "Show all" button.
- `2-expanded-show-more.png` — "Show all": the first page of 10
workspaces, with a "Show more" button (more remain).
- `3-all-paginated.png` — "Show more": all 14 workspaces loaded, button
gone.

Review in cubic:
https://cubic.dev/pr/twentyhq/twenty/pull/22359?utm_source=github

https://claude.ai/code/session_012nWtviSBdfFeHEASTtwvJ7
This commit is contained in:
martmull
2026-07-01 16:38:51 +02:00
committed by GitHub
parent b8a3399230
commit 27dea0ed0b
16 changed files with 974 additions and 183 deletions
@@ -1800,6 +1800,17 @@ type ApplicationRegistrationVariableDTO {
updatedAt: DateTime!
}
type VersionDistributionEntry {
version: String!
count: Int!
}
type ApplicationRegistrationStats {
activeInstalls: Int!
mostInstalledVersion: String
versionDistribution: [VersionDistributionEntry!]!
}
type BillingTrialPeriod {
duration: Float!
isCreditCardRequired: Boolean!
@@ -1978,17 +1989,6 @@ type UsageBreakdownItem {
creditsUsed: Float!
}
type VersionDistributionEntry {
version: String!
count: Int!
}
type ApplicationRegistrationStats {
activeInstalls: Int!
mostInstalledVersion: String
versionDistribution: [VersionDistributionEntry!]!
}
type CreateApplicationRegistration {
applicationRegistration: ApplicationRegistration!
clientSecret: String!
@@ -1431,6 +1431,19 @@ export interface ApplicationRegistrationVariableDTO {
__typename: 'ApplicationRegistrationVariableDTO'
}
export interface VersionDistributionEntry {
version: Scalars['String']
count: Scalars['Int']
__typename: 'VersionDistributionEntry'
}
export interface ApplicationRegistrationStats {
activeInstalls: Scalars['Int']
mostInstalledVersion?: Scalars['String']
versionDistribution: VersionDistributionEntry[]
__typename: 'ApplicationRegistrationStats'
}
export interface BillingTrialPeriod {
duration: Scalars['Float']
isCreditCardRequired: Scalars['Boolean']
@@ -1608,19 +1621,6 @@ export interface UsageBreakdownItem {
__typename: 'UsageBreakdownItem'
}
export interface VersionDistributionEntry {
version: Scalars['String']
count: Scalars['Int']
__typename: 'VersionDistributionEntry'
}
export interface ApplicationRegistrationStats {
activeInstalls: Scalars['Int']
mostInstalledVersion?: Scalars['String']
versionDistribution: VersionDistributionEntry[]
__typename: 'ApplicationRegistrationStats'
}
export interface CreateApplicationRegistration {
applicationRegistration: ApplicationRegistration
clientSecret: Scalars['String']
@@ -4509,6 +4509,21 @@ export interface ApplicationRegistrationVariableDTOGenqlSelection{
__scalar?: boolean | number
}
export interface VersionDistributionEntryGenqlSelection{
version?: boolean | number
count?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ApplicationRegistrationStatsGenqlSelection{
activeInstalls?: boolean | number
mostInstalledVersion?: boolean | number
versionDistribution?: VersionDistributionEntryGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface BillingTrialPeriodGenqlSelection{
duration?: boolean | number
isCreditCardRequired?: boolean | number
@@ -4694,21 +4709,6 @@ export interface UsageBreakdownItemGenqlSelection{
__scalar?: boolean | number
}
export interface VersionDistributionEntryGenqlSelection{
version?: boolean | number
count?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ApplicationRegistrationStatsGenqlSelection{
activeInstalls?: boolean | number
mostInstalledVersion?: boolean | number
versionDistribution?: VersionDistributionEntryGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface CreateApplicationRegistrationGenqlSelection{
applicationRegistration?: ApplicationRegistrationGenqlSelection
clientSecret?: boolean | number
@@ -7542,6 +7542,22 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const VersionDistributionEntry_possibleTypes: string[] = ['VersionDistributionEntry']
export const isVersionDistributionEntry = (obj?: { __typename?: any } | null): obj is VersionDistributionEntry => {
if (!obj?.__typename) throw new Error('__typename is missing in "isVersionDistributionEntry"')
return VersionDistributionEntry_possibleTypes.includes(obj.__typename)
}
const ApplicationRegistrationStats_possibleTypes: string[] = ['ApplicationRegistrationStats']
export const isApplicationRegistrationStats = (obj?: { __typename?: any } | null): obj is ApplicationRegistrationStats => {
if (!obj?.__typename) throw new Error('__typename is missing in "isApplicationRegistrationStats"')
return ApplicationRegistrationStats_possibleTypes.includes(obj.__typename)
}
const BillingTrialPeriod_possibleTypes: string[] = ['BillingTrialPeriod']
export const isBillingTrialPeriod = (obj?: { __typename?: any } | null): obj is BillingTrialPeriod => {
if (!obj?.__typename) throw new Error('__typename is missing in "isBillingTrialPeriod"')
@@ -7686,22 +7702,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const VersionDistributionEntry_possibleTypes: string[] = ['VersionDistributionEntry']
export const isVersionDistributionEntry = (obj?: { __typename?: any } | null): obj is VersionDistributionEntry => {
if (!obj?.__typename) throw new Error('__typename is missing in "isVersionDistributionEntry"')
return VersionDistributionEntry_possibleTypes.includes(obj.__typename)
}
const ApplicationRegistrationStats_possibleTypes: string[] = ['ApplicationRegistrationStats']
export const isApplicationRegistrationStats = (obj?: { __typename?: any } | null): obj is ApplicationRegistrationStats => {
if (!obj?.__typename) throw new Error('__typename is missing in "isApplicationRegistrationStats"')
return ApplicationRegistrationStats_possibleTypes.includes(obj.__typename)
}
const CreateApplicationRegistration_possibleTypes: string[] = ['CreateApplicationRegistration']
export const isCreateApplicationRegistration = (obj?: { __typename?: any } | null): obj is CreateApplicationRegistration => {
if (!obj?.__typename) throw new Error('__typename is missing in "isCreateApplicationRegistration"')
@@ -53,11 +53,11 @@ export default {
166,
170,
172,
177,
178,
185,
188,
191,
179,
180,
187,
190,
193,
204,
220,
232,
@@ -3491,6 +3491,31 @@ export default {
1
]
},
"VersionDistributionEntry": {
"version": [
1
],
"count": [
21
],
"__typename": [
1
]
},
"ApplicationRegistrationStats": {
"activeInstalls": [
21
],
"mostInstalledVersion": [
1
],
"versionDistribution": [
175
],
"__typename": [
1
]
},
"BillingTrialPeriod": {
"duration": [
11
@@ -3510,10 +3535,10 @@ export default {
1
],
"type": [
177
179
],
"status": [
178
180
],
"issuer": [
1
@@ -3526,7 +3551,7 @@ export default {
"SSOIdentityProviderStatus": {},
"AuthProviders": {
"sso": [
176
178
],
"google": [
6
@@ -3563,10 +3588,10 @@ export default {
3
],
"authProviders": [
179
181
],
"authBypassProviders": [
180
182
],
"logo": [
1
@@ -3614,7 +3639,7 @@ export default {
1
],
"modelFamily": [
185
187
],
"modelFamilyLabel": [
1
@@ -3629,7 +3654,7 @@ export default {
11
],
"nativeCapabilities": [
183
185
],
"isDeprecated": [
6
@@ -3668,7 +3693,7 @@ export default {
1
],
"trialPeriods": [
175
177
],
"__typename": [
1
@@ -3676,7 +3701,7 @@ export default {
},
"Support": {
"supportDriver": [
188
190
],
"supportFrontChatId": [
1
@@ -3702,7 +3727,7 @@ export default {
},
"Captcha": {
"provider": [
191
193
],
"siteKey": [
1
@@ -3739,7 +3764,7 @@ export default {
172
],
"metadata": [
193
195
],
"__typename": [
1
@@ -3764,13 +3789,13 @@ export default {
1
],
"authProviders": [
179
181
],
"billing": [
186
188
],
"aiModels": [
184
186
],
"signInPrefilled": [
6
@@ -3794,25 +3819,25 @@ export default {
6
],
"support": [
187
189
],
"isAttachmentPreviewEnabled": [
6
],
"sentry": [
189
191
],
"captcha": [
190
192
],
"api": [
192
194
],
"canManageFeatureFlags": [
6
],
"publicFeatureFlags": [
194
196
],
"isMicrosoftMessagingEnabled": [
6
@@ -3851,7 +3876,7 @@ export default {
6
],
"maintenance": [
195
197
],
"__typename": [
1
@@ -3871,31 +3896,6 @@ export default {
1
]
},
"VersionDistributionEntry": {
"version": [
1
],
"count": [
21
],
"__typename": [
1
]
},
"ApplicationRegistrationStats": {
"activeInstalls": [
21
],
"mostInstalledVersion": [
1
],
"versionDistribution": [
198
],
"__typename": [
1
]
},
"CreateApplicationRegistration": {
"applicationRegistration": [
7
@@ -4182,7 +4182,7 @@ export default {
3
],
"type": [
177
179
],
"issuer": [
1
@@ -4191,7 +4191,7 @@ export default {
1
],
"status": [
178
180
],
"__typename": [
1
@@ -4210,7 +4210,7 @@ export default {
},
"FindAvailableSSOIDP": {
"type": [
177
179
],
"id": [
3
@@ -4222,7 +4222,7 @@ export default {
1
],
"status": [
178
180
],
"workspace": [
224
@@ -4236,7 +4236,7 @@ export default {
3
],
"type": [
177
179
],
"issuer": [
1
@@ -4245,7 +4245,7 @@ export default {
1
],
"status": [
178
180
],
"__typename": [
1
@@ -4253,7 +4253,7 @@ export default {
},
"SSOConnection": {
"type": [
177
179
],
"id": [
3
@@ -4265,7 +4265,7 @@ export default {
1
],
"status": [
178
180
],
"__typename": [
1
@@ -4735,13 +4735,13 @@ export default {
},
"UsageAnalytics": {
"usageByUser": [
197
199
],
"usageByOperationType": [
197
199
],
"usageByModel": [
197
199
],
"timeSeries": [
263
@@ -6369,7 +6369,7 @@ export default {
76
],
"getPublicWorkspaceDataByDomain": [
181,
183,
{
"origin": [
1
@@ -6377,7 +6377,7 @@ export default {
}
],
"getPublicWorkspaceDataById": [
182,
184,
{
"id": [
3,
@@ -6472,7 +6472,7 @@ export default {
}
],
"findApplicationRegistrationStats": [
199,
176,
{
"id": [
1,
@@ -11647,7 +11647,7 @@ export default {
3
],
"status": [
178
180
],
"__typename": [
1
@@ -202,6 +202,13 @@ export type ApplicationRegistration = {
updatedAt: Scalars['DateTime']['output'];
};
export type ApplicationRegistrationInstalledWorkspaces = {
__typename?: 'ApplicationRegistrationInstalledWorkspaces';
hasMore: Scalars['Boolean']['output'];
totalCount: Scalars['Int']['output'];
workspaces: Array<InstalledWorkspace>;
};
export enum ApplicationRegistrationSourceType {
LOCAL = 'LOCAL',
NPM = 'NPM',
@@ -209,6 +216,13 @@ export enum ApplicationRegistrationSourceType {
TARBALL = 'TARBALL'
}
export type ApplicationRegistrationStats = {
__typename?: 'ApplicationRegistrationStats';
activeInstalls: Scalars['Int']['output'];
mostInstalledVersion?: Maybe<Scalars['String']['output']>;
versionDistribution: Array<VersionDistributionEntry>;
};
export type ApplicationRegistrationVariableDto = {
__typename?: 'ApplicationRegistrationVariableDTO';
createdAt: Scalars['DateTime']['output'];
@@ -308,6 +322,12 @@ export enum FeatureFlagKey {
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED'
}
export type FindApplicationRegistrationInstalledWorkspacesInput = {
id: Scalars['String']['input'];
page?: Scalars['Int']['input'];
searchTerm?: InputMaybe<Scalars['String']['input']>;
};
export enum HealthIndicatorId {
app = 'app',
connectedAccount = 'connectedAccount',
@@ -316,6 +336,14 @@ export enum HealthIndicatorId {
worker = 'worker'
}
export type InstalledWorkspace = {
__typename?: 'InstalledWorkspace';
displayName?: Maybe<Scalars['String']['output']>;
id: Scalars['UUID']['output'];
logo?: Maybe<Scalars['String']['output']>;
version?: Maybe<Scalars['String']['output']>;
};
export type InstanceAndAllWorkspacesUpgradeStatus = {
__typename?: 'InstanceAndAllWorkspacesUpgradeStatus';
computedAt: Scalars['DateTime']['output'];
@@ -551,6 +579,8 @@ export type Query = {
__typename?: 'Query';
adminPanelRecentUsers: Array<AdminPanelRecentUser>;
adminPanelTopWorkspaces: Array<AdminPanelTopWorkspace>;
findAdminApplicationRegistrationInstalledWorkspaces: ApplicationRegistrationInstalledWorkspaces;
findAdminApplicationRegistrationStats: ApplicationRegistrationStats;
findAdminApplicationRegistrationVariables: Array<ApplicationRegistrationVariableDto>;
findAllApplicationRegistrations: Array<ApplicationRegistration>;
findOneAdminApplicationRegistration: ApplicationRegistration;
@@ -589,6 +619,16 @@ export type QueryAdminPanelTopWorkspacesArgs = {
};
export type QueryFindAdminApplicationRegistrationInstalledWorkspacesArgs = {
input: FindApplicationRegistrationInstalledWorkspacesInput;
};
export type QueryFindAdminApplicationRegistrationStatsArgs = {
id: Scalars['String']['input'];
};
export type QueryFindAdminApplicationRegistrationVariablesArgs = {
applicationRegistrationId: Scalars['String']['input'];
};
@@ -839,6 +879,12 @@ export type UserLookup = {
workspaces: Array<WorkspaceInfo>;
};
export type VersionDistributionEntry = {
__typename?: 'VersionDistributionEntry';
count: Scalars['Int']['output'];
version: Scalars['String']['output'];
};
export type VersionInfo = {
__typename?: 'VersionInfo';
currentVersion?: Maybe<Scalars['String']['output']>;
@@ -1027,6 +1073,20 @@ export type UpdateAdminApplicationRegistrationVariableMutationVariables = Exact<
export type UpdateAdminApplicationRegistrationVariableMutation = { __typename?: 'Mutation', updateAdminApplicationRegistrationVariable: { __typename?: 'ApplicationRegistrationVariableDTO', id: string, key: string, description: string, isSecret: boolean, isRequired: boolean, isFilled: boolean, createdAt: string, updatedAt: string } };
export type FindAdminApplicationRegistrationInstalledWorkspacesQueryVariables = Exact<{
input: FindApplicationRegistrationInstalledWorkspacesInput;
}>;
export type FindAdminApplicationRegistrationInstalledWorkspacesQuery = { __typename?: 'Query', findAdminApplicationRegistrationInstalledWorkspaces: { __typename?: 'ApplicationRegistrationInstalledWorkspaces', totalCount: number, hasMore: boolean, workspaces: Array<{ __typename?: 'InstalledWorkspace', id: string, displayName?: string | null, logo?: string | null, version?: string | null }> } };
export type FindAdminApplicationRegistrationStatsQueryVariables = Exact<{
id: Scalars['String']['input'];
}>;
export type FindAdminApplicationRegistrationStatsQuery = { __typename?: 'Query', findAdminApplicationRegistrationStats: { __typename?: 'ApplicationRegistrationStats', activeInstalls: number, mostInstalledVersion?: string | null, versionDistribution: Array<{ __typename?: 'VersionDistributionEntry', version: string, count: number }> } };
export type FindAdminApplicationRegistrationVariablesQueryVariables = Exact<{
applicationRegistrationId: Scalars['String']['input'];
}>;
@@ -1277,6 +1337,8 @@ export const BackfillApplicationInstallationDocument = {"kind":"Document","defin
export const SyncMarketplaceCatalogDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SyncMarketplaceCatalog"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"syncMarketplaceCatalog"}}]}}]} as unknown as DocumentNode<SyncMarketplaceCatalogMutation, SyncMarketplaceCatalogMutationVariables>;
export const UpdateAdminApplicationRegistrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateAdminApplicationRegistration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateApplicationRegistrationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateAdminApplicationRegistration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<UpdateAdminApplicationRegistrationMutation, UpdateAdminApplicationRegistrationMutationVariables>;
export const UpdateAdminApplicationRegistrationVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateAdminApplicationRegistrationVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateApplicationRegistrationVariableInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateAdminApplicationRegistrationVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isSecret"}},{"kind":"Field","name":{"kind":"Name","value":"isRequired"}},{"kind":"Field","name":{"kind":"Name","value":"isFilled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<UpdateAdminApplicationRegistrationVariableMutation, UpdateAdminApplicationRegistrationVariableMutationVariables>;
export const FindAdminApplicationRegistrationInstalledWorkspacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAdminApplicationRegistrationInstalledWorkspaces"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"FindApplicationRegistrationInstalledWorkspacesInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAdminApplicationRegistrationInstalledWorkspaces"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"hasMore"}},{"kind":"Field","name":{"kind":"Name","value":"workspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"version"}}]}}]}}]}}]} as unknown as DocumentNode<FindAdminApplicationRegistrationInstalledWorkspacesQuery, FindAdminApplicationRegistrationInstalledWorkspacesQueryVariables>;
export const FindAdminApplicationRegistrationStatsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAdminApplicationRegistrationStats"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAdminApplicationRegistrationStats"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeInstalls"}},{"kind":"Field","name":{"kind":"Name","value":"mostInstalledVersion"}},{"kind":"Field","name":{"kind":"Name","value":"versionDistribution"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}}]}}]}}]} as unknown as DocumentNode<FindAdminApplicationRegistrationStatsQuery, FindAdminApplicationRegistrationStatsQueryVariables>;
export const FindAdminApplicationRegistrationVariablesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAdminApplicationRegistrationVariables"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAdminApplicationRegistrationVariables"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"applicationRegistrationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isSecret"}},{"kind":"Field","name":{"kind":"Name","value":"isRequired"}},{"kind":"Field","name":{"kind":"Name","value":"isFilled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<FindAdminApplicationRegistrationVariablesQuery, FindAdminApplicationRegistrationVariablesQueryVariables>;
export const FindAllApplicationRegistrationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindAllApplicationRegistrationsQuery, FindAllApplicationRegistrationsQueryVariables>;
export const CreateDatabaseConfigVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateDatabaseConfigVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"value"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createDatabaseConfigVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}},{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"value"}}}]}]}}]} as unknown as DocumentNode<CreateDatabaseConfigVariableMutation, CreateDatabaseConfigVariableMutationVariables>;
@@ -0,0 +1,18 @@
import { gql } from '@apollo/client';
export const FIND_ADMIN_APPLICATION_REGISTRATION_INSTALLED_WORKSPACES = gql`
query FindAdminApplicationRegistrationInstalledWorkspaces(
$input: FindApplicationRegistrationInstalledWorkspacesInput!
) {
findAdminApplicationRegistrationInstalledWorkspaces(input: $input) {
totalCount
hasMore
workspaces {
id
displayName
logo
version
}
}
}
`;
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const FIND_ADMIN_APPLICATION_REGISTRATION_STATS = gql`
query FindAdminApplicationRegistrationStats($id: String!) {
findAdminApplicationRegistrationStats(id: $id) {
activeInstalls
mostInstalledVersion
versionDistribution {
version
count
}
}
}
`;
@@ -1,16 +1,8 @@
import {
IconBrandDocker,
IconChartBar,
IconStatusChange,
} from 'twenty-ui/icon';
import { type ApplicationRegistration } from '~/generated-metadata/graphql';
import { SettingsApplicationRegistrationInstalledWorkspaces } from '~/pages/settings/applications/components/SettingsApplicationRegistrationInstalledWorkspaces';
import { SettingsApplicationRegistrationInstallStats } from '~/pages/settings/applications/components/SettingsApplicationRegistrationInstallStats';
import { H2Title } from 'twenty-ui/typography';
import { useLingui } from '@lingui/react/macro';
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import {
type ApplicationRegistration,
FindApplicationRegistrationStatsDocument,
} from '~/generated-metadata/graphql';
import { useQuery } from '@apollo/client/react';
import { Section } from 'twenty-ui/layout';
export const SettingsApplicationRegistrationGeneralStats = ({
@@ -20,60 +12,17 @@ export const SettingsApplicationRegistrationGeneralStats = ({
}) => {
const { t } = useLingui();
const applicationRegistrationId = registration.id;
const { data: statsData } = useQuery(
FindApplicationRegistrationStatsDocument,
{
variables: { id: applicationRegistrationId },
skip: !applicationRegistrationId,
},
);
const stats = statsData?.findApplicationRegistrationStats;
const hasStats = (stats?.activeInstalls ?? 0) > 0;
if (!hasStats) {
return null;
}
const versionDistributionLabel =
stats?.versionDistribution
?.map(
(entry: { version: string; count: number }) =>
`${entry.version} (${entry.count})`,
)
.join(', ') || '—';
const statsItems = [
{
Icon: IconBrandDocker,
label: t`Active installs`,
value: stats?.activeInstalls ?? '—',
},
{
Icon: IconStatusChange,
label: t`Most installed version`,
value: stats?.mostInstalledVersion ?? '—',
},
{
Icon: IconChartBar,
label: t`Distribution`,
value: versionDistributionLabel,
},
];
return (
<Section>
<H2Title
title={t`Install Stats`}
description={t`Usage across all workspaces on this server`}
/>
<SettingsTableCard
rounded
items={statsItems}
gridAutoColumns="200px 1fr"
<SettingsApplicationRegistrationInstallStats
registration={registration}
/>
<SettingsApplicationRegistrationInstalledWorkspaces
registration={registration}
/>
</Section>
);
@@ -0,0 +1,60 @@
import {
IconBrandDocker,
IconChartBar,
IconStatusChange,
} from 'twenty-ui/icon';
import { useLingui } from '@lingui/react/macro';
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import { type ApplicationRegistration } from '~/generated-metadata/graphql';
import { FindAdminApplicationRegistrationStatsDocument } from '~/generated-admin/graphql';
import { useQuery } from '@apollo/client/react';
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
export const SettingsApplicationRegistrationInstallStats = ({
registration,
}: {
registration: ApplicationRegistration;
}) => {
const { t } = useLingui();
const apolloAdminClient = useApolloAdminClient();
const applicationRegistrationId = registration.id;
const { data } = useQuery(FindAdminApplicationRegistrationStatsDocument, {
client: apolloAdminClient,
variables: { id: applicationRegistrationId },
skip: !applicationRegistrationId,
});
const stats = data?.findAdminApplicationRegistrationStats;
const versionDistributionLabel =
stats?.versionDistribution
?.map(
(entry: { version: string; count: number }) =>
`${entry.version} (${entry.count})`,
)
.join(', ') || '—';
const statsItems = [
{
Icon: IconBrandDocker,
label: t`Active installs`,
value: stats?.activeInstalls ?? '—',
},
{
Icon: IconStatusChange,
label: t`Most installed version`,
value: stats?.mostInstalledVersion ?? '—',
},
{
Icon: IconChartBar,
label: t`Distribution`,
value: versionDistributionLabel,
},
];
return (
<SettingsTableCard rounded items={statsItems} gridAutoColumns="200px 1fr" />
);
};
@@ -0,0 +1,216 @@
import styled from '@emotion/styled';
import { useQuery } from '@apollo/client/react';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useDebounce } from 'use-debounce';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/data-display';
import { IconChevronDown, IconDotsVertical } from 'twenty-ui/icon';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
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 { type ApplicationRegistration } from '~/generated-metadata/graphql';
import { FindAdminApplicationRegistrationInstalledWorkspacesDocument } from '~/generated-admin/graphql';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { SettingsPath } from 'twenty-shared/types';
const INITIAL_VISIBLE_WORKSPACES = 3;
const INSTALLED_WORKSPACES_GRID_TEMPLATE_COLUMNS = '1fr 120px';
const StyledSection = styled(Section)`
margin-top: ${themeCssVariables.spacing[5]};
`;
const StyledSearchInputContainer = styled.div`
padding-bottom: ${themeCssVariables.spacing[2]};
`;
const StyledButtonContainer = styled.div`
display: flex;
justify-content: center;
margin-top: ${themeCssVariables.spacing[2]};
`;
export const SettingsApplicationRegistrationInstalledWorkspaces = ({
registration,
}: {
registration: ApplicationRegistration;
}) => {
const { t } = useLingui();
const apolloAdminClient = useApolloAdminClient();
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearchTerm] = useDebounce(searchTerm, 300);
const [isExpanded, setIsExpanded] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const applicationRegistrationId = registration.id;
const { data, loading, error, fetchMore } = useQuery(
FindAdminApplicationRegistrationInstalledWorkspacesDocument,
{
client: apolloAdminClient,
variables: {
input: {
id: applicationRegistrationId,
page: 1,
searchTerm: debouncedSearchTerm,
},
},
skip: !applicationRegistrationId,
},
);
const result = data?.findAdminApplicationRegistrationInstalledWorkspaces;
const workspaces = result?.workspaces ?? [];
const totalCount = result?.totalCount ?? 0;
const hasMore = result?.hasMore ?? false;
const isSearching = debouncedSearchTerm.trim() !== '';
// Don't render a misleading "no installs" state when the query actually failed
if (isDefined(error)) {
return null;
}
// The app is installed nowhere (as opposed to a search yielding no matches)
const hasNoInstalls = totalCount === 0 && !isSearching && !loading;
if (hasNoInstalls) {
return null;
}
const visibleWorkspaces = isExpanded
? workspaces
: workspaces.slice(0, INITIAL_VISIBLE_WORKSPACES);
const handleSearchChange = (nextSearchTerm: string) => {
setSearchTerm(nextSearchTerm);
setCurrentPage(1);
setIsExpanded(false);
};
const handleShowMore = () => {
const nextPage = currentPage + 1;
fetchMore({
variables: {
input: {
id: applicationRegistrationId,
page: nextPage,
searchTerm: debouncedSearchTerm,
},
},
updateQuery: (previousData, { fetchMoreResult }) => {
if (!isDefined(fetchMoreResult)) {
return previousData;
}
return {
findAdminApplicationRegistrationInstalledWorkspaces: {
...fetchMoreResult.findAdminApplicationRegistrationInstalledWorkspaces,
workspaces: [
...previousData
.findAdminApplicationRegistrationInstalledWorkspaces.workspaces,
...fetchMoreResult
.findAdminApplicationRegistrationInstalledWorkspaces.workspaces,
],
},
};
},
});
setCurrentPage(nextPage);
};
return (
<StyledSection>
<StyledSearchInputContainer>
<SettingsTextInput
instanceId="installed-workspaces-search"
value={searchTerm}
onChange={handleSearchChange}
placeholder={t`Search by workspace name or version...`}
fullWidth
/>
</StyledSearchInputContainer>
{loading ? (
<SettingsSectionSkeletonLoader />
) : (
<Table>
<TableRow
gridTemplateColumns={INSTALLED_WORKSPACES_GRID_TEMPLATE_COLUMNS}
>
<TableHeader>{t`Workspace`}</TableHeader>
<TableHeader align="right">{t`Version installed`}</TableHeader>
</TableRow>
<TableBody>
{visibleWorkspaces.map((workspace) => (
<TableRow
key={workspace.id}
gridTemplateColumns={INSTALLED_WORKSPACES_GRID_TEMPLATE_COLUMNS}
to={getSettingsPath(SettingsPath.AdminPanelWorkspaceDetail, {
workspaceId: workspace.id,
})}
>
<TableCell
color={themeCssVariables.font.color.primary}
gap={themeCssVariables.spacing[2]}
overflow="hidden"
>
<Avatar
avatarUrl={getAbsoluteImageUrl(workspace.logo ?? undefined)}
placeholder={workspace.displayName ?? '—'}
placeholderColorSeed={workspace.id}
size="md"
/>
<OverflowingTextWithTooltip
text={workspace.displayName ?? '—'}
/>
</TableCell>
<TableCell
align="right"
color={themeCssVariables.font.color.tertiary}
>
{workspace.version ?? '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{!isExpanded && totalCount > INITIAL_VISIBLE_WORKSPACES && (
<StyledButtonContainer>
<Button
title={t`Show all`}
Icon={IconChevronDown}
variant="secondary"
size="small"
onClick={() => setIsExpanded(true)}
/>
</StyledButtonContainer>
)}
{isExpanded && hasMore && (
<StyledButtonContainer>
<Button
title={t`Show more`}
Icon={IconDotsVertical}
variant="secondary"
size="small"
onClick={handleShowMore}
/>
</StyledButtonContainer>
)}
</StyledSection>
);
};
@@ -37,9 +37,11 @@ export const SettingsApplicationRegistrationGeneralTab = ({
registration={registration}
/>
)}
<SettingsApplicationRegistrationGeneralStats
registration={registration}
/>
{fromAdmin && (
<SettingsApplicationRegistrationGeneralStats
registration={registration}
/>
)}
<SettingsAdminApplicationRegistrationDangerZone
registration={registration}
fromAdmin={fromAdmin}
@@ -52,6 +52,9 @@ import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/
import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration-variable/dtos/update-application-registration-variable.input';
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 { ApplicationRegistrationInstalledWorkspacesDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-installed-workspaces.dto';
import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
import { FindApplicationRegistrationInstalledWorkspacesInput } from 'src/engine/core-modules/application/application-registration/dtos/find-application-registration-installed-workspaces.input';
import { UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
import {
BACKFILL_APPLICATION_INSTALLATION_JOB_NAME,
@@ -102,6 +105,8 @@ import { ModelsDevProviderSuggestionDTO } from './dtos/models-dev-provider-sugge
import { QueueMetricsDataDTO } from './dtos/queue-metrics-data.dto';
import { SetMaintenanceModeInput } from './dtos/set-maintenance-mode.input';
const INSTALLED_WORKSPACES_PAGE_SIZE = 10;
@UsePipes(ResolverValidationPipe)
@AdminResolver()
@UseFilters(
@@ -819,6 +824,32 @@ export class AdminPanelResolver {
);
}
@UseGuards(AdminPanelGuard)
@Query(() => ApplicationRegistrationStatsDTO)
async findAdminApplicationRegistrationStats(
@Args('id') id: string,
): Promise<ApplicationRegistrationStatsDTO> {
return this.applicationRegistrationService.getStatsGlobal(id);
}
@UseGuards(AdminPanelGuard)
@Query(() => ApplicationRegistrationInstalledWorkspacesDTO)
async findAdminApplicationRegistrationInstalledWorkspaces(
@Args('input')
{
id,
page,
searchTerm,
}: FindApplicationRegistrationInstalledWorkspacesInput,
): Promise<ApplicationRegistrationInstalledWorkspacesDTO> {
return this.applicationRegistrationService.getInstalledWorkspacesGlobal(
id,
page ?? 1,
INSTALLED_WORKSPACES_PAGE_SIZE,
searchTerm,
);
}
@UseGuards(AdminPanelGuard)
@Mutation(() => ApplicationRegistrationVariableDTO)
async updateAdminApplicationRegistrationVariable(
@@ -6,7 +6,7 @@ import crypto from 'crypto';
import * as bcrypt from 'bcrypt';
import { type Manifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
import { ILike, type FindOptionsWhere, type Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-oauth/constants/oauth-scopes';
@@ -16,6 +16,7 @@ import {
ApplicationRegistrationException,
ApplicationRegistrationExceptionCode,
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
import { type ApplicationRegistrationInstalledWorkspacesDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-installed-workspaces.dto';
import { type ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
import { type CreateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.input';
import { type PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/public-application-registration.dto';
@@ -398,6 +399,21 @@ export class ApplicationRegistrationService {
): Promise<ApplicationRegistrationStatsDTO> {
await this.findOneById(applicationRegistrationId, ownerWorkspaceId);
return this.computeStats(applicationRegistrationId);
}
// Admin panel views apps across all workspaces, so ownership is not enforced.
async getStatsGlobal(
applicationRegistrationId: string,
): Promise<ApplicationRegistrationStatsDTO> {
await this.findOneByIdGlobal(applicationRegistrationId);
return this.computeStats(applicationRegistrationId);
}
private async computeStats(
applicationRegistrationId: string,
): Promise<ApplicationRegistrationStatsDTO> {
const versionDistribution: { version: string; count: number }[] =
await this.applicationRepository
.createQueryBuilder('application')
@@ -426,6 +442,73 @@ export class ApplicationRegistrationService {
};
}
// Installed workspaces are only exposed in the admin panel, which views apps
// across all workspaces, so ownership is not enforced.
async getInstalledWorkspacesGlobal(
applicationRegistrationId: string,
page: number,
pageSize: number,
searchTerm?: string,
): Promise<ApplicationRegistrationInstalledWorkspacesDTO> {
await this.findOneByIdGlobal(applicationRegistrationId);
return this.computeInstalledWorkspaces(
applicationRegistrationId,
page,
pageSize,
searchTerm,
);
}
private async computeInstalledWorkspaces(
applicationRegistrationId: string,
page: number,
pageSize: number,
searchTerm?: string,
): Promise<ApplicationRegistrationInstalledWorkspacesDTO> {
const safePage = page < 1 ? 1 : page;
const offset = (safePage - 1) * pageSize;
const trimmedSearch = searchTerm?.trim();
const where: FindOptionsWhere<ApplicationEntity> = {
applicationRegistrationId,
};
const whereClauses: FindOptionsWhere<ApplicationEntity>[] =
isDefined(trimmedSearch) && trimmedSearch.length > 0
? [
{
...where,
workspace: { displayName: ILike(`%${trimmedSearch}%`) },
},
{ ...where, version: ILike(`%${trimmedSearch}%`) },
]
: [where];
const [applications, totalCount] =
await this.applicationRepository.findAndCount({
where: whereClauses,
relations: { workspace: true },
order: { workspace: { displayName: 'ASC' }, id: 'ASC' },
skip: offset,
take: pageSize,
});
const workspaces = applications.map((application) => ({
id: application.workspace.id,
displayName: application.workspace.displayName ?? null,
logo: application.workspace.logo ?? null,
version: application.version ?? null,
}));
return {
workspaces,
totalCount,
hasMore: offset + workspaces.length < totalCount,
};
}
async transferOwnership(params: {
applicationRegistrationId: string;
targetWorkspaceSubdomain: string;
@@ -0,0 +1,30 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('InstalledWorkspace')
export class InstalledWorkspaceDTO {
@Field(() => UUIDScalarType)
id: string;
@Field(() => String, { nullable: true })
displayName: string | null;
@Field(() => String, { nullable: true })
logo: string | null;
@Field(() => String, { nullable: true })
version: string | null;
}
@ObjectType('ApplicationRegistrationInstalledWorkspaces')
export class ApplicationRegistrationInstalledWorkspacesDTO {
@Field(() => [InstalledWorkspaceDTO])
workspaces: InstalledWorkspaceDTO[];
@Field(() => Int)
totalCount: number;
@Field(() => Boolean)
hasMore: boolean;
}
@@ -0,0 +1,22 @@
import { Field, InputType, Int } from '@nestjs/graphql';
import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
@InputType()
export class FindApplicationRegistrationInstalledWorkspacesInput {
@Field()
@IsString()
id: string;
@Field(() => Int, { defaultValue: 1 })
@IsInt()
@Min(1)
@IsOptional()
page?: number;
@Field({ nullable: true })
@IsString()
@MaxLength(256)
@IsOptional()
searchTerm?: string;
}
@@ -0,0 +1,278 @@
import { randomUUID } from 'crypto';
import { gql } from 'graphql-tag';
import { type DataSource } from 'typeorm';
import { makeAdminPanelAPIRequestWithGuestRole } from 'test/integration/graphql/suites/admin-panel/utils/make-admin-panel-api-request-with-guest-role.util';
import { makeAdminPanelAPIRequest } from 'test/integration/twenty-config/utils/make-admin-panel-api-request.util';
import {
SEED_APPLE_WORKSPACE_ID,
SEED_YCOMBINATOR_WORKSPACE_ID,
} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
// displayNames of the seeded workspaces (see seeder-workspaces.constant.ts).
// They drive the ASC ordering and the searchTerm-by-workspace-name assertions.
const APPLE_WORKSPACE_DISPLAY_NAME = 'Apple';
const YCOMBINATOR_WORKSPACE_DISPLAY_NAME = 'YCombinator';
const FIND_STATS = gql`
query FindAdminApplicationRegistrationStats($id: String!) {
findAdminApplicationRegistrationStats(id: $id) {
activeInstalls
mostInstalledVersion
versionDistribution {
version
count
}
}
}
`;
const FIND_INSTALLED_WORKSPACES = gql`
query FindAdminApplicationRegistrationInstalledWorkspaces(
$input: FindApplicationRegistrationInstalledWorkspacesInput!
) {
findAdminApplicationRegistrationInstalledWorkspaces(input: $input) {
totalCount
hasMore
workspaces {
id
displayName
logo
version
}
}
}
`;
type SeededApplication = {
id: string;
workspaceId: string;
version: string;
};
describe('Admin panel application registration stats and installed workspaces (integration)', () => {
let dataSource: DataSource;
let applicationRegistrationId: string;
const seededApplicationIds: string[] = [];
const insertApplication = async ({
workspaceId,
version,
}: {
workspaceId: string;
version: string;
}): Promise<SeededApplication> => {
const id = randomUUID();
await dataSource.query(
`INSERT INTO core."application"
(id, "universalIdentifier", name, version, "sourcePath",
"sourceType", "workspaceId", "applicationRegistrationId")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
id,
randomUUID(),
'admin-panel-stats-integration-test-app',
version,
'',
'local',
workspaceId,
applicationRegistrationId,
],
);
seededApplicationIds.push(id);
return { id, workspaceId, version };
};
beforeAll(async () => {
dataSource = global.testDataSource;
applicationRegistrationId = randomUUID();
await dataSource.query(
`INSERT INTO core."applicationRegistration"
(id, "universalIdentifier", name, "oAuthClientId",
"oAuthRedirectUris", "oAuthScopes", "sourceType")
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
applicationRegistrationId,
randomUUID(),
'admin-panel-stats-integration-test-registration',
randomUUID(),
[],
[],
'local',
],
);
// Two installs on version 2.0.0 (one per seeded workspace) and one on
// 1.0.0, so the distribution is deterministic and 2.0.0 is the most
// installed version.
await insertApplication({
workspaceId: SEED_APPLE_WORKSPACE_ID,
version: '2.0.0',
});
await insertApplication({
workspaceId: SEED_YCOMBINATOR_WORKSPACE_ID,
version: '2.0.0',
});
await insertApplication({
workspaceId: SEED_YCOMBINATOR_WORKSPACE_ID,
version: '1.0.0',
});
});
afterAll(async () => {
if (seededApplicationIds.length > 0) {
await dataSource.query(
`DELETE FROM core."application" WHERE id = ANY($1)`,
[seededApplicationIds],
);
}
await dataSource.query(
`DELETE FROM core."applicationRegistration" WHERE id = $1`,
[applicationRegistrationId],
);
});
describe('findAdminApplicationRegistrationStats', () => {
it('returns active installs, version distribution and the most installed version', async () => {
const response = await makeAdminPanelAPIRequest({
query: FIND_STATS,
variables: { id: applicationRegistrationId },
});
expect(response.body.errors).toBeUndefined();
const stats = response.body.data?.findAdminApplicationRegistrationStats;
expect(stats).toBeDefined();
expect(stats.activeInstalls).toBe(3);
expect(stats.mostInstalledVersion).toBe('2.0.0');
const distributionByVersion = Object.fromEntries(
stats.versionDistribution.map(
(entry: { version: string; count: number }) => [
entry.version,
entry.count,
],
),
);
expect(distributionByVersion).toEqual({ '2.0.0': 2, '1.0.0': 1 });
// Distribution is ordered by count DESC, so the top entry matches
// mostInstalledVersion.
expect(stats.versionDistribution[0].version).toBe('2.0.0');
});
it('rejects a caller without the SECURITY permission flag', async () => {
const response = await makeAdminPanelAPIRequestWithGuestRole({
query: FIND_STATS,
variables: { id: applicationRegistrationId },
});
expect(response.body.errors).toBeDefined();
expect(
response.body.data?.findAdminApplicationRegistrationStats,
).toBeFalsy();
});
});
describe('findAdminApplicationRegistrationInstalledWorkspaces', () => {
it('returns installed workspaces ordered by display name with totalCount and hasMore', async () => {
const response = await makeAdminPanelAPIRequest({
query: FIND_INSTALLED_WORKSPACES,
variables: { input: { id: applicationRegistrationId, page: 1 } },
});
expect(response.body.errors).toBeUndefined();
const result =
response.body.data?.findAdminApplicationRegistrationInstalledWorkspaces;
expect(result).toBeDefined();
expect(result.totalCount).toBe(3);
expect(result.hasMore).toBe(false);
expect(result.workspaces).toHaveLength(3);
// Ordered by workspace.displayName ASC — Apple before YCombinator.
expect(result.workspaces[0].displayName).toBe(
APPLE_WORKSPACE_DISPLAY_NAME,
);
expect(result.workspaces[0].version).toBe('2.0.0');
expect(result.workspaces[1].displayName).toBe(
YCOMBINATOR_WORKSPACE_DISPLAY_NAME,
);
expect(result.workspaces[2].displayName).toBe(
YCOMBINATOR_WORKSPACE_DISPLAY_NAME,
);
});
it('filters by workspace display name via searchTerm', async () => {
const response = await makeAdminPanelAPIRequest({
query: FIND_INSTALLED_WORKSPACES,
variables: {
input: {
id: applicationRegistrationId,
page: 1,
searchTerm: APPLE_WORKSPACE_DISPLAY_NAME,
},
},
});
expect(response.body.errors).toBeUndefined();
const result =
response.body.data?.findAdminApplicationRegistrationInstalledWorkspaces;
expect(result.totalCount).toBe(1);
expect(result.hasMore).toBe(false);
expect(result.workspaces).toHaveLength(1);
expect(result.workspaces[0].displayName).toBe(
APPLE_WORKSPACE_DISPLAY_NAME,
);
});
it('filters by application version via searchTerm', async () => {
const response = await makeAdminPanelAPIRequest({
query: FIND_INSTALLED_WORKSPACES,
variables: {
input: {
id: applicationRegistrationId,
page: 1,
searchTerm: '1.0.0',
},
},
});
expect(response.body.errors).toBeUndefined();
const result =
response.body.data?.findAdminApplicationRegistrationInstalledWorkspaces;
expect(result.totalCount).toBe(1);
expect(result.workspaces).toHaveLength(1);
expect(result.workspaces[0].version).toBe('1.0.0');
expect(result.workspaces[0].displayName).toBe(
YCOMBINATOR_WORKSPACE_DISPLAY_NAME,
);
});
it('rejects a caller without the SECURITY permission flag', async () => {
const response = await makeAdminPanelAPIRequestWithGuestRole({
query: FIND_INSTALLED_WORKSPACES,
variables: { input: { id: applicationRegistrationId, page: 1 } },
});
expect(response.body.errors).toBeDefined();
expect(
response.body.data?.findAdminApplicationRegistrationInstalledWorkspaces,
).toBeFalsy();
});
});
});
@@ -0,0 +1,26 @@
import { type ASTNode, print } from 'graphql';
import request from 'supertest';
/* global APP_PORT, APPLE_PHIL_GUEST_ACCESS_TOKEN */
type GraphqlOperation = {
query: ASTNode;
variables?: Record<string, unknown>;
};
// Hits the admin GraphQL schema (/admin-panel) with a guest user that lacks the
// SECURITY permission flag, so it can assert the resolver-level guards reject it.
export const makeAdminPanelAPIRequestWithGuestRole = (
graphqlOperation: GraphqlOperation,
) => {
const client = request(`http://localhost:${APP_PORT}`);
return client
.post('/admin-panel')
.set('Authorization', `Bearer ${APPLE_PHIL_GUEST_ACCESS_TOKEN}`)
.send({
query: print(graphqlOperation.query),
variables: graphqlOperation.variables || {},
})
.expect(200);
};