Merge branch 'master' into bugfix/n8n-crash-recovery-null-startedAt

This commit is contained in:
Hardik Sharma 2025-11-20 18:41:39 +05:30 committed by GitHub
commit 60a7460ed0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
109 changed files with 4431 additions and 2385 deletions

View File

@ -1,119 +0,0 @@
#!/bin/bash
set -euo pipefail
# Script to determine Docker tags for runners images (Alpine and distroless variants)
#
# Usage: determine-runners-tags.sh RELEASE_TYPE N8N_VERSION_TAG GHCR_BASE DOCKER_BASE PLATFORM GITHUB_OUTPUT
#
# Example:
# determine-runners-tags.sh \
# "stable" \
# "1.123.0" \
# "ghcr.io/n8n-io/runners" \
# "n8nio/runners" \
# "amd64" \
# "$GITHUB_OUTPUT"
#
# Output (written to GITHUB_OUTPUT):
# Alpine variant:
# tags=ghcr.io/n8n-io/runners:1.123.0-amd64, n8nio/runners:1.123.0-amd64
# ghcr_platform_tag=ghcr.io/n8n-io/runners:1.123.0-amd64
# dockerhub_platform_tag=n8nio/runners:1.123.0-amd64
# primary_ghcr_manifest_tag=ghcr.io/n8n-io/runners:1.123.0
#
# Distroless variant:
# tags_distroless=ghcr.io/n8n-io/runners:1.123.0-distroless-amd64, n8nio/runners:1.123.0-distroless-amd64
# ghcr_platform_tag_distroless=ghcr.io/n8n-io/runners:1.123.0-distroless-amd64
# dockerhub_platform_tag_distroless=n8nio/runners:1.123.0-distroless-amd64
# primary_ghcr_manifest_tag_distroless=ghcr.io/n8n-io/runners:1.123.0-distroless
RELEASE_TYPE="${1:?Missing RELEASE_TYPE argument}"
N8N_VERSION_TAG="${2:?Missing N8N_VERSION_TAG argument}"
GHCR_BASE="${3:?Missing GHCR_BASE argument}"
DOCKER_BASE="${4:?Missing DOCKER_BASE argument}"
PLATFORM="${5:?Missing PLATFORM argument}"
GITHUB_OUTPUT="${6:?Missing GITHUB_OUTPUT argument}"
generate_tags() {
local VARIANT_SUFFIX="$1"
local OUTPUT_FILE="$2"
local GHCR_TAGS_FOR_PUSH=""
local DOCKER_TAGS_FOR_PUSH=""
local PRIMARY_GHCR_MANIFEST_TAG_VALUE=""
case "$RELEASE_TYPE" in
"stable")
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}${VARIANT_SUFFIX}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:${N8N_VERSION_TAG}${VARIANT_SUFFIX}-${PLATFORM}"
;;
"nightly")
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:nightly${VARIANT_SUFFIX}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:nightly${VARIANT_SUFFIX}-${PLATFORM}"
;;
"branch")
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}${VARIANT_SUFFIX}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH=""
;;
"dev"|*)
if [[ "$N8N_VERSION_TAG" == pr-* ]]; then
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}${VARIANT_SUFFIX}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH=""
else
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:dev${VARIANT_SUFFIX}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:dev${VARIANT_SUFFIX}-${PLATFORM}"
fi
;;
esac
local ALL_TAGS="${GHCR_TAGS_FOR_PUSH}"
if [[ -n "$DOCKER_TAGS_FOR_PUSH" ]]; then
ALL_TAGS="${ALL_TAGS}\n${DOCKER_TAGS_FOR_PUSH}"
fi
{
echo "tags<<EOF"
echo -e "$ALL_TAGS"
echo "EOF"
echo "ghcr_platform_tag=${GHCR_TAGS_FOR_PUSH}"
echo "dockerhub_platform_tag=${DOCKER_TAGS_FOR_PUSH}"
} >> "$OUTPUT_FILE"
# Only output manifest tags from the first platform to avoid duplicates
if [[ "$PLATFORM" == "amd64" ]]; then
echo "primary_ghcr_manifest_tag=${PRIMARY_GHCR_MANIFEST_TAG_VALUE}" >> "$OUTPUT_FILE"
fi
}
# Reads outputs from a temp file and appends them to GITHUB_OUTPUT with _distroless suffix
# Transforms variable names: tags -> tags_distroless, ghcr_platform_tag -> ghcr_platform_tag_distroless
transform_and_append_distroless_outputs() {
local TEMP_FILE="$1"
while IFS= read -r line; do
if [[ "$line" == "tags<<EOF" ]]; then
echo "tags_distroless<<EOF" >> "$GITHUB_OUTPUT"
elif [[ "$line" =~ ^(ghcr_platform_tag|dockerhub_platform_tag|primary_ghcr_manifest_tag)= ]]; then
key="${line%%=*}"
value="${line#*=}"
echo "${key}_distroless=${value}" >> "$GITHUB_OUTPUT"
else
# Pass through EOF markers and tag content
echo "$line" >> "$GITHUB_OUTPUT"
fi
done < "$TEMP_FILE"
}
# Generate tags for Alpine variant (no suffix)
generate_tags "" "$GITHUB_OUTPUT"
# Generate tags for distroless variant
DISTROLESS_OUTPUT=$(mktemp)
generate_tags "-distroless" "$DISTROLESS_OUTPUT"
transform_and_append_distroless_outputs "$DISTROLESS_OUTPUT"
rm "$DISTROLESS_OUTPUT"

171
.github/scripts/docker/docker-config.mjs vendored Normal file
View File

@ -0,0 +1,171 @@
#!/usr/bin/env node
import { appendFileSync } from 'node:fs';
class BuildContext {
constructor() {
this.githubOutput = process.env.GITHUB_OUTPUT || null;
}
determine({ event, pr, branch, version, releaseType, pushEnabled }) {
let context = {
version: '',
release_type: '',
platforms: ['linux/amd64', 'linux/arm64'],
push_to_ghcr: true,
push_to_docker: false,
};
// Determine version and release type based on event
switch (event) {
case 'schedule':
context.version = 'nightly';
context.release_type = 'nightly';
context.push_to_docker = true;
break;
case 'pull_request':
context.version = `pr-${pr}`;
context.release_type = 'dev';
context.push_to_ghcr = false;
break;
case 'workflow_dispatch':
context.version = `branch-${this.sanitizeBranch(branch)}`;
context.release_type = 'branch';
context.platforms = ['linux/amd64'];
break;
case 'push':
if (branch === 'master') {
context.version = 'dev';
context.release_type = 'dev';
context.push_to_docker = true;
} else {
context.version = `branch-${this.sanitizeBranch(branch)}`;
context.release_type = 'branch';
context.platforms = ['linux/amd64'];
}
break;
case 'workflow_call':
case 'release':
if (!version) throw new Error('Version required for release');
context.version = version;
context.release_type = releaseType || 'stable';
context.push_to_docker = true;
break;
default:
throw new Error(`Unknown event: ${event}`);
}
// Handle push_enabled override
if (pushEnabled !== undefined) {
context.push_enabled = pushEnabled;
} else {
context.push_enabled = context.push_to_ghcr;
}
return context;
}
sanitizeBranch(branch) {
if (!branch) return 'unknown';
return branch
.toLowerCase()
.replace(/[^a-z0-9._-]/g, '-')
.replace(/^[.-]/, '')
.replace(/[.-]$/, '')
.substring(0, 128);
}
buildMatrix(platforms) {
const runners = {
'linux/amd64': 'blacksmith-4vcpu-ubuntu-2204',
'linux/arm64': 'blacksmith-4vcpu-ubuntu-2204-arm',
};
const matrix = {
platform: [],
include: [],
};
for (const platform of platforms) {
const shortName = platform.split('/').pop(); // amd64 or arm64
matrix.platform.push(shortName);
matrix.include.push({
platform: shortName,
runner: runners[platform],
docker_platform: platform,
});
}
return matrix;
}
output(context, matrix = null) {
const buildMatrix = matrix || this.buildMatrix(context.platforms);
if (this.githubOutput) {
const outputs = [
`version=${context.version}`,
`release_type=${context.release_type}`,
`platforms=${JSON.stringify(context.platforms)}`,
`push_to_ghcr=${context.push_to_ghcr}`,
`push_to_docker=${context.push_to_docker}`,
`push_enabled=${context.push_enabled}`,
`build_matrix=${JSON.stringify(buildMatrix)}`,
];
appendFileSync(this.githubOutput, outputs.join('\n') + '\n');
} else {
console.log(JSON.stringify({ ...context, build_matrix: buildMatrix }, null, 2));
}
}
}
// CLI - Simple argument parsing
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const getArg = (name) => {
const index = args.indexOf(`--${name}`);
if (index === -1 || !args[index + 1]) return undefined;
const value = args[index + 1];
// Handle empty strings and 'null' as undefined
return value === '' || value === 'null' ? undefined : value;
};
try {
const context = new BuildContext();
const pushEnabledArg = getArg('push-enabled');
const result = context.determine({
event: getArg('event') || process.env.GITHUB_EVENT_NAME,
pr: getArg('pr') || process.env.GITHUB_PR_NUMBER,
branch: getArg('branch') || process.env.GITHUB_REF_NAME,
version: getArg('version'),
releaseType: getArg('release-type'),
pushEnabled: pushEnabledArg === 'true' ? true : pushEnabledArg === 'false' ? false : undefined,
});
const matrix = context.buildMatrix(result.platforms);
// Debug output when GITHUB_OUTPUT is set (running in Actions)
if (context.githubOutput) {
console.log('=== Build Context ===');
console.log(`version: ${result.version}`);
console.log(`release_type: ${result.release_type}`);
console.log(`platforms: ${JSON.stringify(result.platforms, null, 2)}`);
console.log(`push_to_ghcr: ${result.push_to_ghcr}`);
console.log(`push_to_docker: ${result.push_to_docker}`);
console.log(`push_enabled: ${result.push_enabled}`);
console.log('build_matrix:', JSON.stringify(matrix, null, 2));
}
context.output(result, matrix);
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
export default BuildContext;

113
.github/scripts/docker/docker-tags.mjs vendored Normal file
View File

@ -0,0 +1,113 @@
#!/usr/bin/env node
import { appendFileSync } from 'node:fs';
class TagGenerator {
constructor() {
this.githubOwner = process.env.GITHUB_REPOSITORY_OWNER || 'n8n-io';
this.dockerUsername = process.env.DOCKER_USERNAME || 'n8nio';
this.githubOutput = process.env.GITHUB_OUTPUT || null;
}
generate({ image, version, platform, includeDockerHub = false }) {
let imageName = image;
let versionSuffix = '';
if (image === 'runners-distroless') {
imageName = 'runners';
versionSuffix = '-distroless';
}
const platformSuffix = platform ? `-${platform.split('/').pop()}` : '';
const fullVersion = `${version}${versionSuffix}${platformSuffix}`;
const tags = {
ghcr: [`ghcr.io/${this.githubOwner}/${imageName}:${fullVersion}`],
docker: includeDockerHub ? [`${this.dockerUsername}/${imageName}:${fullVersion}`] : [],
};
tags.all = [...tags.ghcr, ...tags.docker];
return tags;
}
output(tags, prefix = '') {
if (this.githubOutput) {
const prefixStr = prefix ? `${prefix}_` : '';
const primaryTag = tags.ghcr[0] ? tags.ghcr[0].replace(/-amd64$|-arm64$/, '') : '';
const outputs = [
`${prefixStr}tags=${tags.all.join(',')}`,
`${prefixStr}ghcr_tag=${tags.ghcr[0] || ''}`,
`${prefixStr}docker_tag=${tags.docker[0] || ''}`,
`${prefixStr}primary_tag=${primaryTag}`,
];
appendFileSync(this.githubOutput, outputs.join('\n') + '\n');
} else {
console.log(JSON.stringify(tags, null, 2));
}
}
generateAll({ version, platform, includeDockerHub = false }) {
const images = ['n8n', 'runners', 'runners-distroless'];
const results = {};
for (const image of images) {
const tags = this.generate({ image, version, platform, includeDockerHub });
const prefix = image.replace('-distroless', '_distroless');
results[prefix] = tags;
if (this.githubOutput) {
this.output(tags, prefix);
}
}
return results;
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const getArg = (name) => {
const index = args.indexOf(`--${name}`);
return index !== -1 && args[index + 1] ? args[index + 1] : undefined;
};
const hasFlag = (name) => args.includes(`--${name}`);
try {
const generator = new TagGenerator();
const version = getArg('version');
if (!version) {
console.error('Error: --version is required');
process.exit(1);
}
if (hasFlag('all')) {
const results = generator.generateAll({
version,
platform: getArg('platform'),
includeDockerHub: hasFlag('include-docker'),
});
if (!generator.githubOutput) {
console.log(JSON.stringify(results, null, 2));
}
} else {
const image = getArg('image');
if (!image) {
console.error('Error: Either --image or --all is required');
process.exit(1);
}
const tags = generator.generate({
image,
version,
platform: getArg('platform'),
includeDockerHub: hasFlag('include-docker'),
});
generator.output(tags);
}
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
export default TagGenerator;

View File

@ -59,7 +59,7 @@ jobs:
name: MariaDB
needs: build
runs-on: blacksmith-4vcpu-ubuntu-2204
timeout-minutes: 20
timeout-minutes: 30
env:
DB_MYSQLDB_PASSWORD: password
DB_MYSQLDB_POOL_SIZE: 1

View File

@ -1,9 +1,7 @@
# This workflow is used to build and push the Docker image for n8nio/n8n and n8nio/runners
# - determine-build-context: Determines what needs to be built based on the trigger
# - build-and-push-docker: This builds on both an ARM64 and AMD64 runner so the builds are native to the platform. Uses blacksmith native runners and build-push-action
# - create_multi_arch_manifest: This creates the multi-arch manifest for the Docker image. Needed to recombine the images from the build-and-push-docker job since they are separate runners.
# - security-scan: This scans the n8nio/n8n Docker image for security vulnerabilities using Trivy.
# - security-scan-runners: This scans the n8nio/runners Docker image for security vulnerabilities using Trivy.
#
# - Uses docker-config.mjs for context determination, this determines what needs to be built based on the trigger
# - Uses docker-tags.mjs for tag generation, this generates the tags for the images
name: 'Docker: Build and Push'
@ -50,6 +48,8 @@ on:
- ready_for_review
paths:
- '.github/workflows/docker-build-push.yml'
- '.github/scripts/docker/docker-config.mjs'
- '.github/scripts/docker/docker-tags.mjs'
- 'docker/images/n8n/Dockerfile'
- 'docker/images/runners/Dockerfile'
- 'docker/images/runners/Dockerfile.distroless'
@ -60,107 +60,24 @@ jobs:
runs-on: ubuntu-latest
outputs:
release_type: ${{ steps.context.outputs.release_type }}
n8n_version: ${{ steps.context.outputs.n8n_version }}
n8n_version: ${{ steps.context.outputs.version }}
push_enabled: ${{ steps.context.outputs.push_enabled }}
build_matrix: ${{ steps.matrix.outputs.matrix }}
push_to_docker: ${{ steps.context.outputs.push_to_docker }}
build_matrix: ${{ steps.context.outputs.build_matrix }}
steps:
- name: Determine build context values
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Determine build context
id: context
run: |
# Debug info
echo "Event: ${{ github.event_name }}"
echo "Ref: ${{ github.ref }}"
echo "Ref Name: ${{ github.ref_name }}"
# Check if called by another workflow (has n8n_version input)
if [[ -n "${{ inputs.n8n_version }}" ]]; then
# workflow_call - used for releases
{
echo "release_type=${{ inputs.release_type }}"
echo "n8n_version=${{ inputs.n8n_version }}"
echo "push_enabled=${{ inputs.push_enabled }}"
} >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
# Nightly builds
{
echo "release_type=nightly"
echo "n8n_version=snapshot"
echo "push_enabled=true"
} >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
# Build branches for Nathan deploy
BRANCH_NAME="${{ github.ref_name }}"
# Fallback to parsing ref if ref_name is empty
if [[ -z "$BRANCH_NAME" ]] && [[ "${{ github.ref }}" =~ ^refs/heads/(.+)$ ]]; then
BRANCH_NAME="${BASH_REMATCH[1]}"
fi
# Sanitize branch name for Docker tag
SAFE_BRANCH_NAME=$(echo "$BRANCH_NAME" | tr '/' '-' | tr -cd '[:alnum:]-_')
if [[ -z "$SAFE_BRANCH_NAME" ]]; then
echo "Error: Could not determine valid branch name"
exit 1
fi
{
echo "release_type=branch"
echo "n8n_version=branch-${SAFE_BRANCH_NAME}"
echo "push_enabled=${{ inputs.push_enabled }}"
} >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "pull_request" ]]; then
# Direct PR triggers for testing Dockerfile changes
{
echo "release_type=dev"
echo "n8n_version=pr-${{ github.event.pull_request.number }}"
echo "push_enabled=false"
} >> "$GITHUB_OUTPUT"
fi
# Output summary for logs
echo "=== Build Context Summary ==="
echo "Release type: $(grep release_type "$GITHUB_OUTPUT" | cut -d= -f2)"
echo "N8N version: $(grep n8n_version "$GITHUB_OUTPUT" | cut -d= -f2)"
echo "Push enabled: $(grep push_enabled "$GITHUB_OUTPUT" | cut -d= -f2)"
- name: Determine build matrix
id: matrix
run: |
RELEASE_TYPE="${{ steps.context.outputs.release_type }}"
# Branch builds only need AMD64, everything else needs both platforms
if [[ "$RELEASE_TYPE" == "branch" ]]; then
MATRIX='{
"platform": ["amd64"],
"include": [{
"platform": "amd64",
"runner": "blacksmith-4vcpu-ubuntu-2204",
"docker_platform": "linux/amd64"
}]
}'
else
# All other builds (stable, nightly, dev, PR) need both platforms
MATRIX='{
"platform": ["amd64", "arm64"],
"include": [{
"platform": "amd64",
"runner": "blacksmith-4vcpu-ubuntu-2204",
"docker_platform": "linux/amd64"
}, {
"platform": "arm64",
"runner": "blacksmith-4vcpu-ubuntu-2204-arm",
"docker_platform": "linux/arm64"
}]
}'
fi
# Output matrix as single line for GITHUB_OUTPUT
echo "matrix=$(echo "$MATRIX" | jq -c .)" >> "$GITHUB_OUTPUT"
echo "Build matrix: $(echo "$MATRIX" | jq .)"
node .github/scripts/docker/docker-config.mjs \
--event "${{ github.event_name }}" \
--pr "${{ github.event.pull_request.number }}" \
--branch "${{ github.ref_name }}" \
--version "${{ inputs.n8n_version }}" \
--release-type "${{ inputs.release_type }}" \
--push-enabled "${{ inputs.push_enabled }}"
build-and-push-docker:
name: Build App, then Build and Push Docker Image (${{ matrix.platform }})
@ -170,10 +87,10 @@ jobs:
strategy:
matrix: ${{ fromJSON(needs.determine-build-context.outputs.build_matrix) }}
outputs:
image_ref: ${{ steps.determine-tags.outputs.primary_ghcr_manifest_tag }}
primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.primary_ghcr_manifest_tag }}
runners_primary_ghcr_manifest_tag: ${{ steps.determine-runners-tags.outputs.primary_ghcr_manifest_tag }}
runners_distroless_primary_ghcr_manifest_tag: ${{ steps.determine-runners-tags.outputs.primary_ghcr_manifest_tag_distroless }}
image_ref: ${{ steps.determine-tags.outputs.n8n_primary_tag }}
primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.n8n_primary_tag }}
runners_primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.runners_primary_tag }}
runners_distroless_primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.runners_distroless_primary_tag }}
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -181,101 +98,23 @@ jobs:
fetch-depth: 0
- name: Setup and Build
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
uses: ./.github/actions/setup-nodejs-blacksmith
with:
build-command: pnpm build:n8n
- name: Determine Docker tags
- name: Determine Docker tags for all images
id: determine-tags
run: |
RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}"
N8N_VERSION_TAG="${{ needs.determine-build-context.outputs.n8n_version }}"
GHCR_BASE="ghcr.io/${{ github.repository_owner }}/n8n"
DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}/n8n"
PLATFORM="${{ matrix.platform }}"
node .github/scripts/docker/docker-tags.mjs \
--all \
--version "${{ needs.determine-build-context.outputs.n8n_version }}" \
--platform "${{ matrix.docker_platform }}" \
${{ needs.determine-build-context.outputs.push_to_docker == 'true' && '--include-docker' || '' }}
GHCR_TAGS_FOR_PUSH=""
DOCKER_TAGS_FOR_PUSH=""
PRIMARY_GHCR_MANIFEST_TAG_VALUE=""
# Validate inputs
if [[ "$RELEASE_TYPE" == "stable" && -z "$N8N_VERSION_TAG" ]]; then
echo "Error: N8N_VERSION_TAG is empty for a stable release."
exit 1
fi
if [[ "$RELEASE_TYPE" == "branch" && -z "$N8N_VERSION_TAG" ]]; then
echo "Error: N8N_VERSION_TAG is empty for a branch release."
exit 1
fi
# Determine tags based on release type
case "$RELEASE_TYPE" in
"stable")
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:${N8N_VERSION_TAG}-${PLATFORM}"
;;
"nightly")
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:nightly"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:nightly-${PLATFORM}"
;;
"branch")
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
# No Docker Hub tags for branch builds
DOCKER_TAGS_FOR_PUSH=""
;;
"dev"|*)
if [[ "$N8N_VERSION_TAG" == pr-* ]]; then
# PR builds only go to GHCR
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH=""
else
# Regular dev builds go to both registries
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:dev"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:dev-${PLATFORM}"
fi
;;
esac
# Combine all tags
ALL_TAGS="${GHCR_TAGS_FOR_PUSH}"
if [[ -n "$DOCKER_TAGS_FOR_PUSH" ]]; then
ALL_TAGS="${ALL_TAGS}\n${DOCKER_TAGS_FOR_PUSH}"
fi
echo "Generated Tags for push: $ALL_TAGS"
{
echo "tags<<EOF"
echo -e "$ALL_TAGS"
echo "EOF"
} >> "$GITHUB_OUTPUT"
{
echo "ghcr_platform_tag=${GHCR_TAGS_FOR_PUSH}"
echo "dockerhub_platform_tag=${DOCKER_TAGS_FOR_PUSH}"
} >> "$GITHUB_OUTPUT"
# Only output manifest tags from the first platform to avoid duplicates
if [[ "$PLATFORM" == "amd64" ]]; then
echo "primary_ghcr_manifest_tag=${PRIMARY_GHCR_MANIFEST_TAG_VALUE}" >> "$GITHUB_OUTPUT"
fi
- name: Determine Docker tags (runners variants)
id: determine-runners-tags
run: |
.github/scripts/determine-runners-tags.sh \
"${{ needs.determine-build-context.outputs.release_type }}" \
"${{ needs.determine-build-context.outputs.n8n_version }}" \
"ghcr.io/${{ github.repository_owner }}/runners" \
"${{ secrets.DOCKER_USERNAME }}/runners" \
"${{ matrix.platform }}" \
"$GITHUB_OUTPUT"
echo "=== Generated Docker Tags ==="
cat "$GITHUB_OUTPUT" | grep "_tags=" | while IFS='=' read -r key value; do
echo "${key}: ${value%%,*}..." # Show first tag for brevity
done
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
@ -289,10 +128,9 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
if: needs.determine-build-context.outputs.push_enabled == 'true' && (
steps.determine-tags.outputs.dockerhub_platform_tag != '' ||
steps.determine-runners-tags.outputs.dockerhub_platform_tag != '' ||
steps.determine-runners-tags.outputs.dockerhub_platform_tag_distroless != '')
if: |
needs.determine-build-context.outputs.push_enabled == 'true' &&
needs.determine-build-context.outputs.push_to_docker == 'true'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
@ -311,7 +149,7 @@ jobs:
provenance: true
sbom: true
push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }}
tags: ${{ steps.determine-tags.outputs.tags }}
tags: ${{ steps.determine-tags.outputs.n8n_tags }}
- name: Build and push task runners Docker image (Alpine)
uses: useblacksmith/build-push-action@574eb0ee0b59c6a687ace24192f0727dfb65d6d7 # v1.2
@ -326,7 +164,7 @@ jobs:
provenance: true
sbom: true
push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }}
tags: ${{ steps.determine-runners-tags.outputs.tags }}
tags: ${{ steps.determine-tags.outputs.runners_tags }}
- name: Build and push task runners Docker image (distroless)
uses: useblacksmith/build-push-action@574eb0ee0b59c6a687ace24192f0727dfb65d6d7 # v1.2
@ -341,7 +179,7 @@ jobs:
provenance: true
sbom: true
push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }}
tags: ${{ steps.determine-runners-tags.outputs.tags_distroless }}
tags: ${{ steps.determine-tags.outputs.runners_distroless_tags }}
create_multi_arch_manifest:
name: Create Multi-Arch Manifest
@ -361,181 +199,70 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine Docker Hub manifest tag
id: dockerhub_check
run: |
RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}"
N8N_VERSION="${{ needs.determine-build-context.outputs.n8n_version }}"
DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}/n8n"
# Determine if Docker Hub manifest is needed and construct the tag
case "$RELEASE_TYPE" in
"stable")
{
echo "DOCKER_MANIFEST_TAG=${DOCKER_BASE}:${N8N_VERSION}"
echo "CREATE_DOCKERHUB_MANIFEST=true"
} >> "$GITHUB_OUTPUT"
;;
"nightly")
{
echo "DOCKER_MANIFEST_TAG=${DOCKER_BASE}:nightly"
echo "CREATE_DOCKERHUB_MANIFEST=true"
} >> "$GITHUB_OUTPUT"
;;
"dev")
if [[ "$N8N_VERSION" != pr-* ]]; then
{
echo "DOCKER_MANIFEST_TAG=${DOCKER_BASE}:dev"
echo "CREATE_DOCKERHUB_MANIFEST=true"
} >> "$GITHUB_OUTPUT"
else
echo "CREATE_DOCKERHUB_MANIFEST=false" >> "$GITHUB_OUTPUT"
fi
;;
*)
echo "CREATE_DOCKERHUB_MANIFEST=false" >> "$GITHUB_OUTPUT"
;;
esac
- name: Determine Docker Hub manifest tags (runners variants)
id: dockerhub_runners_check
run: |
RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}"
N8N_VERSION="${{ needs.determine-build-context.outputs.n8n_version }}"
DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}/runners"
# Generate manifest tags for both Alpine and distroless variants
for VARIANT in "" "_distroless"; do
SUFFIX="${VARIANT//_/-}" # Convert _distroless to -distroless for tag
OUTPUT_SUFFIX="${VARIANT}" # Keep underscore for output var names
case "$RELEASE_TYPE" in
"stable")
echo "DOCKER_MANIFEST_TAG${OUTPUT_SUFFIX}=${DOCKER_BASE}:${N8N_VERSION}${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "CREATE_DOCKERHUB_MANIFEST${OUTPUT_SUFFIX}=true" >> "$GITHUB_OUTPUT"
;;
"nightly")
echo "DOCKER_MANIFEST_TAG${OUTPUT_SUFFIX}=${DOCKER_BASE}:nightly${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "CREATE_DOCKERHUB_MANIFEST${OUTPUT_SUFFIX}=true" >> "$GITHUB_OUTPUT"
;;
"dev")
if [[ "$N8N_VERSION" != pr-* ]]; then
echo "DOCKER_MANIFEST_TAG${OUTPUT_SUFFIX}=${DOCKER_BASE}:dev${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "CREATE_DOCKERHUB_MANIFEST${OUTPUT_SUFFIX}=true" >> "$GITHUB_OUTPUT"
else
echo "CREATE_DOCKERHUB_MANIFEST${OUTPUT_SUFFIX}=false" >> "$GITHUB_OUTPUT"
fi
;;
*)
echo "CREATE_DOCKERHUB_MANIFEST${OUTPUT_SUFFIX}=false" >> "$GITHUB_OUTPUT"
;;
esac
done
- name: Login to Docker Hub
if: steps.dockerhub_check.outputs.CREATE_DOCKERHUB_MANIFEST == 'true' ||
steps.dockerhub_runners_check.outputs.CREATE_DOCKERHUB_MANIFEST == 'true' ||
steps.dockerhub_runners_check.outputs.CREATE_DOCKERHUB_MANIFEST_distroless == 'true'
if: needs.determine-build-context.outputs.push_to_docker == 'true'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Create GHCR multi-arch manifest
if: needs.build-and-push-docker.outputs.primary_ghcr_manifest_tag != ''
- name: Create GHCR multi-arch manifests
run: |
MANIFEST_TAG="${{ needs.build-and-push-docker.outputs.primary_ghcr_manifest_tag }}"
RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}"
echo "Creating GHCR manifest: $MANIFEST_TAG"
# Function to create manifest for an image
create_manifest() {
local IMAGE_NAME=$1
local MANIFEST_TAG=$2
# For branch builds, only AMD64 is built
if [[ "$RELEASE_TYPE" == "branch" ]]; then
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64
else
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64 \
${MANIFEST_TAG}-arm64
fi
if [[ -z "$MANIFEST_TAG" ]]; then
echo "Skipping $IMAGE_NAME - no manifest tag"
return
fi
- name: Create GHCR multi-arch manifest for runners (Alpine)
if: needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag != ''
echo "Creating GHCR manifest for $IMAGE_NAME: $MANIFEST_TAG"
# For branch builds, only AMD64 is built
if [[ "$RELEASE_TYPE" == "branch" ]]; then
docker buildx imagetools create \
--tag "$MANIFEST_TAG" \
"${MANIFEST_TAG}-amd64"
else
docker buildx imagetools create \
--tag "$MANIFEST_TAG" \
"${MANIFEST_TAG}-amd64" \
"${MANIFEST_TAG}-arm64"
fi
}
# Create manifests for all images
create_manifest "n8n" "${{ needs.build-and-push-docker.outputs.primary_ghcr_manifest_tag }}"
create_manifest "runners" "${{ needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag }}"
create_manifest "runners-distroless" "${{ needs.build-and-push-docker.outputs.runners_distroless_primary_ghcr_manifest_tag }}"
- name: Create Docker Hub manifests
if: needs.determine-build-context.outputs.push_to_docker == 'true'
run: |
MANIFEST_TAG="${{ needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag }}"
RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}"
VERSION="${{ needs.determine-build-context.outputs.n8n_version }}"
DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}"
echo "Creating GHCR runners manifest: $MANIFEST_TAG"
# Create manifests for each image type
declare -A images=(
["n8n"]="${VERSION}"
["runners"]="${VERSION}"
["runners-distroless"]="${VERSION}-distroless"
)
# For branch builds, only AMD64 is built
if [[ "$RELEASE_TYPE" == "branch" ]]; then
for image in "${!images[@]}"; do
TAG_SUFFIX="${images[$image]}"
IMAGE_NAME="${image//-distroless/}" # Remove -distroless from image name
echo "Creating Docker Hub manifest for $image"
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64
else
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64 \
${MANIFEST_TAG}-arm64
fi
- name: Create GHCR multi-arch manifest for runners (distroless)
if: needs.build-and-push-docker.outputs.runners_distroless_primary_ghcr_manifest_tag != ''
run: |
MANIFEST_TAG="${{ needs.build-and-push-docker.outputs.runners_distroless_primary_ghcr_manifest_tag }}"
RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}"
echo "Creating GHCR runners distroless manifest: $MANIFEST_TAG"
# For branch builds, only AMD64 is built
if [[ "$RELEASE_TYPE" == "branch" ]]; then
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64
else
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64 \
${MANIFEST_TAG}-arm64
fi
- name: Create Docker Hub multi-arch manifest
if: steps.dockerhub_check.outputs.CREATE_DOCKERHUB_MANIFEST == 'true'
run: |
MANIFEST_TAG="${{ steps.dockerhub_check.outputs.DOCKER_MANIFEST_TAG }}"
echo "Creating Docker Hub manifest: $MANIFEST_TAG"
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64 \
${MANIFEST_TAG}-arm64
- name: Create Docker Hub multi-arch manifest for runners (Alpine)
if: steps.dockerhub_runners_check.outputs.CREATE_DOCKERHUB_MANIFEST == 'true'
run: |
MANIFEST_TAG="${{ steps.dockerhub_runners_check.outputs.DOCKER_MANIFEST_TAG }}"
echo "Creating Docker Hub manifest: $MANIFEST_TAG"
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64 \
${MANIFEST_TAG}-arm64
- name: Create Docker Hub multi-arch manifest for runners (distroless)
if: steps.dockerhub_runners_check.outputs.CREATE_DOCKERHUB_MANIFEST_distroless == 'true'
run: |
MANIFEST_TAG="${{ steps.dockerhub_runners_check.outputs.DOCKER_MANIFEST_TAG_distroless }}"
echo "Creating Docker Hub distroless manifest: $MANIFEST_TAG"
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64 \
${MANIFEST_TAG}-arm64
--tag "${DOCKER_BASE}/${IMAGE_NAME}:${TAG_SUFFIX}" \
"${DOCKER_BASE}/${IMAGE_NAME}:${TAG_SUFFIX}-amd64" \
"${DOCKER_BASE}/${IMAGE_NAME}:${TAG_SUFFIX}-arm64"
done
call-success-url:
name: Call Success URL
@ -566,7 +293,7 @@ jobs:
security-scan-runners:
name: Security Scan (runners)
needs: [determine-build-context, build-and-push-docker]
needs: [determine-build-context, build-and-push-docker, create_multi_arch_manifest]
if: |
success() &&
(needs.determine-build-context.outputs.release_type == 'stable' ||

View File

@ -11,15 +11,6 @@ WORKDIR /app/task-runner-javascript
RUN corepack enable pnpm
# Install extra runtime-only npm packages. Allow usage in the Code node via
# 'NODE_FUNCTION_ALLOW_EXTERNAL' env variable on n8n-task-runners.json.
RUN rm -f node_modules/.modules.yaml
RUN mv package.json package.json.bak
COPY docker/images/runners/package.json /app/task-runner-javascript/package.json
RUN pnpm install --prod --no-lockfile --silent
RUN mv package.json extras.json
RUN mv package.json.bak package.json
# Remove `catalog` and `workspace` references from package.json to allow `pnpm add` in extended images
RUN node -e "const pkg = require('./package.json'); \
Object.keys(pkg.dependencies || {}).forEach(k => { \
@ -79,11 +70,6 @@ RUN uv sync \
--all-extras \
--no-editable
# Install extra runtime-only Python packages. Allow usage in the Code node via
# 'N8N_RUNNERS_EXTERNAL_ALLOW' env variable on n8n-task-runners.json.
COPY docker/images/runners/extras.txt /app/task-runner-python/extras.txt
RUN uv pip install -r /app/task-runner-python/extras.txt
# ==============================================================================
# STAGE 3: Task Runner Launcher download
# ==============================================================================

View File

@ -26,14 +26,24 @@ WORKDIR /app/task-runner-javascript
RUN corepack enable pnpm
# Install extra runtime-only npm packages. Allow usage in the Code node via
# 'NODE_FUNCTION_ALLOW_EXTERNAL' env variable on n8n-task-runners.json.
RUN rm -f node_modules/.modules.yaml
RUN mv package.json package.json.bak
COPY docker/images/runners/package.json /app/task-runner-javascript/package.json
RUN pnpm install --prod --no-lockfile --silent
RUN mv package.json extras.json
RUN mv package.json.bak package.json
# Remove `catalog` and `workspace` references from package.json to allow `pnpm add`
RUN node -e "const pkg = require('./package.json'); \
Object.keys(pkg.dependencies || {}).forEach(k => { \
const val = pkg.dependencies[k]; \
if (val === 'catalog:' || val.startsWith('catalog:') || val.startsWith('workspace:')) \
delete pkg.dependencies[k]; \
}); \
Object.keys(pkg.devDependencies || {}).forEach(k => { \
const val = pkg.devDependencies[k]; \
if (val === 'catalog:' || val.startsWith('catalog:') || val.startsWith('workspace:')) \
delete pkg.devDependencies[k]; \
}); \
delete pkg.devDependencies; \
require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2));"
# Install moment by default (special case for n8n cloud)
RUN rm -f node_modules/.modules.yaml && \
pnpm add moment@2.30.1 --prod --no-lockfile
# ==============================================================================
# STAGE 2: Python runner build (@n8n/task-runner-python) with uv
@ -81,11 +91,6 @@ RUN uv sync \
--all-extras \
--no-editable
# Install extra runtime-only Python packages. Allow usage in the Code node via
# 'N8N_RUNNERS_EXTERNAL_ALLOW' env variable on n8n-task-runners.json.
COPY docker/images/runners/extras.txt /app/task-runner-python/extras.txt
RUN uv pip install -r /app/task-runner-python/extras.txt
# ==============================================================================
# STAGE 3: Task Runner Launcher download
# ==============================================================================

View File

@ -1,5 +0,0 @@
# Runtime-only extra Requirements File for installing dependencies in the Python task runner image.
# Installed at Docker image build time. Allow usage in the Code node
# via 'N8N_RUNNERS_EXTERNAL_ALLOW' env variable on n8n-task-runners.json.
# numpy==2.3.2

View File

@ -1,8 +0,0 @@
{
"name": "task-runner-runtime-extras",
"description": "Runtime-only extra dependencies for installing packages in the JavaScript task runner image. Installed at Docker image build time. Allow usage in the Code node via 'NODE_FUNCTION_ALLOW_EXTERNAL' env variable on n8n-task-runners.json.",
"private": true,
"dependencies": {
"moment": "2.30.1"
}
}

View File

@ -134,6 +134,7 @@ export interface ChatModelDto {
description: string | null;
updatedAt: string | null;
createdAt: string | null;
allowFileUploads?: boolean;
}
/**
@ -159,6 +160,18 @@ export const emptyChatModelsResponse: ChatModelsResponse = {
'custom-agent': { models: [] },
};
/**
* Chat attachment schema for incoming requests.
* Requires base64 data and fileName.
* MimeType, fileType, fileExtension, and fileSize are populated server-side.
*/
export const chatAttachmentSchema = z.object({
data: z.string(),
fileName: z.string(),
});
export type ChatAttachment = z.infer<typeof chatAttachmentSchema>;
export class ChatHubSendMessageRequest extends Z.class({
messageId: z.string().uuid(),
sessionId: z.string().uuid(),
@ -172,6 +185,7 @@ export class ChatHubSendMessageRequest extends Z.class({
}),
),
tools: z.array(INodeSchema),
attachments: z.array(chatAttachmentSchema),
}) {}
export class ChatHubRegenerateMessageRequest extends Z.class({
@ -246,6 +260,8 @@ export interface ChatHubMessageDto {
previousMessageId: ChatMessageId | null;
retryOfMessageId: ChatMessageId | null;
revisionOfMessageId: ChatMessageId | null;
attachments: Array<{ fileName?: string; mimeType?: string }>;
}
export type ChatHubConversationsResponse = ChatHubSessionDto[];

View File

@ -27,6 +27,8 @@ export {
emptyChatModelsResponse,
type ChatModelsRequest,
type ChatModelsResponse,
chatAttachmentSchema,
type ChatAttachment,
ChatHubSendMessageRequest,
ChatHubRegenerateMessageRequest,
ChatHubEditMessageRequest,

View File

@ -0,0 +1,19 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const table = {
messages: 'chat_hub_messages',
} as const;
export class AddAttachmentsToChatHubMessages1761773155024 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns(table.messages, [
column('attachments').json.comment(
'File attachments for the message (if any), stored as JSON. Files are stored as base64-encoded data URLs.',
),
]);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns(table.messages, ['attachments']);
}
}

View File

@ -0,0 +1,69 @@
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
const TABLE_NAME = 'oauth_authorization_codes';
const TEMP_TABLE_NAME = 'temp_oauth_authorization_codes';
export class ChangeOAuthStateColumnToUnboundedVarchar1763572724000
implements IrreversibleMigration
{
async up({
isSqlite,
isMysql,
isPostgres,
escape,
copyTable,
queryRunner,
schemaBuilder: { createTable, column, dropTable },
}: MigrationContext) {
const tableName = escape.tableName(TABLE_NAME);
if (isSqlite) {
const tempTableName = escape.tableName(TEMP_TABLE_NAME);
await createTable(TEMP_TABLE_NAME)
.withColumns(
column('code').varchar(255).primary.notNull,
column('clientId').varchar().notNull,
column('userId').uuid.notNull,
column('redirectUri').varchar().notNull,
column('codeChallenge').varchar().notNull,
column('codeChallengeMethod').varchar(255).notNull,
column('expiresAt').bigint.notNull.comment('Unix timestamp in milliseconds'),
column('state').varchar(),
column('used').bool.notNull.default(false),
)
.withForeignKey('clientId', {
tableName: 'oauth_clients',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
await copyTable(TABLE_NAME, TEMP_TABLE_NAME);
await dropTable(TABLE_NAME);
await queryRunner.query(`ALTER TABLE ${tempTableName} RENAME TO ${tableName};`);
} else if (isMysql) {
await queryRunner.query(
`ALTER TABLE ${tableName} MODIFY COLUMN ${escape.columnName('state')} TEXT;`,
);
await queryRunner.query(
`ALTER TABLE ${tableName} MODIFY COLUMN ${escape.columnName('codeChallenge')} TEXT NOT NULL;`,
);
await queryRunner.query(
`ALTER TABLE ${tableName} MODIFY COLUMN ${escape.columnName('redirectUri')} TEXT NOT NULL;`,
);
} else if (isPostgres) {
await queryRunner.query(
`ALTER TABLE ${tableName} ALTER COLUMN ${escape.columnName('state')} TYPE VARCHAR,` +
` ALTER COLUMN ${escape.columnName('codeChallenge')} TYPE VARCHAR,` +
` ALTER COLUMN ${escape.columnName('redirectUri')} TYPE VARCHAR;`,
);
}
}
}

View File

@ -111,8 +111,10 @@ import { CreateChatHubAgentTable1760020000000 } from '../common/1760020000000-Cr
import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRoleNames';
import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities';
import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable';
import { AddAttachmentsToChatHubMessages1761773155024 } from '../common/1761773155024-AddAttachmentsToChatHubMessages';
import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn';
import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords';
import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar';
import type { Migration } from '../migration-types';
export const mysqlMigrations: Migration[] = [
@ -231,4 +233,6 @@ export const mysqlMigrations: Migration[] = [
BackfillMissingWorkflowHistoryRecords1762763704614,
AddWorkflowHistoryAutoSaveFields1762847206508,
AddToolsColumnToChatHubTables1761830340990,
ChangeOAuthStateColumnToUnboundedVarchar1763572724000,
AddAttachmentsToChatHubMessages1761773155024,
];

View File

@ -109,10 +109,12 @@ import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRole
import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities';
import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable';
import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-DropUnusedChatHubColumns';
import { AddAttachmentsToChatHubMessages1761773155024 } from '../common/1761773155024-AddAttachmentsToChatHubMessages';
import { AddToolsColumnToChatHubTables1761830340990 } from '../common/1761830340990-AddToolsColumnToChatHubTables';
import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn';
import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords';
import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields';
import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar';
import type { Migration } from '../migration-types';
export const postgresMigrations: Migration[] = [
@ -231,4 +233,6 @@ export const postgresMigrations: Migration[] = [
ChangeDefaultForIdInUserTable1762771264000,
AddWorkflowHistoryAutoSaveFields1762847206508,
AddToolsColumnToChatHubTables1761830340990,
ChangeOAuthStateColumnToUnboundedVarchar1763572724000,
AddAttachmentsToChatHubMessages1761773155024,
];

View File

@ -105,10 +105,12 @@ import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRole
import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities';
import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable';
import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-DropUnusedChatHubColumns';
import { AddAttachmentsToChatHubMessages1761773155024 } from '../common/1761773155024-AddAttachmentsToChatHubMessages';
import { AddToolsColumnToChatHubTables1761830340990 } from '../common/1761830340990-AddToolsColumnToChatHubTables';
import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn';
import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords';
import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields';
import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar';
import type { Migration } from '../migration-types';
const sqliteMigrations: Migration[] = [
@ -223,6 +225,8 @@ const sqliteMigrations: Migration[] = [
BackfillMissingWorkflowHistoryRecords1762763704614,
AddWorkflowHistoryAutoSaveFields1762847206508,
AddToolsColumnToChatHubTables1761830340990,
ChangeOAuthStateColumnToUnboundedVarchar1763572724000,
AddAttachmentsToChatHubMessages1761773155024,
];
export { sqliteMigrations };

View File

@ -408,7 +408,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
async hardDelete(ids: { workflowId: string; executionId: string }) {
return await Promise.all([
this.delete(ids.executionId),
this.binaryDataService.deleteMany([ids]),
this.binaryDataService.deleteMany([{ type: 'execution', ...ids }]),
]);
}
@ -509,6 +509,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
}
const ids = executions.map(({ id, workflowId }) => ({
type: 'execution' as const,
executionId: id,
workflowId,
}));
@ -605,7 +606,11 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
*/
withDeleted: true,
})
).map(({ id: executionId, workflowId }) => ({ workflowId, executionId }));
).map(({ id: executionId, workflowId }) => ({
type: 'execution' as const,
workflowId,
executionId,
}));
return workflowIdsAndExecutionIds;
}

View File

@ -12,6 +12,7 @@
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch"
},
"main": "dist/index.js",

View File

@ -0,0 +1,148 @@
import { Container } from '@n8n/di';
import type {
ContextEstablishmentOptions,
ContextEstablishmentResult,
IContextEstablishmentHook,
} from '../context-establishment-hook';
import {
ContextEstablishmentHookMetadata,
ContextEstablishmentHook,
} from '../context-establishment-hook-metadata';
describe('@ContextEstablishmentHook decorator', () => {
let hookMetadata: ContextEstablishmentHookMetadata;
beforeEach(() => {
jest.resetAllMocks();
hookMetadata = new ContextEstablishmentHookMetadata();
Container.set(ContextEstablishmentHookMetadata, hookMetadata);
});
it('should register hook in ContextEstablishmentHookMetadata', () => {
@ContextEstablishmentHook()
class TestHook implements IContextEstablishmentHook {
hookDescription = { name: 'test.hook' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
const registeredHooks = hookMetadata.getClasses();
expect(registeredHooks).toContain(TestHook);
expect(registeredHooks).toHaveLength(1);
});
it('should register multiple hooks', () => {
@ContextEstablishmentHook()
class FirstHook implements IContextEstablishmentHook {
hookDescription = { name: 'first.hook' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
@ContextEstablishmentHook()
class SecondHook implements IContextEstablishmentHook {
hookDescription = { name: 'second.hook' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
@ContextEstablishmentHook()
class ThirdHook implements IContextEstablishmentHook {
hookDescription = { name: 'third.hook' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
const registeredHooks = hookMetadata.getClasses();
expect(registeredHooks).toContain(FirstHook);
expect(registeredHooks).toContain(SecondHook);
expect(registeredHooks).toContain(ThirdHook);
expect(registeredHooks).toHaveLength(3);
});
it('should apply Service decorator', () => {
@ContextEstablishmentHook()
class TestHook implements IContextEstablishmentHook {
hookDescription = { name: 'test.hook' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
expect(Container.has(TestHook)).toBe(true);
});
it('should allow instantiation of registered hooks with accessible hookDescription', () => {
@ContextEstablishmentHook()
class TestHook implements IContextEstablishmentHook {
hookDescription = { name: 'credentials.bearerToken' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
const hookInstance = Container.get(TestHook);
expect(hookInstance).toBeInstanceOf(TestHook);
expect(hookInstance.hookDescription).toEqual({ name: 'credentials.bearerToken' });
expect(hookInstance.hookDescription.name).toBe('credentials.bearerToken');
});
it('should register hooks with different description names', () => {
@ContextEstablishmentHook()
class BearerTokenHook implements IContextEstablishmentHook {
hookDescription = { name: 'credentials.bearerToken' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
@ContextEstablishmentHook()
class ApiKeyHook implements IContextEstablishmentHook {
hookDescription = { name: 'credentials.apiKey' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
const registeredHooks = hookMetadata.getClasses();
const bearerTokenHook = Container.get(BearerTokenHook);
const apiKeyHook = Container.get(ApiKeyHook);
expect(registeredHooks).toHaveLength(2);
expect(bearerTokenHook.hookDescription.name).toBe('credentials.bearerToken');
expect(apiKeyHook.hookDescription.name).toBe('credentials.apiKey');
});
});

View File

@ -0,0 +1,200 @@
import { Container, Service } from '@n8n/di';
import { ContextEstablishmentHookClass } from './context-establishment-hook';
/**
* Registry entry for a context establishment hook.
*
* This is a lightweight wrapper around the hook class constructor that can be
* extended in the future to include additional metadata if needed (e.g., module
* source, registration timestamp, feature flags, license flags).
*
* @internal
*/
type ContextEstablishmentHookEntry = {
/** The hook class constructor for DI container instantiation */
class: ContextEstablishmentHookClass;
};
/**
* Low-level metadata registry for context establishment hooks.
*
* This service acts as a simple collection of registered hook classes that gets
* populated automatically by the @ContextEstablishmentHook decorator at module
* load time. It serves as the foundation for the higher-level Hook Registry.
*
* **Architecture:**
* ```
* Decorator ContextEstablishmentHookMetadata Hook Registry Execution Engine
* (registration) (collection) (discovery) (execution)
* ```
*
* @see ContextEstablishmentHook decorator for automatic registration
* @see IContextEstablishmentHook for hook interface
*/
@Service()
export class ContextEstablishmentHookMetadata {
/**
* Internal collection of registered hook classes.
*
* Uses Set for efficient deduplication (though duplicate registration
* should not occur with proper decorator usage).
*/
private readonly contextEstablishmentHooks: Set<ContextEstablishmentHookEntry> = new Set();
/**
* Registers a hook class in the metadata collection.
*
* Called automatically by the @ContextEstablishmentHook decorator during
* module loading. Should not be called directly by application code.
*
* **Note:** This method does not validate uniqueness or check for naming
* conflicts. Validation happens later in the Hook Registry.
*
* @param hookEntry - The hook class entry to register
*
* @internal Called by decorator only
*/
register(hookEntry: ContextEstablishmentHookEntry) {
this.contextEstablishmentHooks.add(hookEntry);
}
/**
* Retrieves all registered hook entries.
*
* Returns an array of [index, entry] tuples compatible with Set.entries().
* Primarily used for debugging or low-level iteration.
*
* **Prefer getClasses()** for most use cases as it returns just the classes.
*
* @returns Array of [index, entry] tuples from the internal Set
*
* @example
* ```typescript
* const entries = metadata.getEntries();
* for (const [index, entry] of entries) {
* console.log(`Hook ${index}:`, entry.class.name);
* }
* ```
*/
getEntries() {
return [...this.contextEstablishmentHooks.entries()];
}
/**
* Retrieves all registered hook classes.
*
* This is the primary method used by the Hook Registry to obtain hook classes
* for instantiation and indexing. Returns just the class constructors without
* the wrapper entry objects.
*
* **Usage pattern:**
* ```typescript
* const classes = metadata.getClasses();
* const hooks = classes.map(HookClass => Container.get(HookClass));
* const hooksByName = new Map(hooks.map(h => [h.hookDescription.name, h]));
* ```
*
* @returns Array of hook class constructors ready for DI instantiation
*
* @example
* ```typescript
* @Service()
* export class HookRegistry {
* constructor(
* private metadata: ContextEstablishmentHookMetadata,
* private container: Container
* ) {
* const hookClasses = metadata.getClasses();
* this.hooks = hookClasses.map(cls => container.get(cls));
* }
* }
* ```
*/
getClasses() {
return [...this.contextEstablishmentHooks.values()].map((entry) => entry.class);
}
}
/**
* Class decorator for context establishment hooks.
*
* This decorator performs two critical functions:
* 1. **Registers** the hook class in ContextEstablishmentHookMetadata for discovery
* 2. **Enables DI** by applying @Service() to make the hook injectable
*
* The decorator executes at module load time (when the class is defined), ensuring
* all hooks are registered before the application starts. This enables automatic
* discovery without manual registration code.
*
* **Registration flow:**
* ```
* @ContextEstablishmentHook() // 1. Decorator executes
* export class BearerTokenHook // 2. Class is defined
*
* ContextEstablishmentHookMetadata // 3. Hook class registered in metadata
*
* @Service() // 4. DI container registration
*
* Hook is discoverable & injectable // 5. Ready for use
* ```
*
* **Design pattern:**
* This follows the declarative registration pattern used throughout n8n for
* extensibility (similar to node registration). Hooks self-register without
* requiring central registration files or manual imports.
*
* **Requirements:**
* - Decorated class MUST implement IContextEstablishmentHook
* - Decorated class MUST have a hookDescription property with unique name
*
* **Important notes:**
* - No decorator parameters needed (hook metadata lives on hook instance)
* - Hooks are registered as singletons via @Service()
* - Registration happens eagerly at module load, not lazily
* - Duplicate decoration of the same class is safe (Set deduplicates)
*
* @see IContextEstablishmentHook for interface requirements
* @see ContextEstablishmentHookMetadata for underlying registry
* @see HookDescription for hook metadata structure
*
* @example
* ```typescript
* // Basic hook registration:
* @ContextEstablishmentHook()
* export class BearerTokenHook implements IContextEstablishmentHook {
* hookDescription = {
* name: 'credentials.bearerToken'
* };
*
* async execute(options: ContextEstablishmentOptions) {
* // Extract bearer token from Authorization header
* const token = this.extractToken(options.triggerItem);
* return {
* triggerItem: this.removeAuthHeader(options.triggerItem),
* contextUpdate: {
* credentials: { version: 1, identity: token }
* }
* };
* }
*
* isApplicableToTriggerNode(nodeType: string) {
* return nodeType === 'n8n-nodes-base.webhook';
* }
* }
* ```
*
* @returns A class decorator function that registers and enables DI for the hook
*/
export const ContextEstablishmentHook =
<T extends ContextEstablishmentHookClass>() =>
(target: T) => {
// Register hook class in metadata for discovery by Hook Registry
Container.get(ContextEstablishmentHookMetadata).register({
class: target,
});
// Enable dependency injection for the hook class
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Service()(target);
};

View File

@ -0,0 +1,328 @@
import type { Constructable } from '@n8n/di';
import type {
INode,
INodeExecutionData,
PlaintextExecutionContext,
IWorkflowBase,
} from 'n8n-workflow';
/**
* Input parameters passed to a context establishment hook during execution.
*
* Hooks receive the current workflow state and extract information from
* trigger items to build the execution context (e.g., credentials, environment).
* All hooks work with plaintext (decrypted) context for runtime operations.
*
* @see IContextEstablishmentHook
* @see PlaintextExecutionContext
*/
export type ContextEstablishmentOptions = {
/** The trigger node that initiated the workflow execution */
triggerNode: INode;
/** The complete workflow definition */
workflow: IWorkflowBase;
/**
* Trigger items from the workflow execution start.
* This array represents items as modified by previous hooks in the chain.
* Hooks can extract data from these items and optionally modify them
* (e.g., removing sensitive headers before storage).
*/
triggerItems: INodeExecutionData[];
/**
* The plaintext execution context built so far.
* Includes base context plus results from any previously executed hooks.
* Contains decrypted credential data for runtime operations.
*
* @see PlaintextExecutionContext for security considerations
*/
context: PlaintextExecutionContext;
/**
* Hook-specific configuration provided by the trigger node.
* Structure varies per hook type (e.g., { removeFromItem: true } for bearer token hook).
*/
options?: Record<string, unknown>;
};
/**
* Result returned by a context establishment hook after execution.
*
* Hooks can modify trigger items (e.g., remove sensitive headers) and
* contribute partial context updates that get merged into the execution context.
* All context data is in plaintext form during hook execution.
*
* @see IContextEstablishmentHook
* @see PlaintextExecutionContext
*/
export type ContextEstablishmentResult = {
/**
* The potentially modified trigger items.
* If undefined, the original trigger items are preserved unchanged.
*
* Common use case: Removing sensitive data (e.g., Authorization headers)
* before storing items in execution history.
*
* @example
* ```typescript
* // Remove Authorization header from trigger items
* const modifiedItems = options.triggerItems.map(item => ({
* ...item,
* json: {
* ...item.json,
* headers: {
* ...item.json.headers,
* authorization: undefined
* }
* }
* }));
* return { triggerItems: modifiedItems, contextUpdate: { ... } };
* ```
*/
triggerItems?: INodeExecutionData[];
/**
* Partial context update to merge into the execution context.
* If undefined, no context updates are applied.
*
* Contains only this hook's contributions (e.g., credentials data).
* Multiple hooks' updates are merged sequentially during execution.
* Context data is in plaintext form and will be encrypted before persistence.
*
* @example
* ```typescript
* // Add credential context from bearer token
* return {
* triggerItems: modifiedItems,
* contextUpdate: {
* credentials: {
* version: 1,
* identity: extractedToken,
* metadata: { source: 'bearer-token' }
* }
* }
* };
* ```
*/
contextUpdate?: Partial<PlaintextExecutionContext>;
};
/**
* Metadata describing a context establishment hook.
*
* This object carries self-describing information about the hook that enables
* runtime discovery, lookup, and instantiation. Each hook instance serves as
* the single source of truth for its own metadata.
*
* **Design rationale:**
* - Hook instances are self-describing (no external configuration files)
* - Name lookup happens at runtime via Registry, not during registration
* - Description can be extended without changing decorator or registry internals
* - Supports future features like versioning, schema validation, and categorization
*
* **Future extensions** may include:
* - `version?: string` - Semantic version of hook implementation for compatibility checks
* - `configSchema?: ZodSchema` - Validation schema for hook-specific options
* - `tags?: string[]` - Categorization tags for grouping and filtering
* - `applicableTriggers?: string[]` - Cached list of compatible trigger node types
* - `deprecated?: boolean | string` - Deprecation status and migration guidance
*
* @see IContextEstablishmentHook.hookDescription
* @see ContextEstablishmentHookMetadata for registration mechanism
*
* @example
* ```typescript
* @ContextEstablishmentHook()
* export class BearerTokenHook implements IContextEstablishmentHook {
* hookDescription = {
* name: 'credentials.bearerToken'
* };
*
* // ... hook implementation
* }
*
* ```
*/
export type HookDescription = {
/**
* Unique identifier for this hook type.
*
* Used by the Hook Registry (to be implemented) to index and retrieve
* hook instances at runtime. Must be unique across all registered hooks.
*
* **Naming convention**: Use namespaced names like 'credentials.bearerToken'
* or 'envVars.tenantConfig' to organize hooks by domain and avoid collisions.
*
* **Usage contexts:**
* - Trigger node configuration specifies hooks by name
* - Hook Registry uses name as lookup key
* - UI displays localized names via i18n (e.g., `hooks.${name}.displayName`)
* - Logging and debugging references hooks by name
* - Error messages include hook name for troubleshooting
*
* **Versioning**: Future hook versions can use naming like 'credentials.bearerToken.v2'
* if breaking changes are needed, though this is not required initially.
*
* @example 'credentials.bearerToken'
* @example 'credentials.apiKey'
* @example 'envVars.tenantConfig'
* @example 'audit.requestMetadata'
*/
name: string;
};
/**
* Interface for context establishment hooks that extract data from trigger
* items and extend the execution context during workflow initialization.
*
* @see ContextEstablishmentOptions - Input parameters
* @see ContextEstablishmentResult - Output structure
* @see PlaintextExecutionContext - Runtime context type with decrypted data
*/
export interface IContextEstablishmentHook {
/**
* Self-describing metadata for this hook instance.
*
* Provides the unique name and future metadata used by the Hook Registry
* for discovery, lookup, and validation. This property makes each hook
* instance self-contained and discoverable without external configuration.
*
* @see HookDescription for detailed metadata structure and future extensions
*
* @example
* ```typescript
* @ContextEstablishmentHook()
* export class BearerTokenHook implements IContextEstablishmentHook {
* hookDescription = {
* name: 'credentials.bearerToken'
* };
*
* async execute(options: ContextEstablishmentOptions) {
* // Hook implementation
* }
*
* isApplicableToTriggerNode(nodeType: string) {
* return nodeType === 'n8n-nodes-base.webhook';
* }
* }
* ```
*/
hookDescription: HookDescription;
/**
* Executes the hook to extract context data from trigger information.
*
* **Implementation requirements:**
* 1. Extract relevant data from trigger items (headers, body, query params, etc.)
* 2. Optionally modify trigger items to remove sensitive data
* 3. Return partial context updates to merge into execution context
* 4. Throw errors for unrecoverable failures (stops workflow execution)
*
* **Execution order:**
* Hooks execute sequentially in the order configured by the trigger node.
* Each hook receives:
* - Trigger items as modified by all previous hooks
* - Context with updates from all previous hooks
*
* **Context handling:**
* - Input context is plaintext (PlaintextExecutionContext) for runtime operations
* - Output updates are plaintext and will be encrypted before persistence
* - Never log or expose plaintext context outside hook execution
*
* **Error handling:**
* - Throw errors if required data is missing (e.g., expected header not found)
* - Use descriptive error messages for debugging
* - Errors stop workflow execution (fail-fast approach)
*
* @param options - Input parameters including trigger node, workflow, items, and current context
* @returns Promise resolving to modified trigger items and context updates
* @throws Error if hook execution fails (stops workflow execution)
*
* @example
* ```typescript
* async execute(options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
* const removeHeader = options.options?.removeFromItem ?? true;
*
* // Extract data
* const token = this.extractToken(options.triggerItems);
* if (!token) {
* throw new Error('Bearer token not found in Authorization header');
* }
*
* // Optionally modify items
* const modifiedItems = removeHeader
* ? this.removeAuthHeader(options.triggerItems)
* : undefined;
*
* // Return context update
* return {
* triggerItems: modifiedItems,
* contextUpdate: {
* credentials: { version: 1, identity: token }
* }
* };
* }
* ```
*/
execute(options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult>;
/**
* Method to determine if this hook is applicable to a specific trigger node type.
*
* **Use cases:**
* - **UI filtering**: Show only relevant hooks for a trigger type in node configuration
* - **Validation**: Prevent incompatible hook configurations at save time
* - **Auto-suggestion**: Suggest applicable hooks based on trigger node selection
* - **Documentation**: Generate trigger-specific hook documentation
*
* **Implementation notes:**
* - Return true if the hook can extract meaningful data from this trigger type
* - Consider transport layer (HTTP, AMQP, manual, etc.)
* - Multiple triggers can share the same hook (e.g., webhook and form trigger both support bearer tokens)
*
* @param nodeType - The node type identifier (e.g., 'n8n-nodes-base.webhook')
* @returns true if this hook can be used with the given trigger node type
*
* @example
* ```typescript
* // Hook only works with HTTP-based triggers
* isApplicableToTriggerNode(nodeType: string): boolean {
* return [
* 'n8n-nodes-base.webhook',
* 'n8n-nodes-base.formTrigger',
* 'n8n-nodes-base.httpRequest'
* ].includes(nodeType);
* }
* ```
*
* @example
* ```typescript
* // Hook works with any trigger that has HTTP headers
* isApplicableToTriggerNode(nodeType: string): boolean {
* return nodeType.includes('webhook') || nodeType.includes('http');
* }
* ```
*/
isApplicableToTriggerNode(nodeType: string): boolean;
}
/**
* Type representing the constructor/class of a context establishment hook.
*
* Used by the dependency injection container to register and instantiate
* hook classes at runtime. Works with the @ContextEstablishmentHook decorator.
*
* @see IContextEstablishmentHook
* @see ContextEstablishmentHook decorator in './index.ts'
*
* @example
* ```typescript
* import { Container } from '@n8n/di';
* import type { ContextEstablishmentHookClass } from './context-establishment-hook';
*
* const HookClass: ContextEstablishmentHookClass = BearerTokenHook;
* const hookInstance = Container.get(HookClass);
* ```
*/
export type ContextEstablishmentHookClass = Constructable<IContextEstablishmentHook>;

View File

@ -0,0 +1,5 @@
export {
ContextEstablishmentHookMetadata,
ContextEstablishmentHook,
} from './context-establishment-hook-metadata';
export type * from './context-establishment-hook';

View File

@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { sanitizeFilename } from './fileUtils';
import { sanitizeFilename } from './sanitize';
describe('sanitizeFilename', () => {
it('should return normal filenames unchanged', () => {

View File

@ -0,0 +1,87 @@
// Constants definition
/* eslint-disable no-control-regex */
const INVALID_CHARS_REGEX = /[<>:"/\\|?*\u0000-\u001F\u007F-\u009F]/g;
const ZERO_WIDTH_CHARS_REGEX = /[\u200B-\u200D\u2060\uFEFF]/g;
const UNICODE_SPACES_REGEX = /[\u00A0\u2000-\u200A]/g;
const LEADING_TRAILING_DOTS_SPACES_REGEX = /^[\s.]+|[\s.]+$/g;
/* eslint-enable no-control-regex */
const WINDOWS_RESERVED_NAMES = new Set([
'CON',
'PRN',
'AUX',
'NUL',
'COM1',
'COM2',
'COM3',
'COM4',
'COM5',
'COM6',
'COM7',
'COM8',
'COM9',
'LPT1',
'LPT2',
'LPT3',
'LPT4',
'LPT5',
'LPT6',
'LPT7',
'LPT8',
'LPT9',
]);
const DEFAULT_FALLBACK_NAME = 'untitled';
const MAX_FILENAME_LENGTH = 200;
/**
* Sanitizes a filename to be compatible with Mac, Linux, and Windows file systems
*
* Main features:
* - Replace invalid characters (e.g. ":" in hello:world)
* - Handle Windows reserved names
* - Limit filename length
* - Normalize Unicode characters
*
* @param filename - The filename to sanitize (without extension)
* @param maxLength - Maximum filename length (default: 200)
* @returns A sanitized filename (without extension)
*
* @example
* sanitizeFilename('hello:world') // returns 'hello_world'
* sanitizeFilename('CON') // returns '_CON'
* sanitizeFilename('') // returns 'untitled'
*/
export const sanitizeFilename = (
filename: string,
maxLength: number = MAX_FILENAME_LENGTH,
): string => {
// Input validation
if (!filename) {
return DEFAULT_FALLBACK_NAME;
}
let baseName = filename
.trim()
.replace(INVALID_CHARS_REGEX, '_')
.replace(ZERO_WIDTH_CHARS_REGEX, '')
.replace(UNICODE_SPACES_REGEX, ' ')
.replace(LEADING_TRAILING_DOTS_SPACES_REGEX, '');
// Handle empty or invalid filenames after cleaning
if (!baseName) {
baseName = DEFAULT_FALLBACK_NAME;
}
// Handle Windows reserved names
if (WINDOWS_RESERVED_NAMES.has(baseName.toUpperCase())) {
baseName = `_${baseName}`;
}
// Truncate if too long
if (baseName.length > maxLength) {
baseName = baseName.slice(0, maxLength);
}
return baseName;
};

View File

@ -7,3 +7,4 @@ export * from './search/reRankSearchResults';
export * from './search/sublimeSearch';
export * from './sort/sortByProperty';
export * from './string/truncate';
export * from './files/sanitize';

View File

@ -2,6 +2,16 @@
This list shows all the versions which include breaking changes and how to upgrade.
# 1.122.0
### What changed?
The way to add third-party dependencies to the `n8nio/runners` image has changed. More details [here](https://docs.n8n.io/hosting/configuration/task-runners/#adding-extra-dependencies).
### When is action necessary?
If you adding third-party dependencies to the `n8nio/runners` image using `package.json` and `extras.txt` and building the image yourself, please extend the image as instructed in the link above.
# 1.113.0
### What changed?

View File

@ -110,6 +110,7 @@
"@n8n/permissions": "workspace:*",
"@n8n/task-runner": "workspace:*",
"@n8n/typeorm": "catalog:",
"@n8n/utils": "workspace:*",
"@n8n_io/ai-assistant-sdk": "catalog:",
"@n8n_io/license-sdk": "2.24.1",
"@rudderstack/rudder-sdk-node": "2.1.4",

View File

@ -87,6 +87,9 @@ export class AuthService {
'/types/nodes.json',
'/types/credentials.json',
'/mcp-oauth/authorize/',
// Skip browser ID check for chat hub attachments
`/${restEndpoint}/chat/conversations/:sessionId/messages/:messageId/attachments/:index`,
];
}

View File

@ -68,7 +68,9 @@ describe('ExecutionRepository', () => {
await executionRepository.deleteExecutionsByFilter({ id: '1' }, ['1'], { ids: ['1'] });
expect(binaryDataService.deleteMany).toHaveBeenCalledWith([{ executionId: '1', workflowId }]);
expect(binaryDataService.deleteMany).toHaveBeenCalledWith([
{ type: 'execution', executionId: '1', workflowId },
]);
});
});

View File

@ -1,12 +1,15 @@
import { testDb, testModules } from '@n8n/backend-test-utils';
import { mockInstance, testDb, testModules } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
import { BinaryDataService } from 'n8n-core';
import { createAdmin, createMember } from '@test-integration/db/users';
import { ChatHubService } from '../chat-hub.service';
import { ChatHubMessageRepository } from '../chat-message.repository';
import { ChatHubSessionRepository } from '../chat-session.repository';
mockInstance(BinaryDataService);
beforeAll(async () => {
await testModules.loadModules(['chat-hub']);
await testDb.init();

View File

@ -32,6 +32,7 @@ export class ChatHubAgentService {
},
createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(),
allowFileUploads: true,
})),
};
}

View File

@ -11,6 +11,7 @@ import {
} from '@n8n/typeorm';
import type { ChatHubSession } from './chat-hub-session.entity';
import type { IBinaryData } from 'n8n-workflow';
@Entity({ name: 'chat_hub_messages' })
export class ChatHubMessage extends WithTimestamps {
@ -167,4 +168,13 @@ export class ChatHubMessage extends WithTimestamps {
*/
@Column({ type: 'varchar', length: 16, default: 'success' })
status: ChatHubMessageStatus;
/**
* File attachments for the message (if any), stored as JSON.
* Storage strategy depends on the binary data mode configuration:
* - When using external storage (e.g., filesystem-v2): Only metadata is stored, with 'id' referencing the external location
* - When using default mode: Base64-encoded data is stored directly in the 'data' field
*/
@Column({ type: 'json', nullable: true })
attachments: Array<IBinaryData> | null;
}

View File

@ -21,8 +21,10 @@ import {
IWorkflowBase,
MEMORY_BUFFER_WINDOW_NODE_TYPE,
MEMORY_MANAGER_NODE_TYPE,
MERGE_NODE_TYPE,
NodeConnectionTypes,
OperationalError,
type IBinaryData,
} from 'n8n-workflow';
import { v4 as uuidv4 } from 'uuid';
@ -49,6 +51,7 @@ export class ChatHubWorkflowService {
projectId: string,
history: ChatHubMessage[],
humanMessage: string,
attachments: IBinaryData[],
credentials: INodeCredentials,
model: ChatHubConversationModel,
systemMessage: string | undefined,
@ -65,6 +68,7 @@ export class ChatHubWorkflowService {
sessionId,
history,
humanMessage,
attachments,
credentials,
model,
systemMessage,
@ -72,6 +76,7 @@ export class ChatHubWorkflowService {
});
const newWorkflow = new WorkflowEntity();
newWorkflow.versionId = uuidv4();
newWorkflow.name = `Chat ${sessionId}`;
newWorkflow.active = false;
@ -147,6 +152,38 @@ export class ChatHubWorkflowService {
});
}
prepareExecutionData(
triggerNode: INode,
sessionId: string,
message: string,
attachments: IBinaryData[],
): IExecuteData[] {
// Attachments are already processed (id field populated) by the caller
return [
{
node: triggerNode,
data: {
main: [
[
{
json: {
sessionId,
action: 'sendMessage',
chatInput: message,
files: attachments.map(({ data, ...metadata }) => metadata),
},
binary: Object.fromEntries(
attachments.map((attachment, index) => [`data${index}`, attachment]),
),
},
],
],
},
source: null,
},
];
}
private getUniqueNodeName(originalName: string, existingNames: Set<string>): string {
if (!existingNames.has(originalName)) {
return originalName;
@ -168,6 +205,7 @@ export class ChatHubWorkflowService {
sessionId,
history,
humanMessage,
attachments,
credentials,
model,
systemMessage,
@ -177,6 +215,7 @@ export class ChatHubWorkflowService {
sessionId: ChatSessionId;
history: ChatHubMessage[];
humanMessage: string;
attachments: IBinaryData[];
credentials: INodeCredentials;
model: ChatHubConversationModel;
systemMessage?: string;
@ -188,6 +227,7 @@ export class ChatHubWorkflowService {
const memoryNode = this.buildMemoryNode(20);
const restoreMemoryNode = this.buildRestoreMemoryNode(history);
const clearMemoryNode = this.buildClearMemoryNode();
const mergeNode = this.buildMergeNode();
const nodes: INode[] = [
chatTriggerNode,
@ -196,6 +236,7 @@ export class ChatHubWorkflowService {
memoryNode,
restoreMemoryNode,
clearMemoryNode,
mergeNode,
];
const nodeNames = new Set(nodes.map((node) => node.name));
@ -221,10 +262,18 @@ export class ChatHubWorkflowService {
const connections: IConnections = {
[NODE_NAMES.CHAT_TRIGGER]: {
[NodeConnectionTypes.Main]: [
[{ node: NODE_NAMES.RESTORE_CHAT_MEMORY, type: NodeConnectionTypes.Main, index: 0 }],
[
{ node: NODE_NAMES.RESTORE_CHAT_MEMORY, type: NodeConnectionTypes.Main, index: 0 },
{ node: NODE_NAMES.MERGE, type: NodeConnectionTypes.Main, index: 0 },
],
],
},
[NODE_NAMES.RESTORE_CHAT_MEMORY]: {
[NodeConnectionTypes.Main]: [
[{ node: NODE_NAMES.MERGE, type: NodeConnectionTypes.Main, index: 1 }],
],
},
[NODE_NAMES.MERGE]: {
[NodeConnectionTypes.Main]: [
[{ node: NODE_NAMES.REPLY_AGENT, type: NodeConnectionTypes.Main, index: 0 }],
],
@ -271,25 +320,12 @@ export class ChatHubWorkflowService {
}, {}),
};
const nodeExecutionStack: IExecuteData[] = [
{
node: chatTriggerNode,
data: {
main: [
[
{
json: {
sessionId,
action: 'sendMessage',
chatInput: humanMessage,
},
},
],
],
},
source: null,
},
];
const nodeExecutionStack = this.prepareExecutionData(
chatTriggerNode,
sessionId,
humanMessage,
attachments,
);
const executionData = createRunExecutionData({
executionData: {
@ -541,6 +577,22 @@ export class ChatHubWorkflowService {
};
}
private buildMergeNode(): INode {
return {
parameters: {
mode: 'combine',
fieldsToMatchString: 'chatInput',
joinMode: 'enrichInput1',
options: {},
},
type: MERGE_NODE_TYPE,
typeVersion: 3.2,
position: [224, -100],
id: uuidv4(),
name: NODE_NAMES.MERGE,
};
}
private buildTitleGeneratorAgentNode(): INode {
return {
parameters: {

View File

@ -0,0 +1,160 @@
import { Service } from '@n8n/di';
import { BINARY_ENCODING, type IBinaryData } from 'n8n-workflow';
import { sanitizeFilename } from '@n8n/utils';
import { BinaryDataService, FileLocation } from 'n8n-core';
import { Not, IsNull } from '@n8n/typeorm';
import { ChatHubMessageRepository } from './chat-message.repository';
import type { ChatMessageId, ChatSessionId, ChatAttachment } from '@n8n/api-types';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import type Stream from 'node:stream';
import FileType from 'file-type';
@Service()
export class ChatHubAttachmentService {
private readonly maxTotalSizeBytes = 200 * 1024 * 1024; // 200 MB
constructor(
private readonly binaryDataService: BinaryDataService,
private readonly messageRepository: ChatHubMessageRepository,
) {}
/**
* Stores attachments through BinaryDataService.
* This populates the 'id' and other metadata for attachments. When external storage is used,
* BinaryDataService replaces base64 data with the storage mode string (e.g., "filesystem-v2").
*/
async store(
sessionId: ChatSessionId,
messageId: ChatMessageId,
attachments: ChatAttachment[],
): Promise<IBinaryData[]> {
let totalSize = 0;
const storedAttachments: IBinaryData[] = [];
for (const attachment of attachments) {
const buffer = Buffer.from(attachment.data, BINARY_ENCODING);
totalSize += buffer.length;
if (totalSize > this.maxTotalSizeBytes) {
const maxSizeMB = Math.floor(this.maxTotalSizeBytes / (1024 * 1024));
throw new BadRequestError(
`Total size of attachments exceeds maximum size of ${maxSizeMB} MB`,
);
}
const stored = await this.processAttachment(sessionId, messageId, attachment, buffer);
storedAttachments.push(stored);
}
return storedAttachments;
}
/*
* Gets a specific attachment from a message by index and returns it as either buffer or stream
*/
async getAttachment(
sessionId: ChatSessionId,
messageId: ChatMessageId,
attachmentIndex: number,
): Promise<
[
IBinaryData,
(
| { type: 'buffer'; buffer: Buffer<ArrayBufferLike>; fileSize: number }
| { type: 'stream'; stream: Stream.Readable; fileSize: number }
),
]
> {
const message = await this.messageRepository.getOneById(messageId, sessionId, []);
if (!message) {
throw new NotFoundError('Message not found');
}
const attachment = message.attachments?.[attachmentIndex];
if (!attachment) {
throw new NotFoundError('Attachment not found');
}
if (attachment.id) {
const metadata = await this.binaryDataService.getMetadata(attachment.id);
const stream = await this.binaryDataService.getAsStream(attachment.id);
return [attachment, { type: 'stream', stream, fileSize: metadata.fileSize }];
}
if (attachment.data) {
const buffer = await this.binaryDataService.getAsBuffer(attachment);
return [attachment, { type: 'buffer', buffer, fileSize: buffer.length }];
}
throw new NotFoundError('Attachment has no stored file');
}
/**
* Deletes all files attached to messages in the session
*/
async deleteAllBySessionId(sessionId: string): Promise<void> {
const messages = await this.messageRepository.getManyBySessionId(sessionId);
await this.deleteAttachments(messages.flatMap((message) => message.attachments ?? []));
}
/**
* Deletes all chat attachment files.
*/
async deleteAll(): Promise<void> {
const messages = await this.messageRepository.find({
where: {
attachments: Not(IsNull()),
},
select: ['attachments'],
});
await this.deleteAttachments(messages.flatMap((message) => message.attachments ?? []));
}
/**
* Deletes attachments by their binary data directly (used for rollback when message wasn't saved)
*/
async deleteAttachments(attachments: IBinaryData[]): Promise<void> {
await this.binaryDataService.deleteManyByBinaryDataId(
attachments.flatMap((attachment) => (attachment.id ? [attachment.id] : [])),
);
}
/**
* Processes a single attachment by populating metadata and storing it.
*/
private async processAttachment(
sessionId: ChatSessionId,
messageId: ChatMessageId,
attachment: ChatAttachment,
buffer: Buffer,
): Promise<IBinaryData> {
const sanitizedFileName = sanitizeFilename(attachment.fileName);
const fileTypeData = await FileType.fromBuffer(buffer);
// Only trust content-based detection for security
const mimeType = fileTypeData?.mime ?? 'application/octet-stream';
// Construct IBinaryData with all required fields
const binaryData: IBinaryData = {
data: attachment.data,
mimeType,
fileName: sanitizedFileName,
fileSize: `${buffer.length}`,
fileExtension: fileTypeData?.ext,
};
return await this.binaryDataService.store(
FileLocation.ofChatHubMessageAttachment(sessionId, messageId),
buffer,
binaryData,
);
}
}

View File

@ -46,6 +46,7 @@ export const NODE_NAMES = {
MEMORY: 'Memory',
RESTORE_CHAT_MEMORY: 'Restore Chat Memory',
CLEAR_CHAT_MEMORY: 'Clear Chat Memory',
MERGE: 'Merge',
} as const;
/* eslint-disable @typescript-eslint/naming-convention */

View File

@ -27,8 +27,11 @@ import type { Response } from 'express';
import { strict as assert } from 'node:assert';
import { ChatHubAgentService } from './chat-hub-agent.service';
import { ChatHubAttachmentService } from './chat-hub.attachment.service';
import { ChatHubService } from './chat-hub.service';
import { ChatModelsRequestDto } from './dto/chat-models-request.dto';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { sanitizeFilename } from '@n8n/utils';
import { ResponseError } from '@/errors/response-errors/abstract/response.error';
@ -37,6 +40,7 @@ export class ChatHubController {
constructor(
private readonly chatService: ChatHubService,
private readonly chatAgentService: ChatHubAgentService,
private readonly chatAttachmentService: ChatHubAttachmentService,
private readonly logger: Logger,
) {}
@ -69,6 +73,49 @@ export class ChatHubController {
return await this.chatService.getConversation(req.user.id, sessionId);
}
@Get('/conversations/:sessionId/messages/:messageId/attachments/:index')
@GlobalScope('chatHub:message')
async getMessageAttachment(
req: AuthenticatedRequest,
res: Response,
@Param('sessionId') sessionId: ChatSessionId,
@Param('messageId') messageId: ChatMessageId,
@Param('index') index: string,
) {
const attachmentIndex = Number.parseInt(index, 10);
if (isNaN(attachmentIndex)) {
throw new BadRequestError('Invalid attachment index');
}
// Verify user has access to this session
await this.chatService.getConversation(req.user.id, sessionId);
const [{ mimeType, fileName }, attachmentAsStreamOrBuffer] =
await this.chatAttachmentService.getAttachment(sessionId, messageId, attachmentIndex);
res.setHeader('Content-Type', mimeType ?? 'application/octet-stream');
if (attachmentAsStreamOrBuffer.fileSize) {
res.setHeader('Content-Length', attachmentAsStreamOrBuffer.fileSize);
}
if (fileName) {
res.setHeader('Content-Disposition', `attachment; filename="${sanitizeFilename(fileName)}"`);
}
if (attachmentAsStreamOrBuffer.type === 'buffer') {
res.send(attachmentAsStreamOrBuffer.buffer);
return;
}
return await new Promise<void>((resolve, reject) => {
attachmentAsStreamOrBuffer.stream.on('end', resolve);
attachmentAsStreamOrBuffer.stream.on('error', reject);
attachmentAsStreamOrBuffer.stream.pipe(res);
});
}
@GlobalScope('chatHub:message')
@Post('/conversations/send')
async sendMessage(

View File

@ -33,10 +33,10 @@ import {
jsonParse,
StructuredChunk,
RESPOND_TO_CHAT_NODE_TYPE,
IExecuteData,
IRunExecutionData,
INodeParameters,
INode,
type IBinaryData,
createRunExecutionData,
} from 'n8n-workflow';
@ -45,10 +45,11 @@ import { ChatHubCredentialsService, CredentialWithProjectId } from './chat-hub-c
import type { ChatHubMessage } from './chat-hub-message.entity';
import { ChatHubWorkflowService } from './chat-hub-workflow.service';
import { JSONL_STREAM_HEADERS, NODE_NAMES, PROVIDER_NODE_TYPE_MAP } from './chat-hub.constants';
import type {
import {
HumanMessagePayload,
RegenerateMessagePayload,
EditMessagePayload,
validChatTriggerParamsShape,
} from './chat-hub.types';
import { ChatHubMessageRepository } from './chat-message.repository';
import { ChatHubSessionRepository } from './chat-session.repository';
@ -64,6 +65,7 @@ import { getBase } from '@/workflow-execute-additional-data';
import { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowService } from '@/workflows/workflow.service';
import { ChatHubAttachmentService } from './chat-hub.attachment.service';
@Service()
export class ChatHubService {
@ -83,6 +85,7 @@ export class ChatHubService {
private readonly chatHubAgentService: ChatHubAgentService,
private readonly chatHubCredentialsService: ChatHubCredentialsService,
private readonly chatHubWorkflowService: ChatHubWorkflowService,
private readonly chatHubAttachmentService: ChatHubAttachmentService,
) {}
async getModels(
@ -191,6 +194,7 @@ export class ChatHubService {
},
createdAt: null,
updatedAt: null,
allowFileUploads: true,
})),
};
}
@ -218,6 +222,7 @@ export class ChatHubService {
},
createdAt: null,
updatedAt: null,
allowFileUploads: true,
})),
};
}
@ -283,6 +288,7 @@ export class ChatHubService {
},
createdAt: null,
updatedAt: null,
allowFileUploads: true,
})),
};
}
@ -341,6 +347,7 @@ export class ChatHubService {
},
createdAt: null,
updatedAt: null,
allowFileUploads: true,
})),
};
}
@ -458,6 +465,7 @@ export class ChatHubService {
},
createdAt: null,
updatedAt: null,
allowFileUploads: true,
})),
};
}
@ -481,30 +489,25 @@ export class ChatHubService {
return [];
}
if (chatTrigger.parameters.availableInChat !== true) {
const chatTriggerParams = validChatTriggerParamsShape.safeParse(
chatTrigger.parameters,
).data;
if (!chatTriggerParams) {
return [];
}
const name =
typeof chatTrigger.parameters.agentName === 'string' &&
chatTrigger.parameters.agentName.length > 0
? chatTrigger.parameters.agentName
: workflow.name;
return [
{
name: name ?? 'Unknown Agent',
description:
typeof chatTrigger.parameters.agentDescription === 'string' &&
chatTrigger.parameters.agentDescription.length > 0
? chatTrigger.parameters.agentDescription
: null,
name: chatTriggerParams.agentName ?? workflow.name ?? 'Unknown Agent',
description: chatTriggerParams.agentDescription ?? null,
model: {
provider: 'n8n',
workflowId: workflow.id,
},
createdAt: workflow.createdAt ? workflow.createdAt.toISOString() : null,
updatedAt: workflow.updatedAt ? workflow.updatedAt.toISOString() : null,
allowFileUploads: chatTriggerParams.options?.allowFileUploads ?? false,
},
];
}),
@ -556,12 +559,31 @@ export class ChatHubService {
}
async sendHumanMessage(res: Response, user: User, payload: HumanMessagePayload) {
const { sessionId, messageId, message, model, credentials, previousMessageId, tools } = payload;
const {
sessionId,
messageId,
message,
model,
credentials,
previousMessageId,
tools,
attachments,
} = payload;
const credentialId = this.getModelCredential(model, credentials);
const { executionData, workflowData } = await this.messageRepository.manager.transaction(
async (trx) => {
// Store attachments early to populate 'id' field via BinaryDataService
const processedAttachments = await this.chatHubAttachmentService.store(
sessionId,
messageId,
attachments,
);
let executionData: IRunExecutionData;
let workflowData: IWorkflowBase;
try {
const result = await this.messageRepository.manager.transaction(async (trx) => {
let session = await this.getChatSession(user, sessionId, trx);
session ??= await this.createChatSession(user, sessionId, model, credentialId, tools, trx);
@ -569,36 +591,36 @@ export class ChatHubService {
const messages = Object.fromEntries((session.messages ?? []).map((m) => [m.id, m]));
const history = this.buildMessageHistory(messages, previousMessageId);
await this.saveHumanMessage(payload, user, previousMessageId, model, undefined, trx);
await this.saveHumanMessage(
payload,
processedAttachments,
user,
previousMessageId,
model,
undefined,
trx,
);
if (model.provider === 'n8n') {
return await this.prepareCustomAgentWorkflow(user, sessionId, model.workflowId, message);
}
if (model.provider === 'custom-agent') {
return await this.prepareChatAgentWorkflow(
model.agentId,
user,
sessionId,
history,
message,
trx,
);
}
return await this.prepareBaseChatWorkflow(
return await this.prepareReplyWorkflow(
user,
sessionId,
credentials,
model,
history,
message,
undefined,
session.tools,
tools,
processedAttachments,
trx,
);
},
);
});
executionData = result.executionData;
workflowData = result.workflowData;
} catch (error) {
// Rollback stored attachments if transaction fails
await this.chatHubAttachmentService.deleteAttachments(processedAttachments);
throw error;
}
await this.executeChatWorkflowWithCleanup(
res,
@ -632,10 +654,6 @@ export class ChatHubService {
const messageToEdit = await this.getChatMessage(session.id, editId, [], trx);
if (!['ai', 'human'].includes(messageToEdit.type)) {
throw new BadRequestError('Only human and AI messages can be edited');
}
if (messageToEdit.type === 'ai') {
// AI edits just change the original message without revisioning or response generation
await this.messageRepository.updateChatMessage(editId, { content: payload.message }, trx);
@ -649,8 +667,12 @@ export class ChatHubService {
// If the message to edit isn't the original message, we want to point to the original message
const revisionOfMessageId = messageToEdit.revisionOfMessageId ?? messageToEdit.id;
// Attachments are already processed (from the original message)
const attachments = messageToEdit.attachments ?? [];
await this.saveHumanMessage(
payload,
attachments,
user,
messageToEdit.previousMessageId,
model,
@ -658,34 +680,20 @@ export class ChatHubService {
trx,
);
if (model.provider === 'n8n') {
return await this.prepareCustomAgentWorkflow(user, sessionId, model.workflowId, message);
}
if (model.provider === 'custom-agent') {
return await this.prepareChatAgentWorkflow(
model.agentId,
user,
sessionId,
history,
message,
trx,
);
}
return await this.prepareBaseChatWorkflow(
return await this.prepareReplyWorkflow(
user,
sessionId,
credentials,
model,
history,
message,
undefined,
session.tools,
attachments,
trx,
);
}
return null;
throw new BadRequestError('Only human and AI messages can be edited');
});
if (!workflow) {
@ -707,7 +715,6 @@ export class ChatHubService {
async regenerateAIMessage(res: Response, user: User, payload: RegenerateMessagePayload) {
const { sessionId, retryId, model, credentials } = payload;
const { provider } = model;
const {
workflow: { workflowData, executionData },
@ -744,37 +751,18 @@ export class ChatHubService {
// If the message being retried is itself a retry, we want to point to the original message
const retryOfMessageId = messageToRetry.retryOfMessageId ?? messageToRetry.id;
const message = lastHumanMessage ? lastHumanMessage.content : '';
let workflow;
if (provider === 'n8n') {
workflow = await this.prepareCustomAgentWorkflow(
user,
sessionId,
model.workflowId,
message,
);
} else if (provider === 'custom-agent') {
workflow = await this.prepareChatAgentWorkflow(
model.agentId,
user,
sessionId,
history,
message,
trx,
);
} else {
workflow = await this.prepareBaseChatWorkflow(
user,
sessionId,
credentials,
model,
history,
message,
undefined,
session.tools,
trx,
);
}
const attachments = lastHumanMessage.attachments ?? [];
const workflow = await this.prepareReplyWorkflow(
user,
sessionId,
credentials,
model,
history,
message,
session.tools,
attachments,
trx,
);
return {
workflow,
@ -795,6 +783,53 @@ export class ChatHubService {
);
}
private async prepareReplyWorkflow(
user: User,
sessionId: ChatSessionId,
credentials: INodeCredentials,
model: ChatHubConversationModel,
history: ChatHubMessage[],
message: string,
tools: INode[],
attachments: IBinaryData[],
trx: EntityManager,
) {
if (model.provider === 'n8n') {
return await this.prepareCustomAgentWorkflow(
user,
sessionId,
model.workflowId,
message,
attachments,
);
}
if (model.provider === 'custom-agent') {
return await this.prepareChatAgentWorkflow(
model.agentId,
user,
sessionId,
history,
message,
attachments,
trx,
);
}
return await this.prepareBaseChatWorkflow(
user,
sessionId,
credentials,
model,
history,
message,
undefined,
tools,
attachments,
trx,
);
}
private async prepareBaseChatWorkflow(
user: User,
sessionId: ChatSessionId,
@ -804,6 +839,7 @@ export class ChatHubService {
message: string,
systemMessage: string | undefined,
tools: INode[],
attachments: IBinaryData[],
trx: EntityManager,
) {
const credential = await this.chatHubCredentialsService.ensureCredentials(
@ -819,6 +855,7 @@ export class ChatHubService {
credential.projectId,
history,
message,
attachments,
credentials,
model,
systemMessage,
@ -833,6 +870,7 @@ export class ChatHubService {
sessionId: ChatSessionId,
history: ChatHubMessage[],
message: string,
attachments: IBinaryData[],
trx: EntityManager,
) {
const agent = await this.chatHubAgentService.getAgentById(agentId, user.id);
@ -879,6 +917,7 @@ export class ChatHubService {
message,
systemMessage,
tools,
attachments,
trx,
);
}
@ -888,6 +927,7 @@ export class ChatHubService {
sessionId: ChatSessionId,
workflowId: string,
message: string,
attachments: IBinaryData[],
) {
const workflowEntity = await this.workflowFinderService.findWorkflowForUser(
workflowId,
@ -920,25 +960,12 @@ export class ChatHubService {
);
}
const nodeExecutionStack: IExecuteData[] = [
{
node: chatTriggerNode,
data: {
main: [
[
{
json: {
sessionId,
action: 'sendMessage',
chatInput: message,
},
},
],
],
},
source: null,
},
];
const nodeExecutionStack = this.chatHubWorkflowService.prepareExecutionData(
chatTriggerNode,
sessionId,
message,
attachments,
);
const executionData = createRunExecutionData({
executionData: {
@ -1448,6 +1475,7 @@ export class ChatHubService {
private async saveHumanMessage(
payload: HumanMessagePayload | EditMessagePayload,
attachments: IBinaryData[],
user: User,
previousMessageId: ChatMessageId | null,
model: ChatHubConversationModel,
@ -1464,6 +1492,7 @@ export class ChatHubService {
previousMessageId,
revisionOfMessageId,
name: user.firstName || 'User',
attachments,
...model,
},
trx,
@ -1662,6 +1691,11 @@ export class ChatHubService {
previousMessageId: message.previousMessageId,
retryOfMessageId: message.retryOfMessageId,
revisionOfMessageId: message.revisionOfMessageId,
attachments: (message.attachments ?? []).map(({ fileName, mimeType }) => ({
fileName,
mimeType,
})),
};
}
@ -1690,6 +1724,7 @@ export class ChatHubService {
}
async deleteAllSessions() {
await this.chatHubAttachmentService.deleteAll();
const result = await this.sessionRepository.deleteAll();
return result;
}
@ -1797,6 +1832,7 @@ export class ChatHubService {
throw new NotFoundError('Session not found');
}
await this.chatHubAttachmentService.deleteAllBySessionId(sessionId);
await this.sessionRepository.deleteChatHubSession(sessionId);
}
}

View File

@ -1,5 +1,21 @@
import type { ChatHubConversationModel, ChatMessageId, ChatSessionId } from '@n8n/api-types';
import type {
ChatHubConversationModel,
ChatHubProvider,
ChatMessageId,
ChatSessionId,
ChatAttachment,
} from '@n8n/api-types';
import type { INode, INodeCredentials } from 'n8n-workflow';
import { z } from 'zod';
export interface ModelWithCredentials {
provider: ChatHubProvider;
model?: string;
workflowId?: string;
credentialId: string | null;
agentId?: string;
name?: string;
}
export interface BaseMessagePayload {
userId: string;
@ -12,6 +28,7 @@ export interface HumanMessagePayload extends BaseMessagePayload {
messageId: ChatMessageId;
message: string;
previousMessageId: ChatMessageId | null;
attachments: ChatAttachment[];
tools: INode[];
}
export interface RegenerateMessagePayload extends BaseMessagePayload {
@ -31,3 +48,14 @@ export interface MessageRecord {
message: string;
hideFromUI: boolean;
}
export const validChatTriggerParamsShape = z.object({
availableInChat: z.literal(true),
agentName: z.string().min(1).optional(),
agentDescription: z.string().min(1).optional(),
options: z
.object({
allowFileUploads: z.boolean().optional(),
})
.optional(),
});

View File

@ -5,6 +5,7 @@ import { DataSource, EntityManager, Repository } from '@n8n/typeorm';
import { ChatHubMessage } from './chat-hub-message.entity';
import { ChatHubSessionRepository } from './chat-session.repository';
import type { IBinaryData } from 'n8n-workflow';
@Service()
export class ChatHubMessageRepository extends Repository<ChatHubMessage> {
@ -33,7 +34,7 @@ export class ChatHubMessageRepository extends Repository<ChatHubMessage> {
async updateChatMessage(
id: ChatMessageId,
fields: { status?: ChatHubMessageStatus; content?: string },
fields: { status?: ChatHubMessageStatus; content?: string; attachments?: IBinaryData[] },
trx?: EntityManager,
) {
return await withTransaction(

View File

@ -7,6 +7,7 @@ import { mock } from 'jest-mock-extended';
import { InstanceSettings } from 'n8n-core';
import { InsightsModule } from '../insights.module';
import { InsightsService } from '../insights.service';
describe('InsightsModule', () => {
let insightsModule: InsightsModule;
@ -23,11 +24,23 @@ describe('InsightsModule', () => {
beforeEach(async () => {
jest.clearAllMocks();
await testDb.truncate(['Project']);
insightsModule = Container.get(InsightsModule);
mockInstanceSettings = mock<InstanceSettings>();
Container.set(InstanceSettings, mockInstanceSettings);
Container.set(Logger, mockLogger());
Container.set(LicenseState, mock<LicenseState>());
Container.set(
InsightsService,
new InsightsService(
mock(),
mock(),
mock(),
Container.get(LicenseState),
mockInstanceSettings,
Container.get(Logger),
),
);
insightsModule = Container.get(InsightsModule);
await createTeamProject();
});

View File

@ -1,17 +1,14 @@
import type { ModuleInterface } from '@n8n/decorators';
import { BackendModule, OnShutdown } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
@BackendModule({ name: 'insights' })
/**
* Only main- and webhook-type instances collect insights because
* only they are informed of finished workflow executions.
*/
@BackendModule({ name: 'insights', instanceTypes: ['main', 'webhook'] })
export class InsightsModule implements ModuleInterface {
async init() {
/**
* Only main- and webhook-type instances collect insights because
* only they are informed of finished workflow executions.
*/
if (Container.get(InstanceSettings).instanceType === 'worker') return;
await import('./insights.controller');
const { InsightsService } = await import('./insights.service');

View File

@ -15,7 +15,7 @@ properties:
example: John's Github account
type:
type: string
example: github
example: githubApi
createdAt:
type: string
format: date-time

View File

@ -13,11 +13,11 @@ properties:
example: Joe's Github Credentials
type:
type: string
example: github
example: githubApi
data:
type: object
writeOnly: true
example: { token: 'ada612vad6fa5df4adf5a5dsf4389adsf76da7s' }
example: { accessToken: 'ada612vad6fa5df4adf5a5dsf4389adsf76da7s' }
createdAt:
type: string
format: date-time

View File

@ -18,7 +18,7 @@ import { In } from '@n8n/typeorm';
import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity';
import omit from 'lodash/omit';
import pick from 'lodash/pick';
import { BinaryDataService } from 'n8n-core';
import { FileLocation, BinaryDataService } from 'n8n-core';
import { NodeApiError, PROJECT_ROOT } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
@ -456,7 +456,9 @@ export class WorkflowService {
select: ['id'],
where: { workflowId },
})
.then((rows) => rows.map(({ id: executionId }) => ({ workflowId, executionId })));
.then((rows) =>
rows.map(({ id: executionId }) => FileLocation.ofExecution(workflowId, executionId)),
);
await this.workflowRepository.delete(workflowId);
await this.binaryDataService.deleteMany(idsForDeletion);

View File

@ -8,6 +8,7 @@ import {
InstanceSettings,
UnrecognizedNodeTypeError,
type DirectoryLoader,
type ErrorReporter,
} from 'n8n-core';
import { Ftp } from 'n8n-nodes-base/credentials/Ftp.credentials';
import { GithubApi } from 'n8n-nodes-base/credentials/GithubApi.credentials';
@ -115,7 +116,8 @@ export async function initBinaryDataService(mode: 'default' | 'filesystem' = 'de
availableModes: [mode],
localStoragePath: '',
});
const binaryDataService = new BinaryDataService(config);
const errorReporter = mock<ErrorReporter>();
const binaryDataService = new BinaryDataService(config, errorReporter);
await binaryDataService.init();
Container.set(BinaryDataService, binaryDataService);
}

View File

@ -2,6 +2,8 @@ import { mock } from 'jest-mock-extended';
import { sign, JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import type { IBinaryData } from 'n8n-workflow';
import type { ErrorReporter } from '@/errors';
import type { BinaryDataConfig } from '../binary-data.config';
import { BinaryDataService } from '../binary-data.service';
@ -11,6 +13,7 @@ jest.useFakeTimers({ now });
describe('BinaryDataService', () => {
const signingSecret = 'test-signing-secret';
const config = mock<BinaryDataConfig>({ signingSecret });
const errorReporter = mock<ErrorReporter>();
const binaryData = mock<IBinaryData>({ id: 'filesystem:id_123' });
const validToken = sign({ id: binaryData.id }, signingSecret, { expiresIn: '1 day' });
@ -19,7 +22,7 @@ describe('BinaryDataService', () => {
jest.resetAllMocks();
config.signingSecret = signingSecret;
service = new BinaryDataService(config);
service = new BinaryDataService(config, errorReporter);
});
describe('createSignedToken', () => {

View File

@ -1,3 +1,4 @@
import { mock } from 'jest-mock-extended';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import { tmpdir } from 'node:os';
@ -5,14 +6,18 @@ import path from 'node:path';
import { Readable } from 'node:stream';
import { FileSystemManager } from '@/binary-data/file-system.manager';
import type { ErrorReporter } from '@/errors';
import { toFileId, toStream } from '@test/utils';
import type { BinaryData } from '../types';
jest.mock('fs');
jest.mock('fs/promises');
const storagePath = tmpdir();
const errorReporter = mock<ErrorReporter>();
const fsManager = new FileSystemManager(storagePath);
const fsManager = new FileSystemManager(storagePath, errorReporter);
const toFullFilePath = (fileId: string) => path.join(storagePath, fileId);
@ -37,7 +42,11 @@ describe('store()', () => {
it('should store a buffer', async () => {
const metadata = { mimeType: 'text/plain' };
const result = await fsManager.store(workflowId, executionId, mockBuffer, metadata);
const result = await fsManager.store(
{ type: 'execution', workflowId, executionId },
mockBuffer,
metadata,
);
expect(result.fileSize).toBe(mockBuffer.length);
});
@ -104,7 +113,10 @@ describe('copyByFileId()', () => {
// @ts-expect-error - private method
jest.spyOn(fsManager, 'toFileId').mockReturnValue(otherFileId);
const targetFileId = await fsManager.copyByFileId(workflowId, executionId, fileId);
const targetFileId = await fsManager.copyByFileId(
{ type: 'execution', workflowId, executionId },
fileId,
);
const sourcePath = toFullFilePath(fileId);
const targetPath = toFullFilePath(targetFileId);
@ -133,8 +145,7 @@ describe('copyByFilePath()', () => {
fsp.writeFile = jest.fn().mockResolvedValue(undefined);
const result = await fsManager.copyByFilePath(
workflowId,
executionId,
{ type: 'execution', workflowId, executionId },
sourceFilePath,
metadata,
);
@ -156,9 +167,9 @@ describe('deleteMany()', () => {
};
it('should delete many files by workflow ID and execution ID', async () => {
const ids = [
{ workflowId, executionId },
{ workflowId: otherWorkflowId, executionId: otherExecutionId },
const ids: BinaryData.FileLocation[] = [
{ type: 'execution', workflowId, executionId },
{ type: 'execution', workflowId: otherWorkflowId, executionId: otherExecutionId },
];
fsp.rm = jest.fn().mockResolvedValue(undefined);
@ -181,7 +192,9 @@ describe('deleteMany()', () => {
});
it('should suppress error on non-existing filepath', async () => {
const ids = [{ workflowId: 'does-not-exist', executionId: 'does-not-exist' }];
const ids: BinaryData.FileLocation[] = [
{ type: 'execution', workflowId: 'does-not-exist', executionId: 'does-not-exist' },
];
fsp.rm = jest.fn().mockResolvedValue(undefined);

View File

@ -34,7 +34,11 @@ describe('store()', () => {
it('should store a buffer', async () => {
const metadata = { mimeType: 'text/plain' };
const result = await objectStoreManager.store(workflowId, executionId, mockBuffer, metadata);
const result = await objectStoreManager.store(
{ type: 'execution', workflowId, executionId },
mockBuffer,
metadata,
);
expect(result.fileId.startsWith(prefix)).toBe(true);
expect(result.fileSize).toBe(mockBuffer.length);
@ -94,7 +98,10 @@ describe('getMetadata()', () => {
describe('copyByFileId()', () => {
it('should copy by file ID and return the file ID', async () => {
const targetFileId = await objectStoreManager.copyByFileId(workflowId, executionId, fileId);
const targetFileId = await objectStoreManager.copyByFileId(
{ type: 'execution', workflowId, executionId },
fileId,
);
expect(targetFileId.startsWith(prefix)).toBe(true);
expect(objectStoreService.get).toHaveBeenCalledWith(fileId, { mode: 'buffer' });
@ -109,8 +116,7 @@ describe('copyByFilePath()', () => {
fs.readFile = jest.fn().mockResolvedValue(mockBuffer);
const result = await objectStoreManager.copyByFilePath(
workflowId,
executionId,
{ type: 'execution', workflowId, executionId },
sourceFilePath,
metadata,
);

View File

@ -7,6 +7,8 @@ import { readFile, stat } from 'node:fs/promises';
import prettyBytes from 'pretty-bytes';
import type { Readable } from 'stream';
import { ErrorReporter } from '@/errors';
import { BinaryDataConfig } from './binary-data.config';
import type { BinaryData } from './types';
import { areConfigModes, binaryToBuffer } from './utils';
@ -19,7 +21,10 @@ export class BinaryDataService {
private managers: Record<string, BinaryData.Manager> = {};
constructor(private readonly config: BinaryDataConfig) {}
constructor(
private readonly config: BinaryDataConfig,
private readonly errorReporter: ErrorReporter,
) {}
async init() {
const { config } = this;
@ -30,7 +35,7 @@ export class BinaryDataService {
if (config.availableModes.includes('filesystem')) {
const { FileSystemManager } = await import('./file-system.manager');
this.managers.filesystem = new FileSystemManager(config.localStoragePath);
this.managers.filesystem = new FileSystemManager(config.localStoragePath, this.errorReporter);
this.managers['filesystem-v2'] = this.managers.filesystem;
await this.managers.filesystem.init();
@ -66,8 +71,7 @@ export class BinaryDataService {
}
async copyBinaryFile(
workflowId: string,
executionId: string,
location: BinaryData.FileLocation,
binaryData: IBinaryData,
filePath: string,
) {
@ -86,12 +90,7 @@ export class BinaryDataService {
mimeType: binaryData.mimeType,
};
const { fileId, fileSize } = await manager.copyByFilePath(
workflowId,
executionId,
filePath,
metadata,
);
const { fileId, fileSize } = await manager.copyByFilePath(location, filePath, metadata);
binaryData.id = this.createBinaryDataId(fileId);
binaryData.fileSize = prettyBytes(fileSize);
@ -101,8 +100,7 @@ export class BinaryDataService {
}
async store(
workflowId: string,
executionId: string,
location: BinaryData.FileLocation,
bufferOrStream: Buffer | Readable,
binaryData: IBinaryData,
) {
@ -121,12 +119,7 @@ export class BinaryDataService {
mimeType: binaryData.mimeType,
};
const { fileId, fileSize } = await manager.store(
workflowId,
executionId,
bufferOrStream,
metadata,
);
const { fileId, fileSize } = await manager.store(location, bufferOrStream, metadata);
binaryData.id = this.createBinaryDataId(fileId);
binaryData.fileSize = prettyBytes(fileSize);
@ -163,17 +156,28 @@ export class BinaryDataService {
return await this.getManager(mode).getMetadata(fileId);
}
async deleteMany(ids: BinaryData.IdsForDeletion) {
async deleteMany(locations: BinaryData.FileLocation[]) {
const manager = this.managers[this.mode];
if (!manager) return;
if (manager.deleteMany) await manager.deleteMany(ids);
if (manager.deleteMany) await manager.deleteMany(locations);
}
async deleteManyByBinaryDataId(ids: string[]) {
const manager = this.managers[this.mode];
const fileIds = ids.flatMap((attachmentId) => {
const [, fileId] = attachmentId.split(':'); // remove mode
return fileId ? [fileId] : [];
});
await manager.deleteManyByFileId?.(fileIds);
}
async duplicateBinaryData(
workflowId: string,
executionId: string,
location: BinaryData.FileLocation,
inputData: Array<INodeExecutionData[] | null>,
) {
if (inputData && this.managers[this.mode]) {
@ -183,11 +187,7 @@ export class BinaryDataService {
return await Promise.all(
executionDataArray.map(async (executionData) => {
if (executionData.binary) {
return await this.duplicateBinaryDataInExecData(
workflowId,
executionId,
executionData,
);
return await this.duplicateBinaryDataInExecData(location, executionData);
}
return executionData;
@ -222,8 +222,7 @@ export class BinaryDataService {
}
private async duplicateBinaryDataInExecData(
workflowId: string,
executionId: string,
location: BinaryData.FileLocation,
executionData: INodeExecutionData,
) {
const manager = this.managers[this.mode];
@ -242,7 +241,7 @@ export class BinaryDataService {
const [_mode, fileId] = binaryDataId.split(':');
return await manager?.copyByFileId(workflowId, executionId, fileId).then((newFileId) => ({
return await manager?.copyByFileId(location, fileId).then((newFileId) => ({
newId: this.createBinaryDataId(newFileId),
key,
}));

View File

@ -1,32 +1,40 @@
import { jsonParse } from 'n8n-workflow';
import { jsonParse, UnexpectedError } from 'n8n-workflow';
import { createReadStream } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { Readable } from 'stream';
import { v4 as uuid } from 'uuid';
import type { ErrorReporter } from '@/errors';
import type { BinaryData } from './types';
import { assertDir, doesNotExist } from './utils';
import { assertDir, doesNotExist, FileLocation } from './utils';
import { DisallowedFilepathError } from '../errors/disallowed-filepath.error';
import { FileNotFoundError } from '../errors/file-not-found.error';
const EXECUTION_ID_EXTRACTOR =
/^(\w+)(?:[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})$/;
const EXECUTION_PATH_MATCHER = /^workflows\/([^/]+)\/executions\/([^/]+)\//;
const CHAT_HUB_ATTACHMENT_PATH_MATCHER = /^chat-hub\/sessions\/([^/]+)\/messages\/([^/]+)\//;
export class FileSystemManager implements BinaryData.Manager {
constructor(private storagePath: string) {}
constructor(
private storagePath: string,
private readonly errorReporter: ErrorReporter,
) {}
async init() {
await assertDir(this.storagePath);
}
async store(
workflowId: string,
executionId: string,
location: BinaryData.FileLocation,
bufferOrStream: Buffer | Readable,
{ mimeType, fileName }: BinaryData.PreWriteMetadata,
) {
const fileId = this.toFileId(workflowId, executionId);
const fileId = this.toFileId(location);
const filePath = this.resolvePath(fileId);
await assertDir(path.dirname(filePath));
@ -70,12 +78,14 @@ export class FileSystemManager implements BinaryData.Manager {
return await jsonParse(await fs.readFile(filePath, { encoding: 'utf-8' }));
}
async deleteMany(ids: BinaryData.IdsForDeletion) {
if (ids.length === 0) return;
async deleteMany(locations: BinaryData.FileLocation[]) {
if (locations.length === 0) return;
// binary files stored in single dir - `filesystem`
const executionIds = ids.map((o) => o.executionId);
const executionIds = locations.flatMap((location) =>
location.type === 'execution' ? [location.executionId] : [],
);
const set = new Set(executionIds);
const fileNames = await fs.readdir(this.storagePath);
@ -92,8 +102,8 @@ export class FileSystemManager implements BinaryData.Manager {
// binary files stored in nested dirs - `filesystem-v2`
const binaryDataDirs = ids.map(({ workflowId, executionId }) =>
this.resolvePath(`workflows/${workflowId}/executions/${executionId}`),
const binaryDataDirs = locations.map((location) =>
this.resolvePath(this.toRelativePath(location)),
);
await Promise.all(
@ -104,12 +114,11 @@ export class FileSystemManager implements BinaryData.Manager {
}
async copyByFilePath(
workflowId: string,
executionId: string,
targetLocation: BinaryData.FileLocation,
sourcePath: string,
{ mimeType, fileName }: BinaryData.PreWriteMetadata,
) {
const targetFileId = this.toFileId(workflowId, executionId);
const targetFileId = this.toFileId(targetLocation);
const targetPath = this.resolvePath(targetFileId);
await assertDir(path.dirname(targetPath));
@ -123,8 +132,8 @@ export class FileSystemManager implements BinaryData.Manager {
return { fileId: targetFileId, fileSize };
}
async copyByFileId(workflowId: string, executionId: string, sourceFileId: string) {
const targetFileId = this.toFileId(workflowId, executionId);
async copyByFileId(targetLocation: BinaryData.FileLocation, sourceFileId: string) {
const targetFileId = this.toFileId(targetLocation);
const sourcePath = this.resolvePath(sourceFileId);
const targetPath = this.resolvePath(targetFileId);
const sourceMetadata = await this.getMetadata(sourceFileId);
@ -155,6 +164,21 @@ export class FileSystemManager implements BinaryData.Manager {
await fs.rm(tempDir, { recursive: true });
}
async deleteManyByFileId(ids: string[]): Promise<void> {
const parsedIds = ids.flatMap((id) => {
try {
const parsed = this.parseFileId(id);
return [parsed];
} catch (e) {
this.errorReporter.warn(`Could not parse file ID ${id}. Skip deletion`);
return [];
}
});
await this.deleteMany(parsedIds);
}
// ----------------------------------
// private methods
// ----------------------------------
@ -165,10 +189,35 @@ export class FileSystemManager implements BinaryData.Manager {
* The legacy ID format `{executionId}{uuid}` for `filesystem` mode is
* no longer used on write, only when reading old stored execution data.
*/
private toFileId(workflowId: string, executionId: string) {
if (!executionId) executionId = 'temp'; // missing only in edge case, see PR #7244
private toFileId(location: BinaryData.FileLocation) {
return `${this.toRelativePath(location)}/binary_data/${uuid()}`;
}
return `workflows/${workflowId}/executions/${executionId}/binary_data/${uuid()}`;
private toRelativePath(location: BinaryData.FileLocation) {
switch (location.type) {
case 'execution': {
const executionId = location.executionId || 'temp'; // missing only in edge case, see PR #7244
return `workflows/${location.workflowId}/executions/${executionId}`;
}
case 'chat-hub-message-attachment':
return `chat-hub/sessions/${location.sessionId}/messages/${location.messageId}`;
}
}
private parseFileId(fileId: string): BinaryData.FileLocation {
const executionMatch = fileId.match(EXECUTION_PATH_MATCHER);
if (executionMatch) {
return FileLocation.ofExecution(executionMatch[1], executionMatch[2]);
}
const chatHubMatch = fileId.match(CHAT_HUB_ATTACHMENT_PATH_MATCHER);
if (chatHubMatch) {
return FileLocation.ofChatHubMessageAttachment(chatHubMatch[1], chatHubMatch[2]);
}
throw new UnexpectedError(`File ID ${fileId} has invalid format.`);
}
private resolvePath(...args: string[]) {

View File

@ -2,4 +2,4 @@ export * from './binary-data.service';
export { BinaryDataConfig } from './binary-data.config';
export type * from './types';
export { ObjectStoreService } from './object-store/object-store.service.ee';
export { isStoredMode as isValidNonDefaultMode } from './utils';
export { isStoredMode as isValidNonDefaultMode, FileLocation } from './utils';

View File

@ -16,12 +16,11 @@ export class ObjectStoreManager implements BinaryData.Manager {
}
async store(
workflowId: string,
executionId: string,
location: BinaryData.FileLocation,
bufferOrStream: Buffer | Readable,
metadata: BinaryData.PreWriteMetadata,
) {
const fileId = this.toFileId(workflowId, executionId);
const fileId = this.toFileId(location);
const buffer = await binaryToBuffer(bufferOrStream);
await this.objectStoreService.put(fileId, buffer, metadata);
@ -56,8 +55,8 @@ export class ObjectStoreManager implements BinaryData.Manager {
return metadata;
}
async copyByFileId(workflowId: string, executionId: string, sourceFileId: string) {
const targetFileId = this.toFileId(workflowId, executionId);
async copyByFileId(targetLocation: BinaryData.FileLocation, sourceFileId: string) {
const targetFileId = this.toFileId(targetLocation);
const sourceFile = await this.objectStoreService.get(sourceFileId, { mode: 'buffer' });
@ -70,12 +69,11 @@ export class ObjectStoreManager implements BinaryData.Manager {
* Copy to object store the temp file written by nodes like Webhook, FTP, and SSH.
*/
async copyByFilePath(
workflowId: string,
executionId: string,
targetLocation: BinaryData.FileLocation,
sourcePath: string,
metadata: BinaryData.PreWriteMetadata,
) {
const targetFileId = this.toFileId(workflowId, executionId);
const targetFileId = this.toFileId(targetLocation);
const sourceFile = await fs.readFile(sourcePath);
await this.objectStoreService.put(targetFileId, sourceFile, metadata);
@ -95,9 +93,14 @@ export class ObjectStoreManager implements BinaryData.Manager {
// private methods
// ----------------------------------
private toFileId(workflowId: string, executionId: string) {
if (!executionId) executionId = 'temp'; // missing only in edge case, see PR #7244
return `workflows/${workflowId}/executions/${executionId}/binary_data/${uuid()}`;
private toFileId(location: BinaryData.FileLocation) {
switch (location.type) {
case 'execution': {
const executionId = location.executionId || 'temp'; // missing only in edge case, see PR #7244
return `workflows/${location.workflowId}/executions/${executionId}/binary_data/${uuid()}`;
}
case 'chat-hub-message-attachment':
return `chat-hub/sessions/${location.sessionId}/messages/${location.messageId}/binary_data/${uuid()}`;
}
}
}

View File

@ -32,14 +32,15 @@ export namespace BinaryData {
export type PreWriteMetadata = Omit<Metadata, 'fileSize'>;
export type IdsForDeletion = Array<{ workflowId: string; executionId: string }>;
export type FileLocation =
| { type: 'execution'; workflowId: string; executionId: string }
| { type: 'chat-hub-message-attachment'; sessionId: string; messageId: string };
export interface Manager {
init(): Promise<void>;
store(
workflowId: string,
executionId: string,
location: FileLocation,
bufferOrStream: Buffer | Readable,
metadata: PreWriteMetadata,
): Promise<WriteResult>;
@ -52,12 +53,12 @@ export namespace BinaryData {
/**
* Present for `FileSystem`, absent for `ObjectStore` (delegated to S3 lifecycle config)
*/
deleteMany?(ids: IdsForDeletion): Promise<void>;
deleteMany?(locations: FileLocation[]): Promise<void>;
deleteManyByFileId?(ids: string[]): Promise<void>;
copyByFileId(workflowId: string, executionId: string, sourceFileId: string): Promise<string>;
copyByFileId(targetLocation: FileLocation, sourceFileId: string): Promise<string>;
copyByFilePath(
workflowId: string,
executionId: string,
targetLocation: FileLocation,
sourcePath: string,
metadata: PreWriteMetadata,
): Promise<WriteResult>;

View File

@ -52,3 +52,16 @@ export async function binaryToBuffer(body: Buffer | Readable) {
if (Buffer.isBuffer(body)) return body;
return await streamToBuffer(body);
}
export const FileLocation = {
ofExecution: (workflowId: string, executionId: string): BinaryData.FileLocation => ({
type: 'execution',
workflowId,
executionId,
}),
ofChatHubMessageAttachment: (sessionId: string, messageId: string): BinaryData.FileLocation => ({
type: 'chat-hub-message-attachment',
sessionId,
messageId,
}),
};

View File

@ -286,8 +286,7 @@ export const describeCommonTests = (
expect(result.data).toEqual(data);
expect(binaryDataService.duplicateBinaryData).toHaveBeenCalledWith(
workflow.id,
additionalData.executionId,
{ type: 'execution', workflowId: workflow.id, executionId: additionalData.executionId },
executeWorkflowData.data,
);
});

View File

@ -34,6 +34,7 @@ import {
} from 'n8n-workflow';
import { BinaryDataService } from '@/binary-data/binary-data.service';
import { FileLocation } from '@/binary-data/utils';
import { NodeExecutionContext } from './node-execution-context';
@ -155,8 +156,7 @@ export class BaseExecuteContext extends NodeExecutionContext {
}
const data = await this.binaryDataService.duplicateBinaryData(
this.workflow.id,
this.additionalData.executionId!,
FileLocation.ofExecution(this.workflow.id, this.additionalData.executionId!),
result.data,
);
return { ...result, data };

View File

@ -14,6 +14,7 @@ import { Readable } from 'stream';
import type { BinaryDataConfig } from '@/binary-data';
import { BinaryDataService } from '@/binary-data/binary-data.service';
import type { ErrorReporter } from '@/errors';
import {
assertBinaryData,
@ -44,11 +45,12 @@ describe('test binary data helper methods', () => {
availableModes: ['default', 'filesystem'],
localStoragePath: temporaryDir,
});
const errorReporter = mock<ErrorReporter>();
let binaryDataService: BinaryDataService;
beforeEach(() => {
jest.resetAllMocks();
binaryDataService = new BinaryDataService(binaryDataConfig);
binaryDataService = new BinaryDataService(binaryDataConfig, errorReporter);
Container.set(BinaryDataService, binaryDataService);
});
@ -592,8 +594,7 @@ describe('copyBinaryFile', () => {
expect(result.fileName).toBe(fileName);
expect(binaryDataService.copyBinaryFile).toHaveBeenCalledWith(
workflowId,
executionId,
{ type: 'execution', workflowId, executionId },
{
...binaryData,
fileExtension: 'txt',
@ -614,8 +615,7 @@ describe('copyBinaryFile', () => {
expect(result.fileName).toBe(fileName);
expect(binaryDataService.copyBinaryFile).toHaveBeenCalledWith(
workflowId,
executionId,
{ type: 'execution', workflowId, executionId },
{
...binaryData,
fileExtension: 'bin',
@ -635,7 +635,7 @@ describe('prepareBinaryData', () => {
jest.resetAllMocks();
Container.set(BinaryDataService, binaryDataService);
binaryDataService.store.mockImplementation(async (_w, _e, _b, binaryData) => binaryData);
binaryDataService.store.mockImplementation(async (_l, _b, binaryData) => binaryData);
});
it('parses filenames correctly', async () => {
@ -644,13 +644,17 @@ describe('prepareBinaryData', () => {
const result = await prepareBinaryData(buffer, executionId, workflowId, fileName);
expect(result.fileName).toEqual(fileName);
expect(binaryDataService.store).toHaveBeenCalledWith(workflowId, executionId, buffer, {
data: '',
fileExtension: undefined,
fileName,
fileType: 'text',
mimeType: 'text/plain',
});
expect(binaryDataService.store).toHaveBeenCalledWith(
{ type: 'execution', executionId, workflowId },
buffer,
{
data: '',
fileExtension: undefined,
fileName,
fileType: 'text',
mimeType: 'text/plain',
},
);
});
it('handles IncomingMessage with responseUrl', async () => {

View File

@ -159,8 +159,7 @@ export async function setBinaryDataBuffer(
executionId: string,
): Promise<IBinaryData> {
return await Container.get(BinaryDataService).store(
workflowId,
executionId,
{ type: 'execution', workflowId, executionId },
bufferOrStream,
binaryData,
);
@ -218,8 +217,7 @@ export async function copyBinaryFile(
}
return await Container.get(BinaryDataService).copyBinaryFile(
workflowId,
executionId,
{ type: 'execution', workflowId, executionId },
returnData,
filePath,
);

View File

@ -11,6 +11,7 @@ const props = defineProps<{
file: File;
isRemovable: boolean;
isPreviewable?: boolean;
href?: string;
}>();
const emit = defineEmits<{
@ -30,6 +31,11 @@ const TypeIcon = computed(() => {
});
function onClick() {
if (props.href) {
window.open(props.href, '_blank', 'noopener noreferrer');
return;
}
if (props.isPreviewable) {
window.open(URL.createObjectURL(props.file));
}
@ -41,12 +47,12 @@ function onDelete() {
<template>
<div class="chat-file" @click="onClick">
<TypeIcon />
<TypeIcon class="chat-icon" />
<p class="chat-file-name">{{ file.name }}</p>
<span v-if="isRemovable" class="chat-file-delete" @click.stop="onDelete">
<IconDelete />
</span>
<IconPreview v-else-if="isPreviewable" class="chat-file-preview" />
<IconPreview v-else-if="isPreviewable || href" class="chat-file-preview" />
</div>
</template>
@ -64,7 +70,14 @@ function onDelete() {
background: white;
color: var(--chat--color-dark);
border: 1px solid var(--chat--color-dark);
cursor: pointer;
&:has(.chat-file-preview) {
cursor: pointer;
}
}
.chat-icon {
flex-shrink: 0;
}
.chat-file-name-tooltip {

View File

@ -188,6 +188,7 @@ import IconLucideSmile from '~icons/lucide/smile';
import IconLucideSparkles from '~icons/lucide/sparkles';
import IconLucideSquare from '~icons/lucide/square';
import IconLucideSquareCheck from '~icons/lucide/square-check';
import IconLucideSquareMinus from '~icons/lucide/square-minus';
import IconLucideSquarePen from '~icons/lucide/square-pen';
import IconLucideSquarePlus from '~icons/lucide/square-plus';
import IconLucideStickyNote from '~icons/lucide/sticky-note';
@ -626,6 +627,7 @@ export const updatedIconSet = {
sparkles: IconLucideSparkles,
square: IconLucideSquare,
'square-check': IconLucideSquareCheck,
'square-minus': IconLucideSquareMinus,
'square-pen': IconLucideSquarePen,
'square-plus': IconLucideSquarePlus,
'sticky-note': IconLucideStickyNote,

View File

@ -42,7 +42,14 @@ const { t } = useI18n();
<div>
<div :class="$style.details">
<span :class="$style.name" data-test-id="node-creator-item-name" v-text="title" />
<ElTag v-if="tag" :class="$style.tag" size="small" round :type="tag.type ?? 'success'">
<ElTag
v-if="tag"
:class="$style.tag"
disable-transitions
size="small"
round
:type="tag.type ?? 'success'"
>
{{ tag.text }}
</ElTag>
<N8nIcon

View File

@ -423,6 +423,7 @@ defineExpose({
<N8nTooltip
:content="creditsTooltipContent"
:popper-class="$style.infoPopper"
:show-after="300"
placement="top"
>
<N8nIcon icon="info" size="small" />
@ -432,6 +433,8 @@ defineExpose({
:disabled="!showAskOwnerTooltip"
:content="t('promptInput.askAdminToUpgrade')"
placement="top"
:show-after="300"
:enterable="false"
>
<N8nLink size="small" theme="text" @click="() => emit('upgrade-click')">
{{ t('promptInput.getMore') }}

View File

@ -86,7 +86,7 @@ export default {
'promptInput.askAdminToUpgrade': 'Ask your admin to upgrade the instance to get more credits',
'promptInput.characterLimitReached': "You've reached the {limit} character limit",
'promptInput.remainingCredits': 'Remaining builder AI credits: <b>{count}</b>',
'promptInput.monthlyCredits': 'Monthly credits: <b>{count}</b>',
'promptInput.monthlyCredits': 'Monthly credits: <b>{count}</b> (1 credit = 1 message)',
'promptInput.creditsRenew': 'Credits renew on: <b>{date}</b>',
'promptInput.creditsExpire': 'Unused credits expire {date}',
} as N8nLocale;

View File

@ -0,0 +1,220 @@
# Component specification
Displays short text or icons to represent status, categories, or counts. Badges help users quickly identify important information through visual indicators without cluttering the interface.
- **Component Name:** N8nBadge
- **Figma Component:** TBD
- **Current Implementation:** Custom component (no Element+ dependency)
- **Reka UI Component:** N/A (Reka UI does not provide a Badge primitive)
- **Nuxt UI Reference:** [Badge](https://ui.nuxt.com/docs/components/badge) (custom implementation, not based on Reka UI)
## Public API Definition
**Props**
- `theme?: BadgeTheme` - Visual style variant for the badge. Values: `'default' | 'success' | 'warning' | 'danger' | 'primary' | 'secondary' | 'tertiary'`. Default: `'default'`
- `default`: Subtle border badge with neutral styling
- `success`: Green indicator for positive states
- `warning`: Yellow/orange indicator for cautionary states
- `danger`: Red indicator for error/critical states
- `primary`: Filled badge with contrasting text (pill-shaped)
- `secondary`: Filled badge with tinted background
- `tertiary`: Minimal badge variant with reduced padding
- `size?: TextSize` - Size of the badge text. Values: `'xsmall' | 'small' | 'mini' | 'medium' | 'large' | 'xlarge'`. Default: `'small'`
- `bold?: boolean` - Whether to render text in bold weight. Default: `false`
- `showBorder?: boolean` - Whether to show the badge border. Default: `true`
**Slots**
- `default` - Badge content (text, icons, or mixed content). Wrapped in N8nText component for consistent typography.
### Template usage examples
**Simple text badge:**
```typescript
<script setup lang="ts">
import { N8nBadge } from '@n8n/design-system'
</script>
<template>
<N8nBadge theme="default">
Read Only
</N8nBadge>
</template>
```
**Status indicators:**
```typescript
<script setup lang="ts">
import { N8nBadge } from '@n8n/design-system'
import { computed } from 'vue'
const status = ref<'success' | 'warning' | 'danger'>('success')
const statusTheme = computed(() => {
const themeMap = {
success: 'success',
warning: 'warning',
danger: 'danger',
}
return themeMap[status.value]
})
</script>
<template>
<N8nBadge :theme="statusTheme">
{{ status }}
</N8nBadge>
</template>
```
**Count badge (primary theme):**
```typescript
<script setup lang="ts">
import { N8nBadge } from '@n8n/design-system'
const filterCount = ref(5)
</script>
<template>
<N8nBadge theme="primary">
{{ filterCount }}
</N8nBadge>
</template>
```
**Tertiary minimal label:**
```typescript
<script setup lang="ts">
import { N8nBadge } from '@n8n/design-system'
</script>
<template>
<N8nBadge theme="tertiary" :bold="true">
Pending
</N8nBadge>
</template>
```
**Badge with icon and text:**
```typescript
<script setup lang="ts">
import { N8nBadge } from '@n8n/design-system'
import ProjectIcon from './ProjectIcon.vue'
const projectBadge = {
icon: 'project',
text: 'Team Project'
}
</script>
<template>
<N8nBadge
theme="tertiary"
:show-border="false"
>
<ProjectIcon :icon="projectBadge.icon" size="mini" />
<span>{{ projectBadge.text }}</span>
</N8nBadge>
</template>
```
**Multiple sizes:**
```typescript
<script setup lang="ts">
import { N8nBadge } from '@n8n/design-system'
</script>
<template>
<N8nBadge theme="primary" size="xsmall">XS</N8nBadge>
<N8nBadge theme="primary" size="small">Small</N8nBadge>
<N8nBadge theme="primary" size="medium">Medium</N8nBadge>
</template>
```
**With custom CSS classes:**
```typescript
<script setup lang="ts">
import { N8nBadge } from '@n8n/design-system'
</script>
<template>
<N8nBadge class="ml-3xs" theme="warning" :bold="true">
Action Required
</N8nBadge>
</template>
```
**Conditional rendering:**
```typescript
<script setup lang="ts">
import { N8nBadge } from '@n8n/design-system'
const isReadOnly = ref(true)
const needsSetup = ref(false)
</script>
<template>
<N8nBadge v-if="isReadOnly" theme="tertiary" :bold="true">
Read Only
</N8nBadge>
<N8nBadge v-if="needsSetup" theme="warning">
Setup Required
</N8nBadge>
</template>
```
**With inline styles (advanced):**
```typescript
<script setup lang="ts">
import { N8nBadge } from '@n8n/design-system'
</script>
<template>
<N8nBadge
theme="warning"
style="height: 25px"
>
Custom Height
</N8nBadge>
</template>
```
**Dynamic theme binding:**
```typescript
<script setup lang="ts">
import { N8nBadge } from '@n8n/design-system'
import type { BadgeTheme } from '@n8n/design-system/types'
interface FileStatus {
status: 'completed' | 'pending' | 'failed'
}
const file = ref<FileStatus>({ status: 'completed' })
const getStatusTheme = (status: string): BadgeTheme => {
const themeMap: Record<string, BadgeTheme> = {
completed: 'success',
pending: 'warning',
failed: 'danger',
}
return themeMap[status] || 'default'
}
const statusLabel = (status: string) => {
const labelMap: Record<string, string> = {
completed: 'Completed',
pending: 'Pending',
failed: 'Failed',
}
return labelMap[status] || status
}
</script>
<template>
<N8nBadge :theme="getStatusTheme(file.status)">
{{ statusLabel(file.status) }}
</N8nBadge>
</template>
```

View File

@ -0,0 +1,148 @@
# Component specification
Displays contextual information when users hover over, focus on, or tap an element. Tooltips help users understand the purpose or state of UI elements without cluttering the interface.
- **Component Name:** N8nTooltip
- **Figma Component:** [Figma](https://www.figma.com/design/8zib7Trf2D2CHYXrEGPHkg/n8n-Design-System-V3?node-id=252-3284&m=dev)
- **Element+ Component:** [ElTooltip](https://element-plus.org/en-US/component/tooltip.html)
- **Reka UI Component:** [Tooltip](https://reka-ui.com/docs/components/tooltip)
- **Nuxt UI Component:** [Tooltip](https://ui.nuxt.com/docs/components/tooltip)
## Public API Definition
**Props**
- `content?: string` - Text content for the tooltip. Supports HTML (sanitized for security).
- `placement?: Placement` - Position of tooltip relative to trigger. Values: `'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end' | 'right' | 'right-start' | 'right-end'`. Default: `'top'`
- `disabled?: boolean` - When `true`, prevents the tooltip from showing.
- `showAfter?: number` - Delay in milliseconds before showing tooltip after trigger is hovered. Default: `0`
- `visible?: boolean` - Manual control of tooltip visibility (programmatic show/hide).
- `popperClass?: string` - Custom CSS class name for the tooltip popper element.
- `enterable?: boolean` - Whether the mouse can enter the tooltip content area. Default: `true`
- `popperOptions?: object` - Popper.js configuration object for advanced positioning control.
- `teleported?: boolean` - Whether to append the tooltip to the body. Default: `true`
- `offset?: number` - Offset of the tooltip from the trigger element (in pixels).
- `showArrow?: boolean` - Whether to show the tooltip arrow. Default: `true`
**Slots**
- `default` - The trigger element that activates the tooltip (required).
- `content` - Custom content for the tooltip body. When not provided, renders `content` prop with HTML sanitization.
### Template usage example
**Simple text tooltip:**
```typescript
<script setup lang="ts">
import { N8nTooltip, N8nIcon } from '@n8n/design-system'
</script>
<template>
<N8nTooltip content="This is helpful information" placement="top">
<N8nIcon icon="info" />
</N8nTooltip>
</template>
```
**Custom content slot:**
```typescript
<script setup lang="ts">
import { N8nTooltip, N8nButton } from '@n8n/design-system'
</script>
<template>
<N8nTooltip placement="right">
<template #content>
<div class="custom-tooltip-content">
<strong>Advanced Feature</strong>
<p>This feature requires a pro plan.</p>
</div>
</template>
<N8nButton label="Hover for details" />
</N8nTooltip>
</template>
```
**Delayed tooltip:**
```typescript
<script setup lang="ts">
import { N8nTooltip } from '@n8n/design-system'
</script>
<template>
<N8nTooltip
content="Appears after 500ms"
:show-after="500"
placement="top"
>
<span>Hover and wait...</span>
</N8nTooltip>
</template>
```
**Programmatically controlled visibility:**
```typescript
<script setup lang="ts">
import { ref } from 'vue'
import { N8nTooltip } from '@n8n/design-system'
const isVisible = ref(false)
const showTooltip = () => {
isVisible.value = true
setTimeout(() => {
isVisible.value = false
}, 2000)
}
</script>
<template>
<N8nTooltip
:visible="isVisible"
content="This tooltip is programmatically controlled"
placement="top"
>
<button @click="showTooltip">
Click to show tooltip for 2 seconds
</button>
</N8nTooltip>
</template>
```
**Custom popper class:**
```typescript
<script setup lang="ts">
import { N8nTooltip } from '@n8n/design-system'
</script>
<template>
<N8nTooltip
content="Styled tooltip"
popper-class="custom-tooltip-class"
placement="top"
>
<span>Hover for custom styled tooltip</span>
</N8nTooltip>
</template>
```
**Disabled tooltip:**
```typescript
<script setup lang="ts">
import { ref } from 'vue'
import { N8nTooltip } from '@n8n/design-system'
const showTooltip = ref(false)
</script>
<template>
<N8nTooltip
:disabled="!showTooltip"
content="Conditionally shown"
placement="top"
>
<span>{{ showTooltip ? 'Tooltip enabled' : 'Tooltip disabled' }}</span>
</N8nTooltip>
</template>
```

View File

@ -62,6 +62,7 @@
"generic.folder": "Folder",
"generic.keepBuilding": "Keep building",
"generic.learnMore": "Learn more",
"generic.recommended": "Recommended",
"generic.reset": "Reset",
"generic.resetAllFilters": "Reset all filters",
"generic.communityNode": "Community Node",
@ -1486,7 +1487,6 @@
"nodeCreator.triggerHelperPanel.manualTriggerDescription": "Runs the flow on clicking a button in n8n. Good for getting started quickly",
"nodeCreator.triggerHelperPanel.manualChatTriggerDisplayName": "On chat message",
"nodeCreator.triggerHelperPanel.manualChatTriggerDescription": "Runs the flow when a user sends a chat message. For use with AI nodes",
"nodeCreator.triggerHelperPanel.manualTriggerTag": "Recommended",
"nodeCreator.triggerHelperPanel.chatTriggerDisplayName": "On chat message",
"nodeCreator.triggerHelperPanel.chatTriggerDescription": "Runs the flow when a user sends a chat message. For use with AI nodes",
"nodeCreator.triggerHelperPanel.whatHappensNext": "What happens next?",
@ -2509,12 +2509,24 @@
"settings.provisioning.scopesProjectsRolesClaimName.help": "The claim name used to provision projects and their roles from Oauth. For SAML / LDAP, this will be the attribute name checked.",
"settings.provisioning.toggle": "Provision instance and project roles",
"settings.provisioning.toggle.help": "Project access can only be defined on external provider. Any existing project access configured in n8n, but not on the provider, will be removed once a user logs in.",
"settings.provisioningConfirmDialog.title": "Enable Just-in-time provisioning (JIT)",
"settings.provisioningConfirmDialog.enable.title": "Enable user role provisioning",
"settings.provisioningConfirmDialog.disable.title": "Disable user role provisioning",
"settings.provisioningConfirmDialog.breakingChangeDescription.firstLine": "When you enable Just-in-time provisioning, your external SSO provider becomes the source of truth for all instance and project roles in n8n.",
"settings.provisioningConfirmDialog.breakingChangeDescription.list.one": "If your SSO provider doesn't specify a role for a member, we'll automatically assign the default role: global:member.",
"settings.provisioningConfirmDialog.breakingChangeDescription.list.two": "Any existing instance and project roles in n8n will be replaced by the roles defined in your SSO provider once the user logs in via SSO.",
"settings.provisioningConfirmDialog.breakingChangeRequiredSteps": "To enable you to migrate your current access settings to your SSO provider, download the two CSV files below. This step is mandatory before enabling JIT.",
"settings.provisioningConfirmDialog.button.confirm": "Activate JIT",
"settings.provisioningConfirmDialog.disable.description": "You're switching instance role management back to n8n.",
"settings.provisioningConfirmDialog.disable.whatWillHappen": "What will happen:",
"settings.provisioningConfirmDialog.disable.list.one": "The SSO n8n_instance_role attribute will be ignored.",
"settings.provisioningConfirmDialog.disable.list.two": "Instance roles must be reassigned manually inside n8n.",
"settings.provisioningConfirmDialog.disable.beforeSaving": "Before saving, make sure:",
"settings.provisioningConfirmDialog.disable.checklist.one": "You are ready to reassign instance roles for all users inside n8n.",
"settings.provisioningConfirmDialog.disable.checklist.two": "You understand that role changes made in SSO will no longer be applied.",
"settings.provisioningConfirmDialog.enable.checkbox": "I have downloaded and reviewed the CSV export. My SSO provider is correctly configured to become the source of truth for user role provisioning on this n8n instance.",
"settings.provisioningConfirmDialog.disable.checkbox": "I confirm that I want to no longer provision user roles from my SSO provider.",
"settings.provisioningConfirmDialog.link.docs": "Link to docs",
"settings.provisioningConfirmDialog.button.enable.confirm": "Save and enable",
"settings.provisioningConfirmDialog.button.disable.confirm": "Save and disable",
"settings.provisioningConfirmDialog.button.cancel": "Cancel",
"settings.provisioningConfirmDialog.button.generateCsvExport": "Generate access settings CSV export",
"settings.provisioningConfirmDialog.button.downloadProjectRolesCsv": "Download existing project access settings csv",
@ -3442,6 +3454,15 @@
"settings.sso.settings.oidc.prompt.consent": "Consent (Ask the user to consent)",
"settings.sso.settings.oidc.prompt.select_account": "Select Account (Allow the user to select an account)",
"settings.sso.settings.oidc.prompt.create": "Create (Ask the OP to show the registration page first)",
"settings.sso.settings.userRoleProvisioning.label": "User role provisioning",
"settings.sso.settings.userRoleProvisioning.help": "Manage instance and project roles from your SSO provider.",
"settings.sso.settings.userRoleProvisioning.help.linkText": "Link to docs",
"settings.sso.settings.userRoleProvisioning.option.disabled.label": "Disabled",
"settings.sso.settings.userRoleProvisioning.option.disabled.description": "User and project roles are managed inside the n8n settings.",
"settings.sso.settings.userRoleProvisioning.option.instanceRole.label": "Instance role",
"settings.sso.settings.userRoleProvisioning.option.instanceRole.description": "The instance role of a user is configured in the \"n8n_instance_role\" attribute on your SSO provider. If none is set on the SSO provider, the member role is used as fallback.",
"settings.sso.settings.userRoleProvisioning.option.instanceAndProjectRoles.label": "Instance and project roles",
"settings.sso.settings.userRoleProvisioning.option.instanceAndProjectRoles.description": "The list of projects a user has access to is configured on the \"n8n_projects\" string array attribute on your SSO provider. Project access cannot be granted from within n8n.",
"settings.sso.settings.test": "Test settings",
"settings.sso.settings.save": "Save settings",
"settings.sso.settings.save.activate.title": "Test and activate SAML SSO",

View File

@ -444,7 +444,7 @@ export type SimplifiedNodeType = Pick<
| 'defaults'
| 'outputs'
> & {
tag?: string;
tag?: NodeCreatorTag;
};
export interface SubcategoryItemProps {
description?: string;

View File

@ -3,9 +3,9 @@ import AssistantsHub from '@/features/ai/assistant/components/AssistantsHub.vue'
import AskAssistantFloatingButton from '@/features/ai/assistant/components/Chat/AskAssistantFloatingButton.vue';
import BannerStack from '@/features/shared/banners/components/BannerStack.vue';
import Modals from '@/app/components/Modals.vue';
import Telemetry from '@/app/components/Telemetry.vue';
import { useHistoryHelper } from '@/app/composables/useHistoryHelper';
import { useTelemetryContext } from '@/app/composables/useTelemetryContext';
import { useTelemetryInitializer } from '@/app/composables/useTelemetryInitializer';
import { useWorkflowDiffRouting } from '@/app/composables/useWorkflowDiffRouting';
import {
APP_MODALS_ELEMENT_ID,
@ -63,6 +63,8 @@ useHistoryHelper(route);
// Initialize workflow diff routing management
useWorkflowDiffRouting();
useTelemetryInitializer();
const loading = ref(true);
const defaultLocale = computed(() => rootStore.defaultLocale);
const isDemoMode = computed(() => route.name === VIEWS.DEMO);
@ -181,7 +183,6 @@ useExposeCssVar('--ask-assistant--floating-button--margin-bottom', askAiFloating
@input-change="onCommandBarChange"
@navigate-to="onCommandBarNavigateTo"
/>
<Telemetry />
<AskAssistantFloatingButton v-if="assistantStore.isFloatingButtonShown" />
</div>
<AssistantsHub />

View File

@ -47,7 +47,7 @@ import type { FolderShortInfo } from '@/features/core/folders/folders.types';
import { useFoldersStore } from '@/features/core/folders/folders.store';
import { useNpsSurveyStore } from '@/app/stores/npsSurvey.store';
import { ProjectTypes } from '@/features/collaboration/projects/projects.types';
import { sanitizeFilename } from '@/app/utils/fileUtils';
import { sanitizeFilename } from '@n8n/utils/files/sanitize';
import { hasPermission } from '@/app/utils/rbac/permissions';
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import { type BaseTextKey, useI18n } from '@n8n/i18n';

View File

@ -1,7 +1,6 @@
<script lang="ts" setup>
import { useUserHelpers } from '@/app/composables/useUserHelpers';
import { ABOUT_MODAL_KEY, SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT, VIEWS } from '@/app/constants';
import { usePostHog } from '@/app/stores/posthog.store';
import { ABOUT_MODAL_KEY, VIEWS } from '@/app/constants';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUIStore } from '@/app/stores/ui.store';
import { hasPermission } from '@/app/utils/rbac/permissions';
@ -22,7 +21,6 @@ const { canUserAccessRouteByName } = useUserHelpers(router);
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const posthogStore = usePostHog();
const uiStore = useUIStore();
const sidebarMenuItems = computed<IMenuItem[]>(() => {
@ -99,17 +97,6 @@ const sidebarMenuItems = computed<IMenuItem[]>(() => {
available: canUserAccessRouteByName(VIEWS.LDAP_SETTINGS),
route: { to: { name: VIEWS.LDAP_SETTINGS } },
},
{
id: 'settings-provisioning',
icon: 'toolbox',
label: i18n.baseText('settings.provisioning.title'),
position: 'top',
available:
canUserAccessRouteByName(VIEWS.PROVISIONING_SETTINGS) &&
// TODO: comment this back one once posthog experiment is done: settingsStore.isEnterpriseFeatureEnabled.provisioning,
posthogStore.isFeatureEnabled(SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT.name),
route: { to: { name: VIEWS.PROVISIONING_SETTINGS } },
},
{
id: 'settings-workersview',
icon: 'waypoints',

View File

@ -1,127 +0,0 @@
import { useRoute } from 'vue-router';
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import type { MockedStore } from '@/__tests__/utils';
import { mockedStore } from '@/__tests__/utils';
import Telemetry from './Telemetry.vue';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useTelemetry } from '@/app/composables/useTelemetry';
vi.mock('vue-router', () => {
const meta = {};
return {
useRouter: vi.fn(),
useRoute: () => ({
meta,
}),
RouterLink: {
template: '<a><slot /></a>',
},
};
});
vi.mock('@/app/composables/useTelemetry', () => {
const init = vi.fn();
return {
useTelemetry: () => ({
init,
}),
};
});
const renderComponent = createComponentRenderer(Telemetry, {
pinia: createTestingPinia(),
});
let route: ReturnType<typeof useRoute>;
let rootStore: MockedStore<typeof useRootStore>;
let settingsStore: MockedStore<typeof useSettingsStore>;
let usersStore: MockedStore<typeof useUsersStore>;
let telemetryPlugin: ReturnType<typeof useTelemetry>;
describe('Telemetry', () => {
beforeEach(() => {
vi.clearAllMocks();
route = useRoute();
rootStore = mockedStore(useRootStore);
settingsStore = mockedStore(useSettingsStore);
usersStore = mockedStore(useUsersStore);
telemetryPlugin = useTelemetry();
});
it('should not throw error when opened', async () => {
expect(() => renderComponent()).not.toThrow();
});
it('should initialize if telemetry is enabled in settings and not disabled on the route', async () => {
settingsStore.telemetry = {
enabled: true,
};
usersStore.currentUserId = '123';
rootStore.instanceId = '456';
renderComponent();
expect(telemetryPlugin.init).toHaveBeenCalledWith(
{
enabled: true,
},
expect.objectContaining({
userId: '123',
instanceId: '456',
}),
);
});
it('should not initialize if telemetry is disabled in settings', async () => {
settingsStore.telemetry = {
enabled: false,
};
renderComponent();
expect(telemetryPlugin.init).not.toHaveBeenCalled();
});
it('should not initialize if telemetry is disabled on the route', async () => {
settingsStore.telemetry = {
enabled: true,
};
route.meta.telemetry = {
disabled: true,
};
renderComponent();
expect(telemetryPlugin.init).not.toHaveBeenCalled();
});
it('should render the iframe with correct src', async () => {
settingsStore.telemetry = {
enabled: true,
};
usersStore.currentUserId = '123';
rootStore.instanceId = '456';
const { container } = renderComponent();
const iframe = container.querySelector('iframe');
expect(iframe).toBeInTheDocument();
expect(iframe).not.toBeVisible();
expect(iframe).toHaveAttribute('src', expect.stringContaining('userId=123'));
expect(iframe).toHaveAttribute('src', expect.stringContaining('instanceId=456'));
});
it('should not render the iframe if telemetry disabled', async () => {
settingsStore.telemetry = {
enabled: false,
};
usersStore.currentUserId = '123';
rootStore.instanceId = '456';
const { container } = renderComponent();
const iframe = container.querySelector('iframe');
expect(iframe).not.toBeInTheDocument();
});
});

View File

@ -1,73 +0,0 @@
<script lang="ts" setup>
import type { ITelemetrySettings } from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { computed, onMounted, watch, ref } from 'vue';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useRoute } from 'vue-router';
const isTelemetryInitialized = ref(false);
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const usersStore = useUsersStore();
const projectsStore = useProjectsStore();
const telemetryPlugin = useTelemetry();
const route = useRoute();
const currentUserId = computed((): string => {
return usersStore.currentUserId ?? '';
});
const isTelemetryEnabledOnRoute = computed((): boolean => {
const routeMeta = route.meta as { telemetry?: { disabled?: boolean } } | undefined;
return routeMeta?.telemetry ? !routeMeta.telemetry.disabled : true;
});
const telemetry = computed((): ITelemetrySettings => {
return settingsStore.telemetry;
});
const isTelemetryEnabled = computed((): boolean => {
return !!telemetry.value?.enabled;
});
const selfInstallSrc = computed((): string => {
return `https://n8n.io/self-install?instanceId=${rootStore.instanceId}&userId=${currentUserId.value}`;
});
watch(telemetry, () => {
init();
});
watch(isTelemetryEnabledOnRoute, (enabled) => {
if (enabled) {
init();
}
});
onMounted(() => {
init();
});
function init() {
if (isTelemetryInitialized.value || !isTelemetryEnabledOnRoute.value || !isTelemetryEnabled.value)
return;
telemetryPlugin.init(telemetry.value, {
instanceId: rootStore.instanceId,
userId: currentUserId.value,
projectId: projectsStore.personalProject?.id,
versionCli: rootStore.versionCli,
});
isTelemetryInitialized.value = true;
}
</script>
<template>
<iframe v-if="isTelemetryEnabled && currentUserId" v-show="false" :src="selfInstallSrc" />
<span v-else v-show="false" />
</template>

View File

@ -319,7 +319,7 @@ const icons = {
object: DATA_TYPE_ICON_MAP.object,
array: DATA_TYPE_ICON_MAP.array,
['string']: DATA_TYPE_ICON_MAP.string,
null: 'case-upper',
null: 'square-minus',
['number']: DATA_TYPE_ICON_MAP.number,
['boolean']: DATA_TYPE_ICON_MAP.boolean,
function: 'code',

View File

@ -0,0 +1,140 @@
import { createTestingPinia } from '@pinia/testing';
import { mount } from '@vue/test-utils';
import { defineComponent, h, nextTick } from 'vue';
import { mock } from 'vitest-mock-extended';
import { useTelemetryInitializer } from './useTelemetryInitializer';
import { useTelemetry } from '@/app/composables/useTelemetry';
import type { Project } from '@/features/collaboration/projects/projects.types';
const mockRouteMeta: Record<string, unknown> = {};
vi.mock('vue-router', () => ({
useRouter: vi.fn(),
useRoute: () => ({
meta: mockRouteMeta,
}),
RouterLink: {
template: '<a><slot /></a>',
},
}));
vi.mock('@/app/composables/useTelemetry', () => {
const init = vi.fn();
return {
useTelemetry: () => ({
init,
}),
};
});
const TestComponent = defineComponent({
setup() {
useTelemetryInitializer();
return () => h('div');
},
});
describe('useTelemetryInitializer', () => {
beforeEach(() => {
vi.clearAllMocks();
// Clear route meta
Object.keys(mockRouteMeta).forEach((key) => delete mockRouteMeta[key]);
});
it('should not throw error when called', () => {
expect(() =>
mount(TestComponent, { global: { plugins: [createTestingPinia()] } }),
).not.toThrow();
});
it('should initialize if telemetry is enabled in settings and not disabled on the route', async () => {
const pinia = createTestingPinia({
stubActions: false,
initialState: {
users: {
currentUserId: '123',
},
projects: {
personalProject: mock<Project>({ id: '789' }),
},
settings: {
settings: {
telemetry: {
enabled: true,
},
},
},
},
});
const telemetryPlugin = useTelemetry();
const wrapper = mount(TestComponent, { global: { plugins: [pinia] } });
await nextTick();
expect(telemetryPlugin.init).toHaveBeenCalledWith(
{
enabled: true,
},
expect.objectContaining({
userId: '123',
projectId: '789',
}),
);
wrapper.unmount();
});
it('should not initialize if telemetry is disabled in settings', async () => {
const pinia = createTestingPinia({
stubActions: false,
initialState: {
settings: {
settings: {
telemetry: {
enabled: false,
},
},
},
},
});
const telemetryPlugin = useTelemetry();
const wrapper = mount(TestComponent, { global: { plugins: [pinia] } });
await nextTick();
expect(telemetryPlugin.init).not.toHaveBeenCalled();
wrapper.unmount();
});
it('should not initialize if telemetry is disabled on the route', async () => {
// Set route meta before mounting
mockRouteMeta.telemetry = {
disabled: true,
};
const pinia = createTestingPinia({
stubActions: false,
initialState: {
settings: {
settings: {
telemetry: {
enabled: true,
},
},
},
},
});
const telemetryPlugin = useTelemetry();
const wrapper = mount(TestComponent, { global: { plugins: [pinia] } });
await nextTick();
expect(telemetryPlugin.init).not.toHaveBeenCalled();
wrapper.unmount();
});
});

View File

@ -0,0 +1,71 @@
import type { ITelemetrySettings } from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { computed, onMounted, watch, ref } from 'vue';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useRoute } from 'vue-router';
/**
* Initializes the telemetry for the application
*/
export function useTelemetryInitializer() {
const isTelemetryInitialized = ref(false);
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const usersStore = useUsersStore();
const projectsStore = useProjectsStore();
const telemetryPlugin = useTelemetry();
const route = useRoute();
const currentUserId = computed((): string => {
return usersStore.currentUserId ?? '';
});
const isTelemetryEnabledOnRoute = computed((): boolean => {
const routeMeta = route.meta as { telemetry?: { disabled?: boolean } } | undefined;
return routeMeta?.telemetry ? !routeMeta.telemetry.disabled : true;
});
const telemetry = computed((): ITelemetrySettings => {
return settingsStore.telemetry;
});
const isTelemetryEnabled = computed((): boolean => {
return !!telemetry.value?.enabled;
});
function init() {
if (
isTelemetryInitialized.value ||
!isTelemetryEnabledOnRoute.value ||
!isTelemetryEnabled.value
)
return;
telemetryPlugin.init(telemetry.value, {
instanceId: rootStore.instanceId,
userId: currentUserId.value,
projectId: projectsStore.personalProject?.id,
versionCli: rootStore.versionCli,
});
isTelemetryInitialized.value = true;
}
watch(telemetry, () => {
init();
});
watch(isTelemetryEnabledOnRoute, (enabled) => {
if (enabled) {
init();
}
});
onMounted(() => {
init();
});
}

View File

@ -36,7 +36,6 @@ export const enum VIEWS {
LOG_STREAMING_SETTINGS = 'LogStreamingSettingsView',
SSO_SETTINGS = 'SSoSettings',
EXTERNAL_SECRETS_SETTINGS = 'ExternalSecretsSettings',
PROVISIONING_SETTINGS = 'ProvisioningSettings',
SAML_ONBOARDING = 'SamlOnboarding',
SOURCE_CONTROL = 'SourceControl',
MFA_VIEW = 'MfaView',

View File

@ -1,4 +1,5 @@
import type { NodeCreatorOpenSource } from '@/Interface';
import { DATA_TABLE_NODE_TYPE, DATA_TABLE_TOOL_NODE_TYPE } from './nodeTypes';
export const TEMPLATE_CATEGORY_AI = 'categories/ai';
@ -57,3 +58,6 @@ export const AI_CODE_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolCo
export const AI_WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolWorkflow';
export const REQUEST_NODE_FORM_URL = 'https://n8n-community.typeform.com/to/K1fBVTZ3';
export const PRE_BUILT_AGENTS_COLLECTION = 'pre-built-agents-collection';
export const RECOMMENDED_NODES: string[] = [DATA_TABLE_NODE_TYPE, DATA_TABLE_TOOL_NODE_TYPE];
export const BETA_NODES: string[] = [];

View File

@ -1,10 +1,9 @@
import { createPinia, setActivePinia } from 'pinia';
import { createComponentRenderer } from '@/__tests__/render';
import router from '@/app/router';
import { SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT, VIEWS } from '@/app/constants';
import { VIEWS } from '@/app/constants';
import { setupServer } from '@/__tests__/server';
import { useSettingsStore } from '@/app/stores/settings.store';
import { usePostHog } from '@/app/stores/posthog.store';
import { useRBACStore } from '@/app/stores/rbac.store';
import type { Scope } from '@n8n/permissions';
import type { RouteRecordName } from 'vue-router';
@ -136,39 +135,6 @@ describe('router', () => {
10000,
);
// TODO: move these tests cases to the test.each above once experiment is over.
test.each<[string, RouteRecordName, Scope[]]>([
['/settings/provisioning', VIEWS.WORKFLOWS, []],
['/settings/provisioning', VIEWS.PROVISIONING_SETTINGS, ['provisioning:manage']],
])(
'should resolve %s to %s with %s user permissions',
async (path, name, scopes) => {
const rbacStore = useRBACStore();
const posthogStore = usePostHog();
rbacStore.setGlobalScopes(scopes);
posthogStore.overrides[SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT.name] = true;
await router.push(path);
expect(initializeAuthenticatedFeaturesSpy).toHaveBeenCalled();
expect(router.currentRoute.value.name).toBe(name);
},
10000,
);
// TODO: remove this test once experiment is over
test('should not resolve /settings/provisioning while experiment is not active', async () => {
await router.push('/');
const rbacStore = useRBACStore();
const posthogStore = usePostHog();
rbacStore.setGlobalScopes(['provisioning:manage']);
vi.spyOn(posthogStore, 'isFeatureEnabled').mockReturnValueOnce(false);
await router.push('/settings/provisioning');
expect(router.currentRoute.value.name).toBe(VIEWS.WORKFLOWS);
});
test.each([
[VIEWS.PERSONAL_SETTINGS, true],
[VIEWS.USAGE, false],

View File

@ -11,12 +11,7 @@ import { useSettingsStore } from '@/app/stores/settings.store';
import { useTemplatesStore } from '@/features/workflows/templates/templates.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useSSOStore } from '@/features/settings/sso/sso.store';
import {
EnterpriseEditionFeature,
VIEWS,
EDITABLE_CANVAS_VIEWS,
SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT,
} from '@/app/constants';
import { EnterpriseEditionFeature, VIEWS, EDITABLE_CANVAS_VIEWS } from '@/app/constants';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { middleware } from '@/app/utils/rbac/middleware';
import type { RouterMiddleware } from '@/app/types/router';
@ -27,7 +22,6 @@ import { MfaRequiredError } from '@n8n/rest-api-client';
import { useCalloutHelpers } from '@/app/composables/useCalloutHelpers';
import { useRecentResources } from '@/features/shared/commandBar/composables/useRecentResources';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
import { usePostHog } from '@/app/stores/posthog.store';
const ChangePasswordView = async () =>
await import('@/features/core/auth/views/ChangePasswordView.vue');
@ -83,8 +77,6 @@ const SettingsSourceControl = async () =>
await import('@/features/integrations/sourceControl.ee/views/SettingsSourceControl.vue');
const SettingsExternalSecrets = async () =>
await import('@/features/integrations/externalSecrets.ee/views/SettingsExternalSecrets.vue');
const SettingsProvisioningView = async () =>
await import('@/features/settings/provisioning/views/SettingsProvisioningView.vue');
const WorkerView = async () =>
await import('@/features/settings/orchestration.ee/views/WorkerView.vue');
const WorkflowHistory = async () =>
@ -830,39 +822,6 @@ export const routes: RouteRecordRaw[] = [
},
},
},
{
path: 'provisioning',
name: VIEWS.PROVISIONING_SETTINGS,
components: {
settingsView: SettingsProvisioningView,
},
meta: {
middleware: ['authenticated', 'rbac', 'custom' /* 'enterprise' */],
middlewareOptions: {
/*
TODO: comment this back in once the custom check using experiment is no longer used
enterprise: {
feature: EnterpriseEditionFeature.Provisioning,
},
*/
rbac: {
scope: 'provisioning:manage',
},
custom: () => {
const posthogStore = usePostHog();
return posthogStore.isFeatureEnabled(SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT.name);
},
},
telemetry: {
pageCategory: 'settings',
getProperties() {
return {
feature: 'provisioning',
};
},
},
},
},
],
},
{

View File

@ -1,89 +1,40 @@
/**
* Filename sanitization utilities
* For handling cross-platform filename compatibility issues
*/
import type { BinaryFileType, IBinaryData } from 'n8n-workflow';
import type { ChatAttachment } from '@n8n/api-types';
// Constants definition
const INVALID_CHARS_REGEX = /[<>:"/\\|?*\u0000-\u001F\u007F-\u009F]/g;
const ZERO_WIDTH_CHARS_REGEX = /[\u200B-\u200D\u2060\uFEFF]/g;
const UNICODE_SPACES_REGEX = /[\u00A0\u2000-\u200A]/g;
const LEADING_TRAILING_DOTS_SPACES_REGEX = /^[\s.]+|[\s.]+$/g;
const WINDOWS_RESERVED_NAMES = new Set([
'CON',
'PRN',
'AUX',
'NUL',
'COM1',
'COM2',
'COM3',
'COM4',
'COM5',
'COM6',
'COM7',
'COM8',
'COM9',
'LPT1',
'LPT2',
'LPT3',
'LPT4',
'LPT5',
'LPT6',
'LPT7',
'LPT8',
'LPT9',
]);
export async function convertFileToBinaryData(file: File): Promise<IBinaryData> {
const reader = new FileReader();
return await new Promise((resolve, reject) => {
reader.onload = () => {
const binaryData: IBinaryData = {
data: (reader.result as string).split('base64,')?.[1] ?? '',
mimeType: file.type,
fileName: file.name,
fileSize: `${file.size} bytes`,
fileExtension: file.name.split('.').pop() ?? '',
fileType: file.type.split('/')[0] as BinaryFileType,
};
resolve(binaryData);
};
reader.onerror = () => {
reject(new Error('Failed to convert file to binary data'));
};
reader.readAsDataURL(file);
});
}
const DEFAULT_FALLBACK_NAME = 'untitled';
const MAX_FILENAME_LENGTH = 200;
/**
* Sanitizes a filename to be compatible with Mac, Linux, and Windows file systems
*
* Main features:
* - Replace invalid characters (e.g. ":" in hello:world)
* - Handle Windows reserved names
* - Limit filename length
* - Normalize Unicode characters
*
* @param filename - The filename to sanitize (without extension)
* @param maxLength - Maximum filename length (default: 200)
* @returns A sanitized filename (without extension)
*
* @example
* sanitizeFilename('hello:world') // returns 'hello_world'
* sanitizeFilename('CON') // returns '_CON'
* sanitizeFilename('') // returns 'untitled'
*/
export const sanitizeFilename = (
filename: string,
maxLength: number = MAX_FILENAME_LENGTH,
): string => {
// Input validation
if (!filename) {
return DEFAULT_FALLBACK_NAME;
}
let baseName = filename
.trim()
.replace(INVALID_CHARS_REGEX, '_')
.replace(ZERO_WIDTH_CHARS_REGEX, '')
.replace(UNICODE_SPACES_REGEX, ' ')
.replace(LEADING_TRAILING_DOTS_SPACES_REGEX, '');
// Handle empty or invalid filenames after cleaning
if (!baseName) {
baseName = DEFAULT_FALLBACK_NAME;
}
// Handle Windows reserved names
if (WINDOWS_RESERVED_NAMES.has(baseName.toUpperCase())) {
baseName = `_${baseName}`;
}
// Truncate if too long
if (baseName.length > maxLength) {
baseName = baseName.slice(0, maxLength);
}
return baseName;
};
export async function convertFileToChatAttachment(file: File): Promise<ChatAttachment> {
const reader = new FileReader();
return await new Promise((resolve, reject) => {
reader.onload = () => {
const attachment: ChatAttachment = {
data: (reader.result as string).split('base64,')?.[1] ?? '',
fileName: file.name,
};
resolve(attachment);
};
reader.onerror = () => {
reject(new Error('Failed to convert file to chat attachment'));
};
reader.readAsDataURL(file);
});
}

View File

@ -27,7 +27,7 @@ import {
type ChatHubSendMessageRequest,
type ChatModelDto,
} from '@n8n/api-types';
import { N8nIconButton, N8nScrollArea } from '@n8n/design-system';
import { N8nIconButton, N8nScrollArea, N8nText } from '@n8n/design-system';
import { useLocalStorage, useMediaQuery, useScroll } from '@vueuse/core';
import { v4 as uuidv4 } from 'uuid';
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
@ -38,6 +38,7 @@ import { useUIStore } from '@/app/stores/ui.store';
import { useChatCredentials } from '@/features/ai/chatHub/composables/useChatCredentials';
import ChatLayout from '@/features/ai/chatHub/components/ChatLayout.vue';
import { INodesSchema, type INode } from 'n8n-workflow';
import { useFileDrop } from '@/features/ai/chatHub/composables/useFileDrop';
const router = useRouter();
const route = useRoute();
@ -208,6 +209,15 @@ const isMissingSelectedCredential = computed(() => !credentialsForSelectedProvid
const editingMessageId = ref<string>();
const didSubmitInCurrentSession = ref(false);
const canAcceptFiles = computed(
() =>
editingMessageId.value === undefined &&
!!selectedModel.value?.allowFileUploads &&
!isMissingSelectedCredential.value,
);
const fileDrop = useFileDrop(canAcceptFiles, onFilesDropped);
function scrollToBottom(smooth: boolean) {
scrollContainerRef.value?.scrollTo({
top: scrollableRef.value?.scrollHeight,
@ -310,7 +320,7 @@ watch(
{ immediate: true },
);
function onSubmit(message: string) {
function onSubmit(message: string, attachments: File[]) {
if (
!message.trim() ||
isResponding.value ||
@ -322,12 +332,13 @@ function onSubmit(message: string) {
didSubmitInCurrentSession.value = true;
chatStore.sendMessage(
void chatStore.sendMessage(
sessionId.value,
message,
selectedModel.value.model,
credentialsForSelectedProvider.value,
canSelectTools.value ? selectedTools.value : [],
attachments,
);
inputRef.value?.setText('');
@ -463,16 +474,31 @@ function handleOpenWorkflow(workflowId: string) {
window.open(routeData.href, '_blank');
}
function onFilesDropped(files: File[]) {
inputRef.value?.addAttachments(files);
}
</script>
<template>
<ChatLayout
:class="{
[$style.chatLayout]: true,
[$style.isNewSession]: isNewSession,
[$style.isExistingSession]: !isNewSession,
[$style.isMobileDevice]: isMobileDevice,
[$style.isDraggingFile]: fileDrop.isDragging.value,
}"
@dragenter="fileDrop.handleDragEnter"
@dragleave="fileDrop.handleDragLeave"
@dragover="fileDrop.handleDragOver"
@drop="fileDrop.handleDrop"
@paste="fileDrop.handlePaste"
>
<div v-if="fileDrop.isDragging.value" :class="$style.dropOverlay">
<N8nText size="large" color="text-dark">Drop files here to attach</N8nText>
</div>
<ChatConversationHeader
ref="headerRef"
:selected-model="selectedModel ?? null"
@ -556,6 +582,10 @@ function handleOpenWorkflow(workflowId: string) {
</template>
<style lang="scss" module>
.chatLayout {
position: relative;
}
.scrollArea {
flex-grow: 1;
flex-shrink: 1;
@ -637,4 +667,22 @@ function handleOpenWorkflow(workflowId: string) {
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.15);
border-radius: 50%;
}
.isDraggingFile {
border-color: var(--color--secondary);
}
.dropOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background-color: color-mix(in srgb, var(--color--background--light-2) 95%, transparent);
pointer-events: none;
}
</style>

View File

@ -174,3 +174,12 @@ export const deleteAgentApi = async (context: IRestApiContext, agentId: string):
const apiEndpoint = `/chat/agents/${agentId}`;
await makeRestApiRequest(context, 'DELETE', apiEndpoint);
};
export function buildChatAttachmentUrl(
context: IRestApiContext,
sessionId: string,
messageId: string,
attachmentIndex: number,
): string {
return `${context.baseUrl}/chat/conversations/${sessionId}/messages/${messageId}/attachments/${attachmentIndex}`;
}

View File

@ -43,14 +43,18 @@ import type {
ChatStreamingState,
} from './chat.types';
import { retry } from '@n8n/utils/retry';
import { convertFileToChatAttachment } from '@/app/utils/fileUtils';
import { buildUiMessages, isMatchedAgent } from './chat.utils';
import { createAiMessageFromStreamingState, flattenModel } from './chat.utils';
import { useToast } from '@/app/composables/useToast';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { type INode } from 'n8n-workflow';
export const useChatStore = defineStore(CHAT_STORE, () => {
const rootStore = useRootStore();
const toast = useToast();
const telemetry = useTelemetry();
const agents = ref<ChatModelsResponse>();
const sessions = ref<ChatHubSessionDto[]>();
const currentEditingAgent = ref<ChatHubAgentDto | null>(null);
@ -402,11 +406,13 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
await fetchSessions();
}
function onStreamError() {
function onStreamError(error: Error) {
if (!streaming.value) {
return;
}
toast.showError(error, 'Could not send message');
const { sessionId } = streaming.value;
streaming.value = undefined;
@ -425,12 +431,13 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
}
}
function sendMessage(
async function sendMessage(
sessionId: ChatSessionId,
message: string,
model: ChatHubConversationModel,
credentials: ChatHubSendMessageRequest['credentials'],
tools: INode[],
files: File[] = [],
) {
const messageId = uuidv4();
const conversation = ensureConversation(sessionId);
@ -438,6 +445,8 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
? conversation.activeMessageChain[conversation.activeMessageChain.length - 1]
: null;
const attachments = await Promise.all(files.map(convertFileToChatAttachment));
addMessage(sessionId, {
id: messageId,
sessionId,
@ -457,6 +466,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
revisionOfMessageId: null,
responses: [],
alternatives: [],
attachments,
});
streaming.value = {
@ -476,6 +486,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
credentials,
previousMessageId,
tools,
attachments,
},
onStreamMessage,
onStreamDone,
@ -522,6 +533,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
revisionOfMessageId: editId,
responses: [],
alternatives: [],
attachments: message.attachments ?? null,
});
} else if (message?.type === 'ai') {
replaceMessageContent(sessionId, editId, content);
@ -674,6 +686,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
tools: agent.tools,
allowFileUploads: true,
};
agents.value?.['custom-agent'].models.push(agentModel);
@ -736,6 +749,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
description: null,
createdAt: null,
updatedAt: null,
allowFileUploads: true,
};
}

View File

@ -251,6 +251,7 @@ export function createAiMessageFromStreamingState(
revisionOfMessageId: null,
responses: [],
alternatives: [],
attachments: [],
...(streaming?.model
? flattenModel(streaming.model)
: {

View File

@ -14,6 +14,9 @@ import type { ChatMessage } from '../chat.types';
import ChatMessageActions from './ChatMessageActions.vue';
import { unflattenModel } from '@/features/ai/chatHub/chat.utils';
import { useAgent } from '@/features/ai/chatHub/composables/useAgent';
import ChatFile from '@n8n/chat/components/ChatFile.vue';
import { buildChatAttachmentUrl } from '@/features/ai/chatHub/chat.api';
import { useRootStore } from '@n8n/stores/useRootStore';
const { message, compact, isEditing, isStreaming, minHeight } = defineProps<{
message: ChatMessage;
@ -35,6 +38,7 @@ const emit = defineEmits<{
}>();
const clipboard = useClipboard();
const rootStore = useRootStore();
const editedText = ref('');
const textareaRef = useTemplateRef('textarea');
@ -51,6 +55,18 @@ const speech = useSpeechSynthesis(messageContent, {
const model = computed(() => unflattenModel(message));
const agent = useAgent(model);
const attachments = computed(() =>
message.attachments.map(({ fileName, mimeType }, index) => ({
file: new File([], fileName ?? 'file', { type: mimeType }), // Placeholder file for display
downloadUrl: buildChatAttachmentUrl(
rootStore.restApiContext,
message.sessionId,
message.id,
index,
),
})),
);
async function handleCopy() {
const text = message.content;
await clipboard.copy(text);
@ -163,6 +179,15 @@ onBeforeMount(() => {
</div>
<template v-else>
<div :class="[$style.chatMessage, { [$style.errorMessage]: message.status === 'error' }]">
<div v-if="attachments.length > 0" :class="$style.attachments">
<ChatFile
v-for="(attachment, index) in attachments"
:key="index"
:file="attachment.file"
:is-removable="false"
:href="attachment.downloadUrl"
/>
</div>
<VueMarkdown
:key="forceReRenderKey"
:class="[$style.chatMessageMarkdown, 'chat-message-markdown']"
@ -225,8 +250,17 @@ onBeforeMount(() => {
flex-direction: column;
}
.attachments {
display: flex;
flex-wrap: wrap;
gap: var(--spacing--2xs);
margin-top: var(--spacing--xs);
}
.chatMessage {
display: block;
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
position: relative;
max-width: fit-content;

View File

@ -2,6 +2,7 @@
import { useToast } from '@/app/composables/useToast';
import { providerDisplayNames } from '@/features/ai/chatHub/constants';
import type { ChatHubLLMProvider, ChatModelDto } from '@n8n/api-types';
import ChatFile from '@n8n/chat/components/ChatFile.vue';
import { N8nIconButton, N8nInput, N8nText } from '@n8n/design-system';
import { useSpeechRecognition } from '@vueuse/core';
import type { INode } from 'n8n-workflow';
@ -18,7 +19,7 @@ const { selectedModel, selectedTools, isMissingCredentials } = defineProps<{
}>();
const emit = defineEmits<{
submit: [string];
submit: [message: string, attachments: File[]];
stop: [];
selectModel: [];
selectTools: [INode[]];
@ -26,7 +27,9 @@ const emit = defineEmits<{
}>();
const inputRef = useTemplateRef<HTMLElement>('inputRef');
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef');
const message = ref('');
const attachments = ref<File[]>([]);
const toast = useToast();
@ -58,12 +61,43 @@ function onStop() {
emit('stop');
}
function onAttach() {
fileInputRef.value?.click();
}
function handleFileSelect(e: Event) {
const target = e.target as HTMLInputElement;
const files = target.files;
if (!files || files.length === 0) {
return;
}
// Store File objects directly instead of converting to base64
for (const file of Array.from(files)) {
attachments.value.push(file);
}
// Reset input
if (target) {
target.value = '';
}
inputRef.value?.focus();
}
function removeAttachment(removed: File) {
attachments.value = attachments.value.filter((attachment) => attachment !== removed);
}
function handleSubmitForm() {
const trimmed = message.value.trim();
if (trimmed) {
speechInput.stop();
emit('submit', trimmed);
emit('submit', trimmed, attachments.value);
message.value = '';
attachments.value = [];
}
}
@ -73,10 +107,16 @@ function handleKeydownTextarea(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && trimmed) {
e.preventDefault();
speechInput.stop();
emit('submit', trimmed);
emit('submit', trimmed, attachments.value);
message.value = '';
attachments.value = [];
}
}
function handleClickInputWrapper() {
inputRef.value?.focus();
}
watch(speechInput.result, (spoken) => {
if (spoken) {
message.value = spoken;
@ -109,6 +149,10 @@ defineExpose({
setText: (text: string) => {
message.value = text;
},
addAttachments: (files: File[]) => {
attachments.value.push(...files);
inputRef.value?.focus();
},
});
</script>
@ -137,66 +181,89 @@ defineExpose({
for {{ providerDisplayNames[llmProvider] }} to continue the conversation
</template>
</N8nText>
<N8nInput
ref="inputRef"
v-model="message"
:class="$style.input"
type="textarea"
:placeholder="placeholder"
autocomplete="off"
:autosize="{ minRows: 1, maxRows: 6 }"
autofocus
:disabled="isMissingCredentials || !selectedModel"
@keydown="handleKeydownTextarea"
<input
ref="fileInputRef"
type="file"
:class="$style.fileInput"
multiple
accept="image/*,.pdf,.doc,.docx,.txt"
@change="handleFileSelect"
/>
<div v-if="isToolsSelectable" :class="$style.tools">
<ToolsSelector
:selected="selectedTools ?? []"
:disabled="isMissingCredentials || !selectedModel || isResponding"
@select="onSelectTools"
/>
</div>
<div :class="$style.inputWrapper" @click="handleClickInputWrapper">
<div v-if="attachments.length > 0" :class="$style.attachments">
<ChatFile
v-for="(file, index) in attachments"
:key="index"
:file="file"
:is-previewable="true"
:is-removable="true"
@remove="removeAttachment"
/>
</div>
<div :class="$style.actions">
<!-- TODO: Implement attachments
<N8nIconButton
native-type="button"
type="secondary"
title="Attach"
:disabled="isMissingCredentials || !selectedModel || isResponding"
icon="paperclip"
icon-size="large"
text
@click="onAttach"
/> -->
<N8nIconButton
v-if="speechInput.isSupported"
native-type="button"
:title="speechInput.isListening.value ? 'Stop recording' : 'Voice input'"
type="secondary"
:disabled="isMissingCredentials || !selectedModel || isResponding"
:icon="speechInput.isListening.value ? 'square' : 'mic'"
:class="{ [$style.recording]: speechInput.isListening.value }"
icon-size="large"
@click="onMic"
/>
<N8nIconButton
v-if="!isResponding"
native-type="submit"
:disabled="isMissingCredentials || !selectedModel || !message.trim()"
title="Send"
icon="arrow-up"
icon-size="large"
/>
<N8nIconButton
v-else
native-type="button"
title="Stop generating"
icon="square"
icon-size="large"
@click="onStop"
<N8nInput
ref="inputRef"
v-model="message"
type="textarea"
:placeholder="placeholder"
autocomplete="off"
:autosize="{ minRows: 1, maxRows: 6 }"
autofocus
:disabled="isMissingCredentials || !selectedModel"
@keydown="handleKeydownTextarea"
/>
<div :class="$style.footer">
<div v-if="isToolsSelectable" :class="$style.tools">
<ToolsSelector
:selected="selectedTools ?? []"
:disabled="isMissingCredentials || !selectedModel || isResponding"
@select="onSelectTools"
/>
</div>
<div :class="$style.actions">
<N8nIconButton
v-if="selectedModel?.allowFileUploads"
native-type="button"
type="secondary"
title="Attach"
:disabled="isMissingCredentials || isResponding"
icon="paperclip"
icon-size="large"
text
@click.stop="onAttach"
/>
<N8nIconButton
v-if="speechInput.isSupported"
native-type="button"
:title="speechInput.isListening.value ? 'Stop recording' : 'Voice input'"
type="secondary"
:disabled="isMissingCredentials || !selectedModel || isResponding"
:icon="speechInput.isListening.value ? 'square' : 'mic'"
:class="{ [$style.recording]: speechInput.isListening.value }"
icon-size="large"
@click.stop="onMic"
/>
<N8nIconButton
v-if="!isResponding"
native-type="submit"
:disabled="isMissingCredentials || !selectedModel || !message.trim()"
title="Send"
icon="arrow-up"
icon-size="large"
@click.stop
/>
<N8nIconButton
v-else
native-type="button"
title="Stop generating"
icon="square"
icon-size="large"
@click.stop="onStop"
/>
</div>
</div>
</div>
</div>
</form>
@ -234,34 +301,89 @@ defineExpose({
}
}
.input {
.fileInput {
display: none;
}
.inputWrapper {
width: 100%;
border-radius: 16px !important;
padding: 16px;
box-shadow: 0 10px 24px 0 #00000010;
background-color: var(--color--background--light-3);
border: var(--border);
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
&:focus-within,
&:hover {
border-color: var(--color--secondary);
}
& textarea {
font: inherit;
line-height: 1.5em;
border-radius: 16px !important;
resize: none;
padding: 16px 16px 64px;
box-shadow: 0 10px 24px 0 #00000010;
background-color: var(--color--background--light-3);
background-color: transparent !important;
border: none !important;
padding: 0 !important;
}
}
.footer {
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: var(--spacing--sm);
}
.tools {
position: absolute;
left: 0;
bottom: 0;
padding: var(--spacing--sm);
flex-grow: 1;
}
.toolsButton {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
padding: var(--spacing--3xs) var(--spacing--xs);
color: var(--color--text);
cursor: pointer;
border-radius: var(--radius);
border: var(--border);
background: var(--color--background--light-3);
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.iconStack {
display: flex;
align-items: center;
position: relative;
}
.icon {
padding: var(--spacing--4xs);
background-color: var(--button--color--background--secondary);
border-radius: 50%;
outline: 2px var(--color--background--light-3) solid;
}
.iconOverlap {
margin-left: -6px;
}
.iconFallback {
display: flex;
align-items: center;
justify-content: center;
}
/* Right-side actions */
.actions {
position: absolute;
right: 0;
bottom: 0;
padding: var(--spacing--sm);
display: flex;
align-items: center;
gap: var(--spacing--2xs);
@ -271,6 +393,12 @@ defineExpose({
}
}
.attachments {
display: flex;
flex-wrap: wrap;
gap: var(--spacing--2xs);
}
.recording {
animation: chatHubPromptRecordingPulse 1.5s ease-in-out infinite;
}

View File

@ -68,6 +68,7 @@ function handleSelectModelById(provider: ChatHubLLMProvider, modelId: string) {
description: null,
updatedAt: null,
createdAt: null,
allowFileUploads: true,
});
}

View File

@ -0,0 +1,97 @@
import { ref, type Ref } from 'vue';
export function useFileDrop(canAcceptFiles: Ref<boolean>, onFilesDropped: (files: File[]) => void) {
const isDragging = ref(false);
function handleDragEnter(e: DragEvent) {
if (!canAcceptFiles.value) {
return;
}
// Check if dragging files (not text or other content)
if (e.dataTransfer?.types.includes('Files')) {
isDragging.value = true;
}
}
function handleDragLeave(e: DragEvent) {
if (!canAcceptFiles.value) {
return;
}
// Only hide overlay if leaving the component
const target = e.currentTarget as HTMLElement;
const relatedTarget = e.relatedTarget as Node | null;
if (relatedTarget && target.contains(relatedTarget)) {
return;
}
isDragging.value = false;
}
function handleDragOver(e: DragEvent) {
if (!canAcceptFiles.value) {
return;
}
e.preventDefault();
e.stopPropagation();
}
function handleDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
isDragging.value = false;
if (!canAcceptFiles.value) {
return;
}
const files = e.dataTransfer?.files;
if (!files || files.length === 0) {
return;
}
onFilesDropped(Array.from(files));
}
function handlePaste(e: ClipboardEvent) {
if (!canAcceptFiles.value) {
return;
}
const items = e.clipboardData?.items;
if (!items) {
return;
}
let hasFiles = false;
const files: File[] = [];
for (const item of Array.from(items)) {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
files.push(file);
hasFiles = true;
}
}
}
// Prevent default paste behavior if files were found
if (hasFiles) {
e.preventDefault();
onFilesDropped(files);
}
}
return {
isDragging,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
handlePaste,
};
}

View File

@ -7,8 +7,6 @@ import type {
INodeExecutionData,
IBinaryKeyData,
IDataObject,
IBinaryData,
BinaryFileType,
IRunExecutionData,
} from 'n8n-workflow';
import { useToast } from '@/app/composables/useToast';
@ -18,12 +16,12 @@ import { MODAL_CONFIRM } from '@/app/constants';
import { useI18n } from '@n8n/i18n';
import type { INodeUi } from '@/Interface';
import type { IExecutionPushResponse } from '@/features/execution/executions/executions.types';
import {
extractBotResponse,
getInputKey,
processFiles,
} from '@/features/execution/logs/logs.utils';
import { convertFileToBinaryData } from '@/app/utils/fileUtils';
export type RunWorkflowChatPayload = {
triggerNode: string;
@ -59,28 +57,6 @@ export function useChatMessaging({
isLoading.value = loading;
};
/** Converts a file to binary data */
async function convertFileToBinaryData(file: File): Promise<IBinaryData> {
const reader = new FileReader();
return await new Promise((resolve, reject) => {
reader.onload = () => {
const binaryData: IBinaryData = {
data: (reader.result as string).split('base64,')?.[1] ?? '',
mimeType: file.type,
fileName: file.name,
fileSize: `${file.size} bytes`,
fileExtension: file.name.split('.').pop() ?? '',
fileType: file.type.split('/')[0] as BinaryFileType,
};
resolve(binaryData);
};
reader.onerror = () => {
reject(new Error('Failed to convert file to binary data'));
};
reader.readAsDataURL(file);
});
}
/** Gets keyed files for the workflow input */
async function getKeyedFiles(files: File[]): Promise<IBinaryKeyData> {
const binaryData: IBinaryKeyData = {};

View File

@ -1,165 +0,0 @@
<script lang="ts" setup>
import { useI18n } from '@n8n/i18n';
import { ElDialog } from 'element-plus';
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
import { ref, watch } from 'vue';
import { useAccessSettingsCsvExport } from '@/features/settings/provisioning/composables/useAccessSettingsCsvExport';
const visible = defineModel<boolean>();
const emit = defineEmits<{
confirmProvisioning: [value?: string];
cancel: [];
}>();
const locale = useI18n();
const downloadingInstanceRolesCsv = ref(false);
const downloadingProjectRolesCsv = ref(false);
const loadingActivatingJit = ref(false);
const {
hasDownloadedInstanceRoleCsv,
hasDownloadedProjectRoleCsv,
downloadProjectRolesCsv,
downloadInstanceRolesCsv,
accessSettingsCsvExportOnModalClose,
} = useAccessSettingsCsvExport();
watch(visible, () => {
loadingActivatingJit.value = false;
accessSettingsCsvExportOnModalClose();
});
const onDownloadInstanceRolesCsv = async () => {
downloadingInstanceRolesCsv.value = true;
try {
await downloadInstanceRolesCsv();
} finally {
downloadingInstanceRolesCsv.value = false;
}
};
const onDownloadProjectRolesCsv = async () => {
downloadingProjectRolesCsv.value = true;
try {
await downloadProjectRolesCsv();
} finally {
downloadingProjectRolesCsv.value = false;
}
};
const onConfirmActivatingProvisioning = () => {
loadingActivatingJit.value = true;
emit('confirmProvisioning');
};
</script>
<template>
<ElDialog
v-model="visible"
:title="locale.baseText('settings.provisioningConfirmDialog.title')"
width="650"
>
<div class="mb-s">
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.breakingChangeDescription.firstLine')
}}</N8nText>
</div>
<ul :class="$style.list" class="mb-s">
<li>
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.breakingChangeDescription.list.one')
}}</N8nText>
</li>
<li>
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.breakingChangeDescription.list.two')
}}</N8nText>
</li>
</ul>
<div class="mb-s">
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.breakingChangeRequiredSteps')
}}</N8nText>
</div>
<div class="mb-s" :class="$style.buttonRow">
<N8nButton
type="secondary"
native-type="button"
data-test-id="provisioning-download-instance-roles-csv-button"
:disabled="downloadingInstanceRolesCsv"
:loading="downloadingInstanceRolesCsv"
:class="$style.button"
@click="onDownloadInstanceRolesCsv"
>{{
locale.baseText('settings.provisioningConfirmDialog.button.downloadInstanceRolesCsv')
}}</N8nButton
>
<N8nIcon
v-if="hasDownloadedInstanceRoleCsv"
icon="check"
color="success"
:class="$style.icon"
/>
</div>
<div class="mb-s" :class="$style.buttonRow">
<N8nButton
type="secondary"
native-type="button"
data-test-id="provisioning-download-project-roles-csv-button"
:disabled="downloadingProjectRolesCsv"
:loading="downloadingProjectRolesCsv"
:class="$style.button"
@click="onDownloadProjectRolesCsv"
>{{
locale.baseText('settings.provisioningConfirmDialog.button.downloadProjectRolesCsv')
}}</N8nButton
>
<N8nIcon
v-if="hasDownloadedProjectRoleCsv"
icon="check"
color="success"
:class="$style.icon"
/>
</div>
<template #footer>
<N8nButton
type="tertiary"
native-type="button"
data-test-id="provisioning-cancel-button"
@click="emit('cancel')"
>{{ locale.baseText('settings.provisioningConfirmDialog.button.cancel') }}</N8nButton
>
<N8nButton
type="primary"
native-type="button"
:disabled="
loadingActivatingJit || !(hasDownloadedInstanceRoleCsv && hasDownloadedProjectRoleCsv)
"
data-test-id="provisioning-confirm-button"
@click="onConfirmActivatingProvisioning"
>{{ locale.baseText('settings.provisioningConfirmDialog.button.confirm') }}</N8nButton
>
</template>
</ElDialog>
</template>
<style lang="scss" module>
.buttonRow {
display: flex;
align-items: center;
}
.button {
min-width: 340px;
}
.icon {
margin-left: var(--spacing--xs);
}
.list {
padding: 0 var(--spacing--sm);
li {
list-style: disc outside;
}
}
</style>

View File

@ -1,250 +0,0 @@
<script lang="ts" setup>
import { onMounted, ref, computed, reactive } from 'vue';
import { useI18n } from '@n8n/i18n';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useToast } from '@/app/composables/useToast';
import { useProvisioningStore } from '../provisioning.store';
import { N8nHeading, N8nText, N8nSpinner, N8nInput, N8nButton } from '@n8n/design-system';
import { type ProvisioningConfig } from '@n8n/rest-api-client';
import EnableJitProvisioningDialog from '../components/EnableJitProvisioningDialog.vue';
const i18n = useI18n();
const documentTitle = useDocumentTitle();
const { showError, showMessage } = useToast();
const provisioningStore = useProvisioningStore();
// Check if provisioning feature is enabled
onMounted(async () => {
documentTitle.set(i18n.baseText('settings.provisioning.title'));
loading.value = true;
try {
await provisioningStore.getProvisioningConfig();
loadFormData();
} catch (error) {
showError(error, i18n.baseText('settings.provisioning.loadError'));
} finally {
loading.value = false;
}
});
const loading = ref(false);
const saving = ref(false);
const confirmationDialogVisible = ref(false);
// Form data (reactive object)
const form = reactive({
scopesName: '',
scopesInstanceRoleClaimName: '',
scopesProjectsRolesClaimName: '',
provisioningEnabled: false,
});
const isFormDirty = computed(() => {
const config = provisioningStore.provisioningConfig;
if (!config) return false;
const formKeysThatMatchWithConfig: Array<keyof typeof form & keyof ProvisioningConfig> = [
'scopesName',
'scopesInstanceRoleClaimName',
'scopesProjectsRolesClaimName',
];
const configChanged = formKeysThatMatchWithConfig.some((key) => form[key] !== config[key]);
const provisioningEnabledChanged =
form.provisioningEnabled !==
(config.scopesProvisionInstanceRole && config.scopesProvisionProjectRoles);
return configChanged || provisioningEnabledChanged;
});
const loadFormData = () => {
const cfg = provisioningStore.provisioningConfig;
if (!cfg) return;
Object.assign(form, {
scopesName: cfg.scopesName || '',
scopesInstanceRoleClaimName: cfg.scopesInstanceRoleClaimName || '',
scopesProjectsRolesClaimName: cfg.scopesProjectsRolesClaimName || '',
});
form.provisioningEnabled = cfg.scopesProvisionInstanceRole;
};
const saveFormValues = async () => {
saving.value = true;
try {
const { provisioningEnabled, ...dataToSave } = form;
await provisioningStore.saveProvisioningConfig({
...dataToSave,
scopesProvisionInstanceRole: provisioningEnabled,
scopesProvisionProjectRoles: provisioningEnabled,
});
await provisioningStore.getProvisioningConfig();
loadFormData();
// Show success message
showMessage({
title: i18n.baseText('settings.provisioning.saveSuccess'),
message: i18n.baseText('settings.provisioning.saveSuccessMessage'),
type: 'success',
duration: 3000,
});
} catch (error) {
showError(error, i18n.baseText('settings.provisioning.saveError'));
} finally {
saving.value = false;
}
};
const onSave = async () => {
if (form.provisioningEnabled) {
confirmationDialogVisible.value = true;
return;
}
await saveFormValues();
};
const onConfirmProvisioning = async () => {
saving.value = true;
await saveFormValues();
confirmationDialogVisible.value = false;
};
</script>
<template>
<div :class="$style.container">
<div :class="$style.heading">
<N8nHeading size="2xlarge">{{ i18n.baseText('settings.provisioning.title') }}</N8nHeading>
</div>
<N8nText color="text-light">
{{ i18n.baseText('settings.provisioning.description') }}
</N8nText>
<div v-if="loading" :class="$style.loading">
<N8nSpinner size="large" />
</div>
<div v-else>
<div :class="$style.group">
<label for="provisioning-enabled">{{
i18n.baseText('settings.provisioning.toggle')
}}</label>
<small>{{ i18n.baseText('settings.provisioning.toggle.help') }}</small>
<input
id="provisioning-enabled"
v-model="form.provisioningEnabled"
type="checkbox"
:class="$style.checkbox"
/>
</div>
<div :class="$style.group">
<label>{{ i18n.baseText('settings.provisioning.scopesName') }}</label>
<N8nInput
v-model="form.scopesName"
type="text"
size="large"
:placeholder="i18n.baseText('settings.provisioning.scopesName.placeholder')"
/>
<small>{{ i18n.baseText('settings.provisioning.scopesName.help') }}</small>
</div>
<div :class="$style.group">
<label>{{ i18n.baseText('settings.provisioning.scopesInstanceRoleClaimName') }}</label>
<N8nInput
v-model="form.scopesInstanceRoleClaimName"
type="text"
size="large"
:placeholder="
i18n.baseText('settings.provisioning.scopesInstanceRoleClaimName.placeholder')
"
/>
<small>{{ i18n.baseText('settings.provisioning.scopesInstanceRoleClaimName.help') }}</small>
</div>
<div :class="$style.group">
<label>{{ i18n.baseText('settings.provisioning.scopesProjectsRolesClaimName') }}</label>
<N8nInput
v-model="form.scopesProjectsRolesClaimName"
type="text"
size="large"
:placeholder="
i18n.baseText('settings.provisioning.scopesProjectsRolesClaimName.placeholder')
"
/>
<small>{{
i18n.baseText('settings.provisioning.scopesProjectsRolesClaimName.help')
}}</small>
</div>
<div :class="$style.buttons">
<N8nButton
:disabled="!isFormDirty || saving"
size="large"
:loading="saving"
@click="onSave"
>
{{ i18n.baseText('settings.provisioning.save') }}
</N8nButton>
</div>
<EnableJitProvisioningDialog
v-model="confirmationDialogVisible"
@confirm-provisioning="onConfirmProvisioning"
@cancel="confirmationDialogVisible = false"
/>
</div>
</div>
</template>
<style lang="scss" module>
.container {
padding-bottom: var(--spacing--2xl);
max-width: 600px;
}
.heading {
margin-bottom: var(--spacing--sm);
}
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: var(--spacing--2xl);
}
.buttons {
display: flex;
justify-content: flex-start;
padding: var(--spacing--2xl) 0 var(--spacing--2xs);
button {
margin: 0 var(--spacing--sm) 0 0;
}
}
.group {
padding: var(--spacing--xl) 0 0;
> label {
display: inline-block;
font-size: var(--font-size--sm);
font-weight: var(--font-weight--medium);
padding: 0 0 var(--spacing--2xs);
}
small {
display: block;
padding: var(--spacing--2xs) 0;
font-size: var(--font-size--2xs);
color: var(--color--text);
}
}
.frequencySelect {
display: block;
width: 240px;
}
.checkbox {
margin-right: var(--spacing--xs);
transform: scale(1.2);
}
</style>

View File

@ -0,0 +1,302 @@
<script lang="ts" setup>
import CopyInput from '@/app/components/CopyInput.vue';
import { MODAL_CONFIRM } from '@/app/constants';
import { SupportedProtocols, useSSOStore } from '../sso.store';
import { useI18n } from '@n8n/i18n';
import { ElSwitch } from 'element-plus';
import { N8nActionBox, N8nButton, N8nInput, N8nOption, N8nSelect } from '@n8n/design-system';
import { computed, onMounted, ref } from 'vue';
import { useToast } from '@/app/composables/useToast';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { useMessage } from '@/app/composables/useMessage';
import UserRoleProvisioningDropdown, {
type UserRoleProvisioningSetting,
} from '../provisioning/components/UserRoleProvisioningDropdown.vue';
import { useUserRoleProvisioningForm } from '../provisioning/composables/useUserRoleProvisioningForm';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useRootStore } from '@n8n/stores/useRootStore';
import { type OidcConfigDto } from '@n8n/api-types';
import ConfirmProvisioningDialog from '../provisioning/components/ConfirmProvisioningDialog.vue';
const i18n = useI18n();
const ssoStore = useSSOStore();
const telemetry = useTelemetry();
const toast = useToast();
const message = useMessage();
const pageRedirectionHelper = usePageRedirectionHelper();
const discoveryEndpoint = ref('');
const clientId = ref('');
const clientSecret = ref('');
const showUserRoleProvisioningDialog = ref(false);
const userRoleProvisioning = ref<UserRoleProvisioningSetting>('disabled');
const { isUserRoleProvisioningChanged, saveProvisioningConfig } =
useUserRoleProvisioningForm(userRoleProvisioning);
type PromptType = 'login' | 'none' | 'consent' | 'select_account' | 'create';
const prompt = ref<PromptType>('select_account');
const handlePromptChange = (value: PromptType) => {
prompt.value = value;
};
type PromptDescription = {
label: string;
value: PromptType;
};
const promptDescriptions: PromptDescription[] = [
{ label: i18n.baseText('settings.sso.settings.oidc.prompt.login'), value: 'login' },
{ label: i18n.baseText('settings.sso.settings.oidc.prompt.none'), value: 'none' },
{ label: i18n.baseText('settings.sso.settings.oidc.prompt.consent'), value: 'consent' },
{
label: i18n.baseText('settings.sso.settings.oidc.prompt.select_account'),
value: 'select_account',
},
{ label: i18n.baseText('settings.sso.settings.oidc.prompt.create'), value: 'create' },
];
const oidcActivatedLabel = computed(() =>
ssoStore.isOidcLoginEnabled
? i18n.baseText('settings.sso.activated')
: i18n.baseText('settings.sso.deactivated'),
);
const authenticationContextClassReference = ref('');
const getOidcConfig = async () => {
const config = await ssoStore.getOidcConfig();
clientId.value = config.clientId;
clientSecret.value = config.clientSecret;
discoveryEndpoint.value = config.discoveryEndpoint;
prompt.value = config.prompt ?? 'select_account';
authenticationContextClassReference.value =
config.authenticationContextClassReference?.join(',') || '';
};
const loadOidcConfig = async () => {
if (!ssoStore.isEnterpriseOidcEnabled) {
return;
}
try {
await getOidcConfig();
} catch (error) {
toast.showError(error, 'error');
}
};
const cannotSaveOidcSettings = computed(() => {
const currentAcrString = authenticationContextClassReference.value
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.join(',');
const storedAcrString = ssoStore.oidcConfig?.authenticationContextClassReference?.join(',') || '';
return (
ssoStore.oidcConfig?.clientId === clientId.value &&
ssoStore.oidcConfig?.clientSecret === clientSecret.value &&
ssoStore.oidcConfig?.discoveryEndpoint === discoveryEndpoint.value &&
ssoStore.oidcConfig?.loginEnabled === ssoStore.isOidcLoginEnabled &&
ssoStore.oidcConfig?.prompt === prompt.value &&
!isUserRoleProvisioningChanged() &&
storedAcrString === authenticationContextClassReference.value &&
currentAcrString === storedAcrString
);
});
async function onOidcSettingsSave(provisioningChangesConfirmed: boolean = false) {
if (ssoStore.oidcConfig?.loginEnabled && !ssoStore.isOidcLoginEnabled) {
const confirmAction = await message.confirm(
i18n.baseText('settings.oidc.confirmMessage.beforeSaveForm.message'),
i18n.baseText('settings.oidc.confirmMessage.beforeSaveForm.headline'),
{
cancelButtonText: i18n.baseText(
'settings.ldap.confirmMessage.beforeSaveForm.cancelButtonText',
),
confirmButtonText: i18n.baseText(
'settings.ldap.confirmMessage.beforeSaveForm.confirmButtonText',
),
},
);
if (confirmAction !== MODAL_CONFIRM) return;
}
if (isUserRoleProvisioningChanged() && !provisioningChangesConfirmed) {
showUserRoleProvisioningDialog.value = true;
return;
}
const acrArray = authenticationContextClassReference.value
.split(',')
.map((s) => s.trim())
.filter(Boolean);
try {
const newConfig = await ssoStore.saveOidcConfig({
clientId: clientId.value,
clientSecret: clientSecret.value,
discoveryEndpoint: discoveryEndpoint.value,
prompt: prompt.value,
loginEnabled: ssoStore.isOidcLoginEnabled,
authenticationContextClassReference: acrArray,
});
if (isUserRoleProvisioningChanged()) {
await saveProvisioningConfig();
showUserRoleProvisioningDialog.value = false;
}
// Update store with saved protocol selection
ssoStore.selectedAuthProtocol = SupportedProtocols.OIDC;
clientSecret.value = newConfig.clientSecret;
sendTrackingEvent(newConfig);
} catch (error) {
toast.showError(error, i18n.baseText('settings.sso.settings.save.error_oidc'));
return;
} finally {
await getOidcConfig();
}
}
function sendTrackingEvent(config: OidcConfigDto) {
const trackingMetadata = {
instance_id: useRootStore().instanceId,
authentication_method: SupportedProtocols.OIDC,
discovery_endpoint: config.discoveryEndpoint,
is_active: config.loginEnabled,
};
telemetry.track('User updated single sign on settings', trackingMetadata);
}
const goToUpgrade = () => {
void pageRedirectionHelper.goToUpgrade('sso', 'upgrade-sso');
};
onMounted(async () => {
await loadOidcConfig();
});
</script>
<template>
<div v-if="ssoStore.isEnterpriseOidcEnabled">
<div :class="$style.group">
<label>Redirect URL</label>
<CopyInput
:value="ssoStore.oidc.callbackUrl"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
toast-title="Redirect URL copied to clipboard"
/>
<small>Copy the Redirect URL to configure your OIDC provider </small>
</div>
<div :class="$style.group">
<label>Discovery Endpoint</label>
<N8nInput
:model-value="discoveryEndpoint"
type="text"
data-test-id="oidc-discovery-endpoint"
placeholder="https://accounts.google.com/.well-known/openid-configuration"
@update:model-value="(v: string) => (discoveryEndpoint = v)"
/>
<small>Paste here your discovery endpoint</small>
</div>
<div :class="$style.group">
<label>Client ID</label>
<N8nInput
:model-value="clientId"
type="text"
data-test-id="oidc-client-id"
@update:model-value="(v: string) => (clientId = v)"
/>
<small>The client ID you received when registering your application with your provider</small>
</div>
<div :class="$style.group">
<label>Client Secret</label>
<N8nInput
:model-value="clientSecret"
type="password"
data-test-id="oidc-client-secret"
@update:model-value="(v: string) => (clientSecret = v)"
/>
<small
>The client Secret you received when registering your application with your provider</small
>
</div>
<div :class="$style.group">
<label>Prompt</label>
<N8nSelect
:model-value="prompt"
data-test-id="oidc-prompt"
@update:model-value="handlePromptChange"
>
<N8nOption
v-for="option in promptDescriptions"
:key="option.value"
:label="option.label"
data-test-id="oidc-prompt-filter-option"
:value="option.value"
/>
</N8nSelect>
<small>The prompt parameter to use when authenticating with the OIDC provider</small>
</div>
<UserRoleProvisioningDropdown v-model="userRoleProvisioning" auth-protocol="oidc" />
<ConfirmProvisioningDialog
v-model="showUserRoleProvisioningDialog"
:new-provisioning-setting="userRoleProvisioning"
auth-protocol="oidc"
@confirm-provisioning="onOidcSettingsSave(true)"
/>
<div :class="$style.group">
<label>Authentication Context Class Reference</label>
<N8nInput
:model-value="authenticationContextClassReference"
type="textarea"
data-test-id="oidc-authentication-context-class-reference"
placeholder="mfa, phrh, pwd"
@update:model-value="(v: string) => (authenticationContextClassReference = v)"
/>
<small
>ACR values to include in the authorization request (acr_values parameter), separated by
commas in order of preference.</small
>
</div>
<div :class="$style.group">
<ElSwitch
v-model="ssoStore.isOidcLoginEnabled"
data-test-id="sso-oidc-toggle"
:class="$style.switch"
:inactive-text="oidcActivatedLabel"
/>
</div>
<div :class="$style.buttons">
<N8nButton
data-test-id="sso-oidc-save"
size="large"
:disabled="cannotSaveOidcSettings"
@click="onOidcSettingsSave(false)"
>
{{ i18n.baseText('settings.sso.settings.save') }}
</N8nButton>
</div>
</div>
<N8nActionBox
v-else
data-test-id="sso-content-unlicensed"
:class="$style.actionBox"
:button-text="i18n.baseText('settings.sso.actionBox.buttonText')"
@click:button="goToUpgrade"
>
<template #heading>
<span>{{ i18n.baseText('settings.sso.actionBox.title') }}</span>
</template>
</N8nActionBox>
</template>
<style lang="scss" module src="../styles/sso-form.module.scss" />

View File

@ -0,0 +1,338 @@
<script lang="ts" setup>
import type { SamlPreferences } from '@n8n/api-types';
import CopyInput from '@/app/components/CopyInput.vue';
import { SupportedProtocols, useSSOStore } from '../sso.store';
import { useI18n } from '@n8n/i18n';
import { captureMessage } from '@sentry/vue';
import { ElSwitch } from 'element-plus';
import { N8nActionBox, N8nButton, N8nInput, N8nRadioButtons, N8nTooltip } from '@n8n/design-system';
import { useToast } from '@/app/composables/useToast';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { useMessage } from '@/app/composables/useMessage';
import { computed, onMounted, ref } from 'vue';
import UserRoleProvisioningDropdown, {
type UserRoleProvisioningSetting,
} from '../provisioning/components/UserRoleProvisioningDropdown.vue';
import { useUserRoleProvisioningForm } from '../provisioning/composables/useUserRoleProvisioningForm';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useTelemetry } from '@/app/composables/useTelemetry';
import ConfirmProvisioningDialog from '../provisioning/components/ConfirmProvisioningDialog.vue';
const i18n = useI18n();
const ssoStore = useSSOStore();
const telemetry = useTelemetry();
const toast = useToast();
const message = useMessage();
const pageRedirectionHelper = usePageRedirectionHelper();
const redirectUrl = ref();
const IdentityProviderSettingsType = {
URL: 'url',
XML: 'xml',
};
const ipsOptions = ref([
{
label: i18n.baseText('settings.sso.settings.ips.options.url'),
value: IdentityProviderSettingsType.URL,
},
{
label: i18n.baseText('settings.sso.settings.ips.options.xml'),
value: IdentityProviderSettingsType.XML,
},
]);
const ipsType = ref(IdentityProviderSettingsType.URL);
const ssoActivatedLabel = computed(() =>
ssoStore.isSamlLoginEnabled
? i18n.baseText('settings.sso.activated')
: i18n.baseText('settings.sso.deactivated'),
);
const metadataUrl = ref();
const metadata = ref();
const ssoSettingsSaved = ref(false);
const entityId = ref();
const showUserRoleProvisioningDialog = ref(false);
const userRoleProvisioning = ref<UserRoleProvisioningSetting>('disabled');
const { isUserRoleProvisioningChanged, saveProvisioningConfig } =
useUserRoleProvisioningForm(userRoleProvisioning);
async function loadSamlConfig() {
if (!ssoStore.isEnterpriseSamlEnabled) {
return;
}
try {
await getSamlConfig();
} catch (error) {
toast.showError(error, 'error');
}
}
const getSamlConfig = async () => {
const config = await ssoStore.getSamlConfig();
entityId.value = config?.entityID;
redirectUrl.value = config?.returnUrl;
if (config?.metadataUrl) {
ipsType.value = IdentityProviderSettingsType.URL;
} else if (config?.metadata) {
ipsType.value = IdentityProviderSettingsType.XML;
}
metadata.value = config?.metadata;
metadataUrl.value = config?.metadataUrl;
ssoSettingsSaved.value = !!config?.metadata;
};
const isSaveEnabled = computed(() => {
if (isUserRoleProvisioningChanged()) {
return true;
} else if (ipsType.value === IdentityProviderSettingsType.URL) {
return !!metadataUrl.value && metadataUrl.value !== ssoStore.samlConfig?.metadataUrl;
} else if (ipsType.value === IdentityProviderSettingsType.XML) {
return !!metadata.value && metadata.value !== ssoStore.samlConfig?.metadata;
}
return false;
});
const isTestEnabled = computed(() => {
if (ipsType.value === IdentityProviderSettingsType.URL) {
return !!metadataUrl.value && ssoSettingsSaved.value;
} else if (ipsType.value === IdentityProviderSettingsType.XML) {
return !!metadata.value && ssoSettingsSaved.value;
}
return false;
});
const sendTrackingEvent = (config?: SamlPreferences) => {
if (!config) {
captureMessage('Single Sign-On SAML: telemtetry data undefined on submit', { level: 'error' });
return;
}
const trackingMetadata = {
instance_id: useRootStore().instanceId,
authentication_method: SupportedProtocols.SAML,
identity_provider: config.metadataUrl ? 'metadata' : 'xml',
is_active: config.loginEnabled ?? false,
};
telemetry.track('User updated single sign on settings', trackingMetadata);
};
const onSave = async (provisioningChangesConfirmed: boolean = false) => {
try {
validateSamlInput();
if (isUserRoleProvisioningChanged() && !provisioningChangesConfirmed) {
showUserRoleProvisioningDialog.value = true;
return;
}
const config: Partial<SamlPreferences> =
ipsType.value === IdentityProviderSettingsType.URL
? { metadataUrl: metadataUrl.value }
: { metadata: metadata.value };
const configResponse = await ssoStore.saveSamlConfig(config);
if (isUserRoleProvisioningChanged()) {
await saveProvisioningConfig();
showUserRoleProvisioningDialog.value = false;
}
// Update store with saved protocol selection
ssoStore.selectedAuthProtocol = SupportedProtocols.SAML;
// Update store with saved metadata config
ssoStore.samlConfig!.metadata = config.metadata;
ssoStore.samlConfig!.metadataUrl = config.metadataUrl;
if (!ssoStore.isSamlLoginEnabled) {
const answer = await message.confirm(
i18n.baseText('settings.sso.settings.save.activate.message'),
i18n.baseText('settings.sso.settings.save.activate.title'),
{
confirmButtonText: i18n.baseText('settings.sso.settings.save.activate.test'),
cancelButtonText: i18n.baseText('settings.sso.settings.save.activate.cancel'),
},
);
if (answer === 'confirm') {
await onTest();
}
}
await getSamlConfig();
sendTrackingEvent(configResponse);
} catch (error) {
toast.showError(error, i18n.baseText('settings.sso.settings.save.error'));
return;
}
};
const onTest = async () => {
try {
const url = await ssoStore.testSamlConfig();
if (typeof window !== 'undefined') {
window.open(url, '_blank');
}
} catch (error) {
toast.showError(error, 'error');
}
};
const validateSamlInput = () => {
if (ipsType.value === IdentityProviderSettingsType.URL) {
// In case the user wants to set the metadata url we want to be sure that
// the provided url is at least a valid http, https url.
try {
const parsedUrl = new URL(metadataUrl.value);
// We allow http and https URLs for now, because we want to avoid a theoretical breaking
// change, this should be restricted to only allow https when switching to V2.
if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
// The content of this error is never seen by the user, because the catch clause
// below catches it and translates it to a more general error message.
throw new Error('The provided protocol is not supported');
}
} catch (error) {
throw new Error(i18n.baseText('settings.sso.settings.ips.url.invalid'));
}
}
};
const isToggleSsoDisabled = computed(() => {
/** Allow users to disable SSO even if config request fails */
if (ssoStore.isSamlLoginEnabled) {
return false;
}
return !ssoSettingsSaved.value;
});
const goToUpgrade = () => {
void pageRedirectionHelper.goToUpgrade('sso', 'upgrade-sso');
};
onMounted(async () => {
await loadSamlConfig();
});
</script>
<template>
<div v-if="ssoStore.isEnterpriseSamlEnabled" data-test-id="sso-content-licensed">
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.redirectUrl.label') }}</label>
<CopyInput
:value="redirectUrl"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
:toast-title="i18n.baseText('settings.sso.settings.redirectUrl.copied')"
/>
<small>{{ i18n.baseText('settings.sso.settings.redirectUrl.help') }}</small>
</div>
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.entityId.label') }}</label>
<CopyInput
:value="entityId"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
:toast-title="i18n.baseText('settings.sso.settings.entityId.copied')"
/>
<small>{{ i18n.baseText('settings.sso.settings.entityId.help') }}</small>
</div>
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.ips.label') }}</label>
<div class="mt-2xs mb-s">
<N8nRadioButtons v-model="ipsType" :options="ipsOptions" />
</div>
<div v-if="ipsType === IdentityProviderSettingsType.URL">
<N8nInput
v-model="metadataUrl"
type="text"
name="metadataUrl"
size="large"
:placeholder="i18n.baseText('settings.sso.settings.ips.url.placeholder')"
data-test-id="sso-provider-url"
/>
<small>{{ i18n.baseText('settings.sso.settings.ips.url.help') }}</small>
</div>
<div v-if="ipsType === IdentityProviderSettingsType.XML">
<N8nInput
v-model="metadata"
type="textarea"
name="metadata"
:rows="4"
data-test-id="sso-provider-xml"
/>
<small>{{ i18n.baseText('settings.sso.settings.ips.xml.help') }}</small>
</div>
<UserRoleProvisioningDropdown v-model="userRoleProvisioning" auth-protocol="saml" />
<ConfirmProvisioningDialog
v-model="showUserRoleProvisioningDialog"
:new-provisioning-setting="userRoleProvisioning"
auth-protocol="saml"
@confirm-provisioning="onSave(true)"
/>
<div :class="$style.group">
<N8nTooltip
v-if="ssoStore.isEnterpriseSamlEnabled"
:disabled="ssoStore.isSamlLoginEnabled || ssoSettingsSaved"
>
<template #content>
<span>
{{ i18n.baseText('settings.sso.activation.tooltip') }}
</span>
</template>
<ElSwitch
v-model="ssoStore.isSamlLoginEnabled"
data-test-id="sso-toggle"
:disabled="isToggleSsoDisabled"
:class="$style.switch"
:inactive-text="ssoActivatedLabel"
/>
</N8nTooltip>
</div>
</div>
<div :class="$style.buttons">
<N8nButton
:disabled="!isSaveEnabled"
size="large"
data-test-id="sso-save"
@click="onSave(false)"
>
{{ i18n.baseText('settings.sso.settings.save') }}
</N8nButton>
<N8nButton
:disabled="!isTestEnabled"
size="large"
type="tertiary"
data-test-id="sso-test"
@click="onTest"
>
{{ i18n.baseText('settings.sso.settings.test') }}
</N8nButton>
</div>
<footer :class="$style.footer">
{{ i18n.baseText('settings.sso.settings.footer.hint') }}
</footer>
</div>
<N8nActionBox
v-else
data-test-id="sso-content-unlicensed"
:class="$style.actionBox"
:description="i18n.baseText('settings.sso.actionBox.description')"
:button-text="i18n.baseText('settings.sso.actionBox.buttonText')"
@click:button="goToUpgrade"
>
<template #heading>
<span>{{ i18n.baseText('settings.sso.actionBox.title') }}</span>
</template>
</N8nActionBox>
</template>
<style lang="scss" module src="../styles/sso-form.module.scss" />

View File

@ -0,0 +1,265 @@
<script lang="ts" setup>
import { useI18n } from '@n8n/i18n';
import { ElDialog } from 'element-plus';
import { N8nButton, N8nCheckbox, N8nIcon, N8nText } from '@n8n/design-system';
import { ref, watch, computed } from 'vue';
import { useAccessSettingsCsvExport } from '@/features/settings/sso/provisioning/composables/useAccessSettingsCsvExport';
import type { UserRoleProvisioningSetting } from './UserRoleProvisioningDropdown.vue';
import type { SupportedProtocolType } from '../../sso.store';
const visible = defineModel<boolean>();
const props = defineProps<{
newProvisioningSetting: UserRoleProvisioningSetting;
authProtocol: SupportedProtocolType;
}>();
const emit = defineEmits<{
confirmProvisioning: [];
cancel: [];
}>();
const locale = useI18n();
const downloadingInstanceRolesCsv = ref(false);
const downloadingProjectRolesCsv = ref(false);
const loading = ref(false);
const confirmationChecked = ref(false);
const {
hasDownloadedInstanceRoleCsv,
hasDownloadedProjectRoleCsv,
downloadProjectRolesCsv,
downloadInstanceRolesCsv,
accessSettingsCsvExportOnModalClose,
} = useAccessSettingsCsvExport();
const isDisablingProvisioning = computed(() => props.newProvisioningSetting === 'disabled');
const messagingKey = computed(() => (isDisablingProvisioning.value ? 'disable' : 'enable'));
const shouldShowProjectRolesCsv = computed(
() => props.newProvisioningSetting === 'instance_and_project_roles',
);
watch(visible, () => {
loading.value = false;
confirmationChecked.value = false;
accessSettingsCsvExportOnModalClose();
});
const onDownloadInstanceRolesCsv = async () => {
downloadingInstanceRolesCsv.value = true;
try {
await downloadInstanceRolesCsv();
} finally {
downloadingInstanceRolesCsv.value = false;
}
};
const onDownloadProjectRolesCsv = async () => {
downloadingProjectRolesCsv.value = true;
try {
await downloadProjectRolesCsv();
} finally {
downloadingProjectRolesCsv.value = false;
}
};
const onConfirmProvisioningSetting = () => {
loading.value = true;
emit('confirmProvisioning');
};
</script>
<template>
<ElDialog
v-model="visible"
:title="locale.baseText(`settings.provisioningConfirmDialog.${messagingKey}.title`)"
width="650"
>
<template v-if="!isDisablingProvisioning">
<div class="mb-s">
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.breakingChangeDescription.firstLine')
}}</N8nText>
</div>
<ul :class="$style.list" class="mb-s">
<li>
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.breakingChangeDescription.list.one')
}}</N8nText>
</li>
<li v-if="newProvisioningSetting === 'instance_and_project_roles'">
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.breakingChangeDescription.list.two')
}}</N8nText>
</li>
</ul>
<div class="mb-s">
<N8nText color="text-base"
><a
:href="`https://docs.n8n.io/user-management/${authProtocol}/setup/`"
target="_blank"
>{{ locale.baseText('settings.provisioningConfirmDialog.link.docs') }}</a
></N8nText
>
</div>
<div class="mb-s">
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.breakingChangeRequiredSteps')
}}</N8nText>
</div>
<div class="mb-s" :class="$style.buttonRow">
<N8nButton
type="secondary"
native-type="button"
data-test-id="provisioning-download-instance-roles-csv-button"
:disabled="downloadingInstanceRolesCsv"
:loading="downloadingInstanceRolesCsv"
:class="$style.button"
@click="onDownloadInstanceRolesCsv"
>{{
locale.baseText('settings.provisioningConfirmDialog.button.downloadInstanceRolesCsv')
}}</N8nButton
>
<N8nIcon
v-if="hasDownloadedInstanceRoleCsv"
icon="check"
color="success"
:class="$style.icon"
/>
</div>
<div v-if="shouldShowProjectRolesCsv" class="mb-s" :class="$style.buttonRow">
<N8nButton
type="secondary"
native-type="button"
data-test-id="provisioning-download-project-roles-csv-button"
:disabled="downloadingProjectRolesCsv"
:loading="downloadingProjectRolesCsv"
:class="$style.button"
@click="onDownloadProjectRolesCsv"
>{{
locale.baseText('settings.provisioningConfirmDialog.button.downloadProjectRolesCsv')
}}</N8nButton
>
<N8nIcon
v-if="hasDownloadedProjectRoleCsv"
icon="check"
color="success"
:class="$style.icon"
/>
</div>
</template>
<template v-else>
<div class="mb-s">
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.disable.description')
}}</N8nText>
</div>
<div class="mb-s">
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.disable.whatWillHappen')
}}</N8nText>
</div>
<ul :class="$style.list" class="mb-s">
<li>
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.disable.list.one')
}}</N8nText>
</li>
<li>
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.disable.list.two')
}}</N8nText>
</li>
</ul>
<div class="mb-s">
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.disable.beforeSaving')
}}</N8nText>
</div>
<ul :class="$style.list" class="mb-s">
<li>
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.disable.checklist.one')
}}</N8nText>
</li>
<li>
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.disable.checklist.two')
}}</N8nText>
</li>
</ul>
<div class="mb-s">
<N8nText color="text-base"
><a
:href="`https://docs.n8n.io/user-management/${authProtocol}/setup/`"
target="_blank"
>{{ locale.baseText('settings.provisioningConfirmDialog.link.docs') }}</a
></N8nText
>
</div>
</template>
<div class="mb-s">
<N8nCheckbox
v-model="confirmationChecked"
:disabled="
!isDisablingProvisioning &&
(!hasDownloadedInstanceRoleCsv ||
(shouldShowProjectRolesCsv && !hasDownloadedProjectRoleCsv))
"
data-test-id="provisioning-confirmation-checkbox"
>
<N8nText color="text-base">{{
locale.baseText(`settings.provisioningConfirmDialog.${messagingKey}.checkbox`)
}}</N8nText>
</N8nCheckbox>
</div>
<template #footer>
<N8nButton
type="tertiary"
native-type="button"
data-test-id="provisioning-cancel-button"
@click="emit('cancel')"
>{{ locale.baseText('settings.provisioningConfirmDialog.button.cancel') }}</N8nButton
>
<N8nButton
type="primary"
native-type="button"
:disabled="
loading ||
!confirmationChecked ||
(!isDisablingProvisioning && !hasDownloadedInstanceRoleCsv) ||
(shouldShowProjectRolesCsv && !hasDownloadedProjectRoleCsv)
"
data-test-id="provisioning-confirm-button"
@click="onConfirmProvisioningSetting"
>{{
locale.baseText(`settings.provisioningConfirmDialog.button.${messagingKey}.confirm`)
}}</N8nButton
>
</template>
</ElDialog>
</template>
<style lang="scss" module>
.buttonRow {
display: flex;
align-items: center;
}
.button {
min-width: 340px;
}
.icon {
margin-left: var(--spacing--xs);
}
.list {
padding: 0 var(--spacing--sm);
li {
list-style: disc outside;
}
}
</style>

View File

@ -0,0 +1,145 @@
<script lang="ts" setup>
import { SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT } from '@/app/constants';
import type { ProvisioningConfig } from '@n8n/rest-api-client/api/provisioning';
import { N8nOption, N8nSelect } from '@n8n/design-system';
import { onMounted } from 'vue';
import { usePostHog } from '@/app/stores/posthog.store';
import { useUserRoleProvisioningStore } from '../composables/userRoleProvisioning.store';
import { useI18n } from '@n8n/i18n';
import { type SupportedProtocolType } from '../../sso.store';
export type UserRoleProvisioningSetting =
| 'disabled'
| 'instance_role'
| 'instance_and_project_roles';
const value = defineModel<UserRoleProvisioningSetting>({ default: 'disabled' });
const { authProtocol } = defineProps<{
authProtocol: SupportedProtocolType;
}>();
const i18n = useI18n();
const posthogStore = usePostHog();
const userRoleProvisioningStore = useUserRoleProvisioningStore();
const isUserRoleProvisioningFeatureEnabled = posthogStore.isFeatureEnabled(
SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT.name,
);
const handleUserRoleProvisioningChange = (newValue: UserRoleProvisioningSetting) => {
value.value = newValue;
};
const getUserRoleProvisioningValueFromConfig = (
config?: ProvisioningConfig,
): UserRoleProvisioningSetting => {
if (!config) {
return 'disabled';
}
if (config.scopesProvisionInstanceRole && config.scopesProvisionProjectRoles) {
return 'instance_and_project_roles';
} else if (config.scopesProvisionInstanceRole) {
return 'instance_role';
} else {
return 'disabled';
}
};
type UserRoleProvisioningDescription = {
label: string;
description: string;
value: UserRoleProvisioningSetting;
};
const userRoleProvisioningDescriptions: UserRoleProvisioningDescription[] = [
{
label: i18n.baseText('settings.sso.settings.userRoleProvisioning.option.disabled.label'),
value: 'disabled',
description: i18n.baseText(
'settings.sso.settings.userRoleProvisioning.option.disabled.description',
),
},
{
label: i18n.baseText('settings.sso.settings.userRoleProvisioning.option.instanceRole.label'),
value: 'instance_role',
description: i18n.baseText(
'settings.sso.settings.userRoleProvisioning.option.instanceRole.description',
),
},
{
label: i18n.baseText(
'settings.sso.settings.userRoleProvisioning.option.instanceAndProjectRoles.label',
),
value: 'instance_and_project_roles',
description: i18n.baseText(
'settings.sso.settings.userRoleProvisioning.option.instanceAndProjectRoles.description',
),
},
];
const loadUserRoleProvisioningConfig = async () => {
const config = await userRoleProvisioningStore.getProvisioningConfig();
value.value = getUserRoleProvisioningValueFromConfig(config);
};
onMounted(async () => {
await loadUserRoleProvisioningConfig();
});
</script>
<template>
<!-- TODO: also check for 'provisioning:manage' permission scope -->
<div v-if="isUserRoleProvisioningFeatureEnabled" :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.userRoleProvisioning.label') }}</label>
<N8nSelect
:model-value="value"
data-test-id="oidc-user-role-provisioning"
:class="$style.userRoleProvisioningSelect"
@update:model-value="handleUserRoleProvisioningChange"
>
<N8nOption
v-for="option in userRoleProvisioningDescriptions"
:key="option.value"
:label="option.label"
data-test-id="oidc-user-role-provisioning-option"
:value="option.value"
>
<div class="list-option">
<div class="option-headline">{{ option.label }}</div>
<div class="option-description">{{ option.description }}</div>
</div>
</N8nOption>
</N8nSelect>
<small
>{{ i18n.baseText('settings.sso.settings.userRoleProvisioning.help') }}
<a :href="`https://docs.n8n.io/user-management/${authProtocol}/setup/`" target="_blank">{{
i18n.baseText('settings.sso.settings.userRoleProvisioning.help.linkText')
}}</a></small
>
</div>
</template>
<style lang="scss" module>
.group {
padding: var(--spacing--xl) 0 0;
> label {
display: inline-block;
font-size: var(--font-size--sm);
font-weight: var(--font-weight--medium);
padding: 0 0 var(--spacing--2xs);
}
small {
display: block;
padding: var(--spacing--2xs) 0 0;
font-size: var(--font-size--2xs);
color: var(--color--text);
}
}
.userRoleProvisioningSelect {
display: block;
max-width: 400px;
}
</style>

View File

@ -0,0 +1,76 @@
import type { Ref } from 'vue';
import { useUserRoleProvisioningStore } from './userRoleProvisioning.store';
import { usePostHog } from '@/app/stores/posthog.store';
import { SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT } from '@/app/constants/experiments';
import type { ProvisioningConfig } from '@n8n/rest-api-client/api/provisioning';
import { type UserRoleProvisioningSetting } from '../components/UserRoleProvisioningDropdown.vue';
/**
* Composable for managing user role provisioning form logic in SSO settings.
*/
export function useUserRoleProvisioningForm(
userRoleProvisioning: Ref<UserRoleProvisioningSetting>,
) {
const provisioningStore = useUserRoleProvisioningStore();
const posthogStore = usePostHog();
const getUserRoleProvisioningValueFromConfig = (
config?: ProvisioningConfig,
): UserRoleProvisioningSetting => {
if (!config) {
return 'disabled';
}
if (config.scopesProvisionInstanceRole && config.scopesProvisionProjectRoles) {
return 'instance_and_project_roles';
} else if (config.scopesProvisionInstanceRole) {
return 'instance_role';
} else {
return 'disabled';
}
};
const getProvisioningConfigFromFormValue = (
formValue: UserRoleProvisioningSetting,
): Pick<ProvisioningConfig, 'scopesProvisionInstanceRole' | 'scopesProvisionProjectRoles'> => {
if (formValue === 'instance_role') {
return {
scopesProvisionInstanceRole: true,
scopesProvisionProjectRoles: false,
};
} else if (formValue === 'instance_and_project_roles') {
return {
scopesProvisionInstanceRole: true,
scopesProvisionProjectRoles: true,
};
} else {
return {
scopesProvisionInstanceRole: false,
scopesProvisionProjectRoles: false,
};
}
};
const isUserRoleProvisioningChanged = (): boolean => {
if (!posthogStore.isFeatureEnabled(SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT.name)) {
return false;
}
return (
getUserRoleProvisioningValueFromConfig(provisioningStore.provisioningConfig) !==
userRoleProvisioning.value
);
};
/**
* Saves the current user role provisioning setting to the store.
*/
const saveProvisioningConfig = async (): Promise<void> => {
await provisioningStore.saveProvisioningConfig(
getProvisioningConfigFromFormValue(userRoleProvisioning.value),
);
};
return {
isUserRoleProvisioningChanged,
saveProvisioningConfig,
};
}

View File

@ -1,21 +1,17 @@
import { computed, ref } from 'vue';
import { ref, readonly } from 'vue';
import { defineStore } from 'pinia';
import { useRootStore } from '@n8n/stores/useRootStore';
import * as provisioningApi from '@n8n/rest-api-client/api/provisioning';
import type { ProvisioningConfig } from '@n8n/rest-api-client/api/provisioning';
export const useProvisioningStore = defineStore('provisioning', () => {
/**
* Composable to load and save provisioning config
*/
export const useUserRoleProvisioningStore = defineStore('userRoleProvisioning', () => {
const rootStore = useRootStore();
const provisioningConfig = ref<ProvisioningConfig | undefined>();
const isProvisioningEnabled = computed(
() =>
provisioningConfig.value?.scopesProvisionInstanceRole ||
provisioningConfig.value?.scopesProvisionProjectRoles ||
false,
);
const getProvisioningConfig = async () => {
try {
const config = await provisioningApi.getProvisioningConfig(rootStore.restApiContext);
@ -42,8 +38,7 @@ export const useProvisioningStore = defineStore('provisioning', () => {
};
return {
provisioningConfig,
isProvisioningEnabled,
provisioningConfig: readonly(provisioningConfig),
getProvisioningConfig,
saveProvisioningConfig,
};

View File

@ -0,0 +1,48 @@
/**
* Shared styles for SSO forms
*/
.switch {
span {
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--bold);
color: var(--color--text--tint-1);
}
}
.buttons {
display: flex;
justify-content: flex-start;
padding: var(--spacing--2xl) 0 var(--spacing--2xs);
button {
margin: 0 var(--spacing--sm) 0 0;
}
}
.group {
padding: var(--spacing--xl) 0 0;
> label {
display: inline-block;
font-size: var(--font-size--sm);
font-weight: var(--font-weight--medium);
padding: 0 0 var(--spacing--2xs);
}
small {
display: block;
padding: var(--spacing--2xs) 0 0;
font-size: var(--font-size--2xs);
color: var(--color--text);
}
}
.actionBox {
margin: var(--spacing--2xl) 0 0;
}
.footer {
color: var(--color--text);
font-size: var(--font-size--2xs);
}

View File

@ -138,6 +138,15 @@ describe('SettingsSso View', () => {
ssoStore.isEnterpriseSamlEnabled = true;
ssoStore.isEnterpriseOidcEnabled = true;
ssoStore.isSamlLoginEnabled = false;
ssoStore.samlConfig = { ...samlConfig, metadataUrl: undefined, metadata: undefined };
ssoStore.getSamlConfig.mockResolvedValue({
...samlConfig,
metadataUrl: undefined,
metadata: undefined,
});
ssoStore.saveSamlConfig.mockResolvedValue({ ...samlConfig, metadata: undefined });
ssoStore.testSamlConfig.mockResolvedValue('https://test-url.com');
const { getByTestId } = renderView();
@ -174,6 +183,11 @@ describe('SettingsSso View', () => {
ssoStore.isEnterpriseSamlEnabled = true;
ssoStore.isEnterpriseOidcEnabled = true;
ssoStore.isSamlLoginEnabled = false;
ssoStore.samlConfig = { ...samlConfig, metadataUrl: undefined, metadata: undefined };
// Mock should return config with metadata but WITHOUT metadataUrl (since user filled XML)
ssoStore.saveSamlConfig.mockResolvedValue({ ...samlConfig, metadataUrl: undefined });
ssoStore.testSamlConfig.mockResolvedValue('https://test-url.com');
const { getByTestId } = renderView();
@ -229,7 +243,8 @@ describe('SettingsSso View', () => {
expect(telemetryTrack).not.toHaveBeenCalled();
expect(ssoStore.getSamlConfig).toHaveBeenCalledTimes(2);
// getSamlConfig only called once (on mount) since save failed validation
expect(ssoStore.getSamlConfig).toHaveBeenCalledTimes(1);
});
it('should ensure the url does not support invalid protocols like mailto', async () => {
@ -256,7 +271,8 @@ describe('SettingsSso View', () => {
expect(telemetryTrack).not.toHaveBeenCalled();
expect(ssoStore.getSamlConfig).toHaveBeenCalledTimes(2);
// getSamlConfig only called once (on mount) since save failed validation
expect(ssoStore.getSamlConfig).toHaveBeenCalledTimes(1);
});
it('allows user to disable SSO even if config request failed', async () => {
@ -325,16 +341,17 @@ describe('SettingsSso View', () => {
});
it('allows user to save OIDC config', async () => {
ssoStore.saveOidcConfig.mockResolvedValue(oidcConfig);
ssoStore.isEnterpriseOidcEnabled = true;
ssoStore.isEnterpriseSamlEnabled = false;
ssoStore.isOidcLoginEnabled = true;
ssoStore.isSamlLoginEnabled = false;
ssoStore.oidcConfig = { ...oidcConfig, discoveryEndpoint: '' };
ssoStore.getOidcConfig.mockResolvedValue({
...oidcConfig,
discoveryEndpoint: '',
});
ssoStore.saveOidcConfig.mockResolvedValue({ ...oidcConfig, loginEnabled: true });
const { getByTestId, getByRole } = renderView();
@ -367,6 +384,12 @@ describe('SettingsSso View', () => {
await userEvent.type(clientSecretInput, 'test-client-secret');
expect(saveButton).not.toBeDisabled();
// Pinia mocked stores don't execute real store logic. In production, saveOidcConfig
// updates oidcConfig.value (sso.store.ts:144), but the mock just returns a value.
// We manually update the store to match what the real store would do.
ssoStore.oidcConfig = oidcConfig;
await userEvent.click(saveButton);
expect(ssoStore.saveOidcConfig).toHaveBeenCalledWith(

Some files were not shown because too many files have changed in this diff Show More