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
@@ -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);
};