[Apps] Content + permission tabs for marketplace and installed apps (#17888)

In this PR
- Content tab for marketplace apps and installed apps: complies with
[figma](https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=34845-126856);
addition of field section showing fields added to standard objects;
initiative: arrow opens a sub-table of fields rows. (suggested because
1/ for marketplace apps we cannot redirect to the actual object page in
settings since the object does not exist yet in the workspace 2/ since
we dont have an quick "go back to application page" option, it can be
annoying to be redirected to a different setting page when we just want
to look at the content of the app objects)
- Permission tab for marketplace apps and installed apps
- left to do - settings tab (in another PR)

There are breaking changes but it's behind a feature flag not exposed
(access to marketplace) so not problematic


marketplace apps

https://github.com/user-attachments/assets/4c660101-50fc-47ce-b90a-8d6f17db5e74

installed apps

https://github.com/user-attachments/assets/c9229ee1-e75f-4cad-8766-758b2c5b37b4
This commit is contained in:
Marie
2026-02-12 18:38:53 +01:00
committed by GitHub
parent 13c2234856
commit 90a30263ac
29 changed files with 1967 additions and 199 deletions
@@ -1,13 +1,202 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Field, Int, ObjectType } from '@nestjs/graphql';
import {
IsArray,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
@ObjectType('MarketplaceAppField')
export class MarketplaceAppFieldDTO {
@IsString()
@Field()
name: string;
@IsString()
@Field()
type: string;
@IsString()
@Field()
label: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
description?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
icon?: string;
@IsString()
@Field({ nullable: true })
objectUniversalIdentifier: string;
@IsString()
@Field({ nullable: true })
universalIdentifier: string;
}
@ObjectType('MarketplaceAppObject')
export class MarketplaceAppObjectDTO {
@IsString()
@Field()
universalIdentifier: string;
@IsString()
@Field()
nameSingular: string;
@IsString()
@Field()
namePlural: string;
@IsString()
@Field()
labelSingular: string;
@IsString()
@Field()
labelPlural: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
description?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
icon?: string;
@IsArray()
@Field(() => [MarketplaceAppFieldDTO])
fields: MarketplaceAppFieldDTO[];
}
@ObjectType('MarketplaceAppLogicFunction')
export class MarketplaceAppLogicFunctionDTO {
@IsString()
@Field()
name: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
description?: string;
@IsOptional()
@IsNumber()
@Field(() => Int, { nullable: true })
timeoutSeconds?: number;
}
@ObjectType('MarketplaceAppFrontComponent')
export class MarketplaceAppFrontComponentDTO {
@IsString()
@Field()
name: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
description?: string;
}
@ObjectType('MarketplaceAppRoleObjectPermission')
export class MarketplaceAppRoleObjectPermissionDTO {
@IsString()
@Field()
objectUniversalIdentifier: string;
@IsOptional()
@Field(() => Boolean, { nullable: true })
canReadObjectRecords?: boolean;
@IsOptional()
@Field(() => Boolean, { nullable: true })
canUpdateObjectRecords?: boolean;
@IsOptional()
@Field(() => Boolean, { nullable: true })
canSoftDeleteObjectRecords?: boolean;
@IsOptional()
@Field(() => Boolean, { nullable: true })
canDestroyObjectRecords?: boolean;
}
@ObjectType('MarketplaceAppRoleFieldPermission')
export class MarketplaceAppRoleFieldPermissionDTO {
@IsString()
@Field()
objectUniversalIdentifier: string;
@IsString()
@Field()
fieldUniversalIdentifier: string;
@IsOptional()
@Field(() => Boolean, { nullable: true })
canReadFieldValue?: boolean;
@IsOptional()
@Field(() => Boolean, { nullable: true })
canUpdateFieldValue?: boolean;
}
@ObjectType('MarketplaceAppDefaultRole')
export class MarketplaceAppDefaultRoleDTO {
@IsString()
@IsNotEmpty()
@Field()
id: string;
@IsString()
@Field()
label: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
description?: string;
@Field(() => Boolean)
canReadAllObjectRecords: boolean;
@Field(() => Boolean)
canUpdateAllObjectRecords: boolean;
@Field(() => Boolean)
canSoftDeleteAllObjectRecords: boolean;
@Field(() => Boolean)
canDestroyAllObjectRecords: boolean;
@Field(() => Boolean)
canUpdateAllSettings: boolean;
@Field(() => Boolean)
canAccessAllTools: boolean;
@IsArray()
@Field(() => [MarketplaceAppRoleObjectPermissionDTO])
objectPermissions: MarketplaceAppRoleObjectPermissionDTO[];
@IsArray()
@Field(() => [MarketplaceAppRoleFieldPermissionDTO])
fieldPermissions: MarketplaceAppRoleFieldPermissionDTO[];
@IsArray()
@Field(() => [String])
permissionFlags: string[];
}
@ObjectType('MarketplaceApp')
export class MarketplaceAppDTO {
@IsString()
@@ -67,4 +256,24 @@ export class MarketplaceAppDTO {
@IsString()
@Field({ nullable: true })
termsUrl?: string;
@IsArray()
@Field(() => [MarketplaceAppObjectDTO])
objects: MarketplaceAppObjectDTO[];
@IsArray()
@Field(() => [MarketplaceAppFieldDTO])
fields: MarketplaceAppFieldDTO[];
@IsArray()
@Field(() => [MarketplaceAppLogicFunctionDTO])
logicFunctions: MarketplaceAppLogicFunctionDTO[];
@IsArray()
@Field(() => [MarketplaceAppFrontComponentDTO])
frontComponents: MarketplaceAppFrontComponentDTO[];
@IsOptional()
@Field(() => MarketplaceAppDefaultRoleDTO, { nullable: true })
defaultRole?: MarketplaceAppDefaultRoleDTO;
}
@@ -5,7 +5,15 @@ import { lowerCase, upperFirst } from 'lodash';
import { type Manifest } from 'twenty-shared/application';
import { PackageJson } from 'type-fest';
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/dtos/marketplace-app.dto';
import {
MarketplaceAppDTO,
MarketplaceAppDefaultRoleDTO,
MarketplaceAppFieldDTO,
MarketplaceAppFrontComponentDTO,
MarketplaceAppLogicFunctionDTO,
MarketplaceAppObjectDTO,
} from 'src/engine/core-modules/application/dtos/marketplace-app.dto';
import { MOCKED_MARKETPLACE_APP } from 'src/engine/core-modules/application/services/mocked-marketplace-app.constant';
type GitHubContent = {
name: string;
@@ -52,7 +60,7 @@ export class MarketplaceService {
private async fetchAllMarketplaceApps(): Promise<MarketplaceAppDTO[]> {
const apps: MarketplaceAppDTO[] = [];
apps.push(this.loadMockApp());
apps.push(MOCKED_MARKETPLACE_APP); // To remove once we have apps on marketplace
try {
const appDirs = await this.getAppDirectoriesFromGitHub();
@@ -82,6 +90,10 @@ export class MarketplaceService {
providers: [],
websiteUrl: '',
termsUrl: '',
objects: [],
fields: [],
logicFunctions: [],
frontComponents: [],
});
}
} catch (error) {
@@ -99,44 +111,12 @@ export class MarketplaceService {
return apps;
}
private loadMockApp(): MarketplaceAppDTO {
// SVG logo as data URL
const logoSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="#1a2744"><ellipse cx="38" cy="20" rx="28" ry="10"/><rect x="10" y="20" width="56" height="50"/><ellipse cx="38" cy="70" rx="28" ry="10"/><ellipse cx="38" cy="35" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/><ellipse cx="38" cy="52" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/><circle cx="72" cy="62" r="22" fill="#1a2744"/><circle cx="72" cy="62" r="18" fill="#fff"/><path d="M72 50 L72 74 M62 58 L72 48 L82 58" stroke="#1a2744" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`;
const logoDataUrl = `data:image/svg+xml,${encodeURIComponent(logoSvg)}`;
return {
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'Data Enrichment',
description: 'Enrich your data easily. Choose your provider.',
icon: 'IconSparkles',
version: '1.0.0',
author: 'Cosmos Labs',
category: 'Data',
logo: logoDataUrl,
screenshots: [
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+1',
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+2',
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+3',
],
aboutDescription:
'Enhance your workspace with automated data intelligence. This app monitors your new records and automatically populates missing details such as job titles, company size, social profiles, and industry insights.',
providers: ['Clearbit', 'Apollo', 'Hunter.io'],
websiteUrl: 'https://google.com',
termsUrl: 'https://google.com',
};
}
private async getAppDirectoriesFromGitHub(): Promise<string[]> {
const directories: string[] = [];
const rootContents = await this.fetchGitHubDirectory('');
for (const entry of rootContents) {
// if (entry.type !== 'dir') continue;
// if (entry.name.startsWith('.')) continue;
// if (entry.name === 'node_modules') continue;
// if (entry.name === 'internal') continue;
if (entry.name === 'community') {
const communityContents = await this.fetchGitHubDirectory('community');
@@ -217,6 +197,61 @@ export class MarketplaceService {
return null;
}
const objects: MarketplaceAppObjectDTO[] = (manifest.objects ?? []).map(
(manifestObject) => ({
universalIdentifier: manifestObject.universalIdentifier,
nameSingular: manifestObject.nameSingular,
namePlural: manifestObject.namePlural,
labelSingular: manifestObject.labelSingular,
labelPlural: manifestObject.labelPlural,
description: manifestObject.description,
icon: manifestObject.icon,
fields: (manifestObject.fields ?? []).map((field) => ({
name: field.name ?? '',
type: field.type,
label: field.label ?? '',
description: field.description,
icon: field.icon,
universalIdentifier: field.universalIdentifier,
objectUniversalIdentifier: manifestObject.universalIdentifier,
})),
}),
);
const fields: MarketplaceAppFieldDTO[] = (manifest.fields ?? []).map(
(manifestField) => {
return {
name: manifestField.name,
type: manifestField.type,
label: manifestField.label,
description: manifestField.description,
icon: manifestField.icon,
objectUniversalIdentifier: manifestField.objectUniversalIdentifier,
universalIdentifier: manifestField.universalIdentifier,
};
},
);
const logicFunctions: MarketplaceAppLogicFunctionDTO[] = (
manifest.logicFunctions ?? []
).map((manifestLogicFunction) => ({
name: manifestLogicFunction.name ?? '',
description: manifestLogicFunction.description,
timeoutSeconds: manifestLogicFunction.timeoutSeconds,
}));
const frontComponents: MarketplaceAppFrontComponentDTO[] = (
manifest.frontComponents ?? []
).map((manifestFrontComponent) => ({
name: manifestFrontComponent.name ?? '',
description: manifestFrontComponent.description,
}));
const defaultRole = this.resolveDefaultRole(
manifest,
application.defaultRoleUniversalIdentifier,
);
return {
id: application.universalIdentifier,
name: application.displayName,
@@ -231,6 +266,59 @@ export class MarketplaceService {
providers: marketplaceData.providers ?? [],
websiteUrl: marketplaceData.websiteUrl,
termsUrl: marketplaceData.termsUrl,
objects,
fields,
logicFunctions,
frontComponents,
defaultRole,
};
}
private resolveDefaultRole(
manifest: Manifest,
defaultRoleUniversalIdentifier: string,
): MarketplaceAppDefaultRoleDTO | undefined {
const roleManifest = manifest.roles?.find(
(role) => role.universalIdentifier === defaultRoleUniversalIdentifier,
);
if (!roleManifest) {
return undefined;
}
return {
id: roleManifest.universalIdentifier,
label: roleManifest.label,
description: roleManifest.description,
canReadAllObjectRecords: roleManifest.canReadAllObjectRecords ?? false,
canUpdateAllObjectRecords:
roleManifest.canUpdateAllObjectRecords ?? false,
canSoftDeleteAllObjectRecords:
roleManifest.canSoftDeleteAllObjectRecords ?? false,
canDestroyAllObjectRecords:
roleManifest.canDestroyAllObjectRecords ?? false,
canUpdateAllSettings: roleManifest.canUpdateAllSettings ?? false,
canAccessAllTools: roleManifest.canAccessAllTools ?? false,
objectPermissions: (roleManifest.objectPermissions ?? []).map(
(permission) => ({
objectUniversalIdentifier: permission.objectUniversalIdentifier,
canReadObjectRecords: permission.canReadObjectRecords,
canUpdateObjectRecords: permission.canUpdateObjectRecords,
canSoftDeleteObjectRecords: permission.canSoftDeleteObjectRecords,
canDestroyObjectRecords: permission.canDestroyObjectRecords,
}),
),
fieldPermissions: (roleManifest.fieldPermissions ?? []).map(
(permission) => ({
objectUniversalIdentifier: permission.objectUniversalIdentifier,
fieldUniversalIdentifier: permission.fieldUniversalIdentifier,
canReadFieldValue: permission.canReadFieldValue,
canUpdateFieldValue: permission.canUpdateFieldValue,
}),
),
permissionFlags: (roleManifest.permissionFlags ?? []).map((flag) =>
flag.toString(),
),
};
}
@@ -0,0 +1,204 @@
import { type MarketplaceAppDTO } from 'src/engine/core-modules/application/dtos/marketplace-app.dto';
// Readable mock identifiers for the Data Enrichment app
const MOCK_APP_ID = 'a1b2c3d4-0000-0000-0000-000000000001';
const MOCK_ROLE_ID = 'a1b2c3d4-0000-0000-0000-000000000010';
const MOCK_ENRICHMENT_JOB_UNIVERSAL_ID = 'a1b2c3d4-0000-0000-0000-000000000100';
// Standard object universalIdentifiers from STANDARD_OBJECTS
const COMPANY_UNIVERSAL_ID = '20202020-b374-4779-a561-80086cb2e17f';
const PERSON_UNIVERSAL_ID = '20202020-e674-48e5-a542-72570eee7213';
// Standard field universalIdentifiers
const COMPANY_NAME_FIELD_UNIVERSAL_ID = '20202020-4d99-4e2e-a84c-4a27837b1ece';
// Enrichment job field universalIdentifiers
const MOCK_ENRICHMENT_JOB_STATUS_FIELD_UNIVERSAL_ID =
'a1b2c3d4-0000-0000-0000-000000000101';
const MOCK_ENRICHMENT_JOB_PROVIDER_FIELD_UNIVERSAL_ID =
'a1b2c3d4-0000-0000-0000-000000000102';
const MOCK_ENRICHMENT_JOB_ENRICHED_AT_FIELD_UNIVERSAL_ID =
'a1b2c3d4-0000-0000-0000-000000000103';
const MOCK_ENRICHMENT_JOB_RECORD_ID_FIELD_UNIVERSAL_ID =
'a1b2c3d4-0000-0000-0000-000000000104';
// App-provided field universalIdentifiers on standard objects
const COMPANY_INDUSTRY_FIELD_UNIVERSAL_ID =
'a1b2c3d4-0000-0000-0000-000000000201';
const COMPANY_EMPLOYEE_COUNT_FIELD_UNIVERSAL_ID =
'a1b2c3d4-0000-0000-0000-000000000202';
const PERSON_LINKEDIN_URL_FIELD_UNIVERSAL_ID =
'a1b2c3d4-0000-0000-0000-000000000203';
const PERSON_JOB_TITLE_FIELD_UNIVERSAL_ID =
'a1b2c3d4-0000-0000-0000-000000000204';
const MOCK_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="#1a2744"><ellipse cx="38" cy="20" rx="28" ry="10"/><rect x="10" y="20" width="56" height="50"/><ellipse cx="38" cy="70" rx="28" ry="10"/><ellipse cx="38" cy="35" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/><ellipse cx="38" cy="52" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/><circle cx="72" cy="62" r="22" fill="#1a2744"/><circle cx="72" cy="62" r="18" fill="#fff"/><path d="M72 50 L72 74 M62 58 L72 48 L82 58" stroke="#1a2744" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`;
export const MOCKED_MARKETPLACE_APP: MarketplaceAppDTO = {
id: MOCK_APP_ID,
name: 'Data Enrichment',
description: 'Enrich your data easily. Choose your provider.',
icon: 'IconSparkles',
version: '1.0.0',
author: 'Cosmos Labs',
category: 'Data',
logo: `data:image/svg+xml,${encodeURIComponent(MOCK_LOGO_SVG)}`,
screenshots: [
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+1',
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+2',
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+3',
],
aboutDescription:
'Enhance your workspace with automated data intelligence. This app monitors your new records and automatically populates missing details such as job titles, company size, social profiles, and industry insights.',
providers: ['Clearbit', 'Apollo', 'Hunter.io'],
websiteUrl: 'https://google.com',
termsUrl: 'https://google.com',
objects: [
{
universalIdentifier: MOCK_ENRICHMENT_JOB_UNIVERSAL_ID,
nameSingular: 'enrichmentJob',
namePlural: 'enrichmentJobs',
labelSingular: 'Enrichment Job',
labelPlural: 'Enrichment Jobs',
description: 'Tracks data enrichment requests and their status',
icon: 'IconSparkles',
fields: [
{
name: 'status',
type: 'SELECT',
label: 'Status',
description: 'Current status of the enrichment job',
icon: 'IconProgressCheck',
universalIdentifier: MOCK_ENRICHMENT_JOB_STATUS_FIELD_UNIVERSAL_ID,
objectUniversalIdentifier: MOCK_ENRICHMENT_JOB_UNIVERSAL_ID,
},
{
name: 'provider',
type: 'TEXT',
label: 'Provider',
description: 'Enrichment provider used',
icon: 'IconCloud',
universalIdentifier: MOCK_ENRICHMENT_JOB_PROVIDER_FIELD_UNIVERSAL_ID,
objectUniversalIdentifier: MOCK_ENRICHMENT_JOB_UNIVERSAL_ID,
},
{
name: 'enrichedAt',
type: 'DATE_TIME',
label: 'Enriched At',
description: 'When the enrichment was completed',
icon: 'IconCalendar',
universalIdentifier:
MOCK_ENRICHMENT_JOB_ENRICHED_AT_FIELD_UNIVERSAL_ID,
objectUniversalIdentifier: MOCK_ENRICHMENT_JOB_UNIVERSAL_ID,
},
{
name: 'recordId',
type: 'TEXT',
label: 'Record ID',
description: 'ID of the enriched record',
icon: 'IconKey',
universalIdentifier: MOCK_ENRICHMENT_JOB_RECORD_ID_FIELD_UNIVERSAL_ID,
objectUniversalIdentifier: MOCK_ENRICHMENT_JOB_UNIVERSAL_ID,
},
],
},
],
fields: [
{
name: 'industry',
type: 'TEXT',
label: 'Industry',
description: 'Company industry from enrichment',
icon: 'IconBuildingFactory2',
objectUniversalIdentifier: COMPANY_UNIVERSAL_ID,
universalIdentifier: COMPANY_INDUSTRY_FIELD_UNIVERSAL_ID,
},
{
name: 'employeeCount',
type: 'NUMBER',
label: 'Employee Count',
description: 'Number of employees from enrichment',
icon: 'IconUsers',
objectUniversalIdentifier: COMPANY_UNIVERSAL_ID,
universalIdentifier: COMPANY_EMPLOYEE_COUNT_FIELD_UNIVERSAL_ID,
},
{
name: 'linkedInUrl',
type: 'LINKS',
label: 'LinkedIn URL',
description: 'LinkedIn profile URL from enrichment',
icon: 'IconBrandLinkedin',
objectUniversalIdentifier: PERSON_UNIVERSAL_ID,
universalIdentifier: PERSON_LINKEDIN_URL_FIELD_UNIVERSAL_ID,
},
{
name: 'jobTitle',
type: 'TEXT',
label: 'Job Title',
description: 'Job title from enrichment',
icon: 'IconBriefcase',
objectUniversalIdentifier: PERSON_UNIVERSAL_ID,
universalIdentifier: PERSON_JOB_TITLE_FIELD_UNIVERSAL_ID,
},
],
logicFunctions: [
{
name: 'enrich-on-create',
description: 'Automatically enriches new records when they are created',
timeoutSeconds: 30,
},
],
frontComponents: [],
defaultRole: {
id: MOCK_ROLE_ID,
label: 'Data Enrichment default role',
description: 'Default permissions for the Data Enrichment app',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: true,
canUpdateAllSettings: false,
canAccessAllTools: false,
objectPermissions: [
{
// Company: revoke soft-delete
objectUniversalIdentifier: COMPANY_UNIVERSAL_ID,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
// Person: no overrides (matches defaults)
objectUniversalIdentifier: PERSON_UNIVERSAL_ID,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: false,
},
{
// Enrichment Job (app-custom object): revoke soft-delete
objectUniversalIdentifier: MOCK_ENRICHMENT_JOB_UNIVERSAL_ID,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
fieldPermissions: [
{
objectUniversalIdentifier: COMPANY_UNIVERSAL_ID,
fieldUniversalIdentifier: COMPANY_NAME_FIELD_UNIVERSAL_ID,
canReadFieldValue: true,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier: PERSON_UNIVERSAL_ID,
fieldUniversalIdentifier: PERSON_JOB_TITLE_FIELD_UNIVERSAL_ID,
canReadFieldValue: true,
canUpdateFieldValue: false,
},
],
permissionFlags: ['DATA_MODEL', 'API_KEYS_AND_WEBHOOKS'],
},
};