mirror of
https://github.com/n8n-io/n8n.git
synced 2025-11-20 17:46:34 +00:00
Merge branch 'master' into bugfix/n8n-crash-recovery-null-startedAt
This commit is contained in:
commit
60a7460ed0
119
.github/scripts/determine-runners-tags.sh
vendored
119
.github/scripts/determine-runners-tags.sh
vendored
@ -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
171
.github/scripts/docker/docker-config.mjs
vendored
Normal 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
113
.github/scripts/docker/docker-tags.mjs
vendored
Normal 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;
|
||||
2
.github/workflows/ci-postgres-mysql.yml
vendored
2
.github/workflows/ci-postgres-mysql.yml
vendored
@ -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
|
||||
|
||||
453
.github/workflows/docker-build-push.yml
vendored
453
.github/workflows/docker-build-push.yml
vendored
@ -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' ||
|
||||
|
||||
@ -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
|
||||
# ==============================================================================
|
||||
|
||||
@ -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
|
||||
# ==============================================================================
|
||||
|
||||
@ -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
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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[];
|
||||
|
||||
@ -27,6 +27,8 @@ export {
|
||||
emptyChatModelsResponse,
|
||||
type ChatModelsRequest,
|
||||
type ChatModelsResponse,
|
||||
chatAttachmentSchema,
|
||||
type ChatAttachment,
|
||||
ChatHubSendMessageRequest,
|
||||
ChatHubRegenerateMessageRequest,
|
||||
ChatHubEditMessageRequest,
|
||||
|
||||
@ -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']);
|
||||
}
|
||||
}
|
||||
@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
};
|
||||
@ -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>;
|
||||
@ -0,0 +1,5 @@
|
||||
export {
|
||||
ContextEstablishmentHookMetadata,
|
||||
ContextEstablishmentHook,
|
||||
} from './context-establishment-hook-metadata';
|
||||
export type * from './context-establishment-hook';
|
||||
@ -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', () => {
|
||||
87
packages/@n8n/utils/src/files/sanitize.ts
Normal file
87
packages/@n8n/utils/src/files/sanitize.ts
Normal 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;
|
||||
};
|
||||
@ -7,3 +7,4 @@ export * from './search/reRankSearchResults';
|
||||
export * from './search/sublimeSearch';
|
||||
export * from './sort/sortByProperty';
|
||||
export * from './string/truncate';
|
||||
export * from './files/sanitize';
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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`,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -32,6 +32,7 @@ export class ChatHubAgentService {
|
||||
},
|
||||
createdAt: agent.createdAt.toISOString(),
|
||||
updatedAt: agent.updatedAt.toISOString(),
|
||||
allowFileUploads: true,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
160
packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts
Normal file
160
packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 */
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -15,7 +15,7 @@ properties:
|
||||
example: John's Github account
|
||||
type:
|
||||
type: string
|
||||
example: github
|
||||
example: githubApi
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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,
|
||||
}));
|
||||
|
||||
@ -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[]) {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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') }}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
```
|
||||
@ -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>
|
||||
```
|
||||
@ -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",
|
||||
|
||||
@ -444,7 +444,7 @@ export type SimplifiedNodeType = Pick<
|
||||
| 'defaults'
|
||||
| 'outputs'
|
||||
> & {
|
||||
tag?: string;
|
||||
tag?: NodeCreatorTag;
|
||||
};
|
||||
export interface SubcategoryItemProps {
|
||||
description?: string;
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
@ -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',
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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[] = [];
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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',
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -251,6 +251,7 @@ export function createAiMessageFromStreamingState(
|
||||
revisionOfMessageId: null,
|
||||
responses: [],
|
||||
alternatives: [],
|
||||
attachments: [],
|
||||
...(streaming?.model
|
||||
? flattenModel(streaming.model)
|
||||
: {
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -68,6 +68,7 @@ function handleSelectModelById(provider: ChatHubLLMProvider, modelId: string) {
|
||||
description: null,
|
||||
updatedAt: null,
|
||||
createdAt: null,
|
||||
allowFileUploads: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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 = {};
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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" />
|
||||
@ -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" />
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
@ -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
Loading…
Reference in New Issue
Block a user