feat(core): Add Apps — a top-level App/Page entity with a first UI

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).
This commit is contained in:
Bernhard Wittmann 2026-09-07 14:25:32 +02:00
parent a0df5b34aa
commit 6fbdfcecdd
No known key found for this signature in database
GPG Key ID: C929BD10AA16F8B9
56 changed files with 2761 additions and 2 deletions

View File

@ -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

82
docs/generated/sqlite-schema/app.md generated Normal file
View File

@ -0,0 +1,82 @@
# app
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```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)
```
</details>
## 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)

73
docs/generated/sqlite-schema/page.md generated Normal file
View File

@ -0,0 +1,73 @@
# page
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```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)
```
</details>
## 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)

View File

@ -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

View File

@ -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,
}) {}

View File

@ -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(),
}) {}

View File

@ -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(),
}) {}

View File

@ -0,0 +1,6 @@
import { pageRouteSchema } from '../../schemas/app.schema';
import { Z } from '../../zod-class';
export class UpdatePageDto extends Z.class({
route: pageRouteSchema.optional(),
}) {}

View File

@ -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';

View File

@ -0,0 +1,28 @@
import { z } from 'zod';
export const appNameSchema = z.string().trim().min(1).max(128);
// URL path segment under /apps/<namespace>; 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);

View File

@ -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',

View File

@ -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',

View File

@ -4,6 +4,7 @@ import { UnknownModuleError } from './errors/unknown-module.error';
export const MODULE_NAMES = [
'agents',
'apps',
'agent-evals',
'insights',
'external-secrets',

View File

@ -158,6 +158,8 @@ type EntityName =
| 'InsightsMetadata'
| 'DataTable'
| 'DataTableColumn'
| 'App'
| 'Page'
| 'ChatHubSession'
| 'ChatHubMessage'
| 'ChatHubAgent'

View File

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

View File

@ -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",

View File

@ -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,

View File

@ -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',

View File

@ -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',
];

View File

@ -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: {},

View File

@ -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<string, unknown> | null;
@ManyToOne(() => Project)
@JoinColumn({ name: 'projectId' })
project: Project;
@Column()
projectId: string;
}

View File

@ -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<App> {
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<Pick<App, 'name' | 'namespace' | 'theme'>>) {
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 });
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<App>;
@Column()
@Index()
appId: string;
@ManyToOne(() => Page, { nullable: true })
@JoinColumn({ name: 'parentPageId' })
parentPage: Relation<Page> | null;
@Column({ nullable: true })
@Index()
parentPageId: string | null;
}

View File

@ -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<Page> {
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<Pick<Page, 'route' | 'content'>>) {
return await this.save(Object.assign(page, updates));
}
async deletePage(id: string) {
await this.delete({ id });
}
}

View File

@ -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<string> {
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<boolean> {
@ -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`.",
);
}

View File

@ -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."
}
]
}

View File

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

View File

@ -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'

View File

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

View File

@ -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<T>(fn: (context: TestMigrationContext) => Promise<T>): Promise<T> {
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<string[]> {
const table = context.escape.tableName(PAGE_TABLE);
const rows = await context.runQuery<Array<{ id: string }>>(`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();
});
});
});

View File

@ -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",

View File

@ -45,6 +45,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
folder: {},
insights: {},
dataTable: {},
app: {},
execution: {},
testRun: {},
workflowTags: {},

View File

@ -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,

View File

@ -0,0 +1,82 @@
<script setup lang="ts">
import { N8nBreadcrumbs } from '@n8n/design-system';
import type { PathItem } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useAppsStore } from '@/features/apps/apps.store';
import { formatRouteSegment, getAncestorPages } from '@/features/apps/pageTree.utils';
const props = defineProps<{
projectId: string;
appId: string;
appName: string;
// Omit when the breadcrumb trail ends at the App itself (no specific page open).
currentPageId?: string;
}>();
const i18n = useI18n();
const router = useRouter();
const appsStore = useAppsStore();
// No leading slash here: the breadcrumb's own separator between items
// already renders one, so "route" wouldn't read "Apps / MyApp / /route".
const routeLabel = (route: string) => formatRouteSegment(route, i18n.baseText('apps.page.index'));
const ancestorPages = computed(() =>
props.currentPageId ? getAncestorPages(appsStore.pages, props.currentPageId) : [],
);
const items = computed<PathItem[]>(() => {
const result: PathItem[] = [
{
id: 'apps-root',
label: i18n.baseText('apps.apps'),
href: `/projects/${props.projectId}/apps`,
},
{
id: props.appId,
label: props.appName,
href: props.currentPageId ? `/projects/${props.projectId}/apps/${props.appId}` : undefined,
},
];
for (const page of ancestorPages.value) {
result.push({
id: page.id,
label: routeLabel(page.route),
href: `/projects/${props.projectId}/apps/${props.appId}/pages/${page.id}`,
});
}
if (props.currentPageId) {
const currentPage = appsStore.pages.find((page) => page.id === props.currentPageId);
if (currentPage) {
result.push({ id: currentPage.id, label: routeLabel(currentPage.route) });
}
}
return result;
});
const onItemSelected = async (item: PathItem) => {
if (item.href) await router.push(item.href);
};
</script>
<template>
<N8nBreadcrumbs
:class="$style.breadcrumbs"
:items="items"
separator="/"
data-test-id="app-breadcrumbs"
@item-selected="onItemSelected"
/>
</template>
<style lang="scss" module>
.breadcrumbs {
margin-bottom: var(--spacing--lg);
}
</style>

View File

@ -0,0 +1,183 @@
<script setup lang="ts">
import { N8nButton, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import CopyInput from '@/app/components/CopyInput.vue';
import PageViewLayout from '@/app/components/layouts/PageViewLayout.vue';
import { useUIStore } from '@/app/stores/ui.store';
import AppBreadcrumbs from '@/features/apps/AppBreadcrumbs.vue';
import PageCard from '@/features/apps/PageCard.vue';
import { useAppsStore } from '@/features/apps/apps.store';
import { useAppDeletion } from '@/features/apps/useAppDeletion';
import { ADD_PAGE_MODAL_KEY, APP_PAGE_DETAILS, PROJECT_APPS } from '@/features/apps/apps.constants';
import type { App } from '@/features/apps/apps.types';
import { getChildCounts } from '@/features/apps/pageTree.utils';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
const props = defineProps<{
projectId: string;
appId: string;
}>();
const i18n = useI18n();
const toast = useToast();
const router = useRouter();
const uiStore = useUIStore();
const documentTitle = useDocumentTitle();
const { confirmAndDeleteApp, confirmAndDeletePage } = useAppDeletion();
const appsStore = useAppsStore();
const app = ref<App | null>(null);
const loading = ref(false);
const rootPages = computed(() => appsStore.pages.filter((page) => page.parentPageId === null));
const childCounts = computed(() => getChildCounts(appsStore.pages));
const appUrl = computed(() =>
app.value ? `${window.location.origin}/apps/${app.value.namespace}` : '',
);
const showErrorAndGoBack = async (error: unknown) => {
toast.showError(error, i18n.baseText('apps.getDetails.error'));
await router.push({ name: PROJECT_APPS, params: { projectId: props.projectId } });
};
const initialize = async () => {
loading.value = true;
try {
const [result] = await Promise.all([
appsStore.getApp(props.projectId, props.appId),
appsStore.fetchPages(props.projectId, props.appId),
]);
app.value = result;
documentTitle.set(`${i18n.baseText('apps.apps')} > ${result.name}`);
} catch (error) {
await showErrorAndGoBack(error);
} finally {
loading.value = false;
}
};
const openAddPageModal = (parentPageId: string | null) => {
uiStore.openModalWithData({
name: ADD_PAGE_MODAL_KEY,
data: { projectId: props.projectId, appId: props.appId, parentPageId },
});
};
const openPage = async (pageId: string) => {
await router.push({
name: APP_PAGE_DETAILS,
params: { projectId: props.projectId, appId: props.appId, pageId },
});
};
const onDeleteApp = async () => {
if (!app.value) return;
const deleted = await confirmAndDeleteApp(props.projectId, app.value);
if (deleted) await router.push({ name: PROJECT_APPS, params: { projectId: props.projectId } });
};
const onDeletePage = async (pageId: string) => {
await confirmAndDeletePage(props.projectId, props.appId, pageId);
};
onMounted(initialize);
// Navigating to a different app reuses this same route component instance —
// onMounted only fires once, so re-run on an appId change.
watch(() => props.appId, initialize);
</script>
<template>
<PageViewLayout data-test-id="app-details-view">
<template #header>
<div :class="$style.breadcrumbsRow">
<AppBreadcrumbs v-if="app" :project-id="projectId" :app-id="appId" :app-name="app.name" />
<N8nButton
v-if="app"
icon-only
icon="trash-2"
variant="subtle"
:aria-label="i18n.baseText('generic.delete')"
data-test-id="app-delete"
@click="onDeleteApp"
/>
</div>
</template>
<div :class="$style.container">
<div v-if="app" :class="$style.urlCard">
<CopyInput
:label="i18n.baseText('apps.url.label')"
:value="appUrl"
data-test-id="app-url"
/>
</div>
<div :class="$style.header">
<N8nText tag="h2" size="medium" bold>{{ i18n.baseText('apps.pages') }}</N8nText>
<N8nButton size="small" data-test-id="app-page-add-root" @click="openAddPageModal(null)">
{{ i18n.baseText('apps.page.new') }}
</N8nButton>
</div>
<N8nText v-if="rootPages.length === 0" color="text-light">
{{ i18n.baseText('apps.pages.empty') }}
</N8nText>
<div :class="$style.pageGrid">
<PageCard
v-for="page in rootPages"
:key="page.id"
:page="page"
:child-count="childCounts.get(page.id) ?? 0"
@open="openPage"
@add-child="openAddPageModal"
@delete="onDeletePage"
/>
</div>
</div>
</PageViewLayout>
</template>
<style lang="scss" module>
.breadcrumbsRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.container {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
width: 100%;
padding-bottom: var(--spacing--lg);
}
.urlCard {
background-color: var(--background--surface);
border-radius: var(--radius--lg);
padding: var(--spacing--md);
margin-bottom: var(--spacing--sm);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing--xs);
}
.pageGrid {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
width: 100%;
}
</style>

View File

@ -0,0 +1,124 @@
<script setup lang="ts">
import { N8nButton, N8nCard, N8nHeading, N8nIcon, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import { computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import PageViewLayout from '@/app/components/layouts/PageViewLayout.vue';
import { useUIStore } from '@/app/stores/ui.store';
import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useAppsStore } from '@/features/apps/apps.store';
import { useAppDeletion } from '@/features/apps/useAppDeletion';
import { ADD_APP_MODAL_KEY, APP_DETAILS } from '@/features/apps/apps.constants';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
const i18n = useI18n();
const toast = useToast();
const router = useRouter();
const uiStore = useUIStore();
const documentTitle = useDocumentTitle();
const { confirmAndDeleteApp } = useAppDeletion();
const projectPages = useProjectPages();
const projectsStore = useProjectsStore();
const appsStore = useAppsStore();
// On the home overview page there's no :projectId route param — fall back to
// the user's personal project, same as Data Tables does.
const currentProject = computed(() =>
projectPages.isOverviewSubPage ? projectsStore.personalProject : projectsStore.currentProject,
);
const projectId = () => currentProject.value?.id ?? '';
const fetchApps = async () => {
try {
await appsStore.fetchApps(projectId());
} catch (error) {
toast.showError(error, i18n.baseText('apps.getDetails.error'));
}
};
const openAddAppModal = () => {
uiStore.openModalWithData({ name: ADD_APP_MODAL_KEY, data: { projectId: projectId() } });
};
const openApp = async (appId: string) => {
await router.push({ name: APP_DETAILS, params: { projectId: projectId(), appId } });
};
onMounted(() => {
documentTitle.set(i18n.baseText('apps.apps'));
void fetchApps();
});
</script>
<template>
<PageViewLayout data-test-id="apps-view">
<template #header>
<div :class="$style.header">
<N8nHeading bold tag="h1" size="2xlarge">{{ i18n.baseText('apps.apps') }}</N8nHeading>
<N8nButton data-test-id="apps-new" @click="openAddAppModal">
{{ i18n.baseText('apps.add.button.label') }}
</N8nButton>
</div>
</template>
<div :class="$style.container">
<N8nText v-if="appsStore.apps.length === 0" color="text-light">
{{ i18n.baseText('apps.empty.heading') }}
</N8nText>
<N8nCard
v-for="app in appsStore.apps"
:key="app.id"
hoverable
:class="$style.appCard"
data-test-id="app-card"
@click="openApp(app.id)"
>
<template #prepend>
<N8nIcon icon="grid-2x2" />
</template>
<N8nText bold>{{ app.name }}</N8nText>
<N8nText color="text-light" size="small">/{{ app.namespace }}</N8nText>
<template #append>
<N8nButton
icon-only
icon="trash-2"
variant="subtle"
:aria-label="i18n.baseText('generic.delete')"
data-test-id="app-delete"
@click.stop="confirmAndDeleteApp(projectId(), app)"
/>
</template>
</N8nCard>
</div>
</PageViewLayout>
</template>
<style lang="scss" module>
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing--lg);
}
.container {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
width: 100%;
padding-bottom: var(--spacing--lg);
}
.appCard {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
cursor: pointer;
}
</style>

View File

@ -0,0 +1,77 @@
<script setup lang="ts">
import { N8nButton, N8nCard, N8nIcon, N8nText, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import type { Page } from '@/features/apps/apps.types';
import { formatRoutePath } from '@/features/apps/pageTree.utils';
const i18n = useI18n();
const props = defineProps<{
page: Page;
childCount: number;
}>();
defineEmits<{
open: [pageId: string];
addChild: [parentPageId: string];
delete: [pageId: string];
}>();
// An empty route means this page is the index page for its level — and,
// since it contributes no path segment of its own, it can't have children
// (a child of it would resolve to the same URL as a sibling of this page).
const routeLabel = computed(() =>
formatRoutePath(props.page.route, i18n.baseText('apps.page.index')),
);
</script>
<template>
<N8nCard hoverable :class="$style.card" data-test-id="page-card" @click="$emit('open', page.id)">
<template #prepend>
<N8nIcon icon="file" />
</template>
<N8nText bold data-test-id="app-page-route">{{ routeLabel }}</N8nText>
<N8nText v-if="childCount > 0" color="text-light" size="small">
{{ i18n.baseText('apps.page.subPageCount', { adjustToNumber: childCount }) }}
</N8nText>
<template #append>
<div :class="$style.actions" @click.stop>
<N8nTooltip v-if="page.route" :content="i18n.baseText('apps.page.addChild')">
<N8nButton
icon-only
icon="plus"
size="small"
variant="subtle"
:aria-label="i18n.baseText('apps.page.addChild')"
data-test-id="app-page-add-child"
@click="$emit('addChild', page.id)"
/>
</N8nTooltip>
<N8nTooltip :content="i18n.baseText('generic.delete')">
<N8nButton
icon-only
icon="trash-2"
size="small"
variant="subtle"
:aria-label="i18n.baseText('generic.delete')"
data-test-id="app-page-delete"
@click="$emit('delete', page.id)"
/>
</N8nTooltip>
</div>
</template>
</N8nCard>
</template>
<style lang="scss" module>
.card {
cursor: pointer;
}
.actions {
display: flex;
gap: var(--spacing--4xs);
}
</style>

View File

@ -0,0 +1,278 @@
<script setup lang="ts">
import { N8nButton, N8nInput, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import CopyInput from '@/app/components/CopyInput.vue';
import PageViewLayout from '@/app/components/layouts/PageViewLayout.vue';
import { useUIStore } from '@/app/stores/ui.store';
import AppBreadcrumbs from '@/features/apps/AppBreadcrumbs.vue';
import PageCard from '@/features/apps/PageCard.vue';
import { useAppsStore } from '@/features/apps/apps.store';
import { useAppDeletion } from '@/features/apps/useAppDeletion';
import { ADD_PAGE_MODAL_KEY, APP_DETAILS, APP_PAGE_DETAILS } from '@/features/apps/apps.constants';
import type { App } from '@/features/apps/apps.types';
import {
formatRoutePath,
getAncestorPages,
getChildCounts,
getPageUrl,
} from '@/features/apps/pageTree.utils';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
const props = defineProps<{
projectId: string;
appId: string;
pageId: string;
}>();
const i18n = useI18n();
const toast = useToast();
const router = useRouter();
const uiStore = useUIStore();
const documentTitle = useDocumentTitle();
const { confirmAndDeletePage } = useAppDeletion();
const appsStore = useAppsStore();
const app = ref<App | null>(null);
const route = ref('');
const loading = ref(false);
const saving = ref(false);
const pageUrl = computed(() => {
if (!app.value) return '';
const ancestors = getAncestorPages(appsStore.pages, props.pageId);
return getPageUrl(app.value.namespace, ancestors, route.value);
});
const childPages = computed(() =>
appsStore.pages.filter((page) => page.parentPageId === props.pageId),
);
const childCounts = computed(() => getChildCounts(appsStore.pages));
const showErrorAndGoBack = async (error: unknown) => {
toast.showError(error, i18n.baseText('apps.page.getDetails.error'));
await router.push({
name: APP_DETAILS,
params: { projectId: props.projectId, appId: props.appId },
});
};
const initialize = async () => {
loading.value = true;
try {
const [result] = await Promise.all([
appsStore.getApp(props.projectId, props.appId),
appsStore.pages.length === 0
? appsStore.fetchPages(props.projectId, props.appId)
: Promise.resolve(),
]);
app.value = result;
const page = appsStore.pages.find((p) => p.id === props.pageId);
if (!page) {
await showErrorAndGoBack(new Error(i18n.baseText('apps.page.notFound')));
return;
}
route.value = page.route;
documentTitle.set(formatRoutePath(route.value, i18n.baseText('apps.page.index')));
} catch (error) {
await showErrorAndGoBack(error);
} finally {
loading.value = false;
}
};
const onSave = async () => {
saving.value = true;
try {
await appsStore.updatePage(props.projectId, props.appId, props.pageId, route.value);
} catch (error) {
toast.showError(error, i18n.baseText('apps.page.save.error'));
} finally {
saving.value = false;
}
};
const onDelete = async () => {
const deleted = await confirmAndDeletePage(props.projectId, props.appId, props.pageId);
if (deleted) {
await router.push({
name: APP_DETAILS,
params: { projectId: props.projectId, appId: props.appId },
});
}
};
const openAddPageModal = (parentPageId: string) => {
uiStore.openModalWithData({
name: ADD_PAGE_MODAL_KEY,
data: { projectId: props.projectId, appId: props.appId, parentPageId },
});
};
const openPage = async (pageId: string) => {
await router.push({
name: APP_PAGE_DETAILS,
params: { projectId: props.projectId, appId: props.appId, pageId },
});
};
const onDeleteChildPage = async (pageId: string) => {
await confirmAndDeletePage(props.projectId, props.appId, pageId);
};
onMounted(initialize);
// Navigating between sibling pages (e.g. via the breadcrumb) reuses this same
// route component instance — onMounted only fires once, so re-run on a pageId
// change or the view keeps showing the previous page.
watch(() => props.pageId, initialize);
</script>
<template>
<PageViewLayout data-test-id="page-view">
<template #header>
<div :class="$style.breadcrumbsRow">
<AppBreadcrumbs
v-if="app"
:project-id="projectId"
:app-id="appId"
:app-name="app.name"
:current-page-id="pageId"
/>
<div v-if="app" :class="$style.headerActions">
<N8nButton :loading="saving" data-test-id="page-save" @click="onSave">
{{ i18n.baseText('apps.page.save') }}
</N8nButton>
<N8nButton
icon-only
icon="trash-2"
variant="subtle"
:aria-label="i18n.baseText('generic.delete')"
data-test-id="page-delete"
@click="onDelete"
/>
</div>
</div>
</template>
<div :class="$style.container">
<div v-if="app" :class="$style.urlCard">
<CopyInput
:label="i18n.baseText('apps.page.url.label')"
:value="pageUrl"
data-test-id="page-url"
/>
</div>
<div :class="$style.routeRow">
<N8nText tag="label">{{ i18n.baseText('apps.page.input.route.label') }}</N8nText>
<N8nInput
v-model="route"
:placeholder="i18n.baseText('apps.page.add.input.route.placeholder')"
data-test-id="page-route-input"
/>
</div>
<N8nText color="text-light" size="small">
{{ i18n.baseText('apps.page.add.input.route.hint') }}
</N8nText>
<div :class="$style.content" data-test-id="page-content-placeholder">
<N8nText color="text-light">{{ i18n.baseText('apps.page.content.placeholder') }}</N8nText>
</div>
<div :class="$style.header">
<N8nText tag="h2" size="medium" bold>{{ i18n.baseText('apps.page.subPages') }}</N8nText>
<N8nButton
v-if="route"
size="small"
data-test-id="page-add-child"
@click="openAddPageModal(pageId)"
>
{{ i18n.baseText('apps.page.new') }}
</N8nButton>
</div>
<N8nText v-if="childPages.length === 0" color="text-light">
{{ i18n.baseText('apps.pages.empty') }}
</N8nText>
<div :class="$style.pageGrid">
<PageCard
v-for="page in childPages"
:key="page.id"
:page="page"
:child-count="childCounts.get(page.id) ?? 0"
@open="openPage"
@add-child="openAddPageModal"
@delete="onDeleteChildPage"
/>
</div>
</div>
</PageViewLayout>
</template>
<style lang="scss" module>
.breadcrumbsRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.headerActions {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.container {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
width: 100%;
padding-bottom: var(--spacing--lg);
}
.urlCard {
background-color: var(--background--surface);
border-radius: var(--radius--lg);
padding: var(--spacing--md);
margin-bottom: var(--spacing--sm);
}
.routeRow {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
width: 100%;
}
.content {
border: 1px dashed var(--border-color);
border-radius: var(--radius--sm);
padding: var(--spacing--xl);
display: flex;
align-items: center;
justify-content: center;
width: 100%;
margin-bottom: var(--spacing--sm);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing--xs);
}
.pageGrid {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
width: 100%;
}
</style>

View File

@ -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<App[]>(context, 'GET', `/projects/${projectId}/apps`);
};
export const getAppApi = async (context: IRestApiContext, projectId: string, appId: string) => {
return await makeRestApiRequest<App>(context, 'GET', `/projects/${projectId}/apps/${appId}`);
};
export const createAppApi = async (
context: IRestApiContext,
projectId: string,
name: string,
namespace: string,
) => {
return await makeRestApiRequest<App>(context, 'POST', `/projects/${projectId}/apps`, {
name,
namespace,
});
};
export const deleteAppApi = async (context: IRestApiContext, projectId: string, appId: string) => {
return await makeRestApiRequest<void>(context, 'DELETE', `/projects/${projectId}/apps/${appId}`);
};
export const fetchPagesApi = async (context: IRestApiContext, projectId: string, appId: string) => {
return await makeRestApiRequest<Page[]>(
context,
'GET',
`/projects/${projectId}/apps/${appId}/pages`,
);
};
export const createPageApi = async (
context: IRestApiContext,
projectId: string,
appId: string,
route: string,
parentPageId?: string,
) => {
return await makeRestApiRequest<Page>(
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<Page>(
context,
'PATCH',
`/projects/${projectId}/apps/${appId}/pages/${pageId}`,
{ route },
);
};
export const deletePageApi = async (
context: IRestApiContext,
projectId: string,
appId: string,
pageId: string,
) => {
return await makeRestApiRequest<void>(
context,
'DELETE',
`/projects/${projectId}/apps/${appId}/pages/${pageId}`,
);
};

View File

@ -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';

View File

@ -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<App[]>([]);
const pages = ref<Page[]>([]);
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,
};
});

View File

@ -0,0 +1,19 @@
export interface App {
id: string;
name: string;
namespace: string;
theme: Record<string, unknown> | 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;
}

View File

@ -0,0 +1,140 @@
<script setup lang="ts">
import { N8nButton, N8nInput, N8nInputLabel, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import Modal from '@/app/components/Modal.vue';
import { useAppsStore } from '@/features/apps/apps.store';
import { APP_DETAILS } from '@/features/apps/apps.constants';
import { useUIStore } from '@/app/stores/ui.store';
type AddAppModalData = { projectId: string };
const props = defineProps<{
modalName: string;
data: AddAppModalData;
}>();
const i18n = useI18n();
const toast = useToast();
const router = useRouter();
const uiStore = useUIStore();
const appsStore = useAppsStore();
const name = ref('');
const namespace = ref('');
const isCreating = ref(false);
const nameInputRef = ref<HTMLInputElement | null>(null);
const namespacePreview = computed(() => `/apps/${namespace.value || '…'}`);
const onSubmit = async () => {
if (!name.value || !namespace.value || isCreating.value) return;
isCreating.value = true;
try {
const app = await appsStore.createApp(props.data.projectId, name.value, namespace.value);
uiStore.closeModal(props.modalName);
await router.push({
name: APP_DETAILS,
params: { projectId: props.data.projectId, appId: app.id },
});
} catch (error) {
toast.showError(error, i18n.baseText('apps.add.error'));
} finally {
isCreating.value = false;
}
};
onMounted(() => {
setTimeout(() => nameInputRef.value?.focus(), 0);
});
</script>
<template>
<Modal :name="props.modalName" :center="true" width="480px" data-test-id="add-app-modal">
<template #header>
<h2>{{ i18n.baseText('apps.add.title') }}</h2>
</template>
<template #content>
<div :class="$style.content">
<N8nInputLabel
:label="i18n.baseText('apps.add.input.name.label')"
:required="true"
input-name="appName"
>
<N8nInput
ref="nameInputRef"
v-model="name"
:placeholder="i18n.baseText('apps.add.input.name.placeholder')"
name="appName"
data-test-id="apps-new-name"
@keydown.enter="onSubmit"
/>
</N8nInputLabel>
<div :class="$style.field">
<N8nInputLabel
:label="i18n.baseText('apps.add.input.namespace.label')"
:required="true"
input-name="appNamespace"
>
<N8nInput
v-model="namespace"
:placeholder="i18n.baseText('apps.add.input.namespace.placeholder')"
name="appNamespace"
data-test-id="apps-new-namespace"
@keydown.enter="onSubmit"
/>
</N8nInputLabel>
<N8nText color="text-light" size="small">
{{
i18n.baseText('apps.add.input.namespace.hint', {
interpolate: { path: namespacePreview },
})
}}
</N8nText>
</div>
</div>
</template>
<template #footer>
<div :class="$style.footer">
<N8nButton
variant="subtle"
size="large"
:label="i18n.baseText('generic.cancel')"
data-test-id="apps-new-cancel"
@click="uiStore.closeModal(props.modalName)"
/>
<N8nButton
:loading="isCreating"
:disabled="!name || !namespace"
size="large"
:label="i18n.baseText('apps.add.button.label')"
data-test-id="apps-new-submit"
@click="onSubmit"
/>
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.content {
display: flex;
flex-direction: column;
gap: var(--spacing--md);
}
.field {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
}
.footer {
display: flex;
gap: var(--spacing--2xs);
justify-content: flex-end;
}
</style>

View File

@ -0,0 +1,115 @@
<script setup lang="ts">
import { N8nButton, N8nInput, N8nInputLabel, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import Modal from '@/app/components/Modal.vue';
import { useAppsStore } from '@/features/apps/apps.store';
import { APP_PAGE_DETAILS } from '@/features/apps/apps.constants';
import { useUIStore } from '@/app/stores/ui.store';
type AddPageModalData = { projectId: string; appId: string; parentPageId: string | null };
const props = defineProps<{
modalName: string;
data: AddPageModalData;
}>();
const i18n = useI18n();
const toast = useToast();
const router = useRouter();
const uiStore = useUIStore();
const appsStore = useAppsStore();
const route = ref('');
const isCreating = ref(false);
const routeInputRef = ref<HTMLInputElement | null>(null);
const onSubmit = async () => {
if (isCreating.value) return;
isCreating.value = true;
try {
const page = await appsStore.createPage(
props.data.projectId,
props.data.appId,
route.value,
props.data.parentPageId ?? undefined,
);
uiStore.closeModal(props.modalName);
await router.push({
name: APP_PAGE_DETAILS,
params: { projectId: props.data.projectId, appId: props.data.appId, pageId: page.id },
});
} catch (error) {
toast.showError(error, i18n.baseText('apps.page.add.error'));
} finally {
isCreating.value = false;
}
};
onMounted(() => {
setTimeout(() => routeInputRef.value?.focus(), 0);
});
</script>
<template>
<Modal :name="props.modalName" :center="true" width="480px" data-test-id="add-page-modal">
<template #header>
<h2>{{ i18n.baseText('apps.page.new') }}</h2>
</template>
<template #content>
<div :class="$style.content">
<N8nInputLabel
:label="i18n.baseText('apps.page.add.input.route.label')"
input-name="pageRoute"
>
<N8nInput
ref="routeInputRef"
v-model="route"
:placeholder="i18n.baseText('apps.page.add.input.route.placeholder')"
name="pageRoute"
data-test-id="app-page-new-route"
@keydown.enter="onSubmit"
/>
</N8nInputLabel>
<N8nText color="text-light" size="small">
{{ i18n.baseText('apps.page.add.input.route.hint') }}
</N8nText>
</div>
</template>
<template #footer>
<div :class="$style.footer">
<N8nButton
variant="subtle"
size="large"
:label="i18n.baseText('generic.cancel')"
data-test-id="app-page-new-cancel"
@click="uiStore.closeModal(props.modalName)"
/>
<N8nButton
:loading="isCreating"
size="large"
:label="i18n.baseText('apps.page.new')"
data-test-id="app-page-new-submit"
@click="onSubmit"
/>
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.content {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
.footer {
display: flex;
gap: var(--spacing--2xs);
justify-content: flex-end;
}
</style>

View File

@ -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 },
},
];

View File

@ -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',
},
],
};

View File

@ -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<string | null, number> {
const counts = new Map<string | null, number>();
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})`;
}

View File

@ -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<boolean> => {
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<boolean> => {
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 };
}