Provide applicatiion assets (#18973)
- improve backend - improve frontend <img width="1293" height="824" alt="image" src="https://github.com/user-attachments/assets/7a4633f1-85cd-4126-b058-dbeae6ba2218" />
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "0.8.0-canary.5",
|
||||
"version": "0.8.0-canary.6",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -211,14 +211,14 @@ export class CreateAppCommand {
|
||||
if (!result.success) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'Authentication skipped. Run `yarn twenty remote add --local` manually.',
|
||||
'Authentication skipped. Run `yarn twenty remote add` manually.',
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'Authentication skipped. Run `yarn twenty remote add --local` manually.',
|
||||
'Authentication skipped. Run `yarn twenty remote add` manually.',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -236,7 +236,7 @@ export class CreateAppCommand {
|
||||
if (!serverResult) {
|
||||
console.log(
|
||||
chalk.gray(
|
||||
'- yarn twenty remote add --local # Authenticate with Twenty',
|
||||
'- yarn twenty remote add # Authenticate with Twenty',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -794,6 +794,7 @@ const createPackageJson = async ({
|
||||
npm: 'please-use-yarn',
|
||||
yarn: '>=4.0.2',
|
||||
},
|
||||
keywords: ['twenty-app'],
|
||||
packageManager: 'yarn@4.9.2',
|
||||
scripts,
|
||||
devDependencies,
|
||||
|
||||
@@ -9,22 +9,11 @@
|
||||
},
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"scripts": {
|
||||
"remote:add": "twenty remote add --local",
|
||||
"remote:status": "twenty remote status",
|
||||
"remote:switch": "twenty remote switch",
|
||||
"remote:list": "twenty remote list",
|
||||
"remote:remove": "twenty remote remove",
|
||||
"dev": "twenty dev",
|
||||
"add": "twenty add",
|
||||
"logs": "twenty logs",
|
||||
"exec": "twenty exec",
|
||||
"uninstall": "twenty uninstall",
|
||||
"help": "twenty help",
|
||||
"lint": "oxlint -c .oxlintrc.json .",
|
||||
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
|
||||
"twenty": "twenty"
|
||||
},
|
||||
"dependencies": {
|
||||
"twenty-sdk": "latest"
|
||||
"twenty-sdk": "latest",
|
||||
"twenty-client-sdk": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-client-sdk",
|
||||
"version": "0.8.0-canary.5",
|
||||
"version": "0.8.0-canary.6",
|
||||
"sideEffects": false,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -38,9 +38,6 @@ type ApplicationRegistration {
|
||||
id: UUID!
|
||||
universalIdentifier: String!
|
||||
name: String!
|
||||
description: String
|
||||
logoUrl: String
|
||||
author: String
|
||||
oAuthClientId: String!
|
||||
oAuthRedirectUris: [String!]!
|
||||
oAuthScopes: [String!]!
|
||||
@@ -48,8 +45,6 @@ type ApplicationRegistration {
|
||||
sourceType: ApplicationRegistrationSourceType!
|
||||
sourcePackage: String
|
||||
latestAvailableVersion: String
|
||||
websiteUrl: String
|
||||
termsUrl: String
|
||||
isListed: Boolean!
|
||||
isFeatured: Boolean!
|
||||
createdAt: DateTime!
|
||||
@@ -2421,91 +2416,30 @@ type File {
|
||||
createdAt: DateTime!
|
||||
}
|
||||
|
||||
type MarketplaceAppField {
|
||||
name: String!
|
||||
type: String!
|
||||
label: String!
|
||||
description: String
|
||||
icon: String
|
||||
objectUniversalIdentifier: String
|
||||
universalIdentifier: String
|
||||
}
|
||||
|
||||
type MarketplaceAppObject {
|
||||
universalIdentifier: String!
|
||||
nameSingular: String!
|
||||
namePlural: String!
|
||||
labelSingular: String!
|
||||
labelPlural: String!
|
||||
description: String
|
||||
icon: String
|
||||
fields: [MarketplaceAppField!]!
|
||||
}
|
||||
|
||||
type MarketplaceAppLogicFunction {
|
||||
name: String!
|
||||
description: String
|
||||
timeoutSeconds: Int
|
||||
}
|
||||
|
||||
type MarketplaceAppFrontComponent {
|
||||
name: String!
|
||||
description: String
|
||||
}
|
||||
|
||||
type MarketplaceAppRoleObjectPermission {
|
||||
objectUniversalIdentifier: String!
|
||||
canReadObjectRecords: Boolean
|
||||
canUpdateObjectRecords: Boolean
|
||||
canSoftDeleteObjectRecords: Boolean
|
||||
canDestroyObjectRecords: Boolean
|
||||
}
|
||||
|
||||
type MarketplaceAppRoleFieldPermission {
|
||||
objectUniversalIdentifier: String!
|
||||
fieldUniversalIdentifier: String!
|
||||
canReadFieldValue: Boolean
|
||||
canUpdateFieldValue: Boolean
|
||||
}
|
||||
|
||||
type MarketplaceAppDefaultRole {
|
||||
id: String!
|
||||
label: String!
|
||||
description: String
|
||||
canReadAllObjectRecords: Boolean!
|
||||
canUpdateAllObjectRecords: Boolean!
|
||||
canSoftDeleteAllObjectRecords: Boolean!
|
||||
canDestroyAllObjectRecords: Boolean!
|
||||
canUpdateAllSettings: Boolean!
|
||||
canAccessAllTools: Boolean!
|
||||
objectPermissions: [MarketplaceAppRoleObjectPermission!]!
|
||||
fieldPermissions: [MarketplaceAppRoleFieldPermission!]!
|
||||
permissionFlags: [String!]!
|
||||
}
|
||||
|
||||
type MarketplaceApp {
|
||||
id: String!
|
||||
name: String!
|
||||
description: String!
|
||||
icon: String!
|
||||
version: String!
|
||||
author: String!
|
||||
category: String!
|
||||
logo: String
|
||||
screenshots: [String!]!
|
||||
aboutDescription: String!
|
||||
providers: [String!]!
|
||||
websiteUrl: String
|
||||
termsUrl: String
|
||||
objects: [MarketplaceAppObject!]!
|
||||
fields: [MarketplaceAppField!]!
|
||||
logicFunctions: [MarketplaceAppLogicFunction!]!
|
||||
frontComponents: [MarketplaceAppFrontComponent!]!
|
||||
defaultRole: MarketplaceAppDefaultRole
|
||||
sourcePackage: String
|
||||
isFeatured: Boolean!
|
||||
}
|
||||
|
||||
type MarketplaceAppDetail {
|
||||
universalIdentifier: String!
|
||||
id: String!
|
||||
name: String!
|
||||
sourceType: ApplicationRegistrationSourceType!
|
||||
sourcePackage: String
|
||||
latestAvailableVersion: String
|
||||
isListed: Boolean!
|
||||
isFeatured: Boolean!
|
||||
manifest: JSON
|
||||
}
|
||||
|
||||
type PublicDomain {
|
||||
id: UUID!
|
||||
domain: String!
|
||||
@@ -3249,6 +3183,7 @@ type Query {
|
||||
findApplicationRegistrationStats(id: String!): ApplicationRegistrationStats!
|
||||
findApplicationRegistrationVariables(applicationRegistrationId: String!): [ApplicationRegistrationVariable!]!
|
||||
applicationRegistrationTarballUrl(id: String!): String
|
||||
getApplicationShareLink(id: String!): String!
|
||||
currentUser: User!
|
||||
currentWorkspace: Workspace!
|
||||
getPublicWorkspaceDataByDomain(origin: String): PublicWorkspaceData!
|
||||
@@ -3274,7 +3209,7 @@ type Query {
|
||||
findManyPublicDomains: [PublicDomain!]!
|
||||
getEmailingDomains: [EmailingDomain!]!
|
||||
findManyMarketplaceApps: [MarketplaceApp!]!
|
||||
findOneMarketplaceApp(universalIdentifier: String!): MarketplaceApp!
|
||||
findMarketplaceAppDetail(universalIdentifier: String!): MarketplaceAppDetail!
|
||||
findManyApplications: [Application!]!
|
||||
findOneApplication(id: UUID, universalIdentifier: UUID): Application!
|
||||
}
|
||||
@@ -3596,6 +3531,7 @@ type Mutation {
|
||||
verifyEmailingDomain(id: String!): EmailingDomain!
|
||||
createOneAppToken(input: CreateOneAppTokenInput!): AppToken!
|
||||
installMarketplaceApp(universalIdentifier: String!, version: String): Boolean!
|
||||
syncMarketplaceCatalog: Boolean!
|
||||
installApplication(appRegistrationId: String!, version: String): Boolean!
|
||||
runWorkspaceMigration(workspaceMigration: WorkspaceMigrationInput!): Boolean!
|
||||
uninstallApplication(universalIdentifier: String!): Boolean!
|
||||
@@ -4441,14 +4377,9 @@ input GetAuthorizationUrlForSSOInput {
|
||||
|
||||
input CreateApplicationRegistrationInput {
|
||||
name: String!
|
||||
description: String
|
||||
logoUrl: String
|
||||
author: String
|
||||
universalIdentifier: String
|
||||
oAuthRedirectUris: [String!]
|
||||
oAuthScopes: [String!]
|
||||
websiteUrl: String
|
||||
termsUrl: String
|
||||
}
|
||||
|
||||
input UpdateApplicationRegistrationInput {
|
||||
@@ -4458,13 +4389,8 @@ input UpdateApplicationRegistrationInput {
|
||||
|
||||
input UpdateApplicationRegistrationPayload {
|
||||
name: String
|
||||
description: String
|
||||
logoUrl: String
|
||||
author: String
|
||||
oAuthRedirectUris: [String!]
|
||||
oAuthScopes: [String!]
|
||||
websiteUrl: String
|
||||
termsUrl: String
|
||||
isListed: Boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -42,9 +42,6 @@ export interface ApplicationRegistration {
|
||||
id: Scalars['UUID']
|
||||
universalIdentifier: Scalars['String']
|
||||
name: Scalars['String']
|
||||
description?: Scalars['String']
|
||||
logoUrl?: Scalars['String']
|
||||
author?: Scalars['String']
|
||||
oAuthClientId: Scalars['String']
|
||||
oAuthRedirectUris: Scalars['String'][]
|
||||
oAuthScopes: Scalars['String'][]
|
||||
@@ -52,8 +49,6 @@ export interface ApplicationRegistration {
|
||||
sourceType: ApplicationRegistrationSourceType
|
||||
sourcePackage?: Scalars['String']
|
||||
latestAvailableVersion?: Scalars['String']
|
||||
websiteUrl?: Scalars['String']
|
||||
termsUrl?: Scalars['String']
|
||||
isListed: Scalars['Boolean']
|
||||
isFeatured: Scalars['Boolean']
|
||||
createdAt: Scalars['DateTime']
|
||||
@@ -2120,99 +2115,32 @@ export interface File {
|
||||
__typename: 'File'
|
||||
}
|
||||
|
||||
export interface MarketplaceAppField {
|
||||
name: Scalars['String']
|
||||
type: Scalars['String']
|
||||
label: Scalars['String']
|
||||
description?: Scalars['String']
|
||||
icon?: Scalars['String']
|
||||
objectUniversalIdentifier?: Scalars['String']
|
||||
universalIdentifier?: Scalars['String']
|
||||
__typename: 'MarketplaceAppField'
|
||||
}
|
||||
|
||||
export interface MarketplaceAppObject {
|
||||
universalIdentifier: Scalars['String']
|
||||
nameSingular: Scalars['String']
|
||||
namePlural: Scalars['String']
|
||||
labelSingular: Scalars['String']
|
||||
labelPlural: Scalars['String']
|
||||
description?: Scalars['String']
|
||||
icon?: Scalars['String']
|
||||
fields: MarketplaceAppField[]
|
||||
__typename: 'MarketplaceAppObject'
|
||||
}
|
||||
|
||||
export interface MarketplaceAppLogicFunction {
|
||||
name: Scalars['String']
|
||||
description?: Scalars['String']
|
||||
timeoutSeconds?: Scalars['Int']
|
||||
__typename: 'MarketplaceAppLogicFunction'
|
||||
}
|
||||
|
||||
export interface MarketplaceAppFrontComponent {
|
||||
name: Scalars['String']
|
||||
description?: Scalars['String']
|
||||
__typename: 'MarketplaceAppFrontComponent'
|
||||
}
|
||||
|
||||
export interface MarketplaceAppRoleObjectPermission {
|
||||
objectUniversalIdentifier: Scalars['String']
|
||||
canReadObjectRecords?: Scalars['Boolean']
|
||||
canUpdateObjectRecords?: Scalars['Boolean']
|
||||
canSoftDeleteObjectRecords?: Scalars['Boolean']
|
||||
canDestroyObjectRecords?: Scalars['Boolean']
|
||||
__typename: 'MarketplaceAppRoleObjectPermission'
|
||||
}
|
||||
|
||||
export interface MarketplaceAppRoleFieldPermission {
|
||||
objectUniversalIdentifier: Scalars['String']
|
||||
fieldUniversalIdentifier: Scalars['String']
|
||||
canReadFieldValue?: Scalars['Boolean']
|
||||
canUpdateFieldValue?: Scalars['Boolean']
|
||||
__typename: 'MarketplaceAppRoleFieldPermission'
|
||||
}
|
||||
|
||||
export interface MarketplaceAppDefaultRole {
|
||||
id: Scalars['String']
|
||||
label: Scalars['String']
|
||||
description?: Scalars['String']
|
||||
canReadAllObjectRecords: Scalars['Boolean']
|
||||
canUpdateAllObjectRecords: Scalars['Boolean']
|
||||
canSoftDeleteAllObjectRecords: Scalars['Boolean']
|
||||
canDestroyAllObjectRecords: Scalars['Boolean']
|
||||
canUpdateAllSettings: Scalars['Boolean']
|
||||
canAccessAllTools: Scalars['Boolean']
|
||||
objectPermissions: MarketplaceAppRoleObjectPermission[]
|
||||
fieldPermissions: MarketplaceAppRoleFieldPermission[]
|
||||
permissionFlags: Scalars['String'][]
|
||||
__typename: 'MarketplaceAppDefaultRole'
|
||||
}
|
||||
|
||||
export interface MarketplaceApp {
|
||||
id: Scalars['String']
|
||||
name: Scalars['String']
|
||||
description: Scalars['String']
|
||||
icon: Scalars['String']
|
||||
version: Scalars['String']
|
||||
author: Scalars['String']
|
||||
category: Scalars['String']
|
||||
logo?: Scalars['String']
|
||||
screenshots: Scalars['String'][]
|
||||
aboutDescription: Scalars['String']
|
||||
providers: Scalars['String'][]
|
||||
websiteUrl?: Scalars['String']
|
||||
termsUrl?: Scalars['String']
|
||||
objects: MarketplaceAppObject[]
|
||||
fields: MarketplaceAppField[]
|
||||
logicFunctions: MarketplaceAppLogicFunction[]
|
||||
frontComponents: MarketplaceAppFrontComponent[]
|
||||
defaultRole?: MarketplaceAppDefaultRole
|
||||
sourcePackage?: Scalars['String']
|
||||
isFeatured: Scalars['Boolean']
|
||||
__typename: 'MarketplaceApp'
|
||||
}
|
||||
|
||||
export interface MarketplaceAppDetail {
|
||||
universalIdentifier: Scalars['String']
|
||||
id: Scalars['String']
|
||||
name: Scalars['String']
|
||||
sourceType: ApplicationRegistrationSourceType
|
||||
sourcePackage?: Scalars['String']
|
||||
latestAvailableVersion?: Scalars['String']
|
||||
isListed: Scalars['Boolean']
|
||||
isFeatured: Scalars['Boolean']
|
||||
manifest?: Scalars['JSON']
|
||||
__typename: 'MarketplaceAppDetail'
|
||||
}
|
||||
|
||||
export interface PublicDomain {
|
||||
id: Scalars['UUID']
|
||||
domain: Scalars['String']
|
||||
@@ -2805,6 +2733,7 @@ export interface Query {
|
||||
findApplicationRegistrationStats: ApplicationRegistrationStats
|
||||
findApplicationRegistrationVariables: ApplicationRegistrationVariable[]
|
||||
applicationRegistrationTarballUrl?: Scalars['String']
|
||||
getApplicationShareLink: Scalars['String']
|
||||
currentUser: User
|
||||
currentWorkspace: Workspace
|
||||
getPublicWorkspaceDataByDomain: PublicWorkspaceData
|
||||
@@ -2830,7 +2759,7 @@ export interface Query {
|
||||
findManyPublicDomains: PublicDomain[]
|
||||
getEmailingDomains: EmailingDomain[]
|
||||
findManyMarketplaceApps: MarketplaceApp[]
|
||||
findOneMarketplaceApp: MarketplaceApp
|
||||
findMarketplaceAppDetail: MarketplaceAppDetail
|
||||
findManyApplications: Application[]
|
||||
findOneApplication: Application
|
||||
__typename: 'Query'
|
||||
@@ -3047,6 +2976,7 @@ export interface Mutation {
|
||||
verifyEmailingDomain: EmailingDomain
|
||||
createOneAppToken: AppToken
|
||||
installMarketplaceApp: Scalars['Boolean']
|
||||
syncMarketplaceCatalog: Scalars['Boolean']
|
||||
installApplication: Scalars['Boolean']
|
||||
runWorkspaceMigration: Scalars['Boolean']
|
||||
uninstallApplication: Scalars['Boolean']
|
||||
@@ -3114,9 +3044,6 @@ export interface ApplicationRegistrationGenqlSelection{
|
||||
id?: boolean | number
|
||||
universalIdentifier?: boolean | number
|
||||
name?: boolean | number
|
||||
description?: boolean | number
|
||||
logoUrl?: boolean | number
|
||||
author?: boolean | number
|
||||
oAuthClientId?: boolean | number
|
||||
oAuthRedirectUris?: boolean | number
|
||||
oAuthScopes?: boolean | number
|
||||
@@ -3124,8 +3051,6 @@ export interface ApplicationRegistrationGenqlSelection{
|
||||
sourceType?: boolean | number
|
||||
sourcePackage?: boolean | number
|
||||
latestAvailableVersion?: boolean | number
|
||||
websiteUrl?: boolean | number
|
||||
termsUrl?: boolean | number
|
||||
isListed?: boolean | number
|
||||
isFeatured?: boolean | number
|
||||
createdAt?: boolean | number
|
||||
@@ -5314,107 +5239,34 @@ export interface FileGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface MarketplaceAppFieldGenqlSelection{
|
||||
name?: boolean | number
|
||||
type?: boolean | number
|
||||
label?: boolean | number
|
||||
description?: boolean | number
|
||||
icon?: boolean | number
|
||||
objectUniversalIdentifier?: boolean | number
|
||||
universalIdentifier?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface MarketplaceAppObjectGenqlSelection{
|
||||
universalIdentifier?: boolean | number
|
||||
nameSingular?: boolean | number
|
||||
namePlural?: boolean | number
|
||||
labelSingular?: boolean | number
|
||||
labelPlural?: boolean | number
|
||||
description?: boolean | number
|
||||
icon?: boolean | number
|
||||
fields?: MarketplaceAppFieldGenqlSelection
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface MarketplaceAppLogicFunctionGenqlSelection{
|
||||
name?: boolean | number
|
||||
description?: boolean | number
|
||||
timeoutSeconds?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface MarketplaceAppFrontComponentGenqlSelection{
|
||||
name?: boolean | number
|
||||
description?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface MarketplaceAppRoleObjectPermissionGenqlSelection{
|
||||
objectUniversalIdentifier?: boolean | number
|
||||
canReadObjectRecords?: boolean | number
|
||||
canUpdateObjectRecords?: boolean | number
|
||||
canSoftDeleteObjectRecords?: boolean | number
|
||||
canDestroyObjectRecords?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface MarketplaceAppRoleFieldPermissionGenqlSelection{
|
||||
objectUniversalIdentifier?: boolean | number
|
||||
fieldUniversalIdentifier?: boolean | number
|
||||
canReadFieldValue?: boolean | number
|
||||
canUpdateFieldValue?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface MarketplaceAppDefaultRoleGenqlSelection{
|
||||
id?: boolean | number
|
||||
label?: boolean | number
|
||||
description?: boolean | number
|
||||
canReadAllObjectRecords?: boolean | number
|
||||
canUpdateAllObjectRecords?: boolean | number
|
||||
canSoftDeleteAllObjectRecords?: boolean | number
|
||||
canDestroyAllObjectRecords?: boolean | number
|
||||
canUpdateAllSettings?: boolean | number
|
||||
canAccessAllTools?: boolean | number
|
||||
objectPermissions?: MarketplaceAppRoleObjectPermissionGenqlSelection
|
||||
fieldPermissions?: MarketplaceAppRoleFieldPermissionGenqlSelection
|
||||
permissionFlags?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface MarketplaceAppGenqlSelection{
|
||||
id?: boolean | number
|
||||
name?: boolean | number
|
||||
description?: boolean | number
|
||||
icon?: boolean | number
|
||||
version?: boolean | number
|
||||
author?: boolean | number
|
||||
category?: boolean | number
|
||||
logo?: boolean | number
|
||||
screenshots?: boolean | number
|
||||
aboutDescription?: boolean | number
|
||||
providers?: boolean | number
|
||||
websiteUrl?: boolean | number
|
||||
termsUrl?: boolean | number
|
||||
objects?: MarketplaceAppObjectGenqlSelection
|
||||
fields?: MarketplaceAppFieldGenqlSelection
|
||||
logicFunctions?: MarketplaceAppLogicFunctionGenqlSelection
|
||||
frontComponents?: MarketplaceAppFrontComponentGenqlSelection
|
||||
defaultRole?: MarketplaceAppDefaultRoleGenqlSelection
|
||||
sourcePackage?: boolean | number
|
||||
isFeatured?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface MarketplaceAppDetailGenqlSelection{
|
||||
universalIdentifier?: boolean | number
|
||||
id?: boolean | number
|
||||
name?: boolean | number
|
||||
sourceType?: boolean | number
|
||||
sourcePackage?: boolean | number
|
||||
latestAvailableVersion?: boolean | number
|
||||
isListed?: boolean | number
|
||||
isFeatured?: boolean | number
|
||||
manifest?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface PublicDomainGenqlSelection{
|
||||
id?: boolean | number
|
||||
domain?: boolean | number
|
||||
@@ -6042,6 +5894,7 @@ export interface QueryGenqlSelection{
|
||||
findApplicationRegistrationStats?: (ApplicationRegistrationStatsGenqlSelection & { __args: {id: Scalars['String']} })
|
||||
findApplicationRegistrationVariables?: (ApplicationRegistrationVariableGenqlSelection & { __args: {applicationRegistrationId: Scalars['String']} })
|
||||
applicationRegistrationTarballUrl?: { __args: {id: Scalars['String']} }
|
||||
getApplicationShareLink?: { __args: {id: Scalars['String']} }
|
||||
currentUser?: UserGenqlSelection
|
||||
currentWorkspace?: WorkspaceGenqlSelection
|
||||
getPublicWorkspaceDataByDomain?: (PublicWorkspaceDataGenqlSelection & { __args?: {origin?: (Scalars['String'] | null)} })
|
||||
@@ -6067,7 +5920,7 @@ export interface QueryGenqlSelection{
|
||||
findManyPublicDomains?: PublicDomainGenqlSelection
|
||||
getEmailingDomains?: EmailingDomainGenqlSelection
|
||||
findManyMarketplaceApps?: MarketplaceAppGenqlSelection
|
||||
findOneMarketplaceApp?: (MarketplaceAppGenqlSelection & { __args: {universalIdentifier: Scalars['String']} })
|
||||
findMarketplaceAppDetail?: (MarketplaceAppDetailGenqlSelection & { __args: {universalIdentifier: Scalars['String']} })
|
||||
findManyApplications?: ApplicationGenqlSelection
|
||||
findOneApplication?: (ApplicationGenqlSelection & { __args?: {id?: (Scalars['UUID'] | null), universalIdentifier?: (Scalars['UUID'] | null)} })
|
||||
__typename?: boolean | number
|
||||
@@ -6303,6 +6156,7 @@ export interface MutationGenqlSelection{
|
||||
verifyEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {id: Scalars['String']} })
|
||||
createOneAppToken?: (AppTokenGenqlSelection & { __args: {input: CreateOneAppTokenInput} })
|
||||
installMarketplaceApp?: { __args: {universalIdentifier: Scalars['String'], version?: (Scalars['String'] | null)} }
|
||||
syncMarketplaceCatalog?: boolean | number
|
||||
installApplication?: { __args: {appRegistrationId: Scalars['String'], version?: (Scalars['String'] | null)} }
|
||||
runWorkspaceMigration?: { __args: {workspaceMigration: WorkspaceMigrationInput} }
|
||||
uninstallApplication?: { __args: {universalIdentifier: Scalars['String']} }
|
||||
@@ -6597,11 +6451,11 @@ export interface UpdateSkillInput {id: Scalars['UUID'],name?: (Scalars['String']
|
||||
|
||||
export interface GetAuthorizationUrlForSSOInput {identityProviderId: Scalars['UUID'],workspaceInviteHash?: (Scalars['String'] | null)}
|
||||
|
||||
export interface CreateApplicationRegistrationInput {name: Scalars['String'],description?: (Scalars['String'] | null),logoUrl?: (Scalars['String'] | null),author?: (Scalars['String'] | null),universalIdentifier?: (Scalars['String'] | null),oAuthRedirectUris?: (Scalars['String'][] | null),oAuthScopes?: (Scalars['String'][] | null),websiteUrl?: (Scalars['String'] | null),termsUrl?: (Scalars['String'] | null)}
|
||||
export interface CreateApplicationRegistrationInput {name: Scalars['String'],universalIdentifier?: (Scalars['String'] | null),oAuthRedirectUris?: (Scalars['String'][] | null),oAuthScopes?: (Scalars['String'][] | null)}
|
||||
|
||||
export interface UpdateApplicationRegistrationInput {id: Scalars['String'],update: UpdateApplicationRegistrationPayload}
|
||||
|
||||
export interface UpdateApplicationRegistrationPayload {name?: (Scalars['String'] | null),description?: (Scalars['String'] | null),logoUrl?: (Scalars['String'] | null),author?: (Scalars['String'] | null),oAuthRedirectUris?: (Scalars['String'][] | null),oAuthScopes?: (Scalars['String'][] | null),websiteUrl?: (Scalars['String'] | null),termsUrl?: (Scalars['String'] | null),isListed?: (Scalars['Boolean'] | null)}
|
||||
export interface UpdateApplicationRegistrationPayload {name?: (Scalars['String'] | null),oAuthRedirectUris?: (Scalars['String'][] | null),oAuthScopes?: (Scalars['String'][] | null),isListed?: (Scalars['Boolean'] | null)}
|
||||
|
||||
export interface CreateApplicationRegistrationVariableInput {applicationRegistrationId: Scalars['String'],key: Scalars['String'],value: Scalars['String'],description?: (Scalars['String'] | null),isSecret?: (Scalars['Boolean'] | null)}
|
||||
|
||||
@@ -8295,62 +8149,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const MarketplaceAppField_possibleTypes: string[] = ['MarketplaceAppField']
|
||||
export const isMarketplaceAppField = (obj?: { __typename?: any } | null): obj is MarketplaceAppField => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isMarketplaceAppField"')
|
||||
return MarketplaceAppField_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const MarketplaceAppObject_possibleTypes: string[] = ['MarketplaceAppObject']
|
||||
export const isMarketplaceAppObject = (obj?: { __typename?: any } | null): obj is MarketplaceAppObject => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isMarketplaceAppObject"')
|
||||
return MarketplaceAppObject_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const MarketplaceAppLogicFunction_possibleTypes: string[] = ['MarketplaceAppLogicFunction']
|
||||
export const isMarketplaceAppLogicFunction = (obj?: { __typename?: any } | null): obj is MarketplaceAppLogicFunction => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isMarketplaceAppLogicFunction"')
|
||||
return MarketplaceAppLogicFunction_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const MarketplaceAppFrontComponent_possibleTypes: string[] = ['MarketplaceAppFrontComponent']
|
||||
export const isMarketplaceAppFrontComponent = (obj?: { __typename?: any } | null): obj is MarketplaceAppFrontComponent => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isMarketplaceAppFrontComponent"')
|
||||
return MarketplaceAppFrontComponent_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const MarketplaceAppRoleObjectPermission_possibleTypes: string[] = ['MarketplaceAppRoleObjectPermission']
|
||||
export const isMarketplaceAppRoleObjectPermission = (obj?: { __typename?: any } | null): obj is MarketplaceAppRoleObjectPermission => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isMarketplaceAppRoleObjectPermission"')
|
||||
return MarketplaceAppRoleObjectPermission_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const MarketplaceAppRoleFieldPermission_possibleTypes: string[] = ['MarketplaceAppRoleFieldPermission']
|
||||
export const isMarketplaceAppRoleFieldPermission = (obj?: { __typename?: any } | null): obj is MarketplaceAppRoleFieldPermission => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isMarketplaceAppRoleFieldPermission"')
|
||||
return MarketplaceAppRoleFieldPermission_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const MarketplaceAppDefaultRole_possibleTypes: string[] = ['MarketplaceAppDefaultRole']
|
||||
export const isMarketplaceAppDefaultRole = (obj?: { __typename?: any } | null): obj is MarketplaceAppDefaultRole => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isMarketplaceAppDefaultRole"')
|
||||
return MarketplaceAppDefaultRole_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const MarketplaceApp_possibleTypes: string[] = ['MarketplaceApp']
|
||||
export const isMarketplaceApp = (obj?: { __typename?: any } | null): obj is MarketplaceApp => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isMarketplaceApp"')
|
||||
@@ -8359,6 +8157,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const MarketplaceAppDetail_possibleTypes: string[] = ['MarketplaceAppDetail']
|
||||
export const isMarketplaceAppDetail = (obj?: { __typename?: any } | null): obj is MarketplaceAppDetail => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isMarketplaceAppDetail"')
|
||||
return MarketplaceAppDetail_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const PublicDomain_possibleTypes: string[] = ['PublicDomain']
|
||||
export const isPublicDomain = (obj?: { __typename?: any } | null): obj is PublicDomain => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isPublicDomain"')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,15 +19,17 @@ The SDK provides helper functions for defining your app entities. As described i
|
||||
|----------|---------|
|
||||
| `defineApplication` | Configure application metadata (required, one per app) |
|
||||
| `defineObject` | Define custom objects with fields |
|
||||
| `defineField` | Extend existing objects with additional fields or define standalone relation fields |
|
||||
| `defineLogicFunction` | Define logic functions with handlers |
|
||||
| `definePreInstallLogicFunction` | Define a pre-install logic function (one per app) |
|
||||
| `definePostInstallLogicFunction` | Define a post-install logic function (one per app) |
|
||||
| `defineFrontComponent` | Define front components for custom UI |
|
||||
| `defineRole` | Configure role permissions and object access |
|
||||
| `defineField` | Extend existing objects with additional fields |
|
||||
| `defineView` | Define saved views for objects |
|
||||
| `defineNavigationMenuItem` | Define sidebar navigation links |
|
||||
| `defineSkill` | Define AI agent skills |
|
||||
| `defineAgent` | Define AI agents |
|
||||
| `definePageLayout` | Define custom page layouts |
|
||||
|
||||
These functions validate your configuration at build time and provide IDE autocompletion and type safety.
|
||||
|
||||
@@ -36,7 +38,7 @@ These functions validate your configuration at build time and provide IDE autoco
|
||||
Custom objects describe both schema and behavior for records in your workspace. Use `defineObject()` to define objects with built-in validation:
|
||||
|
||||
```typescript
|
||||
// src/app/postCard.object.ts
|
||||
// src/objects/postCard.object.ts
|
||||
import { defineObject, FieldType } from 'twenty-sdk';
|
||||
|
||||
enum PostCardStatus {
|
||||
@@ -110,7 +112,7 @@ Key points:
|
||||
- The `universalIdentifier` must be unique and stable across deployments.
|
||||
- Each field requires a `name`, `type`, `label`, and its own stable `universalIdentifier`.
|
||||
- The `fields` array is optional — you can define objects without custom fields.
|
||||
- You can scaffold new objects using `yarn twenty entity:add`, which guides you through naming, fields, and relationships.
|
||||
- You can scaffold new objects using `yarn twenty add`, which guides you through naming, fields, and relationships.
|
||||
|
||||
<Note>
|
||||
**Base fields are created automatically.** When you define a custom object, Twenty automatically adds standard fields
|
||||
@@ -120,6 +122,195 @@ Key points:
|
||||
but this is not recommended.
|
||||
</Note>
|
||||
|
||||
### Defining fields on existing objects
|
||||
|
||||
Use `defineField()` to add fields to objects you don't own — such as standard Twenty objects (Person, Company, etc.) or objects from other apps. Unlike inline fields in `defineObject()`, standalone fields require an `objectUniversalIdentifier` to specify which object they extend:
|
||||
|
||||
```typescript
|
||||
// src/fields/company-loyalty-tier.field.ts
|
||||
import { defineField, FieldType } from 'twenty-sdk';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: 'f2a1b3c4-d5e6-7890-abcd-ef1234567890',
|
||||
objectUniversalIdentifier: '701aecb9-eb1c-4d84-9d94-b954b231b64b', // Company object
|
||||
name: 'loyaltyTier',
|
||||
type: FieldType.SELECT,
|
||||
label: 'Loyalty Tier',
|
||||
icon: 'IconStar',
|
||||
options: [
|
||||
{ value: 'BRONZE', label: 'Bronze', position: 0, color: 'orange' },
|
||||
{ value: 'SILVER', label: 'Silver', position: 1, color: 'gray' },
|
||||
{ value: 'GOLD', label: 'Gold', position: 2, color: 'yellow' },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Key points:
|
||||
- `objectUniversalIdentifier` identifies the target object. For standard objects, use `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS` exported from `twenty-sdk`.
|
||||
- When defining fields inline in `defineObject()`, you do **not** need `objectUniversalIdentifier` — it's inherited from the parent object.
|
||||
- `defineField()` is the only way to add fields to objects you didn't create with `defineObject()`.
|
||||
|
||||
### Relations
|
||||
|
||||
Relations connect objects together. In Twenty, relations are always **bidirectional** — you define both sides, and each side references the other.
|
||||
|
||||
There are two relation types:
|
||||
|
||||
| Relation type | Description | Has foreign key? |
|
||||
|---------------|-------------|-----------------|
|
||||
| `MANY_TO_ONE` | Many records of this object point to one record of the target | Yes (`joinColumnName`) |
|
||||
| `ONE_TO_MANY` | One record of this object has many records of the target | No (inverse side) |
|
||||
|
||||
#### How relations work
|
||||
|
||||
Every relation requires **two fields** that reference each other:
|
||||
|
||||
1. The **MANY_TO_ONE** side — lives on the object that holds the foreign key
|
||||
2. The **ONE_TO_MANY** side — lives on the object that owns the collection
|
||||
|
||||
Both fields use `FieldType.RELATION` and cross-reference each other via `relationTargetFieldMetadataUniversalIdentifier`.
|
||||
|
||||
#### Example: Post Card has many Recipients
|
||||
|
||||
Suppose a `PostCard` can be sent to many `PostCardRecipient` records. Each recipient belongs to exactly one post card.
|
||||
|
||||
**Step 1: Define the ONE_TO_MANY side on PostCard** (the "one" side):
|
||||
|
||||
```typescript
|
||||
// src/fields/post-card-recipients-on-post-card.field.ts
|
||||
import { defineField, FieldType, RelationType } from 'twenty-sdk';
|
||||
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
|
||||
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';
|
||||
|
||||
// Export so the other side can reference it
|
||||
export const POST_CARD_RECIPIENTS_FIELD_ID = 'a1111111-1111-1111-1111-111111111111';
|
||||
// Import from the other side
|
||||
import { POST_CARD_FIELD_ID } from './post-card-on-post-card-recipient.field';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
|
||||
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'postCardRecipients',
|
||||
label: 'Post Card Recipients',
|
||||
icon: 'IconUsers',
|
||||
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Define the MANY_TO_ONE side on PostCardRecipient** (the "many" side — holds the foreign key):
|
||||
|
||||
```typescript
|
||||
// src/fields/post-card-on-post-card-recipient.field.ts
|
||||
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk';
|
||||
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
|
||||
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';
|
||||
|
||||
// Export so the other side can reference it
|
||||
export const POST_CARD_FIELD_ID = 'b2222222-2222-2222-2222-222222222222';
|
||||
// Import from the other side
|
||||
import { POST_CARD_RECIPIENTS_FIELD_ID } from './post-card-recipients-on-post-card.field';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: POST_CARD_FIELD_ID,
|
||||
objectUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'postCard',
|
||||
label: 'Post Card',
|
||||
icon: 'IconMail',
|
||||
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.CASCADE,
|
||||
joinColumnName: 'postCardId',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
**Circular imports:** Both relation fields reference each other's `universalIdentifier`. To avoid circular import issues, export your field IDs as named constants from each file, and import them in the other file. The build system resolves these at compile time.
|
||||
</Note>
|
||||
|
||||
#### Relating to standard objects
|
||||
|
||||
To create a relation with a built-in Twenty object (Person, Company, etc.), use `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS`:
|
||||
|
||||
```typescript
|
||||
// src/fields/person-on-self-hosting-user.field.ts
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
RelationType,
|
||||
OnDeleteAction,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk';
|
||||
import { SELF_HOSTING_USER_UNIVERSAL_IDENTIFIER } from '../objects/self-hosting-user.object';
|
||||
|
||||
export const PERSON_FIELD_ID = 'c3333333-3333-3333-3333-333333333333';
|
||||
export const SELF_HOSTING_USER_REVERSE_FIELD_ID = 'd4444444-4444-4444-4444-444444444444';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PERSON_FIELD_ID,
|
||||
objectUniversalIdentifier: SELF_HOSTING_USER_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'person',
|
||||
label: 'Person',
|
||||
description: 'Person matching with the self hosting user',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier: SELF_HOSTING_USER_REVERSE_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'personId',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### Relation field properties
|
||||
|
||||
| Property | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `type` | Yes | Must be `FieldType.RELATION` |
|
||||
| `relationTargetObjectMetadataUniversalIdentifier` | Yes | The `universalIdentifier` of the target object |
|
||||
| `relationTargetFieldMetadataUniversalIdentifier` | Yes | The `universalIdentifier` of the matching field on the target object |
|
||||
| `universalSettings.relationType` | Yes | `RelationType.MANY_TO_ONE` or `RelationType.ONE_TO_MANY` |
|
||||
| `universalSettings.onDelete` | MANY_TO_ONE only | What happens when the referenced record is deleted: `CASCADE`, `SET_NULL`, `RESTRICT`, or `NO_ACTION` |
|
||||
| `universalSettings.joinColumnName` | MANY_TO_ONE only | Database column name for the foreign key (e.g., `postCardId`) |
|
||||
|
||||
#### Inline relation fields in defineObject
|
||||
|
||||
You can also define relation fields directly inside `defineObject()`. In that case, omit `objectUniversalIdentifier` — it's inherited from the parent object:
|
||||
|
||||
```typescript
|
||||
export default defineObject({
|
||||
universalIdentifier: '...',
|
||||
nameSingular: 'postCardRecipient',
|
||||
// ...
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: POST_CARD_FIELD_ID,
|
||||
type: FieldType.RELATION,
|
||||
name: 'postCard',
|
||||
label: 'Post Card',
|
||||
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.CASCADE,
|
||||
joinColumnName: 'postCardId',
|
||||
},
|
||||
},
|
||||
// ... other fields
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Application config (application-config.ts)
|
||||
|
||||
@@ -161,13 +352,29 @@ Notes:
|
||||
- `defaultRoleUniversalIdentifier` must match the role file (see below).
|
||||
- Pre-install and post-install functions are automatically detected during the manifest build. See [Pre-install functions](#pre-install-functions) and [Post-install functions](#post-install-functions).
|
||||
|
||||
#### Marketplace metadata
|
||||
|
||||
If you plan to [publish your app](/developers/extend/apps/publishing), these optional fields control how your app appears in the marketplace:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `author` | Author or company name |
|
||||
| `category` | App category for marketplace filtering |
|
||||
| `logoUrl` | Path to your app logo (relative to `./assets/`) |
|
||||
| `screenshots` | Array of screenshot paths (relative to `./assets/`) |
|
||||
| `aboutDescription` | Longer markdown description for the "About" tab |
|
||||
| `websiteUrl` | Link to your website |
|
||||
| `termsUrl` | Link to terms of service |
|
||||
| `emailSupport` | Support email address |
|
||||
| `issueReportUrl` | Link to issue tracker |
|
||||
|
||||
#### Roles and permissions
|
||||
|
||||
Applications can define roles that encapsulate permissions on your workspace's objects and actions. The field `defaultRoleUniversalIdentifier` in `application-config.ts` designates the default role used by your app's logic functions.
|
||||
|
||||
- The runtime API key injected as `TWENTY_API_KEY` is derived from this default function role.
|
||||
- The typed client will be restricted to the permissions granted to that role.
|
||||
- Follow least‑privilege: create a dedicated role with only the permissions your functions need, then reference its universal identifier.
|
||||
- Follow least-privilege: create a dedicated role with only the permissions your functions need, then reference its universal identifier.
|
||||
|
||||
##### Default function role (*.role.ts)
|
||||
|
||||
@@ -219,7 +426,7 @@ The `universalIdentifier` of this role is then referenced in `application-config
|
||||
- **application-config.ts** points to that role so your functions inherit its permissions.
|
||||
|
||||
Notes:
|
||||
- Start from the scaffolded role, then progressively restrict it following least‑privilege.
|
||||
- Start from the scaffolded role, then progressively restrict it following least-privilege.
|
||||
- Replace the `objectPermissions` and `fieldPermissions` with the objects/fields your functions need.
|
||||
- `permissionFlags` control access to platform-level capabilities. Keep them minimal; add only what you need.
|
||||
- See a working example in the Hello World app: [`packages/twenty-apps/hello-world/src/roles/function-role.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/hello-world/src/roles/function-role.ts).
|
||||
@@ -229,7 +436,7 @@ Notes:
|
||||
Each function file uses `defineLogicFunction()` to export a configuration with a handler and optional triggers.
|
||||
|
||||
```typescript
|
||||
// src/app/createPostCard.logic-function.ts
|
||||
// src/logic-functions/createPostCard.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk';
|
||||
import { CoreApiClient, type Person } from 'twenty-sdk/generated';
|
||||
@@ -318,7 +525,7 @@ export default definePreInstallLogicFunction({
|
||||
You can also manually execute the pre-install function at any time using the CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty function:execute --preInstall
|
||||
yarn twenty exec --preInstall
|
||||
```
|
||||
|
||||
Key points:
|
||||
@@ -327,7 +534,7 @@ Key points:
|
||||
- Only one pre-install function is allowed per application. The manifest build will error if more than one is detected.
|
||||
- The function's `universalIdentifier` is automatically set as `preInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`.
|
||||
- The default timeout is set to 300 seconds (5 minutes) to allow for longer preparation tasks.
|
||||
- Pre-install functions do not need triggers — they are invoked by the platform before installation or manually via `function:execute --preInstall`.
|
||||
- Pre-install functions do not need triggers — they are invoked by the platform before installation or manually via `exec --preInstall`.
|
||||
|
||||
### Post-install functions
|
||||
|
||||
@@ -355,7 +562,7 @@ export default definePostInstallLogicFunction({
|
||||
You can also manually execute the post-install function at any time using the CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty function:execute --postInstall
|
||||
yarn twenty exec --postInstall
|
||||
```
|
||||
|
||||
Key points:
|
||||
@@ -364,7 +571,7 @@ Key points:
|
||||
- Only one post-install function is allowed per application. The manifest build will error if more than one is detected.
|
||||
- The function's `universalIdentifier` is automatically set as `postInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`.
|
||||
- The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
|
||||
- Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
|
||||
- Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `exec --postInstall`.
|
||||
|
||||
### Route trigger payload
|
||||
|
||||
@@ -412,7 +619,7 @@ The `RoutePayload` type has the following structure:
|
||||
|----------|------|-------------|
|
||||
| `headers` | `Record<string, string \| undefined>` | HTTP headers (only those listed in `forwardedRequestHeaders`) |
|
||||
| `queryStringParameters` | `Record<string, string \| undefined>` | Query string parameters (multiple values joined with commas) |
|
||||
| `pathParameters` | `Record<string, string \| undefined>` | Path parameters extracted from the route pattern (e.g., `/users/:id` → `{ id: '123' }`) |
|
||||
| `pathParameters` | `Record<string, string \| undefined>` | Path parameters extracted from the route pattern (e.g., `/users/:id` -> `{ id: '123' }`) |
|
||||
| `body` | `object \| null` | Parsed request body (JSON) |
|
||||
| `isBase64Encoded` | `boolean` | Whether the body is base64 encoded |
|
||||
| `requestContext.http.method` | `string` | HTTP method (GET, POST, PUT, PATCH, DELETE) |
|
||||
@@ -458,7 +665,7 @@ const handler = async (event: RoutePayload) => {
|
||||
|
||||
You can create new functions in two ways:
|
||||
|
||||
- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new logic function. This generates a starter file with a handler and config.
|
||||
- **Scaffolded**: Run `yarn twenty add` and choose the option to add a new logic function. This generates a starter file with a handler and config.
|
||||
- **Manual**: Create a new `*.logic-function.ts` file and use `defineLogicFunction()`, following the same pattern.
|
||||
|
||||
### Marking a logic function as a tool
|
||||
@@ -470,7 +677,7 @@ To mark a logic function as a tool, set `isTool: true` and provide a `toolInputS
|
||||
```typescript
|
||||
// src/logic-functions/enrich-company.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import { CoreApiClient } from 'twenty-sdk/generated';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
const handler = async (params: { companyName: string; domain?: string }) => {
|
||||
const client = new CoreApiClient();
|
||||
@@ -558,7 +765,7 @@ Key points:
|
||||
|
||||
You can create new front components in two ways:
|
||||
|
||||
- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new front component.
|
||||
- **Scaffolded**: Run `yarn twenty add` and choose the option to add a new front component.
|
||||
- **Manual**: Create a new `.tsx` file and use `defineFrontComponent()`, following the same pattern.
|
||||
|
||||
### Skills
|
||||
@@ -592,46 +799,122 @@ Key points:
|
||||
|
||||
You can create new skills in two ways:
|
||||
|
||||
- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new skill.
|
||||
- **Scaffolded**: Run `yarn twenty add` and choose the option to add a new skill.
|
||||
- **Manual**: Create a new file and use `defineSkill()`, following the same pattern.
|
||||
|
||||
### Generated typed clients
|
||||
### Typed API clients (`twenty-client-sdk`)
|
||||
|
||||
Two typed clients are auto-generated by `yarn twenty dev` and stored in `node_modules/twenty-sdk/generated` based on your workspace schema:
|
||||
The `twenty-client-sdk` package provides two typed GraphQL clients for interacting with the Twenty API from your logic functions and front components:
|
||||
|
||||
- **`CoreApiClient`** — queries the `/graphql` endpoint for workspace data
|
||||
- **`MetadataApiClient`** — queries the `/metadata` endpoint for workspace configuration and file uploads
|
||||
| Client | Import | Endpoint | Generated? |
|
||||
|--------|--------|----------|------------|
|
||||
| `CoreApiClient` | `twenty-client-sdk/core` | `/graphql` — workspace data (records, objects) | Yes, at dev/build time |
|
||||
| `MetadataApiClient` | `twenty-client-sdk/metadata` | `/metadata` — workspace config, file uploads | No, ships pre-built |
|
||||
|
||||
#### CoreApiClient
|
||||
|
||||
`CoreApiClient` is the main client for querying and mutating workspace data. It is **generated from your workspace schema** during `yarn twenty dev` or `yarn twenty build`, so it's fully typed to match your objects and fields.
|
||||
|
||||
```typescript
|
||||
import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
const client = new CoreApiClient();
|
||||
const { me } = await client.query({ me: { id: true, displayName: true } });
|
||||
|
||||
const metadataClient = new MetadataApiClient();
|
||||
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
|
||||
// Query records
|
||||
const { companies } = await client.query({
|
||||
companies: {
|
||||
edges: {
|
||||
node: {
|
||||
id: true,
|
||||
name: true,
|
||||
domainName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create a record
|
||||
const { createCompany } = await client.mutation({
|
||||
createCompany: {
|
||||
__args: {
|
||||
data: {
|
||||
name: 'Acme Corp',
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Both clients are re-generated automatically by `yarn twenty dev` whenever your objects or fields change.
|
||||
The client uses a selection-set syntax: pass `true` to include a field, use `__args` for arguments, and nest objects for relations. You get full autocompletion and type checking based on your workspace schema.
|
||||
|
||||
#### Runtime credentials in logic functions
|
||||
<Note>
|
||||
**CoreApiClient is generated at dev/build time.** If you try to use it without running `yarn twenty dev` or `yarn twenty build` first, it throws an error. The generation happens automatically — the CLI introspects your workspace's GraphQL schema, generates a typed client using `@genql/cli`, writes the generated sources to `node_modules/twenty-client-sdk/dist/core/generated/`, and replaces the stubs in `node_modules/twenty-client-sdk/dist/core.mjs` and `node_modules/twenty-client-sdk/dist/core.cjs`.
|
||||
</Note>
|
||||
|
||||
When your function runs on Twenty, the platform injects credentials as environment variables before your code executes:
|
||||
#### Using CoreSchema for type annotations
|
||||
|
||||
- `TWENTY_API_URL`: Base URL of the Twenty API your app targets.
|
||||
- `TWENTY_API_KEY`: Short‑lived key scoped to your application's default function role.
|
||||
`CoreSchema` provides TypeScript types matching your workspace objects, useful for typing component state or function parameters:
|
||||
|
||||
Notes:
|
||||
- You do not need to pass URL or API key to the generated client. It reads `TWENTY_API_URL` and `TWENTY_API_KEY` from process.env at runtime.
|
||||
- The API key's permissions are determined by the role referenced in your `application-config.ts` via `defaultRoleUniversalIdentifier`. This is the default role used by logic functions of your application.
|
||||
- Applications can define roles to follow least‑privilege. Grant only the permissions your functions need, then point `defaultRoleUniversalIdentifier` to that role's universal identifier.
|
||||
```typescript
|
||||
import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
|
||||
import { useState } from 'react';
|
||||
|
||||
const [company, setCompany] = useState<
|
||||
Pick<CoreSchema.Company, 'id' | 'name'> | undefined
|
||||
>(undefined);
|
||||
|
||||
const client = new CoreApiClient();
|
||||
const result = await client.query({
|
||||
company: {
|
||||
__args: { filter: { position: { eq: 1 } } },
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
setCompany(result.company);
|
||||
```
|
||||
|
||||
#### MetadataApiClient
|
||||
|
||||
`MetadataApiClient` ships pre-built with the SDK (no generation required). It queries the `/metadata` endpoint for workspace configuration, applications, and file uploads:
|
||||
|
||||
```typescript
|
||||
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||||
|
||||
const metadataClient = new MetadataApiClient();
|
||||
|
||||
// Query workspace info
|
||||
const { currentWorkspace } = await metadataClient.query({
|
||||
currentWorkspace: { id: true, displayName: true },
|
||||
});
|
||||
|
||||
// List installed applications
|
||||
const { findManyApplications } = await metadataClient.query({
|
||||
findManyApplications: {
|
||||
id: true,
|
||||
name: true,
|
||||
version: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### Runtime credentials
|
||||
|
||||
When your code runs on Twenty (logic functions or front components), the platform injects credentials as environment variables:
|
||||
|
||||
- `TWENTY_API_URL` — Base URL of the Twenty API
|
||||
- `TWENTY_API_KEY` — Short-lived key scoped to your application's default function role
|
||||
|
||||
You do **not** need to pass these to the clients — they read from `process.env` automatically. The API key's permissions are determined by the role referenced in `defaultRoleUniversalIdentifier` in your `application-config.ts`.
|
||||
|
||||
#### Uploading files
|
||||
|
||||
The generated `MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields on your workspace objects. Because standard GraphQL clients do not support multipart file uploads natively, the client provides this dedicated method that implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec) under the hood.
|
||||
`MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields. It implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec):
|
||||
|
||||
```typescript
|
||||
import { MetadataApiClient } from 'twenty-sdk/generated';
|
||||
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const metadataClient = new MetadataApiClient();
|
||||
@@ -641,25 +924,14 @@ const fileBuffer = fs.readFileSync('./invoice.pdf');
|
||||
const uploadedFile = await metadataClient.uploadFile(
|
||||
fileBuffer, // file contents as a Buffer
|
||||
'invoice.pdf', // filename
|
||||
'application/pdf', // MIME type (defaults to 'application/octet-stream')
|
||||
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universal identifier
|
||||
'application/pdf', // MIME type
|
||||
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universalIdentifier
|
||||
);
|
||||
|
||||
console.log(uploadedFile);
|
||||
// { id: '...', path: '...', size: 12345, createdAt: '...', url: 'https://...' }
|
||||
```
|
||||
|
||||
The method signature:
|
||||
|
||||
```typescript
|
||||
uploadFile(
|
||||
fileBuffer: Buffer,
|
||||
filename: string,
|
||||
contentType: string,
|
||||
fieldMetadataUniversalIdentifier: string,
|
||||
): Promise<{ id: string; path: string; size: number; createdAt: string; url: string }>
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `fileBuffer` | `Buffer` | The raw file contents |
|
||||
@@ -668,8 +940,7 @@ uploadFile(
|
||||
| `fieldMetadataUniversalIdentifier` | `string` | The `universalIdentifier` of the file-type field on your object |
|
||||
|
||||
Key points:
|
||||
- The `uploadFile` method is available on `MetadataApiClient` because the upload mutation is resolved by the `/metadata` endpoint.
|
||||
- It uses the field's `universalIdentifier` (not its workspace-specific ID), so your upload code works across any workspace where your app is installed — consistent with how apps reference fields everywhere else.
|
||||
- Uses the field's `universalIdentifier` (not its workspace-specific ID), so your upload code works across any workspace where your app is installed.
|
||||
- The returned `url` is a signed URL you can use to access the uploaded file.
|
||||
|
||||
### Hello World example
|
||||
|
||||
@@ -48,7 +48,7 @@ From here you can:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Add a new entity to your application (guided)
|
||||
yarn twenty entity:add
|
||||
yarn twenty add
|
||||
|
||||
# Watch your application's function logs
|
||||
yarn twenty function:logs
|
||||
@@ -122,7 +122,7 @@ With `--minimal`, only the core files are created (`application-config.ts`, `rol
|
||||
At a high level:
|
||||
|
||||
- **package.json**: Declares the app name, version, engines (Node 24+, Yarn 4), and adds `twenty-sdk` plus a `twenty` script that delegates to the local `twenty` CLI. Run `yarn twenty help` to list all available commands.
|
||||
- **.gitignore**: Ignores common artifacts such as `node_modules`, `.yarn`, `generated/` (typed client), `dist/`, `build/`, coverage folders, log files, and `.env*` files.
|
||||
- **.gitignore**: Ignores common artifacts such as `node_modules`, `.yarn`, `.twenty/`, `dist/`, `build/`, coverage folders, log files, and `.env*` files.
|
||||
- **yarn.lock**, **.yarnrc.yml**, **.yarn/**: Lock and configure the Yarn 4 toolchain used by the project.
|
||||
- **.nvmrc**: Pins the Node.js version expected by the project.
|
||||
- **.oxlintrc.json** and **tsconfig.json**: Provide linting and TypeScript configuration for your app's TypeScript sources.
|
||||
@@ -165,8 +165,8 @@ export default defineObject({
|
||||
|
||||
Later commands will add more files and folders:
|
||||
|
||||
- `yarn twenty dev` will auto-generate two typed API clients in `node_modules/twenty-sdk/generated`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`).
|
||||
- `yarn twenty entity:add` will add entity definition files under `src/` for your custom objects, functions, front components, roles, skills, and more.
|
||||
- `yarn twenty dev` will auto-generate the typed `CoreApiClient` (for workspace data via `/graphql`) into `node_modules/twenty-client-sdk/`. The `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built and is available immediately. Import them from `twenty-client-sdk/core` and `twenty-client-sdk/metadata` respectively.
|
||||
- `yarn twenty add` will add entity definition files under `src/` for your custom objects, functions, front components, roles, skills, and more.
|
||||
|
||||
## Authentication
|
||||
|
||||
|
||||
@@ -12,7 +12,25 @@ Apps are currently in alpha testing. The feature is functional but still evolvin
|
||||
Once your app is [built and tested locally](/developers/extend/apps/building), you have two paths for distributing it:
|
||||
|
||||
- **Publish to npm** — list your app in the Twenty marketplace for any workspace to discover and install.
|
||||
- **Push a tarball** — deploy your app to a specific Twenty server for internal use without making it publicly available.
|
||||
- **Deploy a tarball** — upload your app directly to a specific Twenty server for internal or private use.
|
||||
|
||||
Both paths start from the same **build** step.
|
||||
|
||||
## Building your app
|
||||
|
||||
The `build` command compiles your TypeScript sources, transpiles logic functions and front components, and generates a `manifest.json` that describes your app's contents:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty build
|
||||
```
|
||||
|
||||
The output is written to `.twenty/output/`. This directory contains everything needed for distribution: compiled code, assets, the manifest, and a copy of your `package.json`.
|
||||
|
||||
To also create a `.tgz` tarball (used by the deploy command internally, or for manual distribution):
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty build --tarball
|
||||
```
|
||||
|
||||
## Publishing to npm
|
||||
|
||||
@@ -21,29 +39,72 @@ Publishing to npm makes your app discoverable in the Twenty marketplace. Any Twe
|
||||
### Requirements
|
||||
|
||||
- An [npm](https://www.npmjs.com) account
|
||||
- Your package name **must** use the `twenty-app-` prefix (e.g., `twenty-app-postcard-sender`)
|
||||
- The `twenty-app` keyword **must** be listed in your `package.json` `keywords` array
|
||||
|
||||
### Adding the required keyword
|
||||
|
||||
The Twenty marketplace discovers apps by searching the npm registry for packages with the `twenty-app` keyword. Add it to your `package.json`:
|
||||
|
||||
```json filename="package.json"
|
||||
{
|
||||
"name": "twenty-app-postcard-sender",
|
||||
"version": "1.0.0",
|
||||
"keywords": ["twenty-app"],
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
<Note>
|
||||
The marketplace searches for `keywords:twenty-app` on the npm registry. Without this keyword, your package won't appear in the marketplace even if it has the `twenty-app-` name prefix.
|
||||
</Note>
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Build your app** — the CLI compiles your TypeScript sources and generates the application manifest:
|
||||
1. **Build your app:**
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty build
|
||||
```
|
||||
|
||||
2. **Publish to npm** — push the built package to the npm registry:
|
||||
2. **Publish to npm:**
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx twenty publish
|
||||
yarn twenty publish
|
||||
```
|
||||
|
||||
### Auto-discovery
|
||||
This runs `npm publish` from the `.twenty/output/` directory.
|
||||
|
||||
Packages with the `twenty-app-` prefix are automatically discovered by the Twenty marketplace catalog. Once published, your app appears in the marketplace within a few minutes — no manual registration or approval required.
|
||||
To publish under a specific dist-tag (e.g., `beta` or `next`):
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty publish --tag beta
|
||||
```
|
||||
|
||||
### How marketplace discovery works
|
||||
|
||||
The Twenty server syncs its marketplace catalog from the npm registry **every hour**:
|
||||
|
||||
1. It searches for all npm packages with the `keywords:twenty-app` keyword
|
||||
2. For each package, it fetches the `manifest.json` from the npm CDN
|
||||
3. The app's metadata (name, description, author, logo, screenshots, category) is extracted from the manifest and displayed in the marketplace
|
||||
|
||||
After publishing, your app can take up to one hour to appear in the marketplace. To trigger the sync immediately instead of waiting for the next hourly run:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty catalog-sync
|
||||
```
|
||||
|
||||
To target a specific remote:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty catalog-sync -r production
|
||||
```
|
||||
|
||||
The metadata shown in the marketplace comes from your `defineApplication()` call in your app source code — fields like `displayName`, `description`, `author`, `category`, `logoUrl`, `screenshots`, `aboutDescription`, `websiteUrl`, and `termsUrl`.
|
||||
|
||||
### CI publishing
|
||||
|
||||
The scaffolded project includes a GitHub Actions workflow that publishes on every release. It runs `app:build`, then `npm publish --provenance` from the build output:
|
||||
The scaffolded project includes a GitHub Actions workflow that publishes on every release:
|
||||
|
||||
```yaml filename=".github/workflows/publish.yml"
|
||||
name: Publish
|
||||
@@ -72,48 +133,117 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
```
|
||||
|
||||
For other CI systems (GitLab CI, CircleCI, etc.), the same three commands apply: `yarn install`, `npx twenty build`, then `npm publish` from `.twenty/output`.
|
||||
For other CI systems (GitLab CI, CircleCI, etc.), the same three commands apply: `yarn install`, `yarn twenty build`, then `npm publish` from `.twenty/output`.
|
||||
|
||||
<Tip>
|
||||
**npm provenance** is optional but recommended. Publishing with `--provenance` adds a trust badge to your npm listing, letting users verify the package was built from a specific commit in a public CI pipeline. See the [npm provenance docs](https://docs.npmjs.com/generating-provenance-statements) for setup instructions.
|
||||
</Tip>
|
||||
|
||||
## Internal distribution
|
||||
## Deploying to a server (tarball)
|
||||
|
||||
For apps you don't want publicly available — proprietary tools, enterprise-only integrations, or experimental builds — you can push a tarball directly to a Twenty server.
|
||||
For apps you don't want publicly available — proprietary tools, enterprise-only integrations, or experimental builds — you can deploy a tarball directly to a Twenty server.
|
||||
|
||||
### Push a tarball
|
||||
### Prerequisites
|
||||
|
||||
Build your app and deploy it to a specific server in one step:
|
||||
Before deploying, you need a configured remote pointing to the target server. Remotes store the server URL and authentication credentials locally in `~/.twenty/config.json`.
|
||||
|
||||
Add a remote:
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx twenty publish --server <server-url>
|
||||
yarn twenty remote add --url https://your-twenty-server.com --as production
|
||||
```
|
||||
|
||||
Any workspace on that server can then install and upgrade the app from the **Applications** settings page.
|
||||
For a local development server:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty remote add --local --as local
|
||||
```
|
||||
|
||||
You can also authenticate with an API key for non-interactive environments:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty remote add --url https://your-twenty-server.com --token <api-key> --as production
|
||||
```
|
||||
|
||||
Manage your remotes:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty remote list # List all configured remotes
|
||||
yarn twenty remote switch prod # Set the default remote
|
||||
yarn twenty remote status # Show active remote and auth status
|
||||
yarn twenty remote remove old # Remove a remote
|
||||
```
|
||||
|
||||
### Deploying
|
||||
|
||||
Build and upload your app to the server in one step:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty deploy
|
||||
```
|
||||
|
||||
This builds the app with `--tarball`, then uploads the tarball to the default remote via a GraphQL multipart upload.
|
||||
|
||||
To deploy to a specific remote:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty deploy -r production
|
||||
```
|
||||
|
||||
### Sharing a deployed app
|
||||
|
||||
Tarball apps are not listed in the public marketplace, so other workspaces on the same server won't discover them by browsing. To share a deployed app:
|
||||
|
||||
1. Go to **Settings > Applications > Registrations** and open your app
|
||||
2. In the **Distribution** tab, click **Copy share link**
|
||||
3. Share this link with users on other workspaces — it takes them directly to the app's install page
|
||||
|
||||
The share link uses the server's base URL (without any workspace subdomain) so it works for any workspace on the server.
|
||||
|
||||
### Version management
|
||||
|
||||
To release an update:
|
||||
|
||||
1. Bump the `version` field in your `package.json`
|
||||
2. Push a new tarball with `npx twenty publish --server <server-url>`
|
||||
3. Workspaces on that server will see the upgrade available in their settings
|
||||
2. Run `yarn twenty deploy` (or `yarn twenty deploy -r production`)
|
||||
3. Workspaces that have the app installed will see the upgrade available in their settings
|
||||
|
||||
<Note>
|
||||
Internal apps are scoped to the server they're pushed to. They won't appear in the public marketplace and can't be installed by workspaces on other servers.
|
||||
</Note>
|
||||
## Installing apps
|
||||
|
||||
## App categories
|
||||
Once an app is published (npm) or deployed (tarball), workspaces install it through the UI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty install
|
||||
```
|
||||
|
||||
Or from the **Settings > Applications** page in the Twenty UI, where both marketplace and tarball-deployed apps can be browsed and installed.
|
||||
|
||||
## App distribution categories
|
||||
|
||||
Twenty organizes apps into three categories based on how they're distributed:
|
||||
|
||||
| Category | How it works | Visible in marketplace? |
|
||||
|----------|-------------|------------------------|
|
||||
| **Development** | Local dev mode apps running via `yarn twenty dev`. Used for building and testing. | No |
|
||||
| **Published** | Apps published to npm with the `twenty-app-` prefix. Listed in the marketplace for any workspace to install. | Yes |
|
||||
| **Internal** | Apps deployed via tarball to a specific server. Available only to workspaces on that server. | No |
|
||||
| **Published (npm)** | Apps published to npm with the `twenty-app` keyword. Listed in the marketplace for any workspace to install. | Yes |
|
||||
| **Internal (tarball)** | Apps deployed via tarball to a specific server. Available only to workspaces on that server via a share link. | No |
|
||||
|
||||
<Tip>
|
||||
Start in **Development** mode while building your app. When it's ready, choose **Published** (npm) for broad distribution or **Internal** (tarball) for private deployment.
|
||||
</Tip>
|
||||
|
||||
## CLI reference
|
||||
|
||||
| Command | Description | Key flags |
|
||||
|---------|-------------|-----------|
|
||||
| `yarn twenty build` | Compile app and generate manifest | `--tarball` — also create a `.tgz` package |
|
||||
| `yarn twenty publish` | Build and publish to npm | `--tag <tag>` — npm dist-tag (e.g., `beta`, `next`) |
|
||||
| `yarn twenty deploy` | Build and upload tarball to a server | `-r, --remote <name>` — target remote |
|
||||
| `yarn twenty catalog-sync` | Trigger marketplace catalog sync on the server | `-r, --remote <name>` — target remote |
|
||||
| `yarn twenty install` | Install a deployed app on a workspace | `-r, --remote <name>` — target remote |
|
||||
| `yarn twenty dev` | Watch and sync local changes | Uses default remote |
|
||||
| `yarn twenty remote add` | Add a server connection | `--url`, `--token`, `--as`, `--local`, `--port` |
|
||||
| `yarn twenty remote list` | List configured remotes | — |
|
||||
| `yarn twenty remote switch` | Set default remote | — |
|
||||
| `yarn twenty remote status` | Show connection status | — |
|
||||
| `yarn twenty remote remove` | Remove a remote | — |
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
@@ -5,6 +5,7 @@ export const StyledMarkdownContainer = styled.div`
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
line-height: 150%;
|
||||
margin: ${themeCssVariables.spacing['1.5']} 0;
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
scroll-margin-bottom: ${themeCssVariables.spacing[10]};
|
||||
scroll-margin-top: ${themeCssVariables.spacing[10]};
|
||||
@@ -134,6 +135,7 @@ export const StyledMarkdownContainer = styled.div`
|
||||
background-color: ${themeCssVariables.background.secondary};
|
||||
border: 1px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.md} !important;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.markdown-block-code * {
|
||||
|
||||
+3
-1
@@ -1,9 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
import { APPLICATION_FRAGMENT } from '@/applications/graphql/fragments/applicationFragment';
|
||||
|
||||
export const FIND_ONE_APPLICATION_BY_UNIVERSAL_IDENTIFIER = gql`
|
||||
${APPLICATION_FRAGMENT}
|
||||
query FindOneApplicationByUniversalIdentifier($universalIdentifier: UUID!) {
|
||||
findOneApplication(universalIdentifier: $universalIdentifier) {
|
||||
id
|
||||
...ApplicationFields
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const MARKETPLACE_APP_DETAIL_FRAGMENT = gql`
|
||||
fragment MarketplaceAppDetailFields on MarketplaceAppDetail {
|
||||
id
|
||||
universalIdentifier
|
||||
name
|
||||
sourceType
|
||||
sourcePackage
|
||||
latestAvailableVersion
|
||||
isListed
|
||||
isFeatured
|
||||
manifest
|
||||
}
|
||||
`;
|
||||
-65
@@ -6,75 +6,10 @@ export const MARKETPLACE_APP_FRAGMENT = gql`
|
||||
name
|
||||
description
|
||||
icon
|
||||
version
|
||||
author
|
||||
category
|
||||
logo
|
||||
screenshots
|
||||
aboutDescription
|
||||
providers
|
||||
websiteUrl
|
||||
termsUrl
|
||||
objects {
|
||||
universalIdentifier
|
||||
nameSingular
|
||||
namePlural
|
||||
labelSingular
|
||||
labelPlural
|
||||
description
|
||||
icon
|
||||
fields {
|
||||
universalIdentifier
|
||||
name
|
||||
type
|
||||
label
|
||||
description
|
||||
icon
|
||||
}
|
||||
}
|
||||
fields {
|
||||
name
|
||||
type
|
||||
label
|
||||
description
|
||||
icon
|
||||
objectUniversalIdentifier
|
||||
}
|
||||
logicFunctions {
|
||||
name
|
||||
description
|
||||
timeoutSeconds
|
||||
}
|
||||
frontComponents {
|
||||
name
|
||||
description
|
||||
}
|
||||
sourcePackage
|
||||
isFeatured
|
||||
defaultRole {
|
||||
id
|
||||
label
|
||||
description
|
||||
canReadAllObjectRecords
|
||||
canUpdateAllObjectRecords
|
||||
canSoftDeleteAllObjectRecords
|
||||
canDestroyAllObjectRecords
|
||||
canUpdateAllSettings
|
||||
canAccessAllTools
|
||||
objectPermissions {
|
||||
objectUniversalIdentifier
|
||||
canReadObjectRecords
|
||||
canUpdateObjectRecords
|
||||
canSoftDeleteObjectRecords
|
||||
canDestroyObjectRecords
|
||||
}
|
||||
fieldPermissions {
|
||||
objectUniversalIdentifier
|
||||
fieldUniversalIdentifier
|
||||
canReadFieldValue
|
||||
canUpdateFieldValue
|
||||
}
|
||||
permissionFlags
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { MARKETPLACE_APP_DETAIL_FRAGMENT } from '@/marketplace/graphql/fragments/marketplaceAppDetailFragment';
|
||||
|
||||
export const FIND_MARKETPLACE_APP_DETAIL = gql`
|
||||
${MARKETPLACE_APP_DETAIL_FRAGMENT}
|
||||
query FindMarketplaceAppDetail($universalIdentifier: String!) {
|
||||
findMarketplaceAppDetail(universalIdentifier: $universalIdentifier) {
|
||||
...MarketplaceAppDetailFields
|
||||
}
|
||||
}
|
||||
`;
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { MARKETPLACE_APP_FRAGMENT } from '@/marketplace/graphql/fragments/marketplaceAppFragment';
|
||||
|
||||
export const FIND_ONE_MARKETPLACE_APP = gql`
|
||||
${MARKETPLACE_APP_FRAGMENT}
|
||||
query FindOneMarketplaceApp($universalIdentifier: String!) {
|
||||
findOneMarketplaceApp(universalIdentifier: $universalIdentifier) {
|
||||
...MarketplaceAppFields
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,42 +1,11 @@
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import {
|
||||
type MarketplaceApp,
|
||||
FindManyMarketplaceAppsDocument,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export type MarketplaceAppWithContentCounts = MarketplaceApp & {
|
||||
content: {
|
||||
objects: number;
|
||||
fields: number;
|
||||
functions: number;
|
||||
frontComponents: number;
|
||||
};
|
||||
};
|
||||
import { FindManyMarketplaceAppsDocument } from '~/generated-metadata/graphql';
|
||||
|
||||
export const useMarketplaceApps = () => {
|
||||
const { data, loading, error } = useQuery(FindManyMarketplaceAppsDocument);
|
||||
|
||||
const marketplaceApps: MarketplaceAppWithContentCounts[] =
|
||||
data?.findManyMarketplaceApps.map((app) => {
|
||||
const totalFieldsCount =
|
||||
(app.objects ?? []).reduce(
|
||||
(count, appObject) => count + appObject.fields.length,
|
||||
0,
|
||||
) + (app.fields ?? []).length;
|
||||
|
||||
return {
|
||||
...app,
|
||||
content: {
|
||||
objects: (app.objects ?? []).length,
|
||||
fields: totalFieldsCount,
|
||||
functions: (app.logicFunctions ?? []).length,
|
||||
frontComponents: (app.frontComponents ?? []).length,
|
||||
},
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
return {
|
||||
data: marketplaceApps,
|
||||
data: data?.findManyMarketplaceApps ?? [],
|
||||
isLoading: loading,
|
||||
error,
|
||||
};
|
||||
|
||||
-5
@@ -5,17 +5,12 @@ export const APPLICATION_REGISTRATION_FRAGMENT = gql`
|
||||
id
|
||||
universalIdentifier
|
||||
name
|
||||
description
|
||||
logoUrl
|
||||
author
|
||||
oAuthClientId
|
||||
oAuthRedirectUris
|
||||
oAuthScopes
|
||||
sourceType
|
||||
sourcePackage
|
||||
latestAvailableVersion
|
||||
websiteUrl
|
||||
termsUrl
|
||||
isListed
|
||||
isFeatured
|
||||
ownerWorkspaceId
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_APPLICATION_SHARE_LINK = gql`
|
||||
query GetApplicationShareLink($id: String!) {
|
||||
getApplicationShareLink(id: $id)
|
||||
}
|
||||
`;
|
||||
+10
-7
@@ -14,6 +14,7 @@ import {
|
||||
IconSettings,
|
||||
} from 'twenty-ui/display';
|
||||
import { SettingsApplicationDetailSkeletonLoader } from '~/pages/settings/applications/components/SettingsApplicationDetailSkeletonLoader';
|
||||
import { SettingsApplicationDetailTitle } from '~/pages/settings/applications/components/SettingsApplicationDetailTitle';
|
||||
import { TabList } from '@/ui/layout/tab-list/components/TabList';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
@@ -40,11 +41,8 @@ export const SettingsApplicationDetails = () => {
|
||||
|
||||
const application = data?.findOneApplication;
|
||||
|
||||
const applicationName = application?.name;
|
||||
|
||||
const title = !isDefined(application)
|
||||
? t`Application details`
|
||||
: applicationName;
|
||||
const applicationName = application?.name ?? t`Application details`;
|
||||
const applicationDescription = application?.description ?? undefined;
|
||||
|
||||
const settingsCustomTabFrontComponentId =
|
||||
application?.settingsCustomTabFrontComponentId;
|
||||
@@ -94,7 +92,12 @@ export const SettingsApplicationDetails = () => {
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
title={title}
|
||||
title={
|
||||
<SettingsApplicationDetailTitle
|
||||
displayName={applicationName}
|
||||
description={applicationDescription}
|
||||
/>
|
||||
}
|
||||
links={[
|
||||
{
|
||||
children: t`Workspace`,
|
||||
@@ -104,7 +107,7 @@ export const SettingsApplicationDetails = () => {
|
||||
children: t`Applications`,
|
||||
href: getSettingsPath(SettingsPath.Applications),
|
||||
},
|
||||
{ children: `${title}` },
|
||||
{ children: applicationName },
|
||||
]}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
|
||||
+306
-270
@@ -1,3 +1,4 @@
|
||||
import { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer';
|
||||
import { useInstallMarketplaceApp } from '@/marketplace/hooks/useInstallMarketplaceApp';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
|
||||
@@ -9,21 +10,26 @@ import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
IconApps,
|
||||
IconAlertTriangle,
|
||||
IconBook,
|
||||
IconBox,
|
||||
IconCheck,
|
||||
IconColumns,
|
||||
IconCommand,
|
||||
IconDownload,
|
||||
IconEyeOff,
|
||||
IconFileText,
|
||||
IconGraph,
|
||||
IconInfoCircle,
|
||||
IconLayoutGrid,
|
||||
IconLego,
|
||||
IconLink,
|
||||
IconListDetails,
|
||||
IconLock,
|
||||
IconMail,
|
||||
IconSettings,
|
||||
IconShield,
|
||||
IconUpload,
|
||||
IconWorld,
|
||||
} from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
@@ -33,89 +39,32 @@ import { useQuery } from '@apollo/client/react';
|
||||
import {
|
||||
PermissionFlagType,
|
||||
FindOneApplicationByUniversalIdentifierDocument,
|
||||
FindOneMarketplaceAppDocument,
|
||||
FindMarketplaceAppDetailDocument,
|
||||
ApplicationRegistrationSourceType,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { useMarketplaceApps } from '~/modules/marketplace/hooks/useMarketplaceApps';
|
||||
import { SettingsApplicationPermissionsTab } from '~/pages/settings/applications/tabs/SettingsApplicationPermissionsTab';
|
||||
import { SettingsAvailableApplicationDetailContentTab } from '~/pages/settings/applications/tabs/SettingsAvailableApplicationDetailContentTab';
|
||||
import { SettingsApplicationDetailTitle } from '~/pages/settings/applications/components/SettingsApplicationDetailTitle';
|
||||
import { isNewerSemver } from '~/pages/settings/applications/utils/isNewerSemver';
|
||||
import { useUpgradeApplication } from '@/marketplace/hooks/useUpgradeApplication';
|
||||
import { SettingsApplicationDetailSettingsTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab';
|
||||
|
||||
const AVAILABLE_APPLICATION_DETAIL_ID = 'available-application-detail';
|
||||
|
||||
const StyledHeader = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
justify-content: space-between;
|
||||
margin-bottom: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
const StyledHeaderLeft = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
const StyledLogo = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${themeCssVariables.background.tertiary};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
height: 48px;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
width: 48px;
|
||||
`;
|
||||
|
||||
const StyledLogoImage = styled.img`
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
width: 32px;
|
||||
`;
|
||||
|
||||
const StyledLogoPlaceholder = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${themeCssVariables.color.blue};
|
||||
border-radius: ${themeCssVariables.border.radius.xs};
|
||||
color: ${themeCssVariables.font.color.inverted};
|
||||
display: flex;
|
||||
font-size: ${themeCssVariables.font.size.lg};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
`;
|
||||
|
||||
const StyledHeaderInfo = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledAppName = styled.div`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-size: ${themeCssVariables.font.size.lg};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
`;
|
||||
|
||||
const StyledAppDescription = styled.div`
|
||||
color: ${themeCssVariables.font.color.secondary};
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
`;
|
||||
|
||||
const StyledContentContainer = styled.div`
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[8]};
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
const StyledMainContent = styled.div`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledSidebar = styled.div`
|
||||
flex-shrink: 0;
|
||||
width: 180px;
|
||||
width: 140px;
|
||||
`;
|
||||
|
||||
const StyledSidebarSection = styled.div`
|
||||
@@ -177,7 +126,7 @@ const StyledScreenshotsContainer = styled.div`
|
||||
display: flex;
|
||||
height: 300px;
|
||||
justify-content: center;
|
||||
margin-bottom: ${themeCssVariables.spacing[4]};
|
||||
margin-bottom: ${themeCssVariables.spacing[2]};
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
@@ -222,43 +171,12 @@ const StyledThumbnailImage = styled.img`
|
||||
|
||||
const StyledSectionTitle = styled.h2`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-size: ${themeCssVariables.font.size.lg};
|
||||
font-size: ${themeCssVariables.font.size.xl};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
margin: 0 0 ${themeCssVariables.spacing[3]} 0;
|
||||
`;
|
||||
|
||||
const StyledAboutText = styled.p`
|
||||
color: ${themeCssVariables.font.color.secondary};
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
line-height: 1.6;
|
||||
margin: 0 0 ${themeCssVariables.spacing[6]} 0;
|
||||
white-space: pre-line;
|
||||
`;
|
||||
|
||||
const StyledProvidersList = styled.ul`
|
||||
color: ${themeCssVariables.font.color.secondary};
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
list-style-type: disc;
|
||||
margin: 0;
|
||||
padding-left: ${themeCssVariables.spacing[5]};
|
||||
`;
|
||||
|
||||
const StyledProviderItem = styled.li`
|
||||
margin-bottom: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledUnlistedBanner = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${themeCssVariables.background.transparent.lighter};
|
||||
border: 1px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
color: ${themeCssVariables.font.color.secondary};
|
||||
display: flex;
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
margin-bottom: ${themeCssVariables.spacing[4]};
|
||||
padding: ${themeCssVariables.spacing[3]} ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
const StyledAboutContainer = styled.div``;
|
||||
|
||||
export const SettingsAvailableApplicationDetails = () => {
|
||||
const { availableApplicationId = '' } = useParams<{
|
||||
@@ -267,12 +185,13 @@ export const SettingsAvailableApplicationDetails = () => {
|
||||
|
||||
const [selectedScreenshotIndex, setSelectedScreenshotIndex] = useState(0);
|
||||
|
||||
const { data: marketplaceApps } = useMarketplaceApps();
|
||||
const { install, isInstalling } = useInstallMarketplaceApp();
|
||||
|
||||
const canInstallMarketplaceApps = useHasPermissionFlag(
|
||||
PermissionFlagType.MARKETPLACE_APPS,
|
||||
);
|
||||
const { data: installedAppData } = useQuery(
|
||||
|
||||
const { data: applicationData } = useQuery(
|
||||
FindOneApplicationByUniversalIdentifierDocument,
|
||||
{
|
||||
variables: { universalIdentifier: availableApplicationId },
|
||||
@@ -280,47 +199,177 @@ export const SettingsAvailableApplicationDetails = () => {
|
||||
},
|
||||
);
|
||||
|
||||
const listedApp = marketplaceApps?.find(
|
||||
(app) => app.id === availableApplicationId,
|
||||
);
|
||||
|
||||
const { data: singleAppData } = useQuery(FindOneMarketplaceAppDocument, {
|
||||
const { data: detailData } = useQuery(FindMarketplaceAppDetailDocument, {
|
||||
variables: { universalIdentifier: availableApplicationId },
|
||||
skip: isDefined(listedApp) || !availableApplicationId,
|
||||
skip: !availableApplicationId,
|
||||
});
|
||||
|
||||
const singleApp = singleAppData?.findOneMarketplaceApp;
|
||||
const application = applicationData?.findOneApplication;
|
||||
|
||||
const application = isDefined(listedApp)
|
||||
? listedApp
|
||||
: isDefined(singleApp)
|
||||
? {
|
||||
...singleApp,
|
||||
content: {
|
||||
objects: (singleApp.objects ?? []).length,
|
||||
fields:
|
||||
(singleApp.objects ?? []).reduce(
|
||||
(count, appObject) => count + appObject.fields.length,
|
||||
0,
|
||||
) + (singleApp.fields ?? []).length,
|
||||
functions: (singleApp.logicFunctions ?? []).length,
|
||||
frontComponents: (singleApp.frontComponents ?? []).length,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
const detail = detailData?.findMarketplaceAppDetail;
|
||||
const manifest = detail?.manifest as Manifest | undefined;
|
||||
const app = manifest?.application;
|
||||
|
||||
const isUnlisted = !isDefined(listedApp) && isDefined(application);
|
||||
const displayName = app?.displayName ?? detail?.name ?? '';
|
||||
const description = app?.description ?? '';
|
||||
const screenshots = app?.screenshots ?? [];
|
||||
const aboutDescription = app?.aboutDescription;
|
||||
|
||||
const isAlreadyInstalled = isDefined(installedAppData?.findOneApplication);
|
||||
const currentVersion = application?.version;
|
||||
const latestAvailableVersion = detail?.latestAvailableVersion;
|
||||
|
||||
const sourceType = detail?.sourceType;
|
||||
const isNpmApp = sourceType === ApplicationRegistrationSourceType.NPM;
|
||||
const registrationId = detail?.id;
|
||||
|
||||
const isUnlisted = isDefined(detail) && !detail.isListed;
|
||||
const installedApp = applicationData?.findOneApplication;
|
||||
const isAlreadyInstalled = isDefined(installedApp);
|
||||
const hasScreenshots = screenshots.length > 0;
|
||||
|
||||
const defaultRole = manifest?.roles?.find(
|
||||
(r) => r.universalIdentifier === app?.defaultRoleUniversalIdentifier,
|
||||
);
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (isDefined(application)) {
|
||||
if (isDefined(detail)) {
|
||||
await install({
|
||||
universalIdentifier: application.id,
|
||||
universalIdentifier: detail.universalIdentifier,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const hasUpdate =
|
||||
isNpmApp &&
|
||||
isDefined(latestAvailableVersion) &&
|
||||
isDefined(currentVersion) &&
|
||||
isNewerSemver(latestAvailableVersion, currentVersion);
|
||||
|
||||
const { upgrade, isUpgrading } = useUpgradeApplication();
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
if (!isDefined(registrationId) || !isDefined(latestAvailableVersion)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await upgrade({
|
||||
appRegistrationId: registrationId,
|
||||
targetVersion: latestAvailableVersion,
|
||||
});
|
||||
};
|
||||
|
||||
const getActionButton = () => {
|
||||
if (!canInstallMarketplaceApps) {
|
||||
return null;
|
||||
}
|
||||
if (!isAlreadyInstalled) {
|
||||
return (
|
||||
<StyledSidebarSection>
|
||||
<Button
|
||||
Icon={IconDownload}
|
||||
title={isInstalling ? t`Installing...` : t`Install`}
|
||||
variant={'primary'}
|
||||
accent={'blue'}
|
||||
onClick={handleInstall}
|
||||
disabled={isInstalling}
|
||||
/>
|
||||
</StyledSidebarSection>
|
||||
);
|
||||
}
|
||||
if (hasUpdate && isDefined(registrationId)) {
|
||||
return (
|
||||
<StyledSidebarSection>
|
||||
<Button
|
||||
Icon={IconUpload}
|
||||
title={
|
||||
isUpgrading
|
||||
? t`Upgrading...`
|
||||
: t`Upgrade to ${latestAvailableVersion}`
|
||||
}
|
||||
variant={'secondary'}
|
||||
accent={'blue'}
|
||||
onClick={handleUpgrade}
|
||||
disabled={isUpgrading}
|
||||
/>
|
||||
</StyledSidebarSection>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<StyledSidebarSection>
|
||||
<Button
|
||||
Icon={IconCheck}
|
||||
title={t`Installed`}
|
||||
variant={'secondary'}
|
||||
accent={'default'}
|
||||
disabled={isAlreadyInstalled}
|
||||
/>
|
||||
</StyledSidebarSection>
|
||||
);
|
||||
};
|
||||
|
||||
const contentEntries = [
|
||||
{
|
||||
icon: IconBox,
|
||||
count: (manifest?.objects ?? []).length,
|
||||
one: t`object`,
|
||||
many: t`objects`,
|
||||
},
|
||||
{
|
||||
icon: IconListDetails,
|
||||
count: (manifest?.fields ?? []).length,
|
||||
one: t`field`,
|
||||
many: t`fields`,
|
||||
},
|
||||
{
|
||||
icon: IconCommand,
|
||||
count: (manifest?.logicFunctions ?? []).length,
|
||||
one: t`logic function`,
|
||||
many: t`logic functions`,
|
||||
},
|
||||
{
|
||||
icon: IconGraph,
|
||||
count: (manifest?.frontComponents ?? []).filter(
|
||||
(fc) =>
|
||||
!isDefined(fc.command) &&
|
||||
fc.universalIdentifier !==
|
||||
manifest?.application
|
||||
.settingsCustomTabFrontComponentUniversalIdentifier,
|
||||
).length,
|
||||
one: t`widget`,
|
||||
many: t`widgets`,
|
||||
},
|
||||
{
|
||||
icon: IconCommand,
|
||||
count: (manifest?.frontComponents ?? []).filter(
|
||||
(fc) => isDefined(fc.command) && !fc.isHeadless,
|
||||
).length,
|
||||
one: t`command`,
|
||||
many: t`commands`,
|
||||
},
|
||||
{
|
||||
icon: IconShield,
|
||||
count: (manifest?.roles ?? []).filter(
|
||||
(role) =>
|
||||
role.universalIdentifier !==
|
||||
manifest?.application.defaultRoleUniversalIdentifier,
|
||||
).length,
|
||||
one: t`role`,
|
||||
many: t`roles`,
|
||||
},
|
||||
{
|
||||
icon: IconBook,
|
||||
count: (manifest?.skills ?? []).length,
|
||||
one: t`skill`,
|
||||
many: t`skills`,
|
||||
},
|
||||
{
|
||||
icon: IconLego,
|
||||
count: (manifest?.agents ?? []).length,
|
||||
one: t`agent`,
|
||||
many: t`agents`,
|
||||
},
|
||||
].filter((entry) => entry.count > 0);
|
||||
|
||||
const activeTabId = useAtomComponentStateValue(
|
||||
activeTabIdComponentState,
|
||||
AVAILABLE_APPLICATION_DETAIL_ID,
|
||||
@@ -333,123 +382,145 @@ export const SettingsAvailableApplicationDetails = () => {
|
||||
{ id: 'settings', title: t`Settings`, Icon: IconSettings },
|
||||
];
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name.charAt(0).toUpperCase();
|
||||
};
|
||||
|
||||
const hasScreenshots =
|
||||
application?.screenshots && application.screenshots.length > 0;
|
||||
|
||||
const renderActiveTabContent = () => {
|
||||
if (!application) return null;
|
||||
if (!detail) return null;
|
||||
|
||||
switch (activeTabId) {
|
||||
case 'about':
|
||||
return (
|
||||
<>
|
||||
{hasScreenshots && (
|
||||
<>
|
||||
<StyledAboutContainer>
|
||||
<StyledScreenshotsContainer>
|
||||
<StyledScreenshotImage
|
||||
src={application.screenshots[selectedScreenshotIndex]}
|
||||
alt={`${application.name} screenshot ${selectedScreenshotIndex + 1}`}
|
||||
src={screenshots[selectedScreenshotIndex]}
|
||||
alt={`${displayName} screenshot ${selectedScreenshotIndex + 1}`}
|
||||
/>
|
||||
</StyledScreenshotsContainer>
|
||||
<StyledScreenshotThumbnails>
|
||||
{application.screenshots
|
||||
.slice(0, 6)
|
||||
.map((screenshot, index) => (
|
||||
<StyledThumbnail
|
||||
key={index}
|
||||
isSelected={index === selectedScreenshotIndex}
|
||||
onClick={() => setSelectedScreenshotIndex(index)}
|
||||
>
|
||||
<StyledThumbnailImage
|
||||
src={screenshot}
|
||||
alt={`${application.name} thumbnail ${index + 1}`}
|
||||
/>
|
||||
</StyledThumbnail>
|
||||
))}
|
||||
{screenshots.slice(0, 6).map((screenshot, index) => (
|
||||
<StyledThumbnail
|
||||
key={index}
|
||||
isSelected={index === selectedScreenshotIndex}
|
||||
onClick={() => setSelectedScreenshotIndex(index)}
|
||||
>
|
||||
<StyledThumbnailImage
|
||||
src={screenshot}
|
||||
alt={`${displayName} thumbnail ${index + 1}`}
|
||||
/>
|
||||
</StyledThumbnail>
|
||||
))}
|
||||
</StyledScreenshotThumbnails>
|
||||
</>
|
||||
</StyledAboutContainer>
|
||||
)}
|
||||
|
||||
<StyledContentContainer>
|
||||
<StyledMainContent>
|
||||
<Section>
|
||||
<StyledSectionTitle>{t`About`}</StyledSectionTitle>
|
||||
<StyledAboutText>
|
||||
{application.aboutDescription}
|
||||
</StyledAboutText>
|
||||
|
||||
<StyledSectionTitle>{t`Providers`}</StyledSectionTitle>
|
||||
<StyledProvidersList>
|
||||
{application.providers.map((provider) => (
|
||||
<StyledProviderItem key={provider}>
|
||||
{provider}
|
||||
</StyledProviderItem>
|
||||
))}
|
||||
</StyledProvidersList>
|
||||
<LazyMarkdownRenderer
|
||||
text={
|
||||
aboutDescription
|
||||
? aboutDescription
|
||||
: t`No description available for this application`
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
</StyledMainContent>
|
||||
|
||||
<StyledSidebar>
|
||||
{getActionButton()}
|
||||
<StyledSidebarSection>
|
||||
<StyledSidebarLabel>{t`Created by`}</StyledSidebarLabel>
|
||||
<StyledSidebarValue>{application.author}</StyledSidebarValue>
|
||||
</StyledSidebarSection>
|
||||
|
||||
<StyledSidebarSection>
|
||||
<StyledSidebarLabel>{t`Category`}</StyledSidebarLabel>
|
||||
<StyledSidebarValue>
|
||||
{application.category}
|
||||
{app?.author ?? 'Unknown'}
|
||||
</StyledSidebarValue>
|
||||
</StyledSidebarSection>
|
||||
|
||||
<StyledSidebarSection>
|
||||
<StyledSidebarLabel>{t`Content`}</StyledSidebarLabel>
|
||||
<StyledContentItem>
|
||||
<IconLayoutGrid size={16} />
|
||||
{application.content.objects} {t`objects`}
|
||||
</StyledContentItem>
|
||||
<StyledContentItem>
|
||||
<IconColumns size={16} />
|
||||
{application.content.fields} {t`fields`}
|
||||
</StyledContentItem>
|
||||
<StyledContentItem>
|
||||
<IconApps size={16} />
|
||||
{application.content.frontComponents} {t`front components`}
|
||||
</StyledContentItem>
|
||||
<StyledContentItem>
|
||||
<IconCommand size={16} />
|
||||
{application.content.functions} {t`functions`}
|
||||
</StyledContentItem>
|
||||
</StyledSidebarSection>
|
||||
{app?.category && (
|
||||
<StyledSidebarSection>
|
||||
<StyledSidebarLabel>{t`Category`}</StyledSidebarLabel>
|
||||
<StyledSidebarValue>{app.category}</StyledSidebarValue>
|
||||
</StyledSidebarSection>
|
||||
)}
|
||||
|
||||
{contentEntries.length > 0 && (
|
||||
<StyledSidebarSection>
|
||||
<StyledSidebarLabel>{t`Content`}</StyledSidebarLabel>
|
||||
{contentEntries.map((entry) => (
|
||||
<StyledContentItem key={entry.one}>
|
||||
<entry.icon size={16} />
|
||||
{entry.count}{' '}
|
||||
{entry.count === 1 ? entry.one : entry.many}
|
||||
</StyledContentItem>
|
||||
))}
|
||||
</StyledSidebarSection>
|
||||
)}
|
||||
|
||||
{isAlreadyInstalled && (
|
||||
<StyledSidebarSection>
|
||||
<StyledSidebarLabel>{t`Current`}</StyledSidebarLabel>
|
||||
<StyledSidebarValue>
|
||||
{installedApp?.version ?? t`Unknown`}
|
||||
</StyledSidebarValue>
|
||||
</StyledSidebarSection>
|
||||
)}
|
||||
|
||||
<StyledSidebarSection>
|
||||
<StyledSidebarLabel>{t`Latest`}</StyledSidebarLabel>
|
||||
<StyledSidebarValue>{application.version}</StyledSidebarValue>
|
||||
<StyledSidebarValue>
|
||||
{detail.latestAvailableVersion ?? '0.0.0'}
|
||||
</StyledSidebarValue>
|
||||
</StyledSidebarSection>
|
||||
|
||||
<StyledSidebarSection>
|
||||
<StyledSidebarLabel>{t`Developers links`}</StyledSidebarLabel>
|
||||
<StyledLink
|
||||
href={application.websiteUrl ?? undefined}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconWorld size={16} />
|
||||
{t`Website`}
|
||||
</StyledLink>
|
||||
<StyledLink
|
||||
href={application.termsUrl ?? undefined}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconFileText size={16} />
|
||||
{t`Terms / Privacy`}
|
||||
</StyledLink>
|
||||
</StyledSidebarSection>
|
||||
{(app?.websiteUrl ||
|
||||
app?.termsUrl ||
|
||||
app?.emailSupport ||
|
||||
app?.issueReportUrl) && (
|
||||
<StyledSidebarSection>
|
||||
<StyledSidebarLabel>{t`Developers links`}</StyledSidebarLabel>
|
||||
{app?.websiteUrl && (
|
||||
<StyledLink
|
||||
href={app.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconWorld size={16} />
|
||||
{t`Website`}
|
||||
</StyledLink>
|
||||
)}
|
||||
{app?.termsUrl && (
|
||||
<StyledLink
|
||||
href={app.termsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconLink size={16} />
|
||||
{t`Terms / Privacy`}
|
||||
</StyledLink>
|
||||
)}
|
||||
{app?.emailSupport && (
|
||||
<StyledLink
|
||||
href={`mailto:${app.emailSupport}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconMail size={16} />
|
||||
{t`Email support`}
|
||||
</StyledLink>
|
||||
)}
|
||||
{app?.issueReportUrl && (
|
||||
<StyledLink
|
||||
href={app.issueReportUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconAlertTriangle size={16} />
|
||||
{t`Report and issue`}
|
||||
</StyledLink>
|
||||
)}
|
||||
</StyledSidebarSection>
|
||||
)}
|
||||
</StyledSidebar>
|
||||
</StyledContentContainer>
|
||||
</>
|
||||
@@ -457,30 +528,32 @@ export const SettingsAvailableApplicationDetails = () => {
|
||||
case 'content':
|
||||
return (
|
||||
<SettingsAvailableApplicationDetailContentTab
|
||||
application={application}
|
||||
applicationId={detail.universalIdentifier}
|
||||
content={manifest}
|
||||
/>
|
||||
);
|
||||
case 'permissions':
|
||||
return (
|
||||
<SettingsApplicationPermissionsTab
|
||||
marketplaceAppDefaultRole={application.defaultRole}
|
||||
marketplaceAppObjects={application.objects}
|
||||
marketplaceAppDefaultRole={defaultRole}
|
||||
marketplaceAppObjects={manifest?.objects}
|
||||
/>
|
||||
);
|
||||
case 'settings':
|
||||
return <div>{t`Settings tab`}</div>;
|
||||
return (
|
||||
<SettingsApplicationDetailSettingsTab application={application} />
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!application) {
|
||||
if (!detail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
title={application.name}
|
||||
links={[
|
||||
{
|
||||
children: t`Workspace`,
|
||||
@@ -490,55 +563,18 @@ export const SettingsAvailableApplicationDetails = () => {
|
||||
children: t`Applications`,
|
||||
href: getSettingsPath(SettingsPath.Applications),
|
||||
},
|
||||
{ children: application.name },
|
||||
{ children: displayName },
|
||||
]}
|
||||
title={
|
||||
<SettingsApplicationDetailTitle
|
||||
displayName={displayName}
|
||||
description={description}
|
||||
logoUrl={app?.logoUrl}
|
||||
isUnlisted={isUnlisted}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
{isUnlisted && (
|
||||
<StyledUnlistedBanner>
|
||||
<IconEyeOff size={16} />
|
||||
{t`This application is not listed on the marketplace. It was shared via a direct link.`}
|
||||
</StyledUnlistedBanner>
|
||||
)}
|
||||
<StyledHeader>
|
||||
<StyledHeaderLeft>
|
||||
<StyledLogo>
|
||||
{application.logo ? (
|
||||
<StyledLogoImage
|
||||
src={application.logo}
|
||||
alt={application.name}
|
||||
/>
|
||||
) : (
|
||||
<StyledLogoPlaceholder>
|
||||
{getInitials(application.name)}
|
||||
</StyledLogoPlaceholder>
|
||||
)}
|
||||
</StyledLogo>
|
||||
<StyledHeaderInfo>
|
||||
<StyledAppName>{application.name}</StyledAppName>
|
||||
<StyledAppDescription>
|
||||
{application.description}
|
||||
</StyledAppDescription>
|
||||
</StyledHeaderInfo>
|
||||
</StyledHeaderLeft>
|
||||
{canInstallMarketplaceApps && (
|
||||
<Button
|
||||
Icon={isAlreadyInstalled ? IconCheck : IconDownload}
|
||||
title={
|
||||
isAlreadyInstalled
|
||||
? t`Installed`
|
||||
: isInstalling
|
||||
? t`Installing...`
|
||||
: t`Install`
|
||||
}
|
||||
variant={isAlreadyInstalled ? 'secondary' : 'primary'}
|
||||
accent={isAlreadyInstalled ? 'default' : 'blue'}
|
||||
onClick={handleInstall}
|
||||
disabled={isAlreadyInstalled || isInstalling}
|
||||
/>
|
||||
)}
|
||||
</StyledHeader>
|
||||
|
||||
<TabList
|
||||
tabs={tabs}
|
||||
componentInstanceId={AVAILABLE_APPLICATION_DETAIL_ID}
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { IconEyeOff } from 'twenty-ui/display';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
|
||||
|
||||
type SettingsApplicationDetailTitleProps = {
|
||||
displayName: string;
|
||||
description?: string;
|
||||
logoUrl?: string;
|
||||
isUnlisted?: boolean;
|
||||
};
|
||||
|
||||
const StyledTitleContainer = styled.div`
|
||||
margin-bottom: ${themeCssVariables.spacing[4]};
|
||||
width: ${() => {
|
||||
return OBJECT_SETTINGS_WIDTH + 'px';
|
||||
}};
|
||||
`;
|
||||
|
||||
const StyledHeader = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const StyledHeaderLeft = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledHeaderTop = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
const StyledLogo = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${themeCssVariables.background.tertiary};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
height: 24px;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
width: 24px;
|
||||
`;
|
||||
|
||||
const StyledLogoImage = styled.img`
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
width: 32px;
|
||||
`;
|
||||
|
||||
const StyledLogoPlaceholder = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${themeCssVariables.color.blue};
|
||||
border-radius: ${themeCssVariables.border.radius.xs};
|
||||
color: ${themeCssVariables.font.color.inverted};
|
||||
display: flex;
|
||||
font-size: ${themeCssVariables.font.size.lg};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
`;
|
||||
|
||||
const StyledAppName = styled.div`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-size: ${themeCssVariables.font.size.lg};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
`;
|
||||
|
||||
const StyledAppDescription = styled.div`
|
||||
color: ${themeCssVariables.font.color.secondary};
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
font-weight: ${themeCssVariables.font.weight.regular};
|
||||
`;
|
||||
|
||||
const StyledUnlistedBanner = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${themeCssVariables.background.transparent.lighter};
|
||||
border: 1px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
color: ${themeCssVariables.font.color.secondary};
|
||||
display: flex;
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
margin-bottom: ${themeCssVariables.spacing[4]};
|
||||
padding: ${themeCssVariables.spacing[3]} ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
export const SettingsApplicationDetailTitle = ({
|
||||
displayName,
|
||||
description,
|
||||
logoUrl,
|
||||
isUnlisted = false,
|
||||
}: SettingsApplicationDetailTitleProps) => {
|
||||
return (
|
||||
<StyledTitleContainer>
|
||||
{isUnlisted && (
|
||||
<StyledUnlistedBanner>
|
||||
<IconEyeOff size={16} />
|
||||
{t`This application is not listed on the marketplace. It was shared via a direct link.`}
|
||||
</StyledUnlistedBanner>
|
||||
)}
|
||||
<StyledHeader>
|
||||
<StyledHeaderLeft>
|
||||
<StyledHeaderTop>
|
||||
<StyledLogo>
|
||||
{logoUrl ? (
|
||||
<StyledLogoImage src={logoUrl} alt={displayName} />
|
||||
) : (
|
||||
<StyledLogoPlaceholder>
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</StyledLogoPlaceholder>
|
||||
)}
|
||||
</StyledLogo>
|
||||
<StyledAppName>{displayName}</StyledAppName>
|
||||
</StyledHeaderTop>
|
||||
{description && (
|
||||
<StyledAppDescription>{description}</StyledAppDescription>
|
||||
)}
|
||||
</StyledHeaderLeft>
|
||||
</StyledHeader>
|
||||
</StyledTitleContainer>
|
||||
);
|
||||
};
|
||||
+10
-2
@@ -1,3 +1,5 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
|
||||
import { SettingsAdminVersionDisplay } from '@/settings/admin-panel/components/SettingsAdminVersionDisplay';
|
||||
import { useUpgradeApplication } from '@/marketplace/hooks/useUpgradeApplication';
|
||||
@@ -11,6 +13,12 @@ import {
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { isNewerSemver } from '~/pages/settings/applications/utils/isNewerSemver';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
export const SettingsApplicationVersionContainer = ({
|
||||
application,
|
||||
latestAvailableVersion,
|
||||
@@ -81,7 +89,7 @@ export const SettingsApplicationVersionContainer = ({
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledContainer>
|
||||
<SettingsAdminTableCard
|
||||
rounded
|
||||
items={versionItems}
|
||||
@@ -101,6 +109,6 @@ export const SettingsApplicationVersionContainer = ({
|
||||
disabled={isUpgrading}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
-25
@@ -71,31 +71,6 @@ export const SettingsApplicationDetailAboutTab = ({
|
||||
return (
|
||||
<>
|
||||
<Section>
|
||||
<H2Title title={t`Name`} description={t`Name of the application`} />
|
||||
<SettingsTextInput
|
||||
instanceId={`application-name-${id}`}
|
||||
value={name}
|
||||
disabled
|
||||
fullWidth
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Description`}
|
||||
description={t`Description of the application`}
|
||||
/>
|
||||
<SettingsTextInput
|
||||
instanceId={`application-description-${id}`}
|
||||
value={description ?? undefined}
|
||||
disabled
|
||||
fullWidth
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Version`}
|
||||
description={t`Version of the application`}
|
||||
/>
|
||||
<SettingsApplicationVersionContainer
|
||||
application={application}
|
||||
latestAvailableVersion={latestAvailableVersion}
|
||||
|
||||
+5
-4
@@ -29,12 +29,13 @@ export const SettingsApplicationDetailEnvironmentVariablesTable = ({
|
||||
},
|
||||
250,
|
||||
);
|
||||
const description =
|
||||
editedEnvVariables.length > 0
|
||||
? t`Set your application configuration variables`
|
||||
: t`No variables to set for this application`;
|
||||
return (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Configuration`}
|
||||
description={t`Set your application configuration variables`}
|
||||
/>
|
||||
<H2Title title={t`Configuration`} description={description} />
|
||||
<StyledContainer>
|
||||
{editedEnvVariables.map((editedEnvVariable) => (
|
||||
<TextInput
|
||||
|
||||
+18
-22
@@ -1,37 +1,33 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import type { Application } from '~/generated-metadata/graphql';
|
||||
import { type Application } from '~/generated-metadata/graphql';
|
||||
import { useUpdateOneApplicationVariable } from '~/pages/settings/applications/hooks/useUpdateOneApplicationVariable';
|
||||
import { SettingsApplicationDetailEnvironmentVariablesTable } from '~/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTable';
|
||||
|
||||
export const SettingsApplicationDetailSettingsTab = ({
|
||||
application,
|
||||
}: {
|
||||
application?: Omit<Application, 'objects'> & {
|
||||
objects: { id: string }[];
|
||||
};
|
||||
application?: Pick<
|
||||
Application,
|
||||
'applicationVariables' | 'id' | 'universalIdentifier' | 'canBeUninstalled'
|
||||
>;
|
||||
}) => {
|
||||
const { updateOneApplicationVariable } = useUpdateOneApplicationVariable();
|
||||
|
||||
if (!isDefined(application)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const envVariables = [...(application.applicationVariables ?? [])].sort(
|
||||
const envVariables = [...(application?.applicationVariables ?? [])].sort(
|
||||
(a, b) => a.key.localeCompare(b.key),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsApplicationDetailEnvironmentVariablesTable
|
||||
envVariables={envVariables}
|
||||
onUpdate={({ key, value }) =>
|
||||
updateOneApplicationVariable({
|
||||
key,
|
||||
value,
|
||||
applicationId: application.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
<SettingsApplicationDetailEnvironmentVariablesTable
|
||||
envVariables={envVariables}
|
||||
onUpdate={({ key, value }) =>
|
||||
application?.id
|
||||
? updateOneApplicationVariable({
|
||||
key,
|
||||
value,
|
||||
applicationId: application.id,
|
||||
})
|
||||
: null
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+50
-43
@@ -9,14 +9,16 @@ import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAt
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import {
|
||||
type ObjectFieldManifest,
|
||||
type ObjectManifest,
|
||||
type RoleManifest,
|
||||
} from 'twenty-shared/application';
|
||||
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
type MarketplaceAppDefaultRole,
|
||||
type MarketplaceAppField,
|
||||
type MarketplaceAppObject,
|
||||
type PermissionFlagType,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
@@ -24,12 +26,12 @@ import { findObjectNameByUniversalIdentifier } from '~/pages/settings/applicatio
|
||||
|
||||
type SettingsApplicationPermissionsTabProps = {
|
||||
defaultRoleId?: string | null;
|
||||
marketplaceAppDefaultRole?: MarketplaceAppDefaultRole | null;
|
||||
marketplaceAppObjects?: MarketplaceAppObject[];
|
||||
marketplaceAppDefaultRole?: RoleManifest;
|
||||
marketplaceAppObjects?: ObjectManifest[];
|
||||
};
|
||||
|
||||
const resolvePermissionIds = (
|
||||
defaultRole: MarketplaceAppDefaultRole,
|
||||
defaultRole: RoleManifest,
|
||||
objectMetadataItems: EnrichedObjectMetadataItem[],
|
||||
): {
|
||||
objectUniversalIdToIdMap: Record<string, string>;
|
||||
@@ -40,10 +42,10 @@ const resolvePermissionIds = (
|
||||
|
||||
const allObjectUniversalIds = new Set<string>();
|
||||
|
||||
for (const permission of defaultRole.objectPermissions) {
|
||||
for (const permission of defaultRole.objectPermissions ?? []) {
|
||||
allObjectUniversalIds.add(permission.objectUniversalIdentifier);
|
||||
}
|
||||
for (const permission of defaultRole.fieldPermissions) {
|
||||
for (const permission of defaultRole.fieldPermissions ?? []) {
|
||||
allObjectUniversalIds.add(permission.objectUniversalIdentifier);
|
||||
}
|
||||
|
||||
@@ -61,7 +63,7 @@ const resolvePermissionIds = (
|
||||
}
|
||||
}
|
||||
|
||||
for (const permission of defaultRole.fieldPermissions) {
|
||||
for (const permission of defaultRole.fieldPermissions ?? []) {
|
||||
const objectName = findObjectNameByUniversalIdentifier(
|
||||
permission.objectUniversalIdentifier,
|
||||
);
|
||||
@@ -100,22 +102,23 @@ const resolvePermissionIds = (
|
||||
};
|
||||
|
||||
const buildSyntheticRole = (
|
||||
defaultRole: MarketplaceAppDefaultRole,
|
||||
defaultRole: RoleManifest,
|
||||
objectUniversalIdToIdMap: Record<string, string>,
|
||||
fieldUniversalIdToIdMap: Record<string, string>,
|
||||
): RoleWithPartialMembers => ({
|
||||
__typename: 'Role',
|
||||
id: defaultRole.id,
|
||||
id: defaultRole.universalIdentifier,
|
||||
label: defaultRole.label,
|
||||
description: defaultRole.description ?? '',
|
||||
icon: '',
|
||||
isEditable: false,
|
||||
canReadAllObjectRecords: defaultRole.canReadAllObjectRecords,
|
||||
canUpdateAllObjectRecords: defaultRole.canUpdateAllObjectRecords,
|
||||
canSoftDeleteAllObjectRecords: defaultRole.canSoftDeleteAllObjectRecords,
|
||||
canDestroyAllObjectRecords: defaultRole.canDestroyAllObjectRecords,
|
||||
canUpdateAllSettings: defaultRole.canUpdateAllSettings,
|
||||
canAccessAllTools: defaultRole.canAccessAllTools,
|
||||
canReadAllObjectRecords: defaultRole.canReadAllObjectRecords ?? false,
|
||||
canUpdateAllObjectRecords: defaultRole.canUpdateAllObjectRecords ?? false,
|
||||
canSoftDeleteAllObjectRecords:
|
||||
defaultRole.canSoftDeleteAllObjectRecords ?? false,
|
||||
canDestroyAllObjectRecords: defaultRole.canDestroyAllObjectRecords ?? false,
|
||||
canUpdateAllSettings: defaultRole.canUpdateAllSettings ?? false,
|
||||
canAccessAllTools: defaultRole.canAccessAllTools ?? false,
|
||||
canBeAssignedToUsers: false,
|
||||
canBeAssignedToAgents: false,
|
||||
canBeAssignedToApiKeys: false,
|
||||
@@ -124,20 +127,22 @@ const buildSyntheticRole = (
|
||||
apiKeys: [],
|
||||
rowLevelPermissionPredicates: [],
|
||||
rowLevelPermissionPredicateGroups: [],
|
||||
objectPermissions: defaultRole.objectPermissions.map((permission) => ({
|
||||
__typename: 'ObjectPermission' as const,
|
||||
objectMetadataId:
|
||||
objectUniversalIdToIdMap[permission.objectUniversalIdentifier] ??
|
||||
permission.objectUniversalIdentifier,
|
||||
canReadObjectRecords: permission.canReadObjectRecords,
|
||||
canUpdateObjectRecords: permission.canUpdateObjectRecords,
|
||||
canSoftDeleteObjectRecords: permission.canSoftDeleteObjectRecords,
|
||||
canDestroyObjectRecords: permission.canDestroyObjectRecords,
|
||||
})),
|
||||
fieldPermissions: defaultRole.fieldPermissions.map((permission) => ({
|
||||
objectPermissions: (defaultRole.objectPermissions ?? []).map(
|
||||
(permission) => ({
|
||||
__typename: 'ObjectPermission' as const,
|
||||
objectMetadataId:
|
||||
objectUniversalIdToIdMap[permission.objectUniversalIdentifier] ??
|
||||
permission.objectUniversalIdentifier,
|
||||
canReadObjectRecords: permission.canReadObjectRecords,
|
||||
canUpdateObjectRecords: permission.canUpdateObjectRecords,
|
||||
canSoftDeleteObjectRecords: permission.canSoftDeleteObjectRecords,
|
||||
canDestroyObjectRecords: permission.canDestroyObjectRecords,
|
||||
}),
|
||||
),
|
||||
fieldPermissions: (defaultRole.fieldPermissions ?? []).map((permission) => ({
|
||||
__typename: 'FieldPermission' as const,
|
||||
id: uuidv4(),
|
||||
roleId: defaultRole.id,
|
||||
roleId: defaultRole.universalIdentifier,
|
||||
objectMetadataId:
|
||||
objectUniversalIdToIdMap[permission.objectUniversalIdentifier] ??
|
||||
permission.objectUniversalIdentifier,
|
||||
@@ -147,16 +152,16 @@ const buildSyntheticRole = (
|
||||
canReadFieldValue: permission.canReadFieldValue,
|
||||
canUpdateFieldValue: permission.canUpdateFieldValue,
|
||||
})),
|
||||
permissionFlags: defaultRole.permissionFlags.map((flag) => ({
|
||||
permissionFlags: (defaultRole.permissionFlags ?? []).map((flag) => ({
|
||||
__typename: 'PermissionFlag' as const,
|
||||
id: uuidv4(),
|
||||
roleId: defaultRole.id,
|
||||
roleId: defaultRole.universalIdentifier,
|
||||
flag: flag as PermissionFlagType,
|
||||
})),
|
||||
});
|
||||
|
||||
const buildFieldMetadataItemFromMarketplaceField = (
|
||||
field: MarketplaceAppField,
|
||||
field: ObjectFieldManifest,
|
||||
): FieldMetadataItem => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
@@ -184,20 +189,20 @@ const buildFieldMetadataItemFromMarketplaceField = (
|
||||
};
|
||||
|
||||
const buildobjectMetadataItemsFromMarketplaceApp = (
|
||||
defaultRole: MarketplaceAppDefaultRole,
|
||||
defaultRole: RoleManifest,
|
||||
objectUniversalIdToIdMap: Record<string, string>,
|
||||
marketplaceAppObjects: MarketplaceAppObject[],
|
||||
marketplaceAppObjects: ObjectManifest[],
|
||||
): EnrichedObjectMetadataItem[] => {
|
||||
const unresolvedUniversalIds = new Set<string>();
|
||||
|
||||
for (const permission of defaultRole.objectPermissions) {
|
||||
for (const permission of defaultRole.objectPermissions ?? []) {
|
||||
if (
|
||||
!isDefined(objectUniversalIdToIdMap[permission.objectUniversalIdentifier])
|
||||
) {
|
||||
unresolvedUniversalIds.add(permission.objectUniversalIdentifier);
|
||||
}
|
||||
}
|
||||
for (const permission of defaultRole.fieldPermissions) {
|
||||
for (const permission of defaultRole.fieldPermissions ?? []) {
|
||||
if (
|
||||
!isDefined(objectUniversalIdToIdMap[permission.objectUniversalIdentifier])
|
||||
) {
|
||||
@@ -221,7 +226,9 @@ const buildobjectMetadataItemsFromMarketplaceApp = (
|
||||
buildFieldMetadataItemFromMarketplaceField,
|
||||
);
|
||||
|
||||
const objectFieldPermissions = defaultRole.fieldPermissions.filter(
|
||||
const objectFieldPermissions = (
|
||||
defaultRole.fieldPermissions ?? []
|
||||
).filter(
|
||||
(permission) => permission.objectUniversalIdentifier === universalId,
|
||||
);
|
||||
|
||||
@@ -277,8 +284,8 @@ const MarketplaceRoleEffect = ({
|
||||
marketplaceAppObjects,
|
||||
onObjectMetadataItemsFromMarketplaceApp,
|
||||
}: {
|
||||
defaultRole: MarketplaceAppDefaultRole;
|
||||
marketplaceAppObjects: MarketplaceAppObject[];
|
||||
defaultRole: RoleManifest;
|
||||
marketplaceAppObjects: ObjectManifest[];
|
||||
onObjectMetadataItemsFromMarketplaceApp: (
|
||||
items: EnrichedObjectMetadataItem[],
|
||||
) => void;
|
||||
@@ -286,7 +293,7 @@ const MarketplaceRoleEffect = ({
|
||||
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
|
||||
const setSettingsDraftRole = useSetAtomFamilyState(
|
||||
settingsDraftRoleFamilyState,
|
||||
defaultRole.id,
|
||||
defaultRole.universalIdentifier,
|
||||
);
|
||||
|
||||
const { resolvedRole, objectMetadataItemsFromMarketplaceApp } =
|
||||
@@ -329,8 +336,8 @@ const MarketplaceAppPermissions = ({
|
||||
defaultRole,
|
||||
marketplaceAppObjects,
|
||||
}: {
|
||||
defaultRole: MarketplaceAppDefaultRole;
|
||||
marketplaceAppObjects: MarketplaceAppObject[];
|
||||
defaultRole: RoleManifest;
|
||||
marketplaceAppObjects: ObjectManifest[];
|
||||
}) => {
|
||||
const [
|
||||
objectMetadataItemsFromMarketplaceApp,
|
||||
@@ -353,7 +360,7 @@ const MarketplaceAppPermissions = ({
|
||||
}
|
||||
/>
|
||||
<SettingsRolePermissions
|
||||
roleId={defaultRole.id}
|
||||
roleId={defaultRole.universalIdentifier}
|
||||
isEditable={false}
|
||||
objectMetadataItemsFromMarketplaceApp={
|
||||
objectMetadataItemsFromMarketplaceApp
|
||||
|
||||
+43
@@ -6,6 +6,7 @@ import { useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
H2Title,
|
||||
IconChartBar,
|
||||
IconCopy,
|
||||
IconDownload,
|
||||
IconExternalLink,
|
||||
IconTag,
|
||||
@@ -17,8 +18,10 @@ import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import {
|
||||
ApplicationRegistrationSourceType,
|
||||
FindApplicationRegistrationStatsDocument,
|
||||
GetApplicationShareLinkDocument,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
|
||||
import { type ApplicationRegistrationData } from '~/pages/settings/applications/tabs/types/ApplicationRegistrationData';
|
||||
|
||||
const StyledButtonGroup = styled.div`
|
||||
@@ -36,9 +39,21 @@ export const SettingsApplicationRegistrationDistributionTab = ({
|
||||
|
||||
const applicationRegistrationId = registration.id;
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard();
|
||||
|
||||
const isNpmSource =
|
||||
registration.sourceType === ApplicationRegistrationSourceType.NPM;
|
||||
|
||||
const isTarballSource =
|
||||
registration.sourceType === ApplicationRegistrationSourceType.TARBALL;
|
||||
|
||||
const { data: shareLinkData } = useQuery(GetApplicationShareLinkDocument, {
|
||||
variables: { id: applicationRegistrationId },
|
||||
skip: !isTarballSource || !applicationRegistrationId,
|
||||
});
|
||||
|
||||
const shareLink = shareLinkData?.getApplicationShareLink;
|
||||
|
||||
const { data: statsData } = useQuery(
|
||||
FindApplicationRegistrationStatsDocument,
|
||||
{
|
||||
@@ -111,6 +126,34 @@ export const SettingsApplicationRegistrationDistributionTab = ({
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{isTarballSource && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Share`}
|
||||
description={t`Share this link with other workspaces on this server to let them install this application.`}
|
||||
/>
|
||||
<StyledButtonGroup>
|
||||
<Button
|
||||
Icon={IconCopy}
|
||||
title={t`Copy share link`}
|
||||
variant="secondary"
|
||||
disabled={!shareLink}
|
||||
onClick={() => {
|
||||
if (shareLink) {
|
||||
copyToClipboard(shareLink, t`Share link copied to clipboard`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
Icon={IconExternalLink}
|
||||
title={t`View marketplace page`}
|
||||
variant="secondary"
|
||||
to={marketplacePageUrl}
|
||||
/>
|
||||
</StyledButtonGroup>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{hasStats && (
|
||||
<Section>
|
||||
<H2Title
|
||||
|
||||
-9
@@ -332,15 +332,6 @@ export const SettingsApplicationRegistrationGeneralTab = ({
|
||||
label: t`Name`,
|
||||
value: registration.name,
|
||||
},
|
||||
...(isNonEmptyString(registration.description)
|
||||
? [
|
||||
{
|
||||
Icon: IconTextCaption,
|
||||
label: t`Description`,
|
||||
value: registration.description,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
Icon: IconWorld,
|
||||
label: t`Universal ID`,
|
||||
|
||||
+88
-22
@@ -2,9 +2,9 @@ import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMeta
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useMemo } from 'react';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
import { type MarketplaceApp } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
type ApplicationDataTableFieldItem,
|
||||
type ApplicationDataTableRow,
|
||||
@@ -14,17 +14,22 @@ import { SettingsApplicationNameDescriptionTable } from '~/pages/settings/applic
|
||||
import { findObjectNameByUniversalIdentifier } from '~/pages/settings/applications/utils/findObjectNameByUniversalIdentifier';
|
||||
|
||||
export const SettingsAvailableApplicationDetailContentTab = ({
|
||||
application,
|
||||
applicationId,
|
||||
content,
|
||||
}: {
|
||||
application: MarketplaceApp;
|
||||
applicationId: string;
|
||||
content?: Manifest;
|
||||
}) => {
|
||||
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
|
||||
|
||||
const { objects, fields, logicFunctions, frontComponents } = application;
|
||||
const objects = content?.objects ?? [];
|
||||
const fields = content?.fields ?? [];
|
||||
const logicFunctions = content?.logicFunctions ?? [];
|
||||
const frontComponents = content?.frontComponents ?? [];
|
||||
|
||||
const objectRows = useMemo(
|
||||
(): ApplicationDataTableRow[] =>
|
||||
(objects ?? []).map((appObject) => ({
|
||||
objects.map((appObject) => ({
|
||||
key: appObject.nameSingular,
|
||||
labelPlural: appObject.labelPlural,
|
||||
icon: appObject.icon ?? undefined,
|
||||
@@ -37,13 +42,13 @@ export const SettingsAvailableApplicationDetailContentTab = ({
|
||||
type: field.type,
|
||||
}),
|
||||
),
|
||||
tagItem: { applicationId: application.id },
|
||||
tagItem: { applicationId },
|
||||
})),
|
||||
[objects, application.id],
|
||||
[objects, applicationId],
|
||||
);
|
||||
|
||||
const fieldGroupRows = useMemo((): ApplicationDataTableRow[] => {
|
||||
if (!isDefined(fields) || fields.length === 0) {
|
||||
if (fields.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -56,11 +61,9 @@ export const SettingsAvailableApplicationDetailContentTab = ({
|
||||
>();
|
||||
|
||||
for (const field of fields) {
|
||||
if (!isDefined(field.objectUniversalIdentifier)) {
|
||||
continue;
|
||||
}
|
||||
const objectUid = field.objectUniversalIdentifier;
|
||||
|
||||
const existing = groupMap.get(field.objectUniversalIdentifier);
|
||||
const existing = groupMap.get(objectUid);
|
||||
const fieldItem: ApplicationDataTableFieldItem = {
|
||||
key: field.name,
|
||||
label: field.label,
|
||||
@@ -71,17 +74,16 @@ export const SettingsAvailableApplicationDetailContentTab = ({
|
||||
if (isDefined(existing)) {
|
||||
existing.fieldItems.push(fieldItem);
|
||||
} else {
|
||||
groupMap.set(field.objectUniversalIdentifier, {
|
||||
objectUniversalIdentifier: field.objectUniversalIdentifier,
|
||||
groupMap.set(objectUid, {
|
||||
objectUniversalIdentifier: objectUid,
|
||||
fieldItems: [fieldItem],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groupMap.values()).map((group) => {
|
||||
const appObject = objects?.find(
|
||||
(appObj) =>
|
||||
appObj.universalIdentifier === group.objectUniversalIdentifier,
|
||||
const appObject = objects.find(
|
||||
(obj) => obj.universalIdentifier === group.objectUniversalIdentifier,
|
||||
);
|
||||
|
||||
if (isDefined(appObject)) {
|
||||
@@ -91,7 +93,7 @@ export const SettingsAvailableApplicationDetailContentTab = ({
|
||||
icon: appObject.icon ?? undefined,
|
||||
fieldsCount: group.fieldItems.length,
|
||||
fields: group.fieldItems,
|
||||
tagItem: { applicationId: application.id },
|
||||
tagItem: { applicationId },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,7 +125,14 @@ export const SettingsAvailableApplicationDetailContentTab = ({
|
||||
tagItem: {},
|
||||
};
|
||||
});
|
||||
}, [fields, objectMetadataItems, objects, application.id]);
|
||||
}, [fields, objectMetadataItems, objects, applicationId]);
|
||||
|
||||
const roles = content?.roles ?? [];
|
||||
const skills = content?.skills ?? [];
|
||||
const agents = content?.agents ?? [];
|
||||
const views = content?.views ?? [];
|
||||
const navigationMenuItems = content?.navigationMenuItems ?? [];
|
||||
const pageLayouts = content?.pageLayouts ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -132,16 +141,73 @@ export const SettingsAvailableApplicationDetailContentTab = ({
|
||||
fieldGroupRows={fieldGroupRows}
|
||||
/>
|
||||
<SettingsApplicationNameDescriptionTable
|
||||
title={t`Functions`}
|
||||
title={t`Logic functions`}
|
||||
description={t`Logic functions provided by this app`}
|
||||
sectionTitle={t`Logic functions`}
|
||||
items={logicFunctions}
|
||||
items={logicFunctions.map((lf) => ({
|
||||
name: lf.name ?? lf.universalIdentifier,
|
||||
description: lf.description,
|
||||
}))}
|
||||
/>
|
||||
<SettingsApplicationNameDescriptionTable
|
||||
title={t`Front components`}
|
||||
description={t`UI components provided by this app`}
|
||||
sectionTitle={t`Front components`}
|
||||
items={frontComponents}
|
||||
items={frontComponents.map((fc) => ({
|
||||
name: fc.name ?? fc.universalIdentifier,
|
||||
description: fc.description,
|
||||
}))}
|
||||
/>
|
||||
<SettingsApplicationNameDescriptionTable
|
||||
title={t`Roles`}
|
||||
description={t`Roles defined by this app`}
|
||||
sectionTitle={t`Roles`}
|
||||
items={roles.map((role) => ({
|
||||
name: role.label,
|
||||
description: role.description,
|
||||
}))}
|
||||
/>
|
||||
<SettingsApplicationNameDescriptionTable
|
||||
title={t`Skills`}
|
||||
description={t`Skills provided by this app`}
|
||||
sectionTitle={t`Skills`}
|
||||
items={skills.map((skill) => ({
|
||||
name: skill.label ?? skill.name,
|
||||
description: skill.description,
|
||||
}))}
|
||||
/>
|
||||
<SettingsApplicationNameDescriptionTable
|
||||
title={t`Agents`}
|
||||
description={t`Agents provided by this app`}
|
||||
sectionTitle={t`Agents`}
|
||||
items={agents.map((agent) => ({
|
||||
name: agent.label ?? agent.name,
|
||||
description: agent.description,
|
||||
}))}
|
||||
/>
|
||||
<SettingsApplicationNameDescriptionTable
|
||||
title={t`Views`}
|
||||
description={t`Views created by this app`}
|
||||
sectionTitle={t`Views`}
|
||||
items={views.map((view) => ({
|
||||
name: view.name,
|
||||
}))}
|
||||
/>
|
||||
<SettingsApplicationNameDescriptionTable
|
||||
title={t`Navigation menu items`}
|
||||
description={t`Navigation items added by this app`}
|
||||
sectionTitle={t`Navigation items`}
|
||||
items={navigationMenuItems.map((item) => ({
|
||||
name: item.name ?? item.universalIdentifier,
|
||||
}))}
|
||||
/>
|
||||
<SettingsApplicationNameDescriptionTable
|
||||
title={t`Page layouts`}
|
||||
description={t`Page layouts defined by this app`}
|
||||
sectionTitle={t`Page layouts`}
|
||||
items={pageLayouts.map((layout) => ({
|
||||
name: layout.name,
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-sdk",
|
||||
"version": "0.8.0-canary.5",
|
||||
"version": "0.8.0-canary.6",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.mjs",
|
||||
"types": "dist/sdk/index.d.ts",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AppInstallCommand } from './install';
|
||||
import { AppPublishCommand } from './publish';
|
||||
import { AppTypecheckCommand } from './typecheck';
|
||||
import { AppUninstallCommand } from './uninstall';
|
||||
import { CatalogSyncCommand } from './catalog-sync';
|
||||
import { DeployCommand } from './deploy';
|
||||
import { LogicFunctionExecuteCommand } from './exec';
|
||||
import { LogicFunctionLogsCommand } from './logs';
|
||||
@@ -22,6 +23,7 @@ export const registerCommands = (program: Command): void => {
|
||||
const publishCommand = new AppPublishCommand();
|
||||
const typecheckCommand = new AppTypecheckCommand();
|
||||
const uninstallCommand = new AppUninstallCommand();
|
||||
const catalogSyncCommand = new CatalogSyncCommand();
|
||||
const deployCommand = new DeployCommand();
|
||||
const addCommand = new EntityAddCommand();
|
||||
const logsCommand = new LogicFunctionLogsCommand();
|
||||
@@ -80,6 +82,16 @@ export const registerCommands = (program: Command): void => {
|
||||
});
|
||||
});
|
||||
|
||||
program
|
||||
.command('catalog-sync')
|
||||
.description('Trigger marketplace catalog sync on the server')
|
||||
.option('-r, --remote <name>', 'Sync on a specific remote')
|
||||
.action(async (options) => {
|
||||
await catalogSyncCommand.execute({
|
||||
remote: options.remote,
|
||||
});
|
||||
});
|
||||
|
||||
program
|
||||
.command('typecheck [appPath]')
|
||||
.description('Run TypeScript type checking on the application')
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ApiService } from '@/cli/utilities/api/api-service';
|
||||
import { ConfigService } from '@/cli/utilities/config/config-service';
|
||||
import chalk from 'chalk';
|
||||
|
||||
export type CatalogSyncCommandOptions = {
|
||||
remote?: string;
|
||||
};
|
||||
|
||||
export class CatalogSyncCommand {
|
||||
async execute(options: CatalogSyncCommandOptions): Promise<void> {
|
||||
if (options.remote) {
|
||||
ConfigService.setActiveRemote(options.remote);
|
||||
}
|
||||
|
||||
console.log(chalk.blue('Syncing marketplace catalog...'));
|
||||
|
||||
const apiService = new ApiService();
|
||||
|
||||
const result = await apiService.syncMarketplaceCatalog();
|
||||
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
`Catalog sync failed: ${result.error instanceof Error ? result.error.message : String(result.error)}`,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.green('✓ Marketplace catalog synced successfully'));
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@ export const registerRemoteCommands = (program: Command): void => {
|
||||
.option('--as <name>', 'Name for this remote')
|
||||
.option('--token <token>', 'API key for non-interactive auth')
|
||||
.option('--url <url>', 'Server URL (alternative to positional arg)')
|
||||
.option('--local', 'Connect to a local Twenty server (auto-detect)')
|
||||
.action(
|
||||
async (
|
||||
nameOrUrl: string | undefined,
|
||||
@@ -78,6 +79,7 @@ export const registerRemoteCommands = (program: Command): void => {
|
||||
as?: string;
|
||||
token?: string;
|
||||
url?: string;
|
||||
local?: boolean;
|
||||
},
|
||||
) => {
|
||||
const configService = new ConfigService();
|
||||
@@ -96,30 +98,50 @@ export const registerRemoteCommands = (program: Command): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the URL — from args, flags, or interactive prompt
|
||||
const apiUrl =
|
||||
nameOrUrl ??
|
||||
options.url ??
|
||||
(options.token
|
||||
? ((await detectLocalServer()) ?? 'http://localhost:2020')
|
||||
: (
|
||||
await inquirer.prompt<{ apiUrl: string }>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'apiUrl',
|
||||
message: 'Twenty server URL:',
|
||||
validate: (input: string) => {
|
||||
try {
|
||||
new URL(input);
|
||||
// Resolve the URL — from args, flags, auto-detect, or interactive prompt
|
||||
let apiUrl = nameOrUrl ?? options.url;
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return 'Please enter a valid URL';
|
||||
}
|
||||
},
|
||||
if (!apiUrl) {
|
||||
const detectedUrl = await detectLocalServer();
|
||||
|
||||
if (options.local) {
|
||||
if (!detectedUrl) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
'No local Twenty server found on ports 2020 or 3000.\n' +
|
||||
'Start one with: yarn twenty server start',
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
apiUrl = detectedUrl;
|
||||
} else if (detectedUrl) {
|
||||
console.log(chalk.gray(`Found local server at ${detectedUrl}`));
|
||||
apiUrl = detectedUrl;
|
||||
} else if (options.token) {
|
||||
apiUrl = 'http://localhost:2020';
|
||||
} else {
|
||||
apiUrl = (
|
||||
await inquirer.prompt<{ apiUrl: string }>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'apiUrl',
|
||||
message: 'Twenty server URL:',
|
||||
validate: (input: string) => {
|
||||
try {
|
||||
new URL(input);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return 'Please enter a valid URL';
|
||||
}
|
||||
},
|
||||
])
|
||||
).apiUrl);
|
||||
},
|
||||
])
|
||||
).apiUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const name = options.as ?? deriveRemoteName(apiUrl);
|
||||
|
||||
|
||||
@@ -42,8 +42,7 @@ const innerAppPublish = async (
|
||||
success: false,
|
||||
error: {
|
||||
code: APP_ERROR_CODES.PUBLISH_FAILED,
|
||||
message:
|
||||
'npm publish failed. Make sure you are logged in (`npm login`) and the package name is available.',
|
||||
message: `npm publish failed`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,6 +66,10 @@ export class ApiService {
|
||||
return this.applicationApi.uninstallApplication(universalIdentifier);
|
||||
}
|
||||
|
||||
syncMarketplaceCatalog(): Promise<ApiResponse<boolean>> {
|
||||
return this.applicationApi.syncMarketplaceCatalog();
|
||||
}
|
||||
|
||||
getSchema(options?: { authToken?: string }): Promise<ApiResponse<string>> {
|
||||
return this.schemaApi.getSchema(options);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,44 @@ import { type Manifest } from 'twenty-shared/application';
|
||||
export class ApplicationApi {
|
||||
constructor(private readonly client: AxiosInstance) {}
|
||||
|
||||
async syncMarketplaceCatalog(): Promise<ApiResponse<boolean>> {
|
||||
try {
|
||||
const query = `
|
||||
mutation SyncMarketplaceCatalog {
|
||||
syncMarketplaceCatalog
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await this.client.post(
|
||||
'/metadata',
|
||||
{ query },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: '*/*',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data.errors) {
|
||||
return {
|
||||
success: false,
|
||||
error: response.data.errors[0],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.data.syncMarketplaceCatalog,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async findApplicationRegistrationByUniversalIdentifier(
|
||||
universalIdentifier: string,
|
||||
): Promise<
|
||||
@@ -63,7 +101,6 @@ export class ApplicationApi {
|
||||
|
||||
async createApplicationRegistration(input: {
|
||||
name: string;
|
||||
description?: string;
|
||||
universalIdentifier: string;
|
||||
}): Promise<
|
||||
ApiResponse<{
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
|
||||
import { ApiService } from '@/cli/utilities/api/api-service';
|
||||
import { type BuiltFileInfo } from '@/cli/utilities/build/common/build-application';
|
||||
import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest-update-checksums';
|
||||
import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer';
|
||||
import { serializeError } from '@/cli/utilities/error/serialize-error';
|
||||
import { FileUploader } from '@/cli/utilities/file/file-uploader';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
|
||||
export type AppSyncOptions = {
|
||||
appPath: string;
|
||||
remote?: string;
|
||||
};
|
||||
|
||||
const ensureApplicationRegistrationExists = async (
|
||||
apiService: ApiService,
|
||||
manifest: Manifest,
|
||||
): Promise<CommandResult> => {
|
||||
const universalIdentifier = manifest.application.universalIdentifier;
|
||||
|
||||
const findResult =
|
||||
await apiService.findApplicationRegistrationByUniversalIdentifier(
|
||||
universalIdentifier,
|
||||
);
|
||||
|
||||
if (findResult.success && findResult.data) {
|
||||
return { success: true, data: undefined };
|
||||
}
|
||||
|
||||
const createResult = await apiService.createApplicationRegistration({
|
||||
name: manifest.application.displayName,
|
||||
description: manifest.application.description,
|
||||
universalIdentifier,
|
||||
});
|
||||
|
||||
if (!createResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: APP_ERROR_CODES.SYNC_FAILED,
|
||||
message: `Failed to create application registration: ${serializeError(createResult.error)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, data: undefined };
|
||||
};
|
||||
|
||||
const ensureDevelopmentApplicationExists = async (
|
||||
apiService: ApiService,
|
||||
manifest: Manifest,
|
||||
): Promise<CommandResult> => {
|
||||
const result = await apiService.createDevelopmentApplication({
|
||||
universalIdentifier: manifest.application.universalIdentifier,
|
||||
name: manifest.application.displayName,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: APP_ERROR_CODES.SYNC_FAILED,
|
||||
message: `Failed to create development application: ${serializeError(result.error)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, data: undefined };
|
||||
};
|
||||
|
||||
export const synchronizeBuiltApplication = async ({
|
||||
appPath,
|
||||
manifest,
|
||||
builtFileInfos,
|
||||
}: {
|
||||
appPath: string;
|
||||
manifest: Manifest;
|
||||
builtFileInfos: Map<string, BuiltFileInfo>;
|
||||
}): Promise<CommandResult> => {
|
||||
const apiService = new ApiService();
|
||||
const universalIdentifier = manifest.application.universalIdentifier;
|
||||
|
||||
const registrationResult = await ensureApplicationRegistrationExists(
|
||||
apiService,
|
||||
manifest,
|
||||
);
|
||||
|
||||
if (!registrationResult.success) {
|
||||
return registrationResult;
|
||||
}
|
||||
|
||||
const applicationResult = await ensureDevelopmentApplicationExists(
|
||||
apiService,
|
||||
manifest,
|
||||
);
|
||||
|
||||
if (!applicationResult.success) {
|
||||
return applicationResult;
|
||||
}
|
||||
|
||||
const fileUploader = new FileUploader({
|
||||
applicationUniversalIdentifier: universalIdentifier,
|
||||
appPath,
|
||||
});
|
||||
|
||||
const uploadResults = await Promise.all(
|
||||
[...builtFileInfos.values()].map((fileInfo) =>
|
||||
fileUploader.uploadFile({
|
||||
builtPath: fileInfo.builtPath,
|
||||
fileFolder: fileInfo.fileFolder,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const failedUploads = uploadResults.filter((result) => !result.success);
|
||||
|
||||
if (failedUploads.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: APP_ERROR_CODES.SYNC_FAILED,
|
||||
message: `Failed to upload ${failedUploads.length} file(s).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const updatedManifest = manifestUpdateChecksums({
|
||||
manifest,
|
||||
builtFileInfos,
|
||||
});
|
||||
|
||||
await writeManifestToOutput(appPath, updatedManifest);
|
||||
|
||||
const syncResult = await apiService.syncApplication(updatedManifest);
|
||||
|
||||
if (!syncResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: APP_ERROR_CODES.SYNC_FAILED,
|
||||
message: `Sync failed: ${serializeError(syncResult.error)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, data: undefined };
|
||||
};
|
||||
-1
@@ -70,7 +70,6 @@ export class RegisterAppOrchestratorStep {
|
||||
|
||||
const createResult = await this.apiService.createApplicationRegistration({
|
||||
name: input.manifest.application.displayName,
|
||||
description: input.manifest.application.description,
|
||||
universalIdentifier,
|
||||
});
|
||||
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class RenameMarketplaceDisplayDataToManifest1774472400000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'RenameMarketplaceDisplayDataToManifest1774472400000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" RENAME COLUMN "marketplaceDisplayData" TO "manifest"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN IF EXISTS "description"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN IF EXISTS "logoUrl"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN IF EXISTS "author"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN IF EXISTS "websiteUrl"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN IF EXISTS "termsUrl"`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" ADD "termsUrl" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" ADD "websiteUrl" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" ADD "author" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" ADD "logoUrl" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" ADD "description" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" RENAME COLUMN "manifest" TO "marketplaceDisplayData"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+24
-46
@@ -24,6 +24,7 @@ import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
|
||||
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/application-oauth/dtos/application-token-pair.dto';
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
|
||||
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
|
||||
import {
|
||||
@@ -36,6 +37,7 @@ import { FileStorageService } from 'src/engine/core-modules/file-storage/file-st
|
||||
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
@@ -64,6 +66,7 @@ export class ApplicationDevelopmentResolver {
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly sdkClientGenerationService: SdkClientGenerationService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => DevelopmentApplicationDTO)
|
||||
@@ -71,10 +74,8 @@ export class ApplicationDevelopmentResolver {
|
||||
@Args() { universalIdentifier, name }: CreateDevelopmentApplicationInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<DevelopmentApplicationDTO> {
|
||||
const applicationRegistrationId = await this.findApplicationRegistrationId(
|
||||
universalIdentifier,
|
||||
workspaceId,
|
||||
);
|
||||
const applicationRegistrationId =
|
||||
await this.findApplicationRegistrationId(universalIdentifier);
|
||||
|
||||
const existing = await this.applicationService.findByUniversalIdentifier({
|
||||
universalIdentifier,
|
||||
@@ -125,7 +126,6 @@ export class ApplicationDevelopmentResolver {
|
||||
): Promise<WorkspaceMigrationDTO> {
|
||||
const applicationRegistrationId = await this.findApplicationRegistrationId(
|
||||
manifest.application.universalIdentifier,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const application = await this.applicationService.findByUniversalIdentifier(
|
||||
@@ -164,6 +164,7 @@ export class ApplicationDevelopmentResolver {
|
||||
applicationRegistrationId,
|
||||
manifest,
|
||||
workspaceId,
|
||||
application.id,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -216,7 +217,6 @@ export class ApplicationDevelopmentResolver {
|
||||
|
||||
private async findApplicationRegistrationId(
|
||||
universalIdentifier: string,
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
const existingRegistration =
|
||||
await this.applicationRegistrationService.findOneByUniversalIdentifier(
|
||||
@@ -230,55 +230,33 @@ export class ApplicationDevelopmentResolver {
|
||||
);
|
||||
}
|
||||
|
||||
const isOwner =
|
||||
await this.applicationRegistrationService.isOwnedByWorkspace(
|
||||
existingRegistration.id,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!isOwner) {
|
||||
throw new ApplicationException(
|
||||
'Cannot sync application: registration is owned by another workspace',
|
||||
ApplicationExceptionCode.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
return existingRegistration.id;
|
||||
}
|
||||
|
||||
private async syncRegistrationMetadata(
|
||||
applicationRegistrationId: string,
|
||||
manifest: { application: ApplicationInput['manifest']['application'] },
|
||||
manifest: ApplicationInput['manifest'],
|
||||
workspaceId: string,
|
||||
applicationId: string,
|
||||
): Promise<void> {
|
||||
const isOwner =
|
||||
await this.applicationRegistrationService.isOwnedByWorkspace(
|
||||
const serverUrl = this.twentyConfigService.get('SERVER_URL');
|
||||
|
||||
const manifestWithResolvedUrls = resolveManifestAssetUrls(
|
||||
manifest,
|
||||
(filePath) =>
|
||||
`${serverUrl}/public-assets/${workspaceId}/${applicationId}/${filePath}`,
|
||||
);
|
||||
|
||||
await this.applicationRegistrationService.updateFromManifest(
|
||||
applicationRegistrationId,
|
||||
manifestWithResolvedUrls,
|
||||
);
|
||||
|
||||
if (manifest.application.serverVariables) {
|
||||
await this.applicationRegistrationVariableService.syncVariableSchemas(
|
||||
applicationRegistrationId,
|
||||
workspaceId,
|
||||
manifest.application.serverVariables,
|
||||
);
|
||||
|
||||
if (isOwner) {
|
||||
await this.applicationRegistrationService.update(
|
||||
{
|
||||
id: applicationRegistrationId,
|
||||
update: {
|
||||
name: manifest.application.displayName,
|
||||
description: manifest.application.description,
|
||||
logoUrl: manifest.application.logoUrl,
|
||||
author: manifest.application.author,
|
||||
websiteUrl: manifest.application.websiteUrl,
|
||||
termsUrl: manifest.application.termsUrl,
|
||||
},
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (manifest.application.serverVariables) {
|
||||
await this.applicationRegistrationVariableService.syncVariableSchemas(
|
||||
applicationRegistrationId,
|
||||
manifest.application.serverVariables,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-153
@@ -1,32 +1,22 @@
|
||||
import { type MarketplaceDisplayData } from 'src/engine/core-modules/application/application-marketplace/types/marketplace-display-data.type';
|
||||
|
||||
export type CuratedAppEntry = {
|
||||
universalIdentifier: string;
|
||||
sourcePackage: string;
|
||||
isFeatured: boolean;
|
||||
|
||||
name: string;
|
||||
description: string;
|
||||
author: string;
|
||||
logoUrl?: string;
|
||||
websiteUrl?: string;
|
||||
termsUrl?: string;
|
||||
|
||||
richDisplayData: MarketplaceDisplayData;
|
||||
latestAvailableVersion?: string;
|
||||
};
|
||||
|
||||
const MOCK_ENRICHMENT_APP_ID = 'a1b2c3d4-0000-0000-0000-000000000001';
|
||||
const MOCK_ENRICHMENT_JOB_ID = 'a1b2c3d4-0000-0000-0000-000000000100';
|
||||
|
||||
const COMPANY_UNIVERSAL_ID = '20202020-b374-4779-a561-80086cb2e17f';
|
||||
const PERSON_UNIVERSAL_ID = '20202020-e674-48e5-a542-72570eee7213';
|
||||
|
||||
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>`;
|
||||
const ENCODED_MOCK_LOGO = `data:image/svg+xml,${encodeURIComponent(MOCK_LOGO_SVG)}`;
|
||||
|
||||
export const MARKETPLACE_CATALOG_INDEX: CuratedAppEntry[] = [
|
||||
{
|
||||
universalIdentifier: MOCK_ENRICHMENT_APP_ID,
|
||||
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000001',
|
||||
sourcePackage: '@twentyhq/app-data-enrichment',
|
||||
isFeatured: true,
|
||||
name: 'Data Enrichment',
|
||||
@@ -34,146 +24,6 @@ export const MARKETPLACE_CATALOG_INDEX: CuratedAppEntry[] = [
|
||||
author: 'Twenty',
|
||||
logoUrl: ENCODED_MOCK_LOGO,
|
||||
websiteUrl: 'https://twenty.com',
|
||||
richDisplayData: {
|
||||
icon: 'IconSparkles',
|
||||
version: '1.0.0',
|
||||
category: 'Data',
|
||||
logo: ENCODED_MOCK_LOGO,
|
||||
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'],
|
||||
objects: [
|
||||
{
|
||||
universalIdentifier: MOCK_ENRICHMENT_JOB_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: 'a1b2c3d4-0000-0000-0000-000000000101',
|
||||
objectUniversalIdentifier: MOCK_ENRICHMENT_JOB_ID,
|
||||
},
|
||||
{
|
||||
name: 'provider',
|
||||
type: 'TEXT',
|
||||
label: 'Provider',
|
||||
description: 'Enrichment provider used',
|
||||
icon: 'IconCloud',
|
||||
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000102',
|
||||
objectUniversalIdentifier: MOCK_ENRICHMENT_JOB_ID,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
name: 'industry',
|
||||
type: 'TEXT',
|
||||
label: 'Industry',
|
||||
description: 'Company industry from enrichment',
|
||||
icon: 'IconBuildingFactory2',
|
||||
objectUniversalIdentifier: COMPANY_UNIVERSAL_ID,
|
||||
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000201',
|
||||
},
|
||||
{
|
||||
name: 'linkedInUrl',
|
||||
type: 'LINKS',
|
||||
label: 'LinkedIn URL',
|
||||
description: 'LinkedIn profile URL from enrichment',
|
||||
icon: 'IconBrandLinkedin',
|
||||
objectUniversalIdentifier: PERSON_UNIVERSAL_ID,
|
||||
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000203',
|
||||
},
|
||||
],
|
||||
logicFunctions: [
|
||||
{
|
||||
name: 'enrich-on-create',
|
||||
description:
|
||||
'Automatically enriches new records when they are created',
|
||||
timeoutSeconds: 30,
|
||||
},
|
||||
],
|
||||
frontComponents: [],
|
||||
defaultRole: {
|
||||
id: 'a1b2c3d4-0000-0000-0000-000000000010',
|
||||
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: [
|
||||
{
|
||||
objectUniversalIdentifier: COMPANY_UNIVERSAL_ID,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
{
|
||||
objectUniversalIdentifier: PERSON_UNIVERSAL_ID,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
],
|
||||
fieldPermissions: [],
|
||||
permissionFlags: ['DATA_MODEL', 'API_KEYS_AND_WEBHOOKS'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||||
sourcePackage: '@twentyhq/hello-world',
|
||||
isFeatured: false,
|
||||
name: 'Hello World',
|
||||
description: 'A simple hello world app to get started with Twenty apps.',
|
||||
author: 'Twenty',
|
||||
websiteUrl: 'https://twenty.com',
|
||||
richDisplayData: {
|
||||
icon: 'IconWorld',
|
||||
version: '0.2.2',
|
||||
category: 'Getting Started',
|
||||
screenshots: [],
|
||||
aboutDescription:
|
||||
'A minimal example app that demonstrates the Twenty app framework. Creates a PostCard object and a logic function to generate new postcards. Great starting point for building your own apps.',
|
||||
providers: [],
|
||||
objects: [
|
||||
{
|
||||
universalIdentifier: 'e2c3d4f5-0000-0000-0000-000000000001',
|
||||
nameSingular: 'postCard',
|
||||
namePlural: 'postCards',
|
||||
labelSingular: 'Post Card',
|
||||
labelPlural: 'Post Cards',
|
||||
description: 'A simple postcard object',
|
||||
icon: 'IconMail',
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
fields: [],
|
||||
logicFunctions: [
|
||||
{
|
||||
name: 'create-new-post-card',
|
||||
description: 'Creates a new postcard record',
|
||||
},
|
||||
],
|
||||
frontComponents: [],
|
||||
},
|
||||
latestAvailableVersion: '1.0.0',
|
||||
},
|
||||
];
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import { MarketplaceCatalogSyncService } from 'src/engine/core-modules/application/application-marketplace/marketplace-catalog-sync.service';
|
||||
|
||||
@Command({
|
||||
name: 'marketplace:catalog-sync',
|
||||
description: 'Sync the marketplace catalog into ApplicationRegistration',
|
||||
})
|
||||
export class MarketplaceCatalogSyncCommand extends CommandRunner {
|
||||
constructor(
|
||||
private readonly marketplaceCatalogSyncService: MarketplaceCatalogSyncService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.marketplaceCatalogSyncService.syncCatalog();
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { GraphQLJSON } from 'graphql-type-json';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
|
||||
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
|
||||
|
||||
@ObjectType('MarketplaceAppDetail')
|
||||
export class MarketplaceAppDetailDTO {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
universalIdentifier: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
name: string;
|
||||
|
||||
@Field(() => ApplicationRegistrationSourceType)
|
||||
sourceType: ApplicationRegistrationSourceType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
sourcePackage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
latestAvailableVersion?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@Field(() => Boolean)
|
||||
isListed: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@Field(() => Boolean)
|
||||
isFeatured: boolean;
|
||||
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
manifest?: Manifest;
|
||||
}
|
||||
+1
-241
@@ -1,204 +1,12 @@
|
||||
import { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
@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 {
|
||||
@@ -221,10 +29,6 @@ export class MarketplaceAppDTO {
|
||||
@Field()
|
||||
icon: string;
|
||||
|
||||
@IsString()
|
||||
@Field()
|
||||
version: string;
|
||||
|
||||
@IsString()
|
||||
@Field()
|
||||
author: string;
|
||||
@@ -238,50 +42,6 @@ export class MarketplaceAppDTO {
|
||||
@Field({ nullable: true })
|
||||
logo?: string;
|
||||
|
||||
@IsArray()
|
||||
@Field(() => [String])
|
||||
screenshots: string[];
|
||||
|
||||
@IsString()
|
||||
@Field()
|
||||
aboutDescription: string;
|
||||
|
||||
@IsArray()
|
||||
@Field(() => [String])
|
||||
providers: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
websiteUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@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()
|
||||
@ValidateNested()
|
||||
@Type(() => MarketplaceAppDefaultRoleDTO)
|
||||
@Field(() => MarketplaceAppDefaultRoleDTO, { nullable: true })
|
||||
defaultRole?: MarketplaceAppDefaultRoleDTO;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
|
||||
+46
-27
@@ -4,6 +4,9 @@ import { ApplicationRegistrationService } from 'src/engine/core-modules/applicat
|
||||
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
|
||||
import { MARKETPLACE_CATALOG_INDEX } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-catalog-index.constant';
|
||||
import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/marketplace.service';
|
||||
import { buildRegistryCdnUrl } from 'src/engine/core-modules/application/application-marketplace/utils/build-registry-cdn-url.util';
|
||||
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class MarketplaceCatalogSyncService {
|
||||
@@ -12,11 +15,12 @@ export class MarketplaceCatalogSyncService {
|
||||
constructor(
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
async syncCatalog(): Promise<void> {
|
||||
await this.syncCuratedApps();
|
||||
await this.syncNpmApps();
|
||||
await this.syncRegistryApps();
|
||||
|
||||
this.logger.log('Marketplace catalog sync completed');
|
||||
}
|
||||
@@ -27,18 +31,12 @@ export class MarketplaceCatalogSyncService {
|
||||
await this.applicationRegistrationService.upsertFromCatalog({
|
||||
universalIdentifier: entry.universalIdentifier,
|
||||
name: entry.name,
|
||||
description:
|
||||
entry.richDisplayData.aboutDescription ?? entry.description,
|
||||
author: entry.author,
|
||||
sourceType: ApplicationRegistrationSourceType.NPM,
|
||||
sourcePackage: entry.sourcePackage,
|
||||
logoUrl: entry.logoUrl ?? null,
|
||||
websiteUrl: entry.websiteUrl ?? null,
|
||||
termsUrl: entry.termsUrl ?? null,
|
||||
latestAvailableVersion: entry.richDisplayData.version ?? null,
|
||||
latestAvailableVersion: entry.latestAvailableVersion ?? null,
|
||||
isListed: true,
|
||||
isFeatured: entry.isFeatured,
|
||||
marketplaceDisplayData: entry.richDisplayData,
|
||||
manifest: null,
|
||||
ownerWorkspaceId: null,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -49,38 +47,59 @@ export class MarketplaceCatalogSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
private async syncNpmApps(): Promise<void> {
|
||||
const npmApps = await this.marketplaceService.fetchAppsFromNpmRegistry();
|
||||
private async syncRegistryApps(): Promise<void> {
|
||||
const packages = await this.marketplaceService.fetchAppsFromRegistry();
|
||||
|
||||
const curatedIdentifiers = new Set(
|
||||
MARKETPLACE_CATALOG_INDEX.map((entry) => entry.universalIdentifier),
|
||||
);
|
||||
|
||||
for (const app of npmApps) {
|
||||
if (curatedIdentifiers.has(app.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const pkg of packages) {
|
||||
try {
|
||||
const manifest =
|
||||
await this.marketplaceService.fetchManifestFromRegistryCdn(
|
||||
pkg.name,
|
||||
pkg.version,
|
||||
);
|
||||
|
||||
if (!manifest) {
|
||||
this.logger.debug(`Skipping ${pkg.name}: no manifest found on CDN`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const universalIdentifier = manifest.application.universalIdentifier;
|
||||
|
||||
if (curatedIdentifiers.has(universalIdentifier)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cdnBaseUrl = this.twentyConfigService.get('APP_REGISTRY_CDN_URL');
|
||||
|
||||
const manifestWithResolvedUrls = resolveManifestAssetUrls(
|
||||
manifest,
|
||||
(filePath) =>
|
||||
buildRegistryCdnUrl({
|
||||
cdnBaseUrl,
|
||||
packageName: pkg.name,
|
||||
version: pkg.version,
|
||||
filePath,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.applicationRegistrationService.upsertFromCatalog({
|
||||
universalIdentifier: app.id,
|
||||
name: app.name,
|
||||
description: app.description,
|
||||
author: app.author,
|
||||
universalIdentifier,
|
||||
name: manifest.application.displayName ?? pkg.name,
|
||||
sourceType: ApplicationRegistrationSourceType.NPM,
|
||||
sourcePackage: app.sourcePackage ?? app.name,
|
||||
logoUrl: null,
|
||||
websiteUrl: app.websiteUrl ?? null,
|
||||
termsUrl: null,
|
||||
latestAvailableVersion: app.version ?? null,
|
||||
sourcePackage: pkg.name,
|
||||
latestAvailableVersion: pkg.version ?? null,
|
||||
isListed: true,
|
||||
isFeatured: false,
|
||||
marketplaceDisplayData: null,
|
||||
manifest: manifestWithResolvedUrls,
|
||||
ownerWorkspaceId: null,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to sync npm app "${app.name}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
`Failed to sync registry app "${pkg.name}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+30
-36
@@ -10,17 +10,14 @@ import {
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.cron.job';
|
||||
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
|
||||
import { MarketplaceAppDetailDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app-detail.dto';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
|
||||
const MARKETPLACE_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class MarketplaceQueryService {
|
||||
private readonly logger = new Logger(MarketplaceQueryService.name);
|
||||
private cachedApps: MarketplaceAppDTO[] | null = null;
|
||||
private cacheExpiresAt = 0;
|
||||
private hasSyncBeenEnqueued = false;
|
||||
|
||||
constructor(
|
||||
@@ -30,10 +27,6 @@ export class MarketplaceQueryService {
|
||||
) {}
|
||||
|
||||
async findManyMarketplaceApps(): Promise<MarketplaceAppDTO[]> {
|
||||
if (this.cachedApps !== null && Date.now() < this.cacheExpiresAt) {
|
||||
return this.cachedApps;
|
||||
}
|
||||
|
||||
const registrations =
|
||||
await this.applicationRegistrationService.findManyListed();
|
||||
|
||||
@@ -46,27 +39,25 @@ export class MarketplaceQueryService {
|
||||
await this.messageQueueService.add(
|
||||
MarketplaceCatalogSyncCronJob.name,
|
||||
{},
|
||||
{ id: 'marketplace-catalog-sync' }, // Avoids triggering multiple pending jobs
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
this.cachedApps = registrations.map((registration) =>
|
||||
return registrations.map((registration) =>
|
||||
this.toMarketplaceAppDTO(registration),
|
||||
);
|
||||
this.cacheExpiresAt = Date.now() + MARKETPLACE_CACHE_TTL_MS;
|
||||
|
||||
return this.cachedApps;
|
||||
}
|
||||
|
||||
async findOneMarketplaceApp(
|
||||
async findMarketplaceAppDetail(
|
||||
universalIdentifier: string,
|
||||
): Promise<MarketplaceAppDTO> {
|
||||
): Promise<MarketplaceAppDetailDTO> {
|
||||
const registration =
|
||||
await this.findRegistrationByUniversalIdentifier(universalIdentifier);
|
||||
|
||||
return this.toMarketplaceAppDTO(registration);
|
||||
return this.toMarketplaceAppDetailDTO(registration);
|
||||
}
|
||||
|
||||
async findRegistrationByUniversalIdentifier(
|
||||
@@ -87,34 +78,37 @@ export class MarketplaceQueryService {
|
||||
return registration;
|
||||
}
|
||||
|
||||
toMarketplaceAppDTO(
|
||||
private toMarketplaceAppDTO(
|
||||
registration: ApplicationRegistrationEntity,
|
||||
): MarketplaceAppDTO {
|
||||
const displayData = registration.marketplaceDisplayData;
|
||||
const app = registration.manifest?.application;
|
||||
|
||||
return {
|
||||
id: registration.universalIdentifier,
|
||||
name: registration.name,
|
||||
description: registration.description ?? '',
|
||||
icon: displayData?.icon ?? 'IconApps',
|
||||
version:
|
||||
displayData?.version ?? registration.latestAvailableVersion ?? '0.0.0',
|
||||
author: registration.author ?? 'Unknown',
|
||||
category: displayData?.category ?? '',
|
||||
logo: displayData?.logo,
|
||||
screenshots: displayData?.screenshots ?? [],
|
||||
aboutDescription:
|
||||
displayData?.aboutDescription ?? registration.description ?? '',
|
||||
providers: displayData?.providers ?? [],
|
||||
websiteUrl: registration.websiteUrl ?? undefined,
|
||||
termsUrl: registration.termsUrl ?? undefined,
|
||||
objects: displayData?.objects ?? [],
|
||||
fields: displayData?.fields ?? [],
|
||||
logicFunctions: displayData?.logicFunctions ?? [],
|
||||
frontComponents: displayData?.frontComponents ?? [],
|
||||
name: app?.displayName ?? registration.name,
|
||||
description: app?.description ?? '',
|
||||
icon: app?.icon ?? 'IconApps',
|
||||
author: app?.author ?? 'Unknown',
|
||||
category: app?.category ?? '',
|
||||
logo: app?.logoUrl ?? undefined,
|
||||
sourcePackage: registration.sourcePackage ?? undefined,
|
||||
defaultRole: displayData?.defaultRole,
|
||||
isFeatured: registration.isFeatured,
|
||||
};
|
||||
}
|
||||
|
||||
private toMarketplaceAppDetailDTO(
|
||||
registration: ApplicationRegistrationEntity,
|
||||
): MarketplaceAppDetailDTO {
|
||||
return {
|
||||
id: registration.id,
|
||||
universalIdentifier: registration.universalIdentifier,
|
||||
name: registration.name,
|
||||
sourceType: registration.sourceType,
|
||||
sourcePackage: registration.sourcePackage ?? undefined,
|
||||
latestAvailableVersion: registration.latestAvailableVersion ?? undefined,
|
||||
isListed: registration.isListed,
|
||||
isFeatured: registration.isFeatured,
|
||||
manifest: registration.manifest ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -11,6 +11,7 @@ import { MarketplaceService } from 'src/engine/core-modules/application/applicat
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { MarketplaceCatalogSyncCommand } from 'src/engine/core-modules/application/application-marketplace/crons/commands/marketplace-catalog-sync.command';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -26,6 +27,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
MarketplaceQueryService,
|
||||
MarketplaceCatalogSyncCronJob,
|
||||
MarketplaceCatalogSyncCronCommand,
|
||||
MarketplaceCatalogSyncCommand,
|
||||
MarketplaceResolver,
|
||||
],
|
||||
exports: [
|
||||
|
||||
+23
-4
@@ -6,6 +6,7 @@ import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorato
|
||||
import { ApplicationRegistrationExceptionFilter } from 'src/engine/core-modules/application/application-registration/application-registration-exception-filter';
|
||||
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
|
||||
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
|
||||
import { MarketplaceAppDetailDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app-detail.dto';
|
||||
import { MarketplaceQueryService } from 'src/engine/core-modules/application/application-marketplace/marketplace-query.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
@@ -13,6 +14,10 @@ import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.cron.job';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
|
||||
@MetadataResolver()
|
||||
@UseFilters(ApplicationRegistrationExceptionFilter)
|
||||
@@ -21,6 +26,8 @@ export class MarketplaceResolver {
|
||||
constructor(
|
||||
private readonly marketplaceQueryService: MarketplaceQueryService,
|
||||
private readonly applicationInstallService: ApplicationInstallService,
|
||||
@InjectMessageQueue(MessageQueue.cronQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {}
|
||||
|
||||
@Query(() => [MarketplaceAppDTO])
|
||||
@@ -28,11 +35,11 @@ export class MarketplaceResolver {
|
||||
return this.marketplaceQueryService.findManyMarketplaceApps();
|
||||
}
|
||||
|
||||
@Query(() => MarketplaceAppDTO)
|
||||
async findOneMarketplaceApp(
|
||||
@Query(() => MarketplaceAppDetailDTO)
|
||||
async findMarketplaceAppDetail(
|
||||
@Args('universalIdentifier') universalIdentifier: string,
|
||||
): Promise<MarketplaceAppDTO> {
|
||||
return this.marketplaceQueryService.findOneMarketplaceApp(
|
||||
): Promise<MarketplaceAppDetailDTO> {
|
||||
return this.marketplaceQueryService.findMarketplaceAppDetail(
|
||||
universalIdentifier,
|
||||
);
|
||||
}
|
||||
@@ -56,4 +63,16 @@ export class MarketplaceResolver {
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS))
|
||||
async syncMarketplaceCatalog(): Promise<boolean> {
|
||||
await this.messageQueueService.add(
|
||||
MarketplaceCatalogSyncCronJob.name,
|
||||
{},
|
||||
{ id: 'marketplace-catalog-sync' }, // Avoids triggering multiple pending jobs
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+63
-41
@@ -1,13 +1,21 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import axios from 'axios';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
|
||||
import { buildRegistryCdnUrl } from 'src/engine/core-modules/application/application-marketplace/utils/build-registry-cdn-url.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
const npmSearchResultSchema = z.object({
|
||||
export type RegistryPackageInfo = {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
author: string;
|
||||
websiteUrl?: string;
|
||||
};
|
||||
|
||||
const registrySearchResultSchema = z.object({
|
||||
objects: z.array(
|
||||
z.object({
|
||||
package: z.object({
|
||||
@@ -16,7 +24,12 @@ const npmSearchResultSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
author: z.object({ name: z.string().optional() }).optional(),
|
||||
links: z.object({ homepage: z.string().optional() }).optional(),
|
||||
links: z
|
||||
.object({
|
||||
homepage: z.string().optional(),
|
||||
npm: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
@@ -28,7 +41,39 @@ export class MarketplaceService {
|
||||
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
|
||||
async fetchAppsFromNpmRegistry(): Promise<MarketplaceAppDTO[]> {
|
||||
async fetchManifestFromRegistryCdn(
|
||||
packageName: string,
|
||||
version: string,
|
||||
): Promise<Manifest | null> {
|
||||
const cdnBaseUrl = this.twentyConfigService.get('APP_REGISTRY_CDN_URL');
|
||||
const url = buildRegistryCdnUrl({
|
||||
cdnBaseUrl,
|
||||
packageName,
|
||||
version,
|
||||
filePath: 'manifest.json',
|
||||
});
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(url, {
|
||||
headers: { 'User-Agent': 'Twenty-Marketplace' },
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
if (!data?.application) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return data as Manifest;
|
||||
} catch {
|
||||
this.logger.debug(
|
||||
`Could not fetch manifest from CDN for ${packageName}@${version}`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchAppsFromRegistry(): Promise<RegistryPackageInfo[]> {
|
||||
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
|
||||
|
||||
try {
|
||||
@@ -40,53 +85,30 @@ export class MarketplaceService {
|
||||
},
|
||||
);
|
||||
|
||||
const parsed = npmSearchResultSchema.safeParse(data);
|
||||
const parsed = registrySearchResultSchema.safeParse(data);
|
||||
|
||||
if (!parsed.success) {
|
||||
this.logger.warn(
|
||||
`Unexpected npm search response shape: ${parsed.error.message}`,
|
||||
`Unexpected registry search response shape: ${parsed.error.message}`,
|
||||
);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed.data.objects
|
||||
.map((result) => {
|
||||
const { name, version, description, author, links } = result.package;
|
||||
const twentyKeyword = (result.package.keywords ?? []).find(
|
||||
(keyword) => keyword.startsWith('twenty-uid:'),
|
||||
);
|
||||
return parsed.data.objects.map((result) => {
|
||||
const { name, version, description, author, links } = result.package;
|
||||
|
||||
if (!isDefined(twentyKeyword)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const universalIdentifier = twentyKeyword.replace('twenty-uid:', '');
|
||||
|
||||
return {
|
||||
id: universalIdentifier,
|
||||
name,
|
||||
description: description ?? '',
|
||||
icon: 'IconApps',
|
||||
version,
|
||||
author: author?.name ?? 'Unknown',
|
||||
category: '',
|
||||
screenshots: [],
|
||||
aboutDescription: description ?? '',
|
||||
providers: [],
|
||||
websiteUrl: links?.homepage,
|
||||
objects: [],
|
||||
fields: [],
|
||||
logicFunctions: [],
|
||||
frontComponents: [],
|
||||
sourcePackage: name,
|
||||
isFeatured: false,
|
||||
};
|
||||
})
|
||||
.filter(isDefined);
|
||||
return {
|
||||
name,
|
||||
version,
|
||||
description: description ?? '',
|
||||
author: author?.name ?? 'Unknown',
|
||||
websiteUrl: links?.homepage ?? links?.npm,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to fetch apps from npm registry: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`Failed to fetch apps from registry ${registryUrl}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
|
||||
return [];
|
||||
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
// Rich display data stored alongside ApplicationRegistration for marketplace
|
||||
// rendering. This is denormalized from the catalog source so it can be displayed
|
||||
// pre-install without resolving the package.
|
||||
export type MarketplaceDisplayData = {
|
||||
icon?: string;
|
||||
version?: string;
|
||||
category?: string;
|
||||
logo?: string;
|
||||
screenshots?: string[];
|
||||
aboutDescription?: string;
|
||||
providers?: string[];
|
||||
objects?: MarketplaceDisplayObject[];
|
||||
fields?: MarketplaceDisplayField[];
|
||||
logicFunctions?: MarketplaceDisplayLogicFunction[];
|
||||
frontComponents?: MarketplaceDisplayFrontComponent[];
|
||||
defaultRole?: MarketplaceDisplayDefaultRole;
|
||||
};
|
||||
|
||||
type MarketplaceDisplayObject = {
|
||||
universalIdentifier: string;
|
||||
nameSingular: string;
|
||||
namePlural: string;
|
||||
labelSingular: string;
|
||||
labelPlural: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
fields: MarketplaceDisplayField[];
|
||||
};
|
||||
|
||||
type MarketplaceDisplayField = {
|
||||
name: string;
|
||||
type: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
objectUniversalIdentifier: string;
|
||||
universalIdentifier: string;
|
||||
};
|
||||
|
||||
type MarketplaceDisplayLogicFunction = {
|
||||
name: string;
|
||||
description?: string;
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
|
||||
type MarketplaceDisplayFrontComponent = {
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type MarketplaceDisplayDefaultRole = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
canReadAllObjectRecords: boolean;
|
||||
canUpdateAllObjectRecords: boolean;
|
||||
canSoftDeleteAllObjectRecords: boolean;
|
||||
canDestroyAllObjectRecords: boolean;
|
||||
canUpdateAllSettings: boolean;
|
||||
canAccessAllTools: boolean;
|
||||
objectPermissions: Array<{
|
||||
objectUniversalIdentifier: string;
|
||||
canReadObjectRecords?: boolean;
|
||||
canUpdateObjectRecords?: boolean;
|
||||
canSoftDeleteObjectRecords?: boolean;
|
||||
canDestroyObjectRecords?: boolean;
|
||||
}>;
|
||||
fieldPermissions: Array<{
|
||||
objectUniversalIdentifier: string;
|
||||
fieldUniversalIdentifier: string;
|
||||
canReadFieldValue?: boolean;
|
||||
canUpdateFieldValue?: boolean;
|
||||
}>;
|
||||
permissionFlags: string[];
|
||||
};
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
|
||||
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
|
||||
|
||||
const buildMinimalManifest = (
|
||||
overrides: Partial<Manifest['application']> = {},
|
||||
): Manifest => ({
|
||||
application: {
|
||||
universalIdentifier: 'app-1',
|
||||
defaultRoleUniversalIdentifier: 'role-1',
|
||||
displayName: 'Test App',
|
||||
description: 'A test app',
|
||||
packageJsonChecksum: null,
|
||||
yarnLockChecksum: null,
|
||||
...overrides,
|
||||
},
|
||||
objects: [],
|
||||
fields: [],
|
||||
logicFunctions: [],
|
||||
frontComponents: [],
|
||||
roles: [],
|
||||
skills: [],
|
||||
agents: [],
|
||||
publicAssets: [],
|
||||
views: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
});
|
||||
|
||||
describe('resolveManifestAssetUrls', () => {
|
||||
const urlBuilder = (filePath: string) =>
|
||||
`https://cdn.example.com/pkg/${filePath}`;
|
||||
|
||||
it('should resolve a relative logoUrl', () => {
|
||||
const manifest = buildMinimalManifest({ logoUrl: 'logo.png' });
|
||||
|
||||
const result = resolveManifestAssetUrls(manifest, urlBuilder);
|
||||
|
||||
expect(result.application.logoUrl).toBe(
|
||||
'https://cdn.example.com/pkg/logo.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not modify an absolute logoUrl', () => {
|
||||
const manifest = buildMinimalManifest({
|
||||
logoUrl: 'https://example.com/logo.png',
|
||||
});
|
||||
|
||||
const result = resolveManifestAssetUrls(manifest, urlBuilder);
|
||||
|
||||
expect(result.application.logoUrl).toBe('https://example.com/logo.png');
|
||||
});
|
||||
|
||||
it('should not modify an http logoUrl', () => {
|
||||
const manifest = buildMinimalManifest({
|
||||
logoUrl: 'http://example.com/logo.png',
|
||||
});
|
||||
|
||||
const result = resolveManifestAssetUrls(manifest, urlBuilder);
|
||||
|
||||
expect(result.application.logoUrl).toBe('http://example.com/logo.png');
|
||||
});
|
||||
|
||||
it('should leave logoUrl undefined when not set', () => {
|
||||
const manifest = buildMinimalManifest();
|
||||
|
||||
const result = resolveManifestAssetUrls(manifest, urlBuilder);
|
||||
|
||||
expect(result.application.logoUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should resolve relative screenshot paths', () => {
|
||||
const manifest = buildMinimalManifest({
|
||||
screenshots: ['screen1.png', 'screen2.png'],
|
||||
});
|
||||
|
||||
const result = resolveManifestAssetUrls(manifest, urlBuilder);
|
||||
|
||||
expect(result.application.screenshots).toEqual([
|
||||
'https://cdn.example.com/pkg/screen1.png',
|
||||
'https://cdn.example.com/pkg/screen2.png',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not modify absolute screenshot URLs', () => {
|
||||
const manifest = buildMinimalManifest({
|
||||
screenshots: [
|
||||
'https://example.com/screen1.png',
|
||||
'https://example.com/screen2.png',
|
||||
],
|
||||
});
|
||||
|
||||
const result = resolveManifestAssetUrls(manifest, urlBuilder);
|
||||
|
||||
expect(result.application.screenshots).toEqual([
|
||||
'https://example.com/screen1.png',
|
||||
'https://example.com/screen2.png',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle a mix of relative and absolute screenshot URLs', () => {
|
||||
const manifest = buildMinimalManifest({
|
||||
screenshots: ['relative.png', 'https://example.com/absolute.png'],
|
||||
});
|
||||
|
||||
const result = resolveManifestAssetUrls(manifest, urlBuilder);
|
||||
|
||||
expect(result.application.screenshots).toEqual([
|
||||
'https://cdn.example.com/pkg/relative.png',
|
||||
'https://example.com/absolute.png',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty screenshots array', () => {
|
||||
const manifest = buildMinimalManifest({ screenshots: [] });
|
||||
|
||||
const result = resolveManifestAssetUrls(manifest, urlBuilder);
|
||||
|
||||
expect(result.application.screenshots).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle undefined screenshots', () => {
|
||||
const manifest = buildMinimalManifest();
|
||||
|
||||
const result = resolveManifestAssetUrls(manifest, urlBuilder);
|
||||
|
||||
expect(result.application.screenshots).toEqual([]);
|
||||
});
|
||||
|
||||
it('should preserve all other manifest properties', () => {
|
||||
const manifest = buildMinimalManifest({
|
||||
logoUrl: 'logo.png',
|
||||
author: 'Test Author',
|
||||
websiteUrl: 'https://test.com',
|
||||
});
|
||||
manifest.objects = [{ universalIdentifier: 'obj-1' } as never];
|
||||
|
||||
const result = resolveManifestAssetUrls(manifest, urlBuilder);
|
||||
|
||||
expect(result.application.author).toBe('Test Author');
|
||||
expect(result.application.websiteUrl).toBe('https://test.com');
|
||||
expect(result.application.displayName).toBe('Test App');
|
||||
expect(result.objects).toEqual([{ universalIdentifier: 'obj-1' }]);
|
||||
});
|
||||
});
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
export const buildRegistryCdnUrl = (params: {
|
||||
cdnBaseUrl: string;
|
||||
packageName: string;
|
||||
version: string;
|
||||
filePath: string;
|
||||
}): string => {
|
||||
return `${params.cdnBaseUrl}/${params.packageName}@${params.version}/${params.filePath}`;
|
||||
};
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
|
||||
const isAbsoluteUrl = (url: string): boolean =>
|
||||
url.startsWith('http://') || url.startsWith('https://');
|
||||
|
||||
export const resolveManifestAssetUrls = (
|
||||
manifest: Manifest,
|
||||
urlBuilder: (filePath: string) => string,
|
||||
): Manifest => {
|
||||
const resolveUrl = (url: string): string =>
|
||||
isAbsoluteUrl(url) ? url : urlBuilder(url);
|
||||
|
||||
return {
|
||||
...manifest,
|
||||
application: {
|
||||
...manifest.application,
|
||||
logoUrl: manifest.application.logoUrl
|
||||
? resolveUrl(manifest.application.logoUrl)
|
||||
: undefined,
|
||||
screenshots: (manifest.application.screenshots ?? []).map(resolveUrl),
|
||||
},
|
||||
};
|
||||
};
|
||||
-4
@@ -140,9 +140,6 @@ export class OAuthRegistrationController {
|
||||
const registration = this.applicationRegistrationRepository.create({
|
||||
universalIdentifier: v4(),
|
||||
name: body.client_name,
|
||||
description: null,
|
||||
logoUrl: body.logo_uri ?? null,
|
||||
author: null,
|
||||
oAuthClientId: clientId,
|
||||
oAuthClientSecretHash: null,
|
||||
oAuthRedirectUris: body.redirect_uris,
|
||||
@@ -150,7 +147,6 @@ export class OAuthRegistrationController {
|
||||
createdByUserId: null,
|
||||
ownerWorkspaceId: null,
|
||||
sourceType: ApplicationRegistrationSourceType.OAUTH_ONLY,
|
||||
websiteUrl: body.client_uri ?? null,
|
||||
});
|
||||
|
||||
await this.applicationRegistrationRepository.save(registration);
|
||||
|
||||
-1
@@ -586,7 +586,6 @@ export class OAuthService {
|
||||
return this.applicationService.create({
|
||||
universalIdentifier: applicationRegistration.universalIdentifier,
|
||||
name: applicationRegistration.name,
|
||||
description: applicationRegistration.description,
|
||||
version: '0.0.0',
|
||||
sourcePath: 'oauth-install',
|
||||
applicationRegistrationId: applicationRegistration.id,
|
||||
|
||||
+2
-22
@@ -18,7 +18,7 @@ import {
|
||||
} from 'typeorm';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { type MarketplaceDisplayData } from 'src/engine/core-modules/application/application-marketplace/types/marketplace-display-data.type';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
|
||||
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
@@ -62,18 +62,6 @@ export class ApplicationRegistrationEntity {
|
||||
@Column({ nullable: false, type: 'text' })
|
||||
name: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
description: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
logoUrl: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
author: string | null;
|
||||
|
||||
@Field()
|
||||
@Column({ nullable: false, type: 'text' })
|
||||
oAuthClientId: string;
|
||||
@@ -128,14 +116,6 @@ export class ApplicationRegistrationEntity {
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
latestAvailableVersion: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
websiteUrl: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
termsUrl: string | null;
|
||||
|
||||
@Field(() => Boolean)
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isListed: boolean;
|
||||
@@ -145,7 +125,7 @@ export class ApplicationRegistrationEntity {
|
||||
isFeatured: boolean;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
marketplaceDisplayData: MarketplaceDisplayData | null;
|
||||
manifest: Manifest | null;
|
||||
|
||||
@OneToMany(
|
||||
() => ApplicationRegistrationVariableEntity,
|
||||
|
||||
+2
@@ -8,6 +8,7 @@ import { ApplicationRegistrationVariableModule } from 'src/engine/core-modules/a
|
||||
import { ApplicationTarballService } from 'src/engine/core-modules/application/application-registration/application-tarball.service';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-server-config/domain-server-config.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
|
||||
import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
|
||||
@@ -24,6 +25,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
|
||||
]),
|
||||
ApplicationRegistrationVariableModule,
|
||||
ApplicationModule,
|
||||
DomainServerConfigModule,
|
||||
FeatureFlagModule,
|
||||
PermissionsModule,
|
||||
FileStorageModule,
|
||||
|
||||
+21
@@ -32,6 +32,7 @@ import { RotateClientSecretDTO } from 'src/engine/core-modules/application/appli
|
||||
import { TransferApplicationRegistrationOwnershipInput } from 'src/engine/core-modules/application/application-registration/dtos/transfer-application-registration-ownership.input';
|
||||
import { UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
|
||||
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
|
||||
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
|
||||
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
@@ -59,6 +60,7 @@ export class ApplicationRegistrationResolver {
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
private readonly applicationTarballService: ApplicationTarballService,
|
||||
private readonly fileUrlService: FileUrlService,
|
||||
private readonly domainServerConfigService: DomainServerConfigService,
|
||||
) {}
|
||||
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
@@ -290,6 +292,25 @@ export class ApplicationRegistrationResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@Query(() => String)
|
||||
async getApplicationShareLink(
|
||||
@Args('id') id: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<string> {
|
||||
const registration = await this.applicationRegistrationService.findOneById(
|
||||
id,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const frontUrl = this.domainServerConfigService.getFrontUrl();
|
||||
|
||||
return `${frontUrl.origin}/settings/applications/available/${registration.universalIdentifier}`;
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
|
||||
|
||||
+24
-43
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import crypto from 'crypto';
|
||||
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IsNull, type Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
@@ -102,7 +103,7 @@ export class ApplicationRegistrationService {
|
||||
): Promise<PublicApplicationRegistrationDTO | null> {
|
||||
const registration = await this.applicationRegistrationRepository.findOne({
|
||||
where: { oAuthClientId: clientId },
|
||||
select: ['id', 'name', 'logoUrl', 'websiteUrl', 'oAuthScopes'],
|
||||
select: ['id', 'name', 'manifest', 'oAuthScopes'],
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
@@ -112,22 +113,12 @@ export class ApplicationRegistrationService {
|
||||
return {
|
||||
id: registration.id,
|
||||
name: registration.name,
|
||||
logoUrl: registration.logoUrl,
|
||||
websiteUrl: registration.websiteUrl,
|
||||
logoUrl: registration.manifest?.application?.logoUrl ?? null,
|
||||
websiteUrl: registration.manifest?.application?.websiteUrl ?? null,
|
||||
oAuthScopes: registration.oAuthScopes,
|
||||
};
|
||||
}
|
||||
|
||||
async isOwnedByWorkspace(id: string, workspaceId: string): Promise<boolean> {
|
||||
const registration = await this.applicationRegistrationRepository.findOne({
|
||||
where: { id },
|
||||
select: ['id', 'ownerWorkspaceId'],
|
||||
});
|
||||
|
||||
return registration?.ownerWorkspaceId === workspaceId;
|
||||
}
|
||||
|
||||
// Global lookup — used by app sync to find existing registrations
|
||||
async findOneByUniversalIdentifier(
|
||||
universalIdentifier: string,
|
||||
): Promise<ApplicationRegistrationEntity | null> {
|
||||
@@ -172,17 +163,12 @@ export class ApplicationRegistrationService {
|
||||
this.applicationRegistrationRepository.create({
|
||||
universalIdentifier,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
logoUrl: input.logoUrl ?? null,
|
||||
author: input.author ?? null,
|
||||
oAuthClientId: clientId,
|
||||
oAuthClientSecretHash: clientSecretHash,
|
||||
oAuthRedirectUris: input.oAuthRedirectUris ?? [],
|
||||
oAuthScopes: input.oAuthScopes ?? [],
|
||||
createdByUserId,
|
||||
ownerWorkspaceId,
|
||||
websiteUrl: input.websiteUrl ?? null,
|
||||
termsUrl: input.termsUrl ?? null,
|
||||
});
|
||||
|
||||
const saved = await this.applicationRegistrationRepository.save(
|
||||
@@ -211,16 +197,10 @@ export class ApplicationRegistrationService {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
if (isDefined(update.name)) updateData.name = update.name;
|
||||
if (isDefined(update.description))
|
||||
updateData.description = update.description;
|
||||
if (isDefined(update.logoUrl)) updateData.logoUrl = update.logoUrl;
|
||||
if (isDefined(update.author)) updateData.author = update.author;
|
||||
if (isDefined(update.oAuthRedirectUris))
|
||||
updateData.oAuthRedirectUris = update.oAuthRedirectUris;
|
||||
if (isDefined(update.oAuthScopes))
|
||||
updateData.oAuthScopes = update.oAuthScopes;
|
||||
if (isDefined(update.websiteUrl)) updateData.websiteUrl = update.websiteUrl;
|
||||
if (isDefined(update.termsUrl)) updateData.termsUrl = update.termsUrl;
|
||||
if (isDefined(update.isListed)) updateData.isListed = update.isListed;
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
@@ -230,6 +210,21 @@ export class ApplicationRegistrationService {
|
||||
return this.findOneById(id, ownerWorkspaceId);
|
||||
}
|
||||
|
||||
async updateFromManifest(
|
||||
applicationRegistrationId: string,
|
||||
manifest: Manifest,
|
||||
): Promise<void> {
|
||||
const existing = await this.applicationRegistrationRepository.findOneOrFail(
|
||||
{ where: { id: applicationRegistrationId } },
|
||||
);
|
||||
|
||||
await this.applicationRegistrationRepository.save({
|
||||
...existing,
|
||||
name: manifest.application.displayName,
|
||||
manifest,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string, ownerWorkspaceId: string): Promise<boolean> {
|
||||
await this.findOneById(id, ownerWorkspaceId);
|
||||
await this.applicationRegistrationRepository.softDelete(id);
|
||||
@@ -269,17 +264,12 @@ export class ApplicationRegistrationService {
|
||||
ApplicationRegistrationEntity,
|
||||
| 'universalIdentifier'
|
||||
| 'name'
|
||||
| 'description'
|
||||
| 'author'
|
||||
| 'sourceType'
|
||||
| 'sourcePackage'
|
||||
| 'logoUrl'
|
||||
| 'websiteUrl'
|
||||
| 'termsUrl'
|
||||
| 'latestAvailableVersion'
|
||||
| 'isListed'
|
||||
| 'isFeatured'
|
||||
| 'marketplaceDisplayData'
|
||||
| 'manifest'
|
||||
| 'ownerWorkspaceId'
|
||||
>,
|
||||
): Promise<void> {
|
||||
@@ -291,15 +281,12 @@ export class ApplicationRegistrationService {
|
||||
await this.applicationRegistrationRepository.save({
|
||||
...existing,
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
author: params.author,
|
||||
sourceType: params.sourceType,
|
||||
sourcePackage: params.sourcePackage,
|
||||
logoUrl: params.logoUrl,
|
||||
websiteUrl: params.websiteUrl,
|
||||
termsUrl: params.termsUrl,
|
||||
latestAvailableVersion: params.latestAvailableVersion,
|
||||
marketplaceDisplayData: params.marketplaceDisplayData,
|
||||
manifest: params.manifest,
|
||||
isListed: params.isListed,
|
||||
isFeatured: params.isFeatured,
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -308,17 +295,12 @@ export class ApplicationRegistrationService {
|
||||
const registration = this.applicationRegistrationRepository.create({
|
||||
universalIdentifier: params.universalIdentifier,
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
author: params.author,
|
||||
sourceType: params.sourceType,
|
||||
sourcePackage: params.sourcePackage,
|
||||
logoUrl: params.logoUrl,
|
||||
websiteUrl: params.websiteUrl,
|
||||
termsUrl: params.termsUrl,
|
||||
latestAvailableVersion: params.latestAvailableVersion,
|
||||
isListed: params.isListed,
|
||||
isFeatured: params.isFeatured,
|
||||
marketplaceDisplayData: params.marketplaceDisplayData,
|
||||
manifest: params.manifest,
|
||||
oAuthClientId: v4(),
|
||||
oAuthRedirectUris: [],
|
||||
oAuthScopes: [],
|
||||
@@ -341,7 +323,6 @@ export class ApplicationRegistrationService {
|
||||
universalIdentifier:
|
||||
TWENTY_CLI_APPLICATION_REGISTRATION.universalIdentifier,
|
||||
name: TWENTY_CLI_APPLICATION_REGISTRATION.name,
|
||||
description: TWENTY_CLI_APPLICATION_REGISTRATION.description,
|
||||
oAuthClientId: v4(),
|
||||
oAuthClientSecretHash: null,
|
||||
oAuthRedirectUris: [],
|
||||
|
||||
+14
@@ -63,6 +63,10 @@ export class ApplicationTarballService {
|
||||
};
|
||||
}>(contentDir, 'manifest.json');
|
||||
|
||||
const packageJson = await readJsonFile<{
|
||||
version: string;
|
||||
}>(contentDir, 'package.json');
|
||||
|
||||
if (manifest === null) {
|
||||
throw new ApplicationRegistrationException(
|
||||
'manifest.json not found or invalid in tarball',
|
||||
@@ -104,6 +108,10 @@ export class ApplicationTarballService {
|
||||
universalIdentifier,
|
||||
name: manifest.application?.displayName ?? 'Unknown App',
|
||||
sourceType: ApplicationRegistrationSourceType.TARBALL,
|
||||
manifest,
|
||||
latestAvailableVersion: packageJson?.version ?? null,
|
||||
isListed: false,
|
||||
isFeatured: false,
|
||||
oAuthClientId: v4(),
|
||||
oAuthRedirectUris: [],
|
||||
oAuthScopes: [],
|
||||
@@ -137,6 +145,12 @@ export class ApplicationTarballService {
|
||||
await this.appRegistrationRepository.update(appRegistration.id, {
|
||||
sourceType: ApplicationRegistrationSourceType.TARBALL,
|
||||
tarballFileId: savedFile.id,
|
||||
name: manifest.application?.displayName ?? 'Unknown App',
|
||||
manifest,
|
||||
latestAvailableVersion: packageJson?.version ?? null,
|
||||
isListed: false,
|
||||
isFeatured: false,
|
||||
ownerWorkspaceId: params.ownerWorkspaceId,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
|
||||
-30
@@ -16,24 +16,6 @@ export class CreateApplicationRegistrationInput {
|
||||
@MaxLength(256)
|
||||
name: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
@IsOptional()
|
||||
logoUrl?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
@IsOptional()
|
||||
author?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
@@ -54,16 +36,4 @@ export class CreateApplicationRegistrationInput {
|
||||
@MaxLength(256, { each: true })
|
||||
@IsOptional()
|
||||
oAuthScopes?: string[];
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
@IsOptional()
|
||||
websiteUrl?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
@IsOptional()
|
||||
termsUrl?: string;
|
||||
}
|
||||
|
||||
-30
@@ -21,24 +21,6 @@ export class UpdateApplicationRegistrationPayload {
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
@IsOptional()
|
||||
logoUrl?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
@IsOptional()
|
||||
author?: string;
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20)
|
||||
@@ -55,18 +37,6 @@ export class UpdateApplicationRegistrationPayload {
|
||||
@IsOptional()
|
||||
oAuthScopes?: string[];
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
@IsOptional()
|
||||
websiteUrl?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
@IsOptional()
|
||||
termsUrl?: string;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
|
||||
@@ -1602,6 +1602,16 @@ export class ConfigVariables {
|
||||
@IsOptional()
|
||||
APP_REGISTRY_URL: string = 'https://registry.npmjs.org';
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.ADVANCED_SETTINGS,
|
||||
description:
|
||||
'CDN base URL for serving files from registry (e.g. https://unpkg.com)',
|
||||
type: ConfigVariableType.STRING,
|
||||
})
|
||||
@IsUrl({ require_tld: false })
|
||||
@IsOptional()
|
||||
APP_REGISTRY_CDN_URL: string = 'https://unpkg.com';
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.ADVANCED_SETTINGS,
|
||||
isSensitive: true,
|
||||
|
||||
-1
@@ -248,7 +248,6 @@ describe('ApplicationRegistrationVariable (integration)', () => {
|
||||
variables: {
|
||||
input: {
|
||||
name: 'GQL Variable Test App',
|
||||
description: 'Created via GraphQL for variable testing',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+10
-10
@@ -14,8 +14,9 @@ const MARKETPLACE_QUERY = `
|
||||
author
|
||||
sourcePackage
|
||||
icon
|
||||
version
|
||||
category
|
||||
logo
|
||||
isFeatured
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -58,7 +59,7 @@ describe('Marketplace Catalog Sync (integration)', () => {
|
||||
name: string;
|
||||
sourcePackage: string;
|
||||
latestAvailableVersion?: string;
|
||||
marketplaceDisplayData?: Record<string, unknown>;
|
||||
manifest?: Record<string, unknown>;
|
||||
}): Promise<string> => {
|
||||
const id = crypto.randomUUID();
|
||||
const oAuthClientId = crypto.randomUUID();
|
||||
@@ -68,7 +69,7 @@ describe('Marketplace Catalog Sync (integration)', () => {
|
||||
(id, "universalIdentifier", name, "oAuthClientId",
|
||||
"oAuthRedirectUris", "oAuthScopes", "workspaceId",
|
||||
"sourceType", "sourcePackage", "latestAvailableVersion",
|
||||
"marketplaceDisplayData", "isListed")
|
||||
"manifest", "isListed")
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||
[
|
||||
id,
|
||||
@@ -81,9 +82,7 @@ describe('Marketplace Catalog Sync (integration)', () => {
|
||||
'npm',
|
||||
params.sourcePackage,
|
||||
params.latestAvailableVersion ?? '1.0.0',
|
||||
params.marketplaceDisplayData
|
||||
? JSON.stringify(params.marketplaceDisplayData)
|
||||
: null,
|
||||
params.manifest ? JSON.stringify(params.manifest) : null,
|
||||
true,
|
||||
],
|
||||
);
|
||||
@@ -134,10 +133,11 @@ describe('Marketplace Catalog Sync (integration)', () => {
|
||||
universalIdentifier: curatedUid,
|
||||
name: 'Data Enrichment',
|
||||
sourcePackage: '@twentyhq/app-data-enrichment',
|
||||
marketplaceDisplayData: {
|
||||
icon: 'IconSparkles',
|
||||
version: '1.0.0',
|
||||
category: 'Data',
|
||||
manifest: {
|
||||
application: {
|
||||
icon: 'IconSparkles',
|
||||
category: 'Data',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
+2
-3
@@ -21,14 +21,13 @@ export const setupApplicationForSync = async ({
|
||||
|
||||
await globalThis.testDataSource.query(
|
||||
`INSERT INTO core."applicationRegistration"
|
||||
(id, "universalIdentifier", name, description, "oAuthClientId",
|
||||
(id, "universalIdentifier", name, "oAuthClientId",
|
||||
"oAuthRedirectUris", "oAuthScopes", "workspaceId", "sourceType")
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
registrationId,
|
||||
applicationUniversalIdentifier,
|
||||
name,
|
||||
description,
|
||||
oAuthClientId,
|
||||
[],
|
||||
[],
|
||||
|
||||
@@ -14,7 +14,6 @@ type TestRegistration = {
|
||||
id: string;
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
oAuthClientId: string;
|
||||
oAuthRedirectUris: string[];
|
||||
oAuthScopes: string[];
|
||||
@@ -28,7 +27,6 @@ const insertRegistration = async (
|
||||
ds: DataSource,
|
||||
params: {
|
||||
name: string;
|
||||
description?: string;
|
||||
clientSecretHash: string;
|
||||
redirectUris: string[];
|
||||
scopes: string[];
|
||||
@@ -40,13 +38,12 @@ const insertRegistration = async (
|
||||
|
||||
await ds.query(
|
||||
`INSERT INTO core."applicationRegistration"
|
||||
(id, "universalIdentifier", name, description, "oAuthClientId", "oAuthClientSecretHash", "oAuthRedirectUris", "oAuthScopes", "workspaceId")
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
(id, "universalIdentifier", name, "oAuthClientId", "oAuthClientSecretHash", "oAuthRedirectUris", "oAuthScopes", "workspaceId")
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
id,
|
||||
universalIdentifier,
|
||||
params.name,
|
||||
params.description ?? null,
|
||||
oAuthClientId,
|
||||
params.clientSecretHash,
|
||||
params.redirectUris,
|
||||
@@ -59,7 +56,6 @@ const insertRegistration = async (
|
||||
id,
|
||||
universalIdentifier,
|
||||
name: params.name,
|
||||
description: params.description ?? null,
|
||||
oAuthClientId,
|
||||
oAuthRedirectUris: params.redirectUris,
|
||||
oAuthScopes: params.scopes,
|
||||
@@ -169,7 +165,6 @@ describe('OAuth (integration)', () => {
|
||||
|
||||
autoInstallRegistration = await insertRegistration(ds, {
|
||||
name: 'OAuth Auto-Install Test App',
|
||||
description: 'App for testing OAuth auto-install',
|
||||
clientSecretHash: autoInstallSecretHash,
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
scopes: ['api'],
|
||||
@@ -583,9 +578,6 @@ describe('OAuth (integration)', () => {
|
||||
const autoCreatedApp = rows[0];
|
||||
|
||||
expect(autoCreatedApp.name).toBe('OAuth Auto-Install Test App');
|
||||
expect(autoCreatedApp.description).toBe(
|
||||
'App for testing OAuth auto-install',
|
||||
);
|
||||
expect(autoCreatedApp.sourcePath).toBe('oauth-install');
|
||||
expect(autoCreatedApp.universalIdentifier).toBe(
|
||||
autoInstallRegistration.universalIdentifier,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'tsconfig-paths/register';
|
||||
|
||||
export default async () => {
|
||||
global.testDataSource.destroy();
|
||||
global.app.close();
|
||||
await global.app.close();
|
||||
await global.testDataSource.destroy();
|
||||
};
|
||||
|
||||
@@ -14,9 +14,10 @@ export type ApplicationManifest = SyncableEntityOptions & {
|
||||
logoUrl?: string;
|
||||
screenshots?: string[];
|
||||
aboutDescription?: string;
|
||||
providers?: string[];
|
||||
websiteUrl?: string;
|
||||
termsUrl?: string;
|
||||
emailSupport?: string;
|
||||
issueReportUrl?: string;
|
||||
preInstallLogicFunctionUniversalIdentifier?: string;
|
||||
postInstallLogicFunctionUniversalIdentifier?: string;
|
||||
settingsCustomTabFrontComponentUniversalIdentifier?: string;
|
||||
|
||||
@@ -32,6 +32,7 @@ export {
|
||||
IconBlockquote,
|
||||
IconBold,
|
||||
IconBolt,
|
||||
IconBook,
|
||||
IconBook2,
|
||||
IconBookmark,
|
||||
IconBookmarkPlus,
|
||||
@@ -198,6 +199,7 @@ export {
|
||||
IconGitBranchDeleted,
|
||||
IconGitCommit,
|
||||
IconGizmo,
|
||||
IconGraph,
|
||||
IconGripVertical,
|
||||
IconH1,
|
||||
IconH2,
|
||||
@@ -242,6 +244,7 @@ export {
|
||||
IconLayoutSidebarRight,
|
||||
IconLayoutSidebarRightCollapse,
|
||||
IconLayoutSidebarRightExpand,
|
||||
IconLego,
|
||||
IconLetterK,
|
||||
IconLibraryPlus,
|
||||
IconLifebuoy,
|
||||
|
||||
@@ -110,6 +110,7 @@ export {
|
||||
IconBlockquote,
|
||||
IconBold,
|
||||
IconBolt,
|
||||
IconBook,
|
||||
IconBook2,
|
||||
IconBookmark,
|
||||
IconBookmarkPlus,
|
||||
@@ -276,6 +277,7 @@ export {
|
||||
IconGitBranchDeleted,
|
||||
IconGitCommit,
|
||||
IconGizmo,
|
||||
IconGraph,
|
||||
IconGripVertical,
|
||||
IconH1,
|
||||
IconH2,
|
||||
@@ -320,6 +322,7 @@ export {
|
||||
IconLayoutSidebarRight,
|
||||
IconLayoutSidebarRightCollapse,
|
||||
IconLayoutSidebarRightExpand,
|
||||
IconLego,
|
||||
IconLetterK,
|
||||
IconLibraryPlus,
|
||||
IconLifebuoy,
|
||||
|
||||
Reference in New Issue
Block a user