From 6fbdfcecdd9c4c2d8bcaff0ffe06fa362b57e084 Mon Sep 17 00:00:00 2001 From: Bernhard Wittmann Date: Mon, 7 Sep 2026 14:25:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(core):=20Add=20Apps=20=E2=80=94=20a=20top-?= =?UTF-8?q?level=20App/Page=20entity=20with=20a=20first=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Day 1 scaffold for the Webpage Node / App-concept Hackweek project: a project-scoped App entity and its Page tree, decoupled from the workflow canvas, plus enough editor UI to build one end-to-end. Backend (packages/cli/src/modules/apps, packages/@n8n/db migration): - App entity (namespace, theme JSON) and Page entity, pages forming a tree via a self-referencing parentPageId. A page's route can be a static slug, a dynamic :param segment (e.g. :id), or empty (meaning it's the index/home page for its level). - Business rules enforced server-side: a namespace is unique per project; a page's route is unique among its siblings; an index page (empty route) can't have children, since it contributes no path segment of its own and a child would collide with a sibling's URL. - New app:* scopes, an appId branch in userHasScopes, an opt-in backend module (apps) enabled by default alongside data-table. Frontend (packages/frontend/editor-ui/src/features/apps): - Apps list, App detail (page tree), and Page detail views, reachable both from the project view and the home overview tab bar. - Create App / Create Page via modals (matching Data Table's pattern), not inline forms. - Page hierarchy shown as a flat card grid per level with drill-down navigation (mirrors n8n's own Folders feature) instead of a fully expanded indented tree. - AppBreadcrumbs: App > ancestor pages > current page/app, each a link back up except the current one. - The App's and each Page's full served URL shown as a copyable field, live as the route is edited. - Delete (with confirmation) on Apps and Pages throughout. Goal package: goals/webpage-node/ (facts.md, plan.md, plan-week.md). --- docs/generated/sqlite-schema/README.md | 23 ++ docs/generated/sqlite-schema/app.md | 82 ++++++ docs/generated/sqlite-schema/page.md | 73 +++++ docs/generated/sqlite-schema/project.md | 12 +- .../api-types/src/dto/app/create-app.dto.ts | 7 + .../api-types/src/dto/app/create-page.dto.ts | 9 + .../api-types/src/dto/app/update-app.dto.ts | 7 + .../api-types/src/dto/app/update-page.dto.ts | 6 + packages/@n8n/api-types/src/dto/index.ts | 5 + .../@n8n/api-types/src/schemas/app.schema.ts | 28 ++ .../modules/__tests__/module-registry.test.ts | 2 + .../src/modules/module-registry.ts | 1 + .../src/modules/modules.config.ts | 1 + .../@n8n/backend-test-utils/src/test-db.ts | 2 + .../common/1788774977417-CreateAppTables.ts | 62 ++++ .../scope-information.test.ts.snap | 6 + packages/@n8n/permissions/src/constants.ee.ts | 1 + .../src/roles/scopes/global-scopes.ee.ts | 7 + .../src/roles/scopes/project-scopes.ee.ts | 17 ++ .../get-resource-permissions.test.ts | 2 + packages/cli/src/modules/apps/app.entity.ts | 26 ++ .../cli/src/modules/apps/app.repository.ts | 41 +++ .../cli/src/modules/apps/apps.controller.ts | 174 +++++++++++ packages/cli/src/modules/apps/apps.module.ts | 16 + packages/cli/src/modules/apps/apps.service.ts | 94 ++++++ .../errors/app-namespace-conflict.error.ts | 9 + .../apps/errors/app-not-found.error.ts | 7 + .../index-page-cannot-have-children.error.ts | 10 + .../apps/errors/page-not-found.error.ts | 7 + .../apps/errors/page-route-conflict.error.ts | 8 + packages/cli/src/modules/apps/page.entity.ts | 33 +++ .../cli/src/modules/apps/page.repository.ts | 48 +++ .../cli/src/permissions.ee/check-access.ts | 26 +- .../ownership-transfer.manifest.json | 5 + .../apps/apps.controller.integration.test.ts | 252 ++++++++++++++++ packages/cli/test/integration/shared/types.ts | 2 + .../integration/shared/utils/test-server.ts | 4 + .../1788774977417-create-app-tables.test.ts | 189 ++++++++++++ .../frontend/@n8n/i18n/src/locales/en.json | 38 +++ .../frontend/@n8n/stores/src/rbac.store.ts | 1 + .../editor-ui/src/app/modules.manifest.ts | 2 + .../src/features/apps/AppBreadcrumbs.vue | 82 ++++++ .../src/features/apps/AppDetailsView.vue | 183 ++++++++++++ .../editor-ui/src/features/apps/AppsView.vue | 124 ++++++++ .../editor-ui/src/features/apps/PageCard.vue | 77 +++++ .../editor-ui/src/features/apps/PageView.vue | 278 ++++++++++++++++++ .../editor-ui/src/features/apps/apps.api.ts | 79 +++++ .../src/features/apps/apps.constants.ts | 8 + .../editor-ui/src/features/apps/apps.store.ts | 90 ++++++ .../editor-ui/src/features/apps/apps.types.ts | 19 ++ .../features/apps/components/AddAppModal.vue | 140 +++++++++ .../features/apps/components/AddPageModal.vue | 115 ++++++++ .../editor-ui/src/features/apps/modals.ts | 16 + .../src/features/apps/module.descriptor.ts | 90 ++++++ .../src/features/apps/pageTree.utils.ts | 45 +++ .../src/features/apps/useAppDeletion.ts | 72 +++++ 56 files changed, 2761 insertions(+), 2 deletions(-) create mode 100644 docs/generated/sqlite-schema/app.md create mode 100644 docs/generated/sqlite-schema/page.md create mode 100644 packages/@n8n/api-types/src/dto/app/create-app.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/app/create-page.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/app/update-app.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/app/update-page.dto.ts create mode 100644 packages/@n8n/api-types/src/schemas/app.schema.ts create mode 100644 packages/@n8n/db/src/migrations/common/1788774977417-CreateAppTables.ts create mode 100644 packages/cli/src/modules/apps/app.entity.ts create mode 100644 packages/cli/src/modules/apps/app.repository.ts create mode 100644 packages/cli/src/modules/apps/apps.controller.ts create mode 100644 packages/cli/src/modules/apps/apps.module.ts create mode 100644 packages/cli/src/modules/apps/apps.service.ts create mode 100644 packages/cli/src/modules/apps/errors/app-namespace-conflict.error.ts create mode 100644 packages/cli/src/modules/apps/errors/app-not-found.error.ts create mode 100644 packages/cli/src/modules/apps/errors/index-page-cannot-have-children.error.ts create mode 100644 packages/cli/src/modules/apps/errors/page-not-found.error.ts create mode 100644 packages/cli/src/modules/apps/errors/page-route-conflict.error.ts create mode 100644 packages/cli/src/modules/apps/page.entity.ts create mode 100644 packages/cli/src/modules/apps/page.repository.ts create mode 100644 packages/cli/test/integration/apps/apps.controller.integration.test.ts create mode 100644 packages/cli/test/migration/1788774977417-create-app-tables.test.ts create mode 100644 packages/frontend/editor-ui/src/features/apps/AppBreadcrumbs.vue create mode 100644 packages/frontend/editor-ui/src/features/apps/AppDetailsView.vue create mode 100644 packages/frontend/editor-ui/src/features/apps/AppsView.vue create mode 100644 packages/frontend/editor-ui/src/features/apps/PageCard.vue create mode 100644 packages/frontend/editor-ui/src/features/apps/PageView.vue create mode 100644 packages/frontend/editor-ui/src/features/apps/apps.api.ts create mode 100644 packages/frontend/editor-ui/src/features/apps/apps.constants.ts create mode 100644 packages/frontend/editor-ui/src/features/apps/apps.store.ts create mode 100644 packages/frontend/editor-ui/src/features/apps/apps.types.ts create mode 100644 packages/frontend/editor-ui/src/features/apps/components/AddAppModal.vue create mode 100644 packages/frontend/editor-ui/src/features/apps/components/AddPageModal.vue create mode 100644 packages/frontend/editor-ui/src/features/apps/modals.ts create mode 100644 packages/frontend/editor-ui/src/features/apps/module.descriptor.ts create mode 100644 packages/frontend/editor-ui/src/features/apps/pageTree.utils.ts create mode 100644 packages/frontend/editor-ui/src/features/apps/useAppDeletion.ts diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md index 00b3a6970a1..488a139292d 100644 --- a/docs/generated/sqlite-schema/README.md +++ b/docs/generated/sqlite-schema/README.md @@ -39,6 +39,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand. | [agents_threads](agents_threads.md) | 6 | | table | | [ai_builder_temporary_workflow](ai_builder_temporary_workflow.md) | 4 | | table | | [annotation_tag_entity](annotation_tag_entity.md) | 4 | | table | +| [app](app.md) | 7 | | table | | [auth_identity](auth_identity.md) | 5 | | table | | [auth_provider_sync_history](auth_provider_sync_history.md) | 11 | | table | | [binary_data](binary_data.md) | 9 | | table | @@ -96,6 +97,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand. | [oauth_clients](oauth_clients.md) | 10 | | table | | [oauth_refresh_tokens](oauth_refresh_tokens.md) | 8 | | table | | [oauth_user_consents](oauth_user_consents.md) | 5 | | table | +| [page](page.md) | 7 | | table | | [poller_state](poller_state.md) | 7 | | table | | [processed_data](processed_data.md) | 5 | | table | | [project](project.md) | 9 | | table | @@ -200,6 +202,7 @@ erDiagram "agents_observations" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "ai_builder_temporary_workflow" }o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "ai_builder_temporary_workflow" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"app" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "auth_identity" }o--o| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE" "chat_hub_agent_tools" |o--|| "chat_hub_tools" : "FOREIGN KEY (toolId) REFERENCES chat_hub_tools (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "chat_hub_agent_tools" |o--|| "chat_hub_agents" : "FOREIGN KEY (agentId) REFERENCES chat_hub_agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" @@ -277,6 +280,8 @@ erDiagram "oauth_refresh_tokens" }o--|| "oauth_clients" : "FOREIGN KEY (clientId) REFERENCES oauth_clients (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "oauth_user_consents" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "oauth_user_consents" }o--|| "oauth_clients" : "FOREIGN KEY (clientId) REFERENCES oauth_clients (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"page" }o--o| "page" : "FOREIGN KEY (parentPageId) REFERENCES page (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"page" }o--|| "app" : "FOREIGN KEY (appId) REFERENCES app (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "poller_state" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "processed_data" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "project" }o--o| "user" : "FOREIGN KEY (creatorId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE" @@ -698,6 +703,15 @@ erDiagram varchar_24_ name datetime_3_ updatedAt } +"app" { + datetime_3_ createdAt + varchar_36_ id PK + varchar_128_ name + varchar_128_ namespace + varchar_36_ projectId FK + TEXT theme + datetime_3_ updatedAt +} "auth_identity" { timestamp createdAt VARCHAR_64_ providerId PK @@ -1241,6 +1255,15 @@ erDiagram TEXT scope varchar userId FK } +"page" { + varchar_36_ appId FK + TEXT content + datetime_3_ createdAt + varchar_36_ id PK + varchar_36_ parentPageId FK + varchar_255_ route + datetime_3_ updatedAt +} "poller_state" { datetime_3_ backoffUntil INTEGER consecutiveErrors diff --git a/docs/generated/sqlite-schema/app.md b/docs/generated/sqlite-schema/app.md new file mode 100644 index 00000000000..b7baa5809e9 --- /dev/null +++ b/docs/generated/sqlite-schema/app.md @@ -0,0 +1,82 @@ +# app + +## Description + +
+Table Definition + +```sql +CREATE TABLE "app" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128) NOT NULL, "namespace" varchar(128) NOT NULL, "theme" text, "projectId" varchar(36) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "FK_f84dd7eb539e46e0c233fa09b20" FOREIGN KEY ("projectId") REFERENCES "project" ("id") ON DELETE CASCADE) +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | +| id | varchar(36) | | false | [page](page.md) | | | +| name | varchar(128) | | false | | | | +| namespace | varchar(128) | | false | | | | +| projectId | varchar(36) | | false | | [project](project.md) | | +| theme | TEXT | | true | | | | +| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE | +| id | PRIMARY KEY | PRIMARY KEY (id) | +| sqlite_autoindex_app_1 | PRIMARY KEY | PRIMARY KEY (id) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| IDX_app_namespace_projectId | CREATE UNIQUE INDEX "IDX_app_namespace_projectId" ON "app" ("namespace", "projectId") | +| sqlite_autoindex_app_1 | PRIMARY KEY (id) | + +## Relations + +```mermaid +erDiagram + +"page" }o--|| "app" : "FOREIGN KEY (appId) REFERENCES app (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"app" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" + +"app" { + datetime_3_ createdAt + varchar_36_ id PK + varchar_128_ name + varchar_128_ namespace + varchar_36_ projectId FK + TEXT theme + datetime_3_ updatedAt +} +"page" { + varchar_36_ appId FK + TEXT content + datetime_3_ createdAt + varchar_36_ id PK + varchar_36_ parentPageId FK + varchar_255_ route + datetime_3_ updatedAt +} +"project" { + datetime_3_ createdAt + varchar creatorId FK + TEXT customTelemetryTags + varchar_512_ description + TEXT icon + varchar_36_ id PK + varchar_255_ name + varchar_36_ type + datetime_3_ updatedAt +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/generated/sqlite-schema/page.md b/docs/generated/sqlite-schema/page.md new file mode 100644 index 00000000000..94f80d940c8 --- /dev/null +++ b/docs/generated/sqlite-schema/page.md @@ -0,0 +1,73 @@ +# page + +## Description + +
+Table Definition + +```sql +CREATE TABLE "page" ("id" varchar(36) PRIMARY KEY NOT NULL, "appId" varchar(36) NOT NULL, "parentPageId" varchar(36), "route" varchar(255) NOT NULL, "content" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "FK_5e3cf5ed8328c4993a910bd239b" FOREIGN KEY ("appId") REFERENCES "app" ("id") ON DELETE CASCADE, CONSTRAINT "FK_b85a31f0bff1e857156d27ea152" FOREIGN KEY ("parentPageId") REFERENCES "page" ("id") ON DELETE CASCADE) +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| appId | varchar(36) | | false | | [app](app.md) | | +| content | TEXT | | true | | | | +| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | +| id | varchar(36) | | false | [page](page.md) | | | +| parentPageId | varchar(36) | | true | | [page](page.md) | | +| route | varchar(255) | | false | | | | +| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (parentPageId) REFERENCES page (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE | +| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (appId) REFERENCES app (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE | +| id | PRIMARY KEY | PRIMARY KEY (id) | +| sqlite_autoindex_page_1 | PRIMARY KEY | PRIMARY KEY (id) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| IDX_page_appId | CREATE INDEX "IDX_page_appId" ON "page" ("appId") | +| IDX_page_parentPageId | CREATE INDEX "IDX_page_parentPageId" ON "page" ("parentPageId") | +| sqlite_autoindex_page_1 | PRIMARY KEY (id) | + +## Relations + +```mermaid +erDiagram + +"page" }o--|| "app" : "FOREIGN KEY (appId) REFERENCES app (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"page" }o--o| "page" : "FOREIGN KEY (parentPageId) REFERENCES page (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" + +"page" { + varchar_36_ appId FK + TEXT content + datetime_3_ createdAt + varchar_36_ id PK + varchar_36_ parentPageId FK + varchar_255_ route + datetime_3_ updatedAt +} +"app" { + datetime_3_ createdAt + varchar_36_ id PK + varchar_128_ name + varchar_128_ namespace + varchar_36_ projectId FK + TEXT theme + datetime_3_ updatedAt +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/generated/sqlite-schema/project.md b/docs/generated/sqlite-schema/project.md index f2adf8c9679..5ba31d189f9 100644 --- a/docs/generated/sqlite-schema/project.md +++ b/docs/generated/sqlite-schema/project.md @@ -20,7 +20,7 @@ CREATE TABLE "project" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(25 | customTelemetryTags | TEXT | '[]' | false | | | | | description | varchar(512) | | true | | | | | icon | TEXT | | true | | | | -| id | varchar(36) | | false | [activity_event](activity_event.md) [agent_chat_attachments](agent_chat_attachments.md) [agent_execution_threads](agent_execution_threads.md) [agents](agents.md) [data_table](data_table.md) [folder](folder.md) [git_connection_project](git_connection_project.md) [insights_metadata](insights_metadata.md) [instance_ai_threads](instance_ai_threads.md) [project_pool_settings](project_pool_settings.md) [project_relation](project_relation.md) [project_secrets_provider_access](project_secrets_provider_access.md) [role_mapping_rule_project](role_mapping_rule_project.md) [shared_credentials](shared_credentials.md) [shared_workflow](shared_workflow.md) [type_availability_policy_scope](type_availability_policy_scope.md) [variables](variables.md) [workflow_review_request](workflow_review_request.md) | | | +| id | varchar(36) | | false | [activity_event](activity_event.md) [agent_chat_attachments](agent_chat_attachments.md) [agent_execution_threads](agent_execution_threads.md) [agents](agents.md) [app](app.md) [data_table](data_table.md) [folder](folder.md) [git_connection_project](git_connection_project.md) [insights_metadata](insights_metadata.md) [instance_ai_threads](instance_ai_threads.md) [project_pool_settings](project_pool_settings.md) [project_relation](project_relation.md) [project_secrets_provider_access](project_secrets_provider_access.md) [role_mapping_rule_project](role_mapping_rule_project.md) [shared_credentials](shared_credentials.md) [shared_workflow](shared_workflow.md) [type_availability_policy_scope](type_availability_policy_scope.md) [variables](variables.md) [workflow_review_request](workflow_review_request.md) | | | | name | varchar(255) | | false | | | | | type | varchar(36) | | false | | | | | updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | @@ -49,6 +49,7 @@ erDiagram "agent_chat_attachments" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "agent_execution_threads" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "agents" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"app" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "data_table" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "folder" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "git_connection_project" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" @@ -154,6 +155,15 @@ erDiagram datetime_3_ updatedAt varchar_36_ versionId } +"app" { + datetime_3_ createdAt + varchar_36_ id PK + varchar_128_ name + varchar_128_ namespace + varchar_36_ projectId FK + TEXT theme + datetime_3_ updatedAt +} "data_table" { datetime_3_ createdAt varchar_36_ id PK diff --git a/packages/@n8n/api-types/src/dto/app/create-app.dto.ts b/packages/@n8n/api-types/src/dto/app/create-app.dto.ts new file mode 100644 index 00000000000..2c608d51a1b --- /dev/null +++ b/packages/@n8n/api-types/src/dto/app/create-app.dto.ts @@ -0,0 +1,7 @@ +import { appNameSchema, appNamespaceSchema } from '../../schemas/app.schema'; +import { Z } from '../../zod-class'; + +export class CreateAppDto extends Z.class({ + name: appNameSchema, + namespace: appNamespaceSchema, +}) {} diff --git a/packages/@n8n/api-types/src/dto/app/create-page.dto.ts b/packages/@n8n/api-types/src/dto/app/create-page.dto.ts new file mode 100644 index 00000000000..e27063f5b1f --- /dev/null +++ b/packages/@n8n/api-types/src/dto/app/create-page.dto.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +import { pageRouteSchema } from '../../schemas/app.schema'; +import { Z } from '../../zod-class'; + +export class CreatePageDto extends Z.class({ + route: pageRouteSchema, + parentPageId: z.string().optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/app/update-app.dto.ts b/packages/@n8n/api-types/src/dto/app/update-app.dto.ts new file mode 100644 index 00000000000..2f4bec46907 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/app/update-app.dto.ts @@ -0,0 +1,7 @@ +import { appNameSchema, appNamespaceSchema } from '../../schemas/app.schema'; +import { Z } from '../../zod-class'; + +export class UpdateAppDto extends Z.class({ + name: appNameSchema.optional(), + namespace: appNamespaceSchema.optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/app/update-page.dto.ts b/packages/@n8n/api-types/src/dto/app/update-page.dto.ts new file mode 100644 index 00000000000..5d9c5552e9d --- /dev/null +++ b/packages/@n8n/api-types/src/dto/app/update-page.dto.ts @@ -0,0 +1,6 @@ +import { pageRouteSchema } from '../../schemas/app.schema'; +import { Z } from '../../zod-class'; + +export class UpdatePageDto extends Z.class({ + route: pageRouteSchema.optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 9f752fa437b..32b35105a8d 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -281,6 +281,11 @@ export { export { OidcConfigDto, UpdateOidcConfigurationDto, OIDC_PROMPT_VALUES } from './oidc/config.dto'; export { TestOidcConfigResponseDto } from './oidc/test-oidc-config-response.dto'; +export { CreateAppDto } from './app/create-app.dto'; +export { UpdateAppDto } from './app/update-app.dto'; +export { CreatePageDto } from './app/create-page.dto'; +export { UpdatePageDto } from './app/update-page.dto'; + export { CreateDataTableDto } from './data-table/create-data-table.dto'; export { UpdateDataTableDto } from './data-table/update-data-table.dto'; export { PublicApiCreateDataTableDto } from './data-table/public-api-create-data-table.dto'; diff --git a/packages/@n8n/api-types/src/schemas/app.schema.ts b/packages/@n8n/api-types/src/schemas/app.schema.ts new file mode 100644 index 00000000000..79c1a9d35b2 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/app.schema.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +export const appNameSchema = z.string().trim().min(1).max(128); + +// URL path segment under /apps/; kebab-case, matches other n8n slug conventions. +const SLUG_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/; +const SLUG_ERROR_MESSAGE = + 'Only lowercase letters, numbers, and single hyphens between them are allowed.'; + +export const appNamespaceSchema = z + .string() + .trim() + .min(1) + .max(128) + .regex(SLUG_REGEX, SLUG_ERROR_MESSAGE); + +// Empty means this page is the index/home page for its level (the app's own +// landing page, for a top-level page with no parent). A page's own segment +// can also be a dynamic param, e.g. ":id" (as in /clients/:id), matching the +// data workflow's input field of the same name. +const PAGE_ROUTE_REGEX = /^(:[a-zA-Z_][a-zA-Z0-9_]*|[a-z0-9]+(-[a-z0-9]+)*)?$/; +const PAGE_ROUTE_ERROR_MESSAGE = `${SLUG_ERROR_MESSAGE} A dynamic segment starts with ':' followed by a name, e.g. ':id'.`; + +export const pageRouteSchema = z + .string() + .trim() + .max(255) + .regex(PAGE_ROUTE_REGEX, PAGE_ROUTE_ERROR_MESSAGE); diff --git a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts index f32d1003065..8e2a71ff788 100644 --- a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts +++ b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts @@ -49,6 +49,7 @@ describe('eligibleModules', () => { 'external-secrets', 'community-packages', 'data-table', + 'apps', 'oauth-server', 'mcp', 'provisioning', @@ -85,6 +86,7 @@ describe('eligibleModules', () => { 'external-secrets', 'community-packages', 'data-table', + 'apps', 'oauth-server', 'mcp', 'provisioning', diff --git a/packages/@n8n/backend-common/src/modules/module-registry.ts b/packages/@n8n/backend-common/src/modules/module-registry.ts index c89b9d43497..6a10633863e 100644 --- a/packages/@n8n/backend-common/src/modules/module-registry.ts +++ b/packages/@n8n/backend-common/src/modules/module-registry.ts @@ -46,6 +46,7 @@ export class ModuleRegistry { 'external-secrets', 'community-packages', 'data-table', + 'apps', // oauth-server precedes mcp: the mcp module registers its protected // resource with the oauth-server module's registry on init. 'oauth-server', diff --git a/packages/@n8n/backend-common/src/modules/modules.config.ts b/packages/@n8n/backend-common/src/modules/modules.config.ts index acfcad293ba..38cc120d35f 100644 --- a/packages/@n8n/backend-common/src/modules/modules.config.ts +++ b/packages/@n8n/backend-common/src/modules/modules.config.ts @@ -4,6 +4,7 @@ import { UnknownModuleError } from './errors/unknown-module.error'; export const MODULE_NAMES = [ 'agents', + 'apps', 'agent-evals', 'insights', 'external-secrets', diff --git a/packages/@n8n/backend-test-utils/src/test-db.ts b/packages/@n8n/backend-test-utils/src/test-db.ts index c003a383946..80816afdf83 100644 --- a/packages/@n8n/backend-test-utils/src/test-db.ts +++ b/packages/@n8n/backend-test-utils/src/test-db.ts @@ -158,6 +158,8 @@ type EntityName = | 'InsightsMetadata' | 'DataTable' | 'DataTableColumn' + | 'App' + | 'Page' | 'ChatHubSession' | 'ChatHubMessage' | 'ChatHubAgent' diff --git a/packages/@n8n/db/src/migrations/common/1788774977417-CreateAppTables.ts b/packages/@n8n/db/src/migrations/common/1788774977417-CreateAppTables.ts new file mode 100644 index 00000000000..b8c288ca7b6 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1788774977417-CreateAppTables.ts @@ -0,0 +1,62 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const APP_TABLE = 'app'; +const PAGE_TABLE = 'page'; + +/** + * Top-level App entity (project-scoped) and its Page tree. A Page's block + * content is stored as-is for now (opaque JSON); the block schema and + * renderer land separately. + */ +export class CreateAppTables1788774977417 implements ReversibleMigration { + async up({ schemaBuilder: { createTable, createIndex, column } }: MigrationContext) { + await createTable(APP_TABLE) + .withColumns( + column('id').varchar(36).primary.notNull, + column('name').varchar(128).notNull, + column('namespace') + .varchar(128) + .notNull.comment('URL path segment under /apps/; unique per project'), + column('theme').json.comment('Shared colors/spacing for the App, app-defined shape'), + column('projectId').varchar(36).notNull, + ) + .withTimestamps.withForeignKey('projectId', { + tableName: 'project', + columnName: 'id', + onDelete: 'CASCADE', + }); + + await createIndex(APP_TABLE, ['namespace', 'projectId'], true); + + await createTable(PAGE_TABLE) + .withColumns( + column('id').varchar(36).primary.notNull, + column('appId').varchar(36).notNull, + column('parentPageId').varchar(36).comment('Self-reference; null for a top-level page'), + column('route') + .varchar(255) + .notNull.comment('Path segment under its parent, may contain :params'), + column('content').json.comment( + 'Block tree (Editor.js-shaped); empty until the renderer lands', + ), + ) + .withTimestamps.withForeignKey('appId', { + tableName: APP_TABLE, + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('parentPageId', { + tableName: PAGE_TABLE, + columnName: 'id', + onDelete: 'CASCADE', + }); + + await createIndex(PAGE_TABLE, ['appId']); + await createIndex(PAGE_TABLE, ['parentPageId']); + } + + async down({ schemaBuilder: { dropTable } }: MigrationContext) { + await dropTable(PAGE_TABLE); + await dropTable(APP_TABLE); + } +} diff --git a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap index 60bd53a40b2..08ef57c4b7f 100644 --- a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap +++ b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap @@ -144,6 +144,12 @@ exports[`Scope Information > ensure scopes are defined correctly 1`] = ` "dataTable:readColumn", "dataTable:writeColumn", "dataTable:listProject", + "app:create", + "app:read", + "app:update", + "app:delete", + "app:list", + "app:listProject", "execution:delete", "execution:read", "execution:retry", diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index df6714b315e..9d3d4325ebc 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -75,6 +75,7 @@ export const RESOURCES = { 'writeColumn', 'listProject', ] as const, + app: [...DEFAULT_OPERATIONS, 'listProject'] as const, execution: ['delete', 'read', 'retry', 'list', 'get', 'reveal'] as const, testRun: ['read', 'list'] as const, workflowTags: ['update', 'list'] as const, diff --git a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts index 3abcfc91a04..c007790f077 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -139,6 +139,12 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'dataTable:writeRow', 'dataTable:readColumn', 'dataTable:writeColumn', + 'app:create', + 'app:delete', + 'app:read', + 'app:update', + 'app:list', + 'app:listProject', 'role:manage', 'role:read', 'role:list', @@ -197,6 +203,7 @@ export const GLOBAL_MEMBER_SCOPES: Scope[] = [ 'variable:list', 'variable:read', 'dataTable:list', + 'app:list', 'mcp:oauth', 'mcpApiKey:create', 'mcpApiKey:rotate', diff --git a/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts index 40d9eab9e5e..44db156026f 100644 --- a/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts @@ -62,6 +62,11 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [ 'dataTable:writeRow', 'dataTable:readColumn', 'dataTable:writeColumn', + 'app:create', + 'app:delete', + 'app:read', + 'app:update', + 'app:listProject', 'projectVariable:list', 'projectVariable:read', 'projectVariable:create', @@ -119,6 +124,11 @@ export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [ 'dataTable:writeRow', 'dataTable:readColumn', 'dataTable:writeColumn', + 'app:create', + 'app:delete', + 'app:read', + 'app:update', + 'app:listProject', ]; export const PROJECT_EDITOR_SCOPES: Scope[] = [ @@ -164,6 +174,11 @@ export const PROJECT_EDITOR_SCOPES: Scope[] = [ 'dataTable:writeRow', 'dataTable:readColumn', 'dataTable:writeColumn', + 'app:create', + 'app:delete', + 'app:read', + 'app:update', + 'app:listProject', 'projectVariable:list', 'projectVariable:read', 'projectVariable:create', @@ -190,6 +205,8 @@ export const PROJECT_VIEWER_SCOPES: Scope[] = [ 'dataTable:read', 'dataTable:readRow', 'dataTable:readColumn', + 'app:listProject', + 'app:read', 'projectVariable:list', 'projectVariable:read', ]; diff --git a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts index cfd41561e76..8dc432e03ce 100644 --- a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts +++ b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts @@ -42,6 +42,7 @@ describe('permissions', () => { folder: {}, insights: {}, dataTable: {}, + app: {}, mcp: {}, mcpApiKey: {}, role: {}, @@ -168,6 +169,7 @@ describe('permissions', () => { list: true, }, dataTable: {}, + app: {}, execution: {}, testRun: {}, workflowTags: {}, diff --git a/packages/cli/src/modules/apps/app.entity.ts b/packages/cli/src/modules/apps/app.entity.ts new file mode 100644 index 00000000000..9052440b619 --- /dev/null +++ b/packages/cli/src/modules/apps/app.entity.ts @@ -0,0 +1,26 @@ +import { Project, WithTimestampsAndStringId } from '@n8n/db'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from '@n8n/typeorm'; + +@Entity() +@Index(['namespace', 'projectId'], { unique: true }) +export class App extends WithTimestampsAndStringId { + constructor() { + super(); + } + + @Column() + name: string; + + @Column() + namespace: string; + + @Column({ type: 'json', nullable: true }) + theme: Record | null; + + @ManyToOne(() => Project) + @JoinColumn({ name: 'projectId' }) + project: Project; + + @Column() + projectId: string; +} diff --git a/packages/cli/src/modules/apps/app.repository.ts b/packages/cli/src/modules/apps/app.repository.ts new file mode 100644 index 00000000000..cafd237d592 --- /dev/null +++ b/packages/cli/src/modules/apps/app.repository.ts @@ -0,0 +1,41 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { App } from './app.entity'; +import { AppNamespaceConflictError } from './errors/app-namespace-conflict.error'; + +@Service() +export class AppRepository extends Repository { + constructor(dataSource: DataSource) { + super(App, dataSource.manager); + } + + async createApp(projectId: string, name: string, namespace: string) { + if (await this.existsBy({ projectId, namespace })) { + throw new AppNamespaceConflictError(namespace); + } + + const app = this.create({ projectId, name, namespace }); + return await this.save(app); + } + + async findManyByProjectId(projectId: string) { + return await this.findBy({ projectId }); + } + + async updateApp(app: App, updates: Partial>) { + if ( + updates.namespace !== undefined && + updates.namespace !== app.namespace && + (await this.existsBy({ projectId: app.projectId, namespace: updates.namespace })) + ) { + throw new AppNamespaceConflictError(updates.namespace); + } + + return await this.save(Object.assign(app, updates)); + } + + async deleteApp(id: string) { + await this.delete({ id }); + } +} diff --git a/packages/cli/src/modules/apps/apps.controller.ts b/packages/cli/src/modules/apps/apps.controller.ts new file mode 100644 index 00000000000..ca2fa99d246 --- /dev/null +++ b/packages/cli/src/modules/apps/apps.controller.ts @@ -0,0 +1,174 @@ +import { CreateAppDto, CreatePageDto, UpdateAppDto, UpdatePageDto } from '@n8n/api-types'; +import { AuthenticatedRequest } from '@n8n/db'; +import { + Body, + Delete, + Get, + Middleware, + Param, + Patch, + Post, + ProjectScope, + RestController, +} from '@n8n/decorators'; +import { NextFunction, Response } from 'express'; + +import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { InstanceWriteAccessService } from '@/services/instance-write-access.service'; +import { ProjectService } from '@/services/project.service.ee'; + +import { AppsService } from './apps.service'; +import { AppNamespaceConflictError } from './errors/app-namespace-conflict.error'; +import { PageRouteConflictError } from './errors/page-route-conflict.error'; + +@RestController('/projects/:projectId/apps') +export class AppsController { + constructor( + private readonly appsService: AppsService, + private readonly projectService: ProjectService, + private readonly instanceWriteAccess: InstanceWriteAccessService, + ) {} + + private checkInstanceWriteAccess(): void { + if (this.instanceWriteAccess.isReadOnly()) { + throw new ForbiddenError( + 'Cannot modify apps on a protected instance. This instance is in read-only mode.', + ); + } + } + + private handleAppError(e: unknown): never { + if (e instanceof AppNamespaceConflictError || e instanceof PageRouteConflictError) { + throw new ConflictError(e.message); + } + throw e; + } + + @Middleware() + async validateProjectExists( + req: AuthenticatedRequest<{ projectId: string }>, + res: Response, + next: NextFunction, + ) { + try { + await this.projectService.getProject(req.params.projectId); + next(); + } catch { + res.status(404).send('Project not found'); + } + } + + @Post('/') + @ProjectScope('app:create') + async createApp( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Body dto: CreateAppDto, + ) { + this.checkInstanceWriteAccess(); + try { + return await this.appsService.createApp(req.params.projectId, dto); + } catch (e: unknown) { + this.handleAppError(e); + } + } + + @Get('/') + @ProjectScope('app:listProject') + async listApps(req: AuthenticatedRequest<{ projectId: string }>, _res: Response) { + return await this.appsService.listApps(req.params.projectId); + } + + @Get('/:appId') + @ProjectScope('app:read') + async getApp( + _req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('appId') appId: string, + ) { + return await this.appsService.getApp(appId); + } + + @Patch('/:appId') + @ProjectScope('app:update') + async updateApp( + _req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('appId') appId: string, + @Body dto: UpdateAppDto, + ) { + this.checkInstanceWriteAccess(); + try { + return await this.appsService.updateApp(appId, dto); + } catch (e: unknown) { + this.handleAppError(e); + } + } + + @Delete('/:appId') + @ProjectScope('app:delete') + async deleteApp( + _req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('appId') appId: string, + ) { + this.checkInstanceWriteAccess(); + await this.appsService.deleteApp(appId); + } + + @Post('/:appId/pages') + @ProjectScope('app:update') + async createPage( + _req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('appId') appId: string, + @Body dto: CreatePageDto, + ) { + this.checkInstanceWriteAccess(); + try { + return await this.appsService.createPage(appId, dto); + } catch (e: unknown) { + this.handleAppError(e); + } + } + + @Get('/:appId/pages') + @ProjectScope('app:read') + async listPages( + _req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('appId') appId: string, + ) { + return await this.appsService.listPages(appId); + } + + @Patch('/:appId/pages/:pageId') + @ProjectScope('app:update') + async updatePage( + _req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('appId') appId: string, + @Param('pageId') pageId: string, + @Body dto: UpdatePageDto, + ) { + this.checkInstanceWriteAccess(); + try { + return await this.appsService.updatePage(appId, pageId, dto); + } catch (e: unknown) { + this.handleAppError(e); + } + } + + @Delete('/:appId/pages/:pageId') + @ProjectScope('app:update') + async deletePage( + _req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('appId') appId: string, + @Param('pageId') pageId: string, + ) { + this.checkInstanceWriteAccess(); + await this.appsService.deletePage(appId, pageId); + } +} diff --git a/packages/cli/src/modules/apps/apps.module.ts b/packages/cli/src/modules/apps/apps.module.ts new file mode 100644 index 00000000000..14d042cc0e9 --- /dev/null +++ b/packages/cli/src/modules/apps/apps.module.ts @@ -0,0 +1,16 @@ +import type { ModuleInterface } from '@n8n/decorators'; +import { BackendModule } from '@n8n/decorators'; + +@BackendModule({ name: 'apps' }) +export class AppsModule implements ModuleInterface { + async init() { + await import('./apps.controller.js'); + } + + async entities() { + const { App } = await import('./app.entity.js'); + const { Page } = await import('./page.entity.js'); + + return [App, Page]; + } +} diff --git a/packages/cli/src/modules/apps/apps.service.ts b/packages/cli/src/modules/apps/apps.service.ts new file mode 100644 index 00000000000..67ea3de8de0 --- /dev/null +++ b/packages/cli/src/modules/apps/apps.service.ts @@ -0,0 +1,94 @@ +import type { CreateAppDto, CreatePageDto, UpdateAppDto, UpdatePageDto } from '@n8n/api-types'; +import { Service } from '@n8n/di'; + +import { AppRepository } from './app.repository'; +import { AppNotFoundError } from './errors/app-not-found.error'; +import { IndexPageCannotHaveChildrenError } from './errors/index-page-cannot-have-children.error'; +import { PageNotFoundError } from './errors/page-not-found.error'; +import { PageRouteConflictError } from './errors/page-route-conflict.error'; +import { PageRepository } from './page.repository'; + +@Service() +export class AppsService { + constructor( + private readonly appRepository: AppRepository, + private readonly pageRepository: PageRepository, + ) {} + + async createApp(projectId: string, dto: CreateAppDto) { + return await this.appRepository.createApp(projectId, dto.name, dto.namespace); + } + + async listApps(projectId: string) { + return await this.appRepository.findManyByProjectId(projectId); + } + + async getApp(appId: string) { + const app = await this.appRepository.findOneBy({ id: appId }); + if (!app) throw new AppNotFoundError(appId); + return app; + } + + async updateApp(appId: string, dto: UpdateAppDto) { + const app = await this.getApp(appId); + return await this.appRepository.updateApp(app, dto); + } + + async deleteApp(appId: string) { + await this.getApp(appId); + await this.appRepository.deleteApp(appId); + } + + async createPage(appId: string, dto: CreatePageDto) { + await this.getApp(appId); + const parentPageId = dto.parentPageId ?? null; + if (parentPageId) { + // getPage scopes the lookup to this app, so a parentPageId belonging to + // another app/project is rejected the same as one that doesn't exist. + const parent = await this.getPage(appId, parentPageId); + if (!parent.route) throw new IndexPageCannotHaveChildrenError(parent.id); + } + if (await this.pageRepository.hasSiblingWithRoute(appId, parentPageId, dto.route)) { + throw new PageRouteConflictError(dto.route); + } + return await this.pageRepository.createPage(appId, parentPageId, dto.route); + } + + /** Flat list; callers build the tree from each page's `parentPageId`. */ + async listPages(appId: string) { + await this.getApp(appId); + return await this.pageRepository.findManyByAppId(appId); + } + + /** Scoped to `appId` so a pageId from a different app is treated as not found, not just unauthorized. */ + async getPage(appId: string, pageId: string) { + const page = await this.pageRepository.findOneBy({ id: pageId }); + if (!page || page.appId !== appId) throw new PageNotFoundError(pageId); + return page; + } + + async updatePage(appId: string, pageId: string, dto: UpdatePageDto) { + const page = await this.getPage(appId, pageId); + if (dto.route !== undefined && dto.route !== page.route) { + if (dto.route === '' && (await this.pageRepository.hasChildren(pageId))) { + throw new IndexPageCannotHaveChildrenError(pageId); + } + if ( + await this.pageRepository.hasSiblingWithRoute( + page.appId, + page.parentPageId, + dto.route, + pageId, + ) + ) { + throw new PageRouteConflictError(dto.route); + } + } + return await this.pageRepository.updatePage(page, dto); + } + + async deletePage(appId: string, pageId: string) { + await this.getPage(appId, pageId); + await this.pageRepository.deletePage(pageId); + } +} diff --git a/packages/cli/src/modules/apps/errors/app-namespace-conflict.error.ts b/packages/cli/src/modules/apps/errors/app-namespace-conflict.error.ts new file mode 100644 index 00000000000..d039625a1de --- /dev/null +++ b/packages/cli/src/modules/apps/errors/app-namespace-conflict.error.ts @@ -0,0 +1,9 @@ +import { UserError } from 'n8n-workflow'; + +export class AppNamespaceConflictError extends UserError { + constructor(namespace: string) { + super(`App with namespace '${namespace}' already exists in this project`, { + level: 'warning', + }); + } +} diff --git a/packages/cli/src/modules/apps/errors/app-not-found.error.ts b/packages/cli/src/modules/apps/errors/app-not-found.error.ts new file mode 100644 index 00000000000..87807328ec0 --- /dev/null +++ b/packages/cli/src/modules/apps/errors/app-not-found.error.ts @@ -0,0 +1,7 @@ +import { NotFoundError } from '@/errors/response-errors/not-found.error'; + +export class AppNotFoundError extends NotFoundError { + constructor(appId: string) { + super(`Could not find the app: '${appId}'`); + } +} diff --git a/packages/cli/src/modules/apps/errors/index-page-cannot-have-children.error.ts b/packages/cli/src/modules/apps/errors/index-page-cannot-have-children.error.ts new file mode 100644 index 00000000000..d3b04696086 --- /dev/null +++ b/packages/cli/src/modules/apps/errors/index-page-cannot-have-children.error.ts @@ -0,0 +1,10 @@ +import { UserError } from 'n8n-workflow'; + +export class IndexPageCannotHaveChildrenError extends UserError { + constructor(pageId: string) { + super( + `Page '${pageId}' is an index page (empty route) and can't have sub-pages: a child page would resolve to the exact same URL as a sibling of this page, since the index page itself contributes no path segment.`, + { level: 'warning' }, + ); + } +} diff --git a/packages/cli/src/modules/apps/errors/page-not-found.error.ts b/packages/cli/src/modules/apps/errors/page-not-found.error.ts new file mode 100644 index 00000000000..bf9e38cf0a4 --- /dev/null +++ b/packages/cli/src/modules/apps/errors/page-not-found.error.ts @@ -0,0 +1,7 @@ +import { NotFoundError } from '@/errors/response-errors/not-found.error'; + +export class PageNotFoundError extends NotFoundError { + constructor(pageId: string) { + super(`Could not find the page: '${pageId}'`); + } +} diff --git a/packages/cli/src/modules/apps/errors/page-route-conflict.error.ts b/packages/cli/src/modules/apps/errors/page-route-conflict.error.ts new file mode 100644 index 00000000000..d1a42bfa0e7 --- /dev/null +++ b/packages/cli/src/modules/apps/errors/page-route-conflict.error.ts @@ -0,0 +1,8 @@ +import { UserError } from 'n8n-workflow'; + +export class PageRouteConflictError extends UserError { + constructor(route: string) { + const label = route || 'the index page'; + super(`Another page at this level already uses '${label}'`, { level: 'warning' }); + } +} diff --git a/packages/cli/src/modules/apps/page.entity.ts b/packages/cli/src/modules/apps/page.entity.ts new file mode 100644 index 00000000000..05eb31ab48f --- /dev/null +++ b/packages/cli/src/modules/apps/page.entity.ts @@ -0,0 +1,33 @@ +import { WithTimestampsAndStringId } from '@n8n/db'; +import { Column, Entity, Index, JoinColumn, ManyToOne, type Relation } from '@n8n/typeorm'; + +import { App } from './app.entity'; + +@Entity() +export class Page extends WithTimestampsAndStringId { + constructor() { + super(); + } + + @Column() + route: string; + + @Column({ type: 'json', nullable: true }) + content: unknown[] | null; + + @ManyToOne(() => App) + @JoinColumn({ name: 'appId' }) + app: Relation; + + @Column() + @Index() + appId: string; + + @ManyToOne(() => Page, { nullable: true }) + @JoinColumn({ name: 'parentPageId' }) + parentPage: Relation | null; + + @Column({ nullable: true }) + @Index() + parentPageId: string | null; +} diff --git a/packages/cli/src/modules/apps/page.repository.ts b/packages/cli/src/modules/apps/page.repository.ts new file mode 100644 index 00000000000..a24fdb81339 --- /dev/null +++ b/packages/cli/src/modules/apps/page.repository.ts @@ -0,0 +1,48 @@ +import { Service } from '@n8n/di'; +import { DataSource, IsNull, Not, Repository } from '@n8n/typeorm'; + +import { Page } from './page.entity'; + +@Service() +export class PageRepository extends Repository { + constructor(dataSource: DataSource) { + super(Page, dataSource.manager); + } + + async createPage(appId: string, parentPageId: string | null, route: string) { + const page = this.create({ appId, parentPageId, route }); + return await this.save(page); + } + + /** Flat list of every page under an App; callers build the tree from `parentPageId`. */ + async findManyByAppId(appId: string) { + return await this.findBy({ appId }); + } + + async hasChildren(pageId: string) { + return await this.existsBy({ parentPageId: pageId }); + } + + /** Whether another page under the same parent already has this route. */ + async hasSiblingWithRoute( + appId: string, + parentPageId: string | null, + route: string, + excludePageId?: string, + ) { + return await this.existsBy({ + appId, + parentPageId: parentPageId ?? IsNull(), + route, + ...(excludePageId ? { id: Not(excludePageId) } : {}), + }); + } + + async updatePage(page: Page, updates: Partial>) { + return await this.save(Object.assign(page, updates)); + } + + async deletePage(id: string) { + await this.delete({ id }); + } +} diff --git a/packages/cli/src/permissions.ee/check-access.ts b/packages/cli/src/permissions.ee/check-access.ts index 858a30c00d0..c9d1b16aa4f 100644 --- a/packages/cli/src/permissions.ee/check-access.ts +++ b/packages/cli/src/permissions.ee/check-access.ts @@ -44,6 +44,26 @@ async function hasGlobalCredentialAccess( return isReadOnlyRequest || globalCredential.isResolvable; } +/** Apps don't have resource-level roles, only project-level access. */ +async function resolveAppProjectId(appId: string): Promise { + const moduleRegistry = Container.get(ModuleRegistry); + if (!moduleRegistry.isActive('apps')) { + throw new NotFoundError(`App with ID "${appId}" not found.`); + } + + const { AppRepository } = await import('@/modules/apps/app.repository.js'); + const app = await Container.get(AppRepository).findOne({ + where: { id: appId }, + relations: ['project'], + }); + + if (!app) { + throw new NotFoundError(`App with ID "${appId}" not found.`); + } + + return app.project.id; +} + /** * Check if a user has the required scopes. The check can be: * @@ -61,11 +81,13 @@ export async function userHasScopes( workflowId, projectId, dataTableId, + appId, }: { credentialId?: string; workflowId?: string; projectId?: string; dataTableId?: string; + appId?: string; } /* only one */, entityManager?: EntityManager, ): Promise { @@ -177,9 +199,11 @@ export async function userHasScopes( return userProjectIds.includes(dataTable.project.id); } + if (appId) return userProjectIds.includes(await resolveAppProjectId(appId)); + if (projectId) return userProjectIds.includes(projectId); throw new UnexpectedError( - "`@ProjectScope` decorator was used but does not have a `credentialId`, `workflowId`, `dataTableId`, or `projectId` in its URL parameters. This is likely an implementation error. If you're a developer, please check your URL is correct or that this should be using `@GlobalScope`.", + "`@ProjectScope` decorator was used but does not have a `credentialId`, `workflowId`, `dataTableId`, `appId`, or `projectId` in its URL parameters. This is likely an implementation error. If you're a developer, please check your URL is correct or that this should be using `@GlobalScope`.", ); } diff --git a/packages/cli/src/services/ownership-transfer/ownership-transfer.manifest.json b/packages/cli/src/services/ownership-transfer/ownership-transfer.manifest.json index 4897c3054fc..55c73b1a193 100644 --- a/packages/cli/src/services/ownership-transfer/ownership-transfer.manifest.json +++ b/packages/cli/src/services/ownership-transfer/ownership-transfer.manifest.json @@ -88,6 +88,11 @@ "name": "GitConnectionProject", "path": "packages/cli/src/modules/git-connections.ee/database/entities/git-connection-project.entity.ts", "reason": "Project-scoped git configuration (which connection the project is linked to), not user data. Dropped via the projectId FK cascade when the source project itself is deleted, as in ownership-transfer flows." + }, + { + "name": "App", + "path": "packages/cli/src/modules/apps/app.entity.ts", + "reason": "Hackweek slice: ownership transfer isn't implemented yet (unlike DataTable, which this entity otherwise follows closely). Dropped via the projectId FK cascade when the source project itself is deleted; revisit before this ships beyond the prototype." } ] } diff --git a/packages/cli/test/integration/apps/apps.controller.integration.test.ts b/packages/cli/test/integration/apps/apps.controller.integration.test.ts new file mode 100644 index 00000000000..838640335b0 --- /dev/null +++ b/packages/cli/test/integration/apps/apps.controller.integration.test.ts @@ -0,0 +1,252 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { getPersonalProject, testDb } from '@n8n/backend-test-utils'; +import type { Project, User } from '@n8n/db'; +import { Container } from '@n8n/di'; + +import { AppRepository } from '@/modules/apps/app.repository'; +import { PageRepository } from '@/modules/apps/page.repository'; +import { createMember, createOwner } from '@test-integration/db/users'; +import type { SuperAgentTest } from '@test-integration/types'; +import * as utils from '@test-integration/utils'; + +let owner: User; +let member: User; +let authOwnerAgent: SuperAgentTest; +let authMemberAgent: SuperAgentTest; +let ownerProject: Project; + +const testServer = utils.setupTestServer({ + endpointGroups: ['apps'], + modules: ['apps'], +}); + +let appRepository: AppRepository; +let pageRepository: PageRepository; + +beforeAll(async () => { + appRepository = Container.get(AppRepository); + pageRepository = Container.get(PageRepository); + + owner = await createOwner(); + member = await createMember(); + + authOwnerAgent = testServer.authAgentFor(owner); + authMemberAgent = testServer.authAgentFor(member); + + ownerProject = await getPersonalProject(owner); +}); + +beforeEach(async () => { + await testDb.truncate(['App', 'Page']); +}); + +describe('POST /projects/:projectId/apps', () => { + test('creates an app for a project member', async () => { + const response = await authOwnerAgent + .post(`/projects/${ownerProject.id}/apps`) + .send({ name: 'My App', namespace: 'my-app' }) + .expect(200); + + expect(response.body.data).toMatchObject({ name: 'My App', namespace: 'my-app' }); + }); + + test('rejects a non-member with 403', async () => { + await authMemberAgent + .post(`/projects/${ownerProject.id}/apps`) + .send({ name: 'My App', namespace: 'my-app' }) + .expect(403); + }); + + test('rejects a duplicate namespace in the same project with 409', async () => { + await appRepository.createApp(ownerProject.id, 'First', 'dup'); + + await authOwnerAgent + .post(`/projects/${ownerProject.id}/apps`) + .send({ name: 'Second', namespace: 'dup' }) + .expect(409); + }); +}); + +describe('GET /projects/:projectId/apps', () => { + test('lists apps for a project member, 403 for a non-member', async () => { + await appRepository.createApp(ownerProject.id, 'My App', 'my-app'); + + const response = await authOwnerAgent.get(`/projects/${ownerProject.id}/apps`).expect(200); + expect(response.body.data).toHaveLength(1); + + await authMemberAgent.get(`/projects/${ownerProject.id}/apps`).expect(403); + }); +}); + +describe('App pages', () => { + test('creates a page and a nested child page under it', async () => { + const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app'); + + const rootResponse = await authOwnerAgent + .post(`/projects/${ownerProject.id}/apps/${app.id}/pages`) + .send({ route: 'home' }) + .expect(200); + + const childResponse = await authOwnerAgent + .post(`/projects/${ownerProject.id}/apps/${app.id}/pages`) + .send({ route: 'nested', parentPageId: rootResponse.body.data.id }) + .expect(200); + + expect(childResponse.body.data.parentPageId).toBe(rootResponse.body.data.id); + + const listResponse = await authOwnerAgent + .get(`/projects/${ownerProject.id}/apps/${app.id}/pages`) + .expect(200); + expect(listResponse.body.data).toHaveLength(2); + }); + + test('accepts an empty route, meaning this page is the index page for its level', async () => { + const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app'); + + const response = await authOwnerAgent + .post(`/projects/${ownerProject.id}/apps/${app.id}/pages`) + .send({ route: '' }) + .expect(200); + + expect(response.body.data.route).toBe(''); + }); + + test('accepts a dynamic param segment, e.g. :id', async () => { + const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app'); + + const response = await authOwnerAgent + .post(`/projects/${ownerProject.id}/apps/${app.id}/pages`) + .send({ route: ':id' }) + .expect(200); + + expect(response.body.data.route).toBe(':id'); + }); + + test('rejects creating a sub-page under an index page', async () => { + const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app'); + const indexPage = await pageRepository.createPage(app.id, null, ''); + + await authOwnerAgent + .post(`/projects/${ownerProject.id}/apps/${app.id}/pages`) + .send({ route: 'child', parentPageId: indexPage.id }) + .expect(400); + }); + + test('rejects turning a page with children into an index page', async () => { + const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app'); + const parent = await pageRepository.createPage(app.id, null, 'parent'); + await pageRepository.createPage(app.id, parent.id, 'child'); + + await authOwnerAgent + .patch(`/projects/${ownerProject.id}/apps/${app.id}/pages/${parent.id}`) + .send({ route: '' }) + .expect(400); + }); + + test('rejects creating a page whose route collides with a sibling, at any level', async () => { + const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app'); + + await authOwnerAgent + .post(`/projects/${ownerProject.id}/apps/${app.id}/pages`) + .send({ route: 'foo' }) + .expect(200); + + await authOwnerAgent + .post(`/projects/${ownerProject.id}/apps/${app.id}/pages`) + .send({ route: 'foo' }) + .expect(409); + }); + + test('rejects creating a second index page at the same level', async () => { + const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app'); + await pageRepository.createPage(app.id, null, ''); + + await authOwnerAgent + .post(`/projects/${ownerProject.id}/apps/${app.id}/pages`) + .send({ route: '' }) + .expect(409); + }); + + test('allows the same route at different levels (different parents)', async () => { + const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app'); + const parentA = await pageRepository.createPage(app.id, null, 'a'); + const parentB = await pageRepository.createPage(app.id, null, 'b'); + + await authOwnerAgent + .post(`/projects/${ownerProject.id}/apps/${app.id}/pages`) + .send({ route: 'same', parentPageId: parentA.id }) + .expect(200); + + await authOwnerAgent + .post(`/projects/${ownerProject.id}/apps/${app.id}/pages`) + .send({ route: 'same', parentPageId: parentB.id }) + .expect(200); + }); + + test('rejects renaming a page to collide with a sibling', async () => { + const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app'); + await pageRepository.createPage(app.id, null, 'foo'); + const other = await pageRepository.createPage(app.id, null, 'bar'); + + await authOwnerAgent + .patch(`/projects/${ownerProject.id}/apps/${app.id}/pages/${other.id}`) + .send({ route: 'foo' }) + .expect(409); + }); + + test('allows saving a page with its own current route unchanged', async () => { + const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app'); + const page = await pageRepository.createPage(app.id, null, 'foo'); + + await authOwnerAgent + .patch(`/projects/${ownerProject.id}/apps/${app.id}/pages/${page.id}`) + .send({ route: 'foo' }) + .expect(200); + }); + + test('deleting an app deletes its pages', async () => { + const app = await appRepository.createApp(ownerProject.id, 'My App', 'my-app'); + await pageRepository.createPage(app.id, null, 'home'); + + await authOwnerAgent.delete(`/projects/${ownerProject.id}/apps/${app.id}`).expect(200); + + const remaining = await pageRepository.findManyByAppId(app.id); + expect(remaining).toHaveLength(0); + }); + + test("rejects updating a page through a different app's URL", async () => { + const appA = await appRepository.createApp(ownerProject.id, 'App A', 'app-a'); + const appB = await appRepository.createApp(ownerProject.id, 'App B', 'app-b'); + const page = await pageRepository.createPage(appA.id, null, 'foo'); + + await authOwnerAgent + .patch(`/projects/${ownerProject.id}/apps/${appB.id}/pages/${page.id}`) + .send({ route: 'moved' }) + .expect(404); + }); + + test("rejects deleting a page through a different app's URL, leaving it untouched", async () => { + const appA = await appRepository.createApp(ownerProject.id, 'App A', 'app-a'); + const appB = await appRepository.createApp(ownerProject.id, 'App B', 'app-b'); + const page = await pageRepository.createPage(appA.id, null, 'foo'); + + await authOwnerAgent + .delete(`/projects/${ownerProject.id}/apps/${appB.id}/pages/${page.id}`) + .expect(404); + + const remaining = await pageRepository.findManyByAppId(appA.id); + expect(remaining).toHaveLength(1); + }); + + test('rejects creating a page whose parentPageId belongs to a different app', async () => { + const appA = await appRepository.createApp(ownerProject.id, 'App A', 'app-a'); + const appB = await appRepository.createApp(ownerProject.id, 'App B', 'app-b'); + const pageInA = await pageRepository.createPage(appA.id, null, 'foo'); + + await authOwnerAgent + .post(`/projects/${ownerProject.id}/apps/${appB.id}/pages`) + .send({ route: 'child', parentPageId: pageInA.id }) + .expect(404); + }); +}); diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index f4f1d13331b..bccf9c74455 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -52,6 +52,7 @@ type EndpointGroup = | 'module-settings' | 'security-settings' | 'data-table' + | 'apps' | 'third-party-licenses' | 'mcp' | 'workflowDependencies' @@ -65,6 +66,7 @@ type ModuleName = | 'external-secrets' | 'community-packages' | 'data-table' + | 'apps' | 'mcp' | 'oauth-server' | 'dynamic-credentials' diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index 05d0e3eacd3..158488dbfa7 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -387,6 +387,10 @@ export const setupTestServer = ({ await import('@/modules/data-table/data-table.module.js'); break; + case 'apps': + await import('@/modules/apps/apps.module.js'); + break; + case 'workflow-reviews': await import('@/modules/workflow-reviews.ee/workflow-reviews.module.js'); break; diff --git a/packages/cli/test/migration/1788774977417-create-app-tables.test.ts b/packages/cli/test/migration/1788774977417-create-app-tables.test.ts new file mode 100644 index 00000000000..7dcc3a5bd55 --- /dev/null +++ b/packages/cli/test/migration/1788774977417-create-app-tables.test.ts @@ -0,0 +1,189 @@ +import { + createTestMigrationContext, + initDbUpToMigration, + runSingleMigration, + undoLastSingleMigration, + type TestMigrationContext, +} from '@n8n/backend-test-utils'; +import { DbConnection } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; +import { randomUUID } from 'node:crypto'; + +const MIGRATION_NAME = 'CreateAppTables1788774977417'; +const APP_TABLE = 'app'; +const PAGE_TABLE = 'page'; + +describe('CreateAppTables migration', () => { + let dataSource: DataSource; + let projectId: string; + + async function withContext(fn: (context: TestMigrationContext) => Promise): Promise { + const context = createTestMigrationContext(dataSource); + try { + return await fn(context); + } finally { + await context.queryRunner.release(); + } + } + + beforeAll(async () => { + await Container.get(DbConnection).init(); + dataSource = Container.get(DataSource); + }); + + beforeEach(async () => { + await withContext(async (context) => await context.queryRunner.clearDatabase()); + await initDbUpToMigration(MIGRATION_NAME); + await runSingleMigration(MIGRATION_NAME); + dataSource = Container.get(DataSource); + projectId = randomUUID(); + await withContext(async (context) => await insertProject(context, projectId)); + }); + + afterAll(async () => { + await Container.get(DbConnection).close(); + }); + + async function insertProject(context: TestMigrationContext, id: string) { + const table = context.escape.tableName('project'); + const now = new Date(); + await context.runQuery( + `INSERT INTO ${table} ("id", "name", "type", "customTelemetryTags", "createdAt", "updatedAt") + VALUES (:id, :name, :type, :tags, :createdAt, :updatedAt)`, + { id, name: 'Project', type: 'team', tags: '[]', createdAt: now, updatedAt: now }, + ); + } + + async function insertApp( + context: TestMigrationContext, + entry: { id?: string; namespace: string; projectId?: string }, + ) { + const table = context.escape.tableName(APP_TABLE); + const now = new Date(); + const id = entry.id ?? randomUUID(); + await context.runQuery( + `INSERT INTO ${table} ("id", "name", "namespace", "projectId", "createdAt", "updatedAt") + VALUES (:id, :name, :namespace, :projectId, :createdAt, :updatedAt)`, + { + id, + name: 'My App', + namespace: entry.namespace, + projectId: entry.projectId ?? projectId, + createdAt: now, + updatedAt: now, + }, + ); + return id; + } + + async function insertPage( + context: TestMigrationContext, + entry: { id?: string; appId: string; parentPageId?: string | null; route: string }, + ) { + const table = context.escape.tableName(PAGE_TABLE); + const now = new Date(); + const id = entry.id ?? randomUUID(); + await context.runQuery( + `INSERT INTO ${table} ("id", "appId", "parentPageId", "route", "createdAt", "updatedAt") + VALUES (:id, :appId, :parentPageId, :route, :createdAt, :updatedAt)`, + { + id, + appId: entry.appId, + parentPageId: entry.parentPageId ?? null, + route: entry.route, + createdAt: now, + updatedAt: now, + }, + ); + return id; + } + + async function getPageIds(context: TestMigrationContext): Promise { + const table = context.escape.tableName(PAGE_TABLE); + const rows = await context.runQuery>(`SELECT "id" FROM ${table}`); + return rows.map((row) => row.id); + } + + it('creates an app and a page under it', async () => { + const pageId = await withContext(async (context) => { + const appId = await insertApp(context, { namespace: 'my-app' }); + return await insertPage(context, { appId, route: 'home' }); + }); + + expect(pageId).toBeTruthy(); + }); + + it('rejects a second app with the same namespace in the same project', async () => { + await expect( + withContext(async (context) => { + await insertApp(context, { namespace: 'dup' }); + await insertApp(context, { namespace: 'dup' }); + }), + ).rejects.toThrow(); + }); + + it('allows the same namespace across two different projects', async () => { + const otherProjectId = randomUUID(); + await withContext(async (context) => { + await insertProject(context, otherProjectId); + await insertApp(context, { namespace: 'shared-name' }); + await insertApp(context, { namespace: 'shared-name', projectId: otherProjectId }); + }); + }); + + it("deletes an app's pages when the app is deleted", async () => { + const remaining = await withContext(async (context) => { + const appId = await insertApp(context, { namespace: 'my-app' }); + await insertPage(context, { appId, route: 'home' }); + + const table = context.escape.tableName(APP_TABLE); + await context.runQuery(`DELETE FROM ${table} WHERE "id" = :id`, { id: appId }); + + return await getPageIds(context); + }); + + expect(remaining).toHaveLength(0); + }); + + it("deletes an app's pages when the owning project is deleted", async () => { + const remaining = await withContext(async (context) => { + const appId = await insertApp(context, { namespace: 'my-app' }); + await insertPage(context, { appId, route: 'home' }); + + const table = context.escape.tableName('project'); + await context.runQuery(`DELETE FROM ${table} WHERE "id" = :id`, { id: projectId }); + + return await getPageIds(context); + }); + + expect(remaining).toHaveLength(0); + }); + + it('deletes a child page when its parent page is deleted', async () => { + const remaining = await withContext(async (context) => { + const appId = await insertApp(context, { namespace: 'my-app' }); + const parentId = await insertPage(context, { appId, route: 'parent' }); + await insertPage(context, { appId, parentPageId: parentId, route: 'child' }); + + const table = context.escape.tableName(PAGE_TABLE); + await context.runQuery(`DELETE FROM ${table} WHERE "id" = :id`, { id: parentId }); + + return await getPageIds(context); + }); + + expect(remaining).toHaveLength(0); + }); + + it('drops both tables on revert', async () => { + await undoLastSingleMigration(); + dataSource = Container.get(DataSource); + + await withContext(async (context) => { + const appTable = context.escape.tableName(APP_TABLE); + const pageTable = context.escape.tableName(PAGE_TABLE); + await expect(context.runQuery(`SELECT 1 FROM ${appTable}`)).rejects.toThrow(); + await expect(context.runQuery(`SELECT 1 FROM ${pageTable}`)).rejects.toThrow(); + }); + }); +}); diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index a52b30d816c..1ffa9d3f0e3 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -5138,6 +5138,44 @@ "contextual.users.settings.unavailable.button.cloud": "Upgrade now", "contextual.feature.unavailable.title": "Available on the Enterprise Plan", "contextual.feature.unavailable.title.cloud": "Available on the Pro Plan", + "apps.apps": "Apps", + "apps.empty.heading": "Create your first app", + "apps.empty.description": "Build a page backed by your workflows", + "apps.add.title": "Create new app", + "apps.add.button.label": "Create app", + "apps.add.input.name.label": "App name", + "apps.add.input.name.placeholder": "Enter app name", + "apps.add.input.namespace.label": "Namespace", + "apps.add.input.namespace.placeholder": "my-app", + "apps.add.input.namespace.hint": "This is the base URL where the app will be available: {path}", + "apps.add.error": "Error creating app", + "apps.delete.confirm.title": "Delete app", + "apps.delete.confirm.message": "Are you sure you want to delete \"{name}\"? This will also delete all of its pages. This can't be undone.", + "apps.delete.error": "Error deleting app", + "apps.notFound": "App not found", + "apps.getDetails.error": "Error loading app", + "apps.url.label": "App URL", + "apps.pages": "Pages", + "apps.page.subPageCount": "{count} sub-page | {count} sub-pages", + "apps.page.subPages": "Sub-pages", + "apps.pages.empty": "No pages yet", + "apps.page.new": "New page", + "apps.page.addChild": "Add sub page", + "apps.page.index": "index", + "apps.page.add.input.route.label": "Route", + "apps.page.add.input.route.placeholder": "my-page", + "apps.page.add.input.route.hint": "Leave blank to make this the index page — the page shown when no further path is given, e.g. the app's own home page at the top level. Use :name (e.g. :id) for a dynamic segment, such as /clients/:id.", + "apps.page.add.error": "Error creating page", + "apps.page.delete.confirm.title": "Delete page", + "apps.page.delete.confirm.message": "Are you sure you want to delete \"{name}\"? This will also delete any sub-pages. This can't be undone.", + "apps.page.delete.error": "Error deleting page", + "apps.page.notFound": "Page not found", + "apps.page.getDetails.error": "Error loading page", + "apps.page.content.placeholder": "Page content editor coming soon", + "apps.page.input.route.label": "Route", + "apps.page.url.label": "Page URL", + "apps.page.save": "Save", + "apps.page.save.error": "Error saving page", "dataTable.dataTables": "Data tables", "dataTables.noResults": "No data tables found", "dataTable.empty.heading": "Create your first data table", diff --git a/packages/frontend/@n8n/stores/src/rbac.store.ts b/packages/frontend/@n8n/stores/src/rbac.store.ts index 8b7fe104e2b..b468f755a35 100644 --- a/packages/frontend/@n8n/stores/src/rbac.store.ts +++ b/packages/frontend/@n8n/stores/src/rbac.store.ts @@ -45,6 +45,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => { folder: {}, insights: {}, dataTable: {}, + app: {}, execution: {}, testRun: {}, workflowTags: {}, diff --git a/packages/frontend/editor-ui/src/app/modules.manifest.ts b/packages/frontend/editor-ui/src/app/modules.manifest.ts index be8beaafab1..94c7d2c8d53 100644 --- a/packages/frontend/editor-ui/src/app/modules.manifest.ts +++ b/packages/frontend/editor-ui/src/app/modules.manifest.ts @@ -1,5 +1,6 @@ import type { FrontendModuleDescription } from '@n8n/frontend-module-sdk'; import { DataTableModule } from '@/features/core/dataTable/module.descriptor'; +import { AppsModule } from '@/features/apps/module.descriptor'; import { InsightsModule } from '@/features/execution/insights/module.descriptor'; import { MCPModule } from '@/features/ai/mcpAccess/module.descriptor'; import { ChatModule } from '@/features/ai/chatHub/module.descriptor'; @@ -16,6 +17,7 @@ import { PromotionsModule } from '@/features/integrations/promotions.ee/module.d export const modules: FrontendModuleDescription[] = [ InsightsModule, DataTableModule, + AppsModule, MCPModule, ChatModule, InstanceAiModule, diff --git a/packages/frontend/editor-ui/src/features/apps/AppBreadcrumbs.vue b/packages/frontend/editor-ui/src/features/apps/AppBreadcrumbs.vue new file mode 100644 index 00000000000..27416005936 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/apps/AppBreadcrumbs.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/apps/AppDetailsView.vue b/packages/frontend/editor-ui/src/features/apps/AppDetailsView.vue new file mode 100644 index 00000000000..f4356060732 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/apps/AppDetailsView.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/apps/AppsView.vue b/packages/frontend/editor-ui/src/features/apps/AppsView.vue new file mode 100644 index 00000000000..e38979c5b63 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/apps/AppsView.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/apps/PageCard.vue b/packages/frontend/editor-ui/src/features/apps/PageCard.vue new file mode 100644 index 00000000000..9cf310b77fc --- /dev/null +++ b/packages/frontend/editor-ui/src/features/apps/PageCard.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/apps/PageView.vue b/packages/frontend/editor-ui/src/features/apps/PageView.vue new file mode 100644 index 00000000000..7a52ba5255f --- /dev/null +++ b/packages/frontend/editor-ui/src/features/apps/PageView.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/apps/apps.api.ts b/packages/frontend/editor-ui/src/features/apps/apps.api.ts new file mode 100644 index 00000000000..197e740a8d7 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/apps/apps.api.ts @@ -0,0 +1,79 @@ +import { makeRestApiRequest } from '@n8n/rest-api-client'; +import type { IRestApiContext } from '@n8n/rest-api-client'; + +import type { App, Page } from '@/features/apps/apps.types'; + +export const fetchAppsApi = async (context: IRestApiContext, projectId: string) => { + return await makeRestApiRequest(context, 'GET', `/projects/${projectId}/apps`); +}; + +export const getAppApi = async (context: IRestApiContext, projectId: string, appId: string) => { + return await makeRestApiRequest(context, 'GET', `/projects/${projectId}/apps/${appId}`); +}; + +export const createAppApi = async ( + context: IRestApiContext, + projectId: string, + name: string, + namespace: string, +) => { + return await makeRestApiRequest(context, 'POST', `/projects/${projectId}/apps`, { + name, + namespace, + }); +}; + +export const deleteAppApi = async (context: IRestApiContext, projectId: string, appId: string) => { + return await makeRestApiRequest(context, 'DELETE', `/projects/${projectId}/apps/${appId}`); +}; + +export const fetchPagesApi = async (context: IRestApiContext, projectId: string, appId: string) => { + return await makeRestApiRequest( + context, + 'GET', + `/projects/${projectId}/apps/${appId}/pages`, + ); +}; + +export const createPageApi = async ( + context: IRestApiContext, + projectId: string, + appId: string, + route: string, + parentPageId?: string, +) => { + return await makeRestApiRequest( + context, + 'POST', + `/projects/${projectId}/apps/${appId}/pages`, + { route, ...(parentPageId ? { parentPageId } : {}) }, + ); +}; + +export const updatePageApi = async ( + context: IRestApiContext, + projectId: string, + appId: string, + pageId: string, + route: string, +) => { + return await makeRestApiRequest( + context, + 'PATCH', + `/projects/${projectId}/apps/${appId}/pages/${pageId}`, + { route }, + ); +}; + +export const deletePageApi = async ( + context: IRestApiContext, + projectId: string, + appId: string, + pageId: string, +) => { + return await makeRestApiRequest( + context, + 'DELETE', + `/projects/${projectId}/apps/${appId}/pages/${pageId}`, + ); +}; diff --git a/packages/frontend/editor-ui/src/features/apps/apps.constants.ts b/packages/frontend/editor-ui/src/features/apps/apps.constants.ts new file mode 100644 index 00000000000..4151fd007a3 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/apps/apps.constants.ts @@ -0,0 +1,8 @@ +export const APPS_VIEW = 'apps'; +export const PROJECT_APPS = 'project-apps'; +export const APP_DETAILS = 'app-details'; +export const APP_PAGE_DETAILS = 'app-page-details'; +export const APPS_STORE = 'appsStore'; + +export const ADD_APP_MODAL_KEY = 'addAppModal'; +export const ADD_PAGE_MODAL_KEY = 'addPageModal'; diff --git a/packages/frontend/editor-ui/src/features/apps/apps.store.ts b/packages/frontend/editor-ui/src/features/apps/apps.store.ts new file mode 100644 index 00000000000..a6fae868de1 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/apps/apps.store.ts @@ -0,0 +1,90 @@ +import { useRootStore } from '@n8n/stores/useRootStore'; +import { defineStore } from 'pinia'; +import { ref } from 'vue'; + +import { + createAppApi, + createPageApi, + deleteAppApi, + deletePageApi, + fetchAppsApi, + fetchPagesApi, + getAppApi, + updatePageApi, +} from '@/features/apps/apps.api'; +import { APPS_STORE } from '@/features/apps/apps.constants'; +import type { App, Page } from '@/features/apps/apps.types'; + +export const useAppsStore = defineStore(APPS_STORE, () => { + const rootStore = useRootStore(); + + const apps = ref([]); + const pages = ref([]); + + const fetchApps = async (projectId: string) => { + apps.value = await fetchAppsApi(rootStore.restApiContext, projectId); + }; + + const getApp = async (projectId: string, appId: string) => { + return await getAppApi(rootStore.restApiContext, projectId, appId); + }; + + const createApp = async (projectId: string, name: string, namespace: string) => { + const app = await createAppApi(rootStore.restApiContext, projectId, name, namespace); + apps.value = [...apps.value, app]; + return app; + }; + + const deleteApp = async (projectId: string, appId: string) => { + await deleteAppApi(rootStore.restApiContext, projectId, appId); + apps.value = apps.value.filter((app) => app.id !== appId); + }; + + const fetchPages = async (projectId: string, appId: string) => { + pages.value = await fetchPagesApi(rootStore.restApiContext, projectId, appId); + }; + + const createPage = async ( + projectId: string, + appId: string, + route: string, + parentPageId?: string, + ) => { + const page = await createPageApi( + rootStore.restApiContext, + projectId, + appId, + route, + parentPageId, + ); + pages.value = [...pages.value, page]; + return page; + }; + + const updatePage = async (projectId: string, appId: string, pageId: string, route: string) => { + const page = await updatePageApi(rootStore.restApiContext, projectId, appId, pageId, route); + pages.value = pages.value.map((p) => (p.id === pageId ? page : p)); + return page; + }; + + const deletePage = async (projectId: string, appId: string, pageId: string) => { + await deletePageApi(rootStore.restApiContext, projectId, appId, pageId); + // Refetch rather than filter locally: deleting a page cascades to its + // sub-pages on the backend, and a shallow filter would leave those + // orphaned rows in the local list. + await fetchPages(projectId, appId); + }; + + return { + apps, + pages, + fetchApps, + getApp, + createApp, + deleteApp, + fetchPages, + createPage, + updatePage, + deletePage, + }; +}); diff --git a/packages/frontend/editor-ui/src/features/apps/apps.types.ts b/packages/frontend/editor-ui/src/features/apps/apps.types.ts new file mode 100644 index 00000000000..a56baf3c41d --- /dev/null +++ b/packages/frontend/editor-ui/src/features/apps/apps.types.ts @@ -0,0 +1,19 @@ +export interface App { + id: string; + name: string; + namespace: string; + theme: Record | null; + projectId: string; + createdAt: string; + updatedAt: string; +} + +export interface Page { + id: string; + appId: string; + parentPageId: string | null; + route: string; + content: unknown[] | null; + createdAt: string; + updatedAt: string; +} diff --git a/packages/frontend/editor-ui/src/features/apps/components/AddAppModal.vue b/packages/frontend/editor-ui/src/features/apps/components/AddAppModal.vue new file mode 100644 index 00000000000..7e11eea558a --- /dev/null +++ b/packages/frontend/editor-ui/src/features/apps/components/AddAppModal.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/apps/components/AddPageModal.vue b/packages/frontend/editor-ui/src/features/apps/components/AddPageModal.vue new file mode 100644 index 00000000000..320cecf476d --- /dev/null +++ b/packages/frontend/editor-ui/src/features/apps/components/AddPageModal.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/apps/modals.ts b/packages/frontend/editor-ui/src/features/apps/modals.ts new file mode 100644 index 00000000000..96f6239dd34 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/apps/modals.ts @@ -0,0 +1,16 @@ +import type { ModalDefinition } from '@n8n/frontend-module-sdk'; + +import { ADD_APP_MODAL_KEY, ADD_PAGE_MODAL_KEY } from './apps.constants'; + +export const APPS_MODALS: ModalDefinition[] = [ + { + key: ADD_APP_MODAL_KEY, + component: async () => await import('./components/AddAppModal.vue'), + initialState: { open: false }, + }, + { + key: ADD_PAGE_MODAL_KEY, + component: async () => await import('./components/AddPageModal.vue'), + initialState: { open: false }, + }, +]; diff --git a/packages/frontend/editor-ui/src/features/apps/module.descriptor.ts b/packages/frontend/editor-ui/src/features/apps/module.descriptor.ts new file mode 100644 index 00000000000..ffa9eedfde5 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/apps/module.descriptor.ts @@ -0,0 +1,90 @@ +import { type FrontendModuleDescription } from '@n8n/frontend-module-sdk'; +import { useI18n } from '@n8n/i18n'; + +import { + APP_DETAILS, + APP_PAGE_DETAILS, + APPS_VIEW, + PROJECT_APPS, +} from '@/features/apps/apps.constants'; +import { APPS_MODALS } from '@/features/apps/modals'; + +const i18n = useI18n(); + +const AppsView = async () => await import('@/features/apps/AppsView.vue'); +const AppDetailsView = async () => await import('@/features/apps/AppDetailsView.vue'); +const PageView = async () => await import('@/features/apps/PageView.vue'); + +export const AppsModule: FrontendModuleDescription = { + id: 'apps', + name: 'Apps', + description: 'Build pages backed by your workflows.', + icon: 'grid-2x2', + modals: APPS_MODALS, + routes: [ + { + name: APPS_VIEW, + path: '/home/apps', + component: AppsView, + meta: { + middleware: ['authenticated', 'custom'], + }, + }, + { + name: PROJECT_APPS, + path: 'apps', + component: AppsView, + meta: { + projectRoute: true, + middleware: ['authenticated', 'custom'], + }, + }, + { + name: APP_DETAILS, + path: 'apps/:appId', + props: true, + component: AppDetailsView, + meta: { + projectRoute: true, + middleware: ['authenticated', 'custom'], + }, + }, + { + name: APP_PAGE_DETAILS, + path: 'apps/:appId/pages/:pageId', + props: true, + component: PageView, + meta: { + projectRoute: true, + middleware: ['authenticated', 'custom'], + }, + }, + ], + projectTabs: { + overview: [ + { + label: i18n.baseText('apps.apps'), + value: APPS_VIEW, + to: { + name: APPS_VIEW, + }, + }, + ], + project: [ + { + label: i18n.baseText('apps.apps'), + value: PROJECT_APPS, + dynamicRoute: { + name: PROJECT_APPS, + includeProjectId: true, + }, + }, + ], + }, + resources: [ + { + key: 'app', + displayName: 'App', + }, + ], +}; diff --git a/packages/frontend/editor-ui/src/features/apps/pageTree.utils.ts b/packages/frontend/editor-ui/src/features/apps/pageTree.utils.ts new file mode 100644 index 00000000000..82ab86c5a17 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/apps/pageTree.utils.ts @@ -0,0 +1,45 @@ +import type { Page } from '@/features/apps/apps.types'; + +/** Ancestors of `pageId`, root-first, excluding the page itself. */ +export function getAncestorPages(pages: Page[], pageId: string): Page[] { + const byId = new Map(pages.map((page) => [page.id, page])); + const ancestors: Page[] = []; + let parentId = byId.get(pageId)?.parentPageId ?? null; + while (parentId) { + const parent = byId.get(parentId); + if (!parent) break; + ancestors.unshift(parent); + parentId = parent.parentPageId; + } + return ancestors; +} + +/** + * The full served URL for a page: the app's namespace plus every ancestor's + * route segment plus this page's own route, joined with `/`. Index pages + * (empty route) contribute no segment of their own. + */ +export function getPageUrl(namespace: string, ancestors: Page[], ownRoute: string): string { + const segments = [...ancestors.map((p) => p.route), ownRoute].filter(Boolean); + const path = ['apps', namespace, ...segments].join('/'); + return `${window.location.origin}/${path}`; +} + +/** How many pages have each parent (keyed by parentPageId, null for root pages). */ +export function getChildCounts(pages: Page[]): Map { + const counts = new Map(); + for (const page of pages) { + counts.set(page.parentPageId, (counts.get(page.parentPageId) ?? 0) + 1); + } + return counts; +} + +/** "foo" -> "foo"; "" -> "(index)" — no leading slash, for breadcrumb items. */ +export function formatRouteSegment(route: string, indexLabel: string): string { + return route || `(${indexLabel})`; +} + +/** "foo" -> "/foo"; "" -> "/ (index)" — with leading slash, for titles and labels. */ +export function formatRoutePath(route: string, indexLabel: string): string { + return route ? `/${route}` : `/ (${indexLabel})`; +} diff --git a/packages/frontend/editor-ui/src/features/apps/useAppDeletion.ts b/packages/frontend/editor-ui/src/features/apps/useAppDeletion.ts new file mode 100644 index 00000000000..1cfefcd1dd6 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/apps/useAppDeletion.ts @@ -0,0 +1,72 @@ +import { useI18n } from '@n8n/i18n'; +import { useToast } from '@n8n/composables/useToast'; + +import { MODAL_CONFIRM } from '@/app/constants'; +import { useMessage } from '@/app/composables/useMessage'; +import { escapeHtml } from '@/app/utils/htmlUtils'; +import { useAppsStore } from '@/features/apps/apps.store'; +import { formatRoutePath } from '@/features/apps/pageTree.utils'; +import type { App } from '@/features/apps/apps.types'; + +/** Confirm-then-delete for Apps and Pages, shared by every view that offers a delete action. */ +export function useAppDeletion() { + const i18n = useI18n(); + const toast = useToast(); + const message = useMessage(); + const appsStore = useAppsStore(); + + const confirm = async (title: string, text: string) => { + const response = await message.confirm(text, title, { + confirmButtonText: i18n.baseText('generic.delete'), + cancelButtonText: i18n.baseText('generic.cancel'), + }); + return response === MODAL_CONFIRM; + }; + + /** @returns whether the app was deleted (false if cancelled or the request failed) */ + const confirmAndDeleteApp = async (projectId: string, app: App): Promise => { + const confirmed = await confirm( + i18n.baseText('apps.delete.confirm.title'), + i18n.baseText('apps.delete.confirm.message', { interpolate: { name: escapeHtml(app.name) } }), + ); + if (!confirmed) return false; + + try { + await appsStore.deleteApp(projectId, app.id); + return true; + } catch (error) { + toast.showError(error, i18n.baseText('apps.delete.error')); + return false; + } + }; + + /** @returns whether the page was deleted (false if cancelled, not found, or the request failed) */ + const confirmAndDeletePage = async ( + projectId: string, + appId: string, + pageId: string, + ): Promise => { + const page = appsStore.pages.find((p) => p.id === pageId); + if (!page) return false; + + const confirmed = await confirm( + i18n.baseText('apps.page.delete.confirm.title'), + i18n.baseText('apps.page.delete.confirm.message', { + interpolate: { + name: escapeHtml(formatRoutePath(page.route, i18n.baseText('apps.page.index'))), + }, + }), + ); + if (!confirmed) return false; + + try { + await appsStore.deletePage(projectId, appId, pageId); + return true; + } catch (error) { + toast.showError(error, i18n.baseText('apps.page.delete.error')); + return false; + } + }; + + return { confirmAndDeleteApp, confirmAndDeletePage }; +}