Compare commits

...

14 commits

Author SHA1 Message Date
Michael Kret 4e6e035e5d Merge branch 'master' of https://github.com/n8n-io/n8n into node-1714-show-result-of-waiting-execution-on-canvas-after-execution 2024-09-19 17:06:26 +03:00
Michael Kret 0572977364 review update 2024-09-19 17:06:20 +03:00
Tomi Turtiainen 8fb31e8459
fix(benchmark): Simplify binary data scenario setup and use larger binary file (#10879) 2024-09-19 16:21:55 +03:00
Ricardo Espinoza cee57b6504
refactor(editor): Migrate LogStreaming.store.ts to composition API (#10719)
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions
Benchmark Docker Image CI / build (push) Waiting to run
2024-09-19 09:15:01 -04:00
Ricardo Espinoza f5474ff791
perf(editor): Use virtual scrolling in RunDataJson.vue (#10838) 2024-09-19 08:43:09 -04:00
Michael Kret 548907e529 Merge branch 'master' of https://github.com/n8n-io/n8n into node-1714-show-result-of-waiting-execution-on-canvas-after-execution 2024-09-19 15:29:49 +03:00
Milorad FIlipović b86fd80fc9
fix(editor): Update gird size when opening credentials support chat (#10882) 2024-09-19 14:06:14 +02:00
कारतोफ्फेलस्क्रिप्ट™ aa6cfa07ef
ci: Upgrade storybook and chromatic (no-changelog) (#10878) 2024-09-19 10:57:41 +02:00
Iván Ovejero 69c6e0790d
refactor(core): Organize Redis under scaling mode (#10864) 2024-09-19 09:52:48 +02:00
Milorad FIlipović 91008b2676
refactor(editor): Migrate AuthView and associated components to composition api (#10713)
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions
2024-09-18 23:38:45 -04:00
Tomi Turtiainen ee7147c6b3
fix(MQTT Node): Close connection if connection attempt fails (#10873)
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions
Benchmark Docker Image CI / build (push) Waiting to run
2024-09-18 20:03:18 +03:00
कारतोफ्फेलस्क्रिप्ट™ 0a317b7072
ci(editor): Use eslint-plugin-prettier for vue files during development (no-changelog) (#10874) 2024-09-18 17:17:00 +02:00
Ricardo Espinoza 05ae2aa9c5
refactor(editor): Migrate RunDataJson.vue to composition API (#10861)
Co-authored-by: Raúl Gómez Morales <raul00gm@gmail.com>
2024-09-18 11:06:25 -04:00
कारतोफ्फेलस्क्रिप्ट™ a7d24d9dac
ci: Skip running pre-commit hooks on rebase and merge (no-changelog) (#10875) 2024-09-18 16:26:34 +02:00
51 changed files with 2412 additions and 5478 deletions

View file

@ -4,7 +4,13 @@ pre-commit:
glob: 'packages/**/*.{js,ts,json}'
run: ./node_modules/.bin/biome check --write --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files}
stage_fixed: true
skip:
- merge
- rebase
prettier_check:
glob: 'packages/**/*.{vue,yml,md}'
run: ./node_modules/.bin/prettier --write --ignore-unknown --no-error-on-unmatched-pattern {staged_files}
stage_fixed: true
skip:
- merge
- rebase

View file

@ -3,8 +3,9 @@ import { check } from 'k6';
const apiBaseUrl = __ENV.API_BASE_URL;
const file = open(__ENV.SCRIPT_FILE_PATH, 'b');
const filename = String(__ENV.SCRIPT_FILE_PATH).split('/').pop();
// This creates a 2MB file (16 * 128 * 1024 = 2 * 1024 * 1024 = 2MB)
const file = Array.from({ length: 128 * 1024 }, () => Math.random().toString().slice(2)).join('');
const filename = 'test.bin';
export default function () {
const data = {

View file

@ -77,7 +77,6 @@ export function handleSummary(data) {
env: {
API_BASE_URL: this.opts.n8nApiBaseUrl,
K6_CLOUD_TOKEN: this.opts.k6ApiToken,
SCRIPT_FILE_PATH: augmentedTestScriptPath,
},
stdio: 'inherit',
})`${k6ExecutablePath} run ${flattedFlags} ${augmentedTestScriptPath}`;

View file

@ -3,19 +3,19 @@
"private": true,
"version": "0.0.1",
"devDependencies": {
"@chromatic-com/storybook": "^1.5.0",
"@storybook/addon-a11y": "^8.1.4",
"@storybook/addon-actions": "^8.1.4",
"@storybook/addon-docs": "^8.1.4",
"@storybook/addon-essentials": "^8.1.4",
"@storybook/addon-interactions": "^8.1.4",
"@storybook/addon-links": "^8.1.4",
"@storybook/addon-themes": "^8.1.4",
"@storybook/blocks": "^8.1.4",
"@storybook/test": "^8.1.4",
"@storybook/vue3": "^8.1.4",
"@storybook/vue3-vite": "^8.1.4",
"chromatic": "^11.4.1",
"storybook": "^8.1.4"
"@chromatic-com/storybook": "^2.0.2",
"@storybook/addon-a11y": "^8.3.1",
"@storybook/addon-actions": "^8.3.1",
"@storybook/addon-docs": "^8.3.1",
"@storybook/addon-essentials": "^8.3.1",
"@storybook/addon-interactions": "^8.3.1",
"@storybook/addon-links": "^8.3.1",
"@storybook/addon-themes": "^8.3.1",
"@storybook/blocks": "^8.3.1",
"@storybook/test": "^8.3.1",
"@storybook/vue3": "^8.3.1",
"@storybook/vue3-vite": "^8.3.1",
"chromatic": "^11.10.2",
"storybook": "^8.3.1"
}
}

View file

@ -1,3 +1,5 @@
const isCI = process.env.CI === 'true';
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
@ -21,58 +23,78 @@ module.exports = {
'import/no-extraneous-dependencies': 'off',
},
},
{
files: ['**/*.vue'],
plugins: isCI ? [] : ['eslint-plugin-prettier'],
rules: {
'vue/no-deprecated-slot-attribute': 'error',
'vue/no-deprecated-slot-scope-attribute': 'error',
'vue/no-multiple-template-root': 'error',
'vue/v-slot-style': 'error',
'vue/no-unused-components': 'error',
'vue/multi-word-component-names': 'off',
'vue/component-name-in-template-casing': [
'error',
'PascalCase',
{
registeredComponentsOnly: true,
},
],
'vue/no-reserved-component-names': [
'error',
{
disallowVueBuiltInComponents: true,
disallowVue3BuiltInComponents: false,
},
],
'vue/prop-name-casing': ['error', 'camelCase'],
'vue/attribute-hyphenation': ['error', 'always'],
'vue/define-emits-declaration': ['error', 'type-literal'],
'vue/require-macro-variable-name': [
'error',
{
defineProps: 'props',
defineEmits: 'emit',
defineSlots: 'slots',
useSlots: 'slots',
useAttrs: 'attrs',
},
],
'vue/block-order': [
'error',
{
order: ['script', 'template', 'style'],
},
],
'vue/no-v-html': 'error',
...(isCI
? {}
: {
'prettier/prettier': ['error', { endOfLine: 'auto' }],
'arrow-body-style': 'off',
'prefer-arrow-callback': 'off',
}),
// TODO: remove these
'vue/no-mutating-props': 'warn',
'vue/no-side-effects-in-computed-properties': 'warn',
'vue/no-v-text-v-html-on-component': 'warn',
'vue/return-in-computed-property': 'warn',
},
},
],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-console': 'warn',
'no-debugger': isCI ? 'error' : 'off',
semi: [2, 'always'],
'comma-dangle': ['error', 'always-multiline'],
'no-tabs': 0,
'no-labels': 0,
'vue/no-deprecated-slot-attribute': 'error',
'vue/no-deprecated-slot-scope-attribute': 'error',
'vue/no-multiple-template-root': 'error',
'vue/v-slot-style': 'error',
'vue/no-unused-components': 'error',
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/no-explicit-any': 'error',
'vue/component-name-in-template-casing': [
'error',
'PascalCase',
{
registeredComponentsOnly: true,
},
],
'vue/no-reserved-component-names': [
'error',
{
disallowVueBuiltInComponents: true,
disallowVue3BuiltInComponents: false,
},
],
'vue/prop-name-casing': ['error', 'camelCase'],
'vue/attribute-hyphenation': ['error', 'always'],
'import/no-extraneous-dependencies': 'warn',
'vue/define-emits-declaration': ['error', 'type-literal'],
'vue/require-macro-variable-name': [
'error',
{
defineProps: 'props',
defineEmits: 'emit',
defineSlots: 'slots',
useSlots: 'slots',
useAttrs: 'attrs',
},
],
'vue/block-order': [
'error',
{
order: ['script', 'template', 'style'],
},
],
'vue/no-v-html': 'error',
// TODO: fix these
'@typescript-eslint/no-unsafe-call': 'off',
@ -84,10 +106,6 @@ module.exports = {
'@typescript-eslint/no-unsafe-member-access': 'off',
// TODO: remove these
'vue/no-mutating-props': 'warn',
'vue/no-side-effects-in-computed-properties': 'warn',
'vue/no-v-text-v-html-on-component': 'warn',
'vue/return-in-computed-property': 'warn',
'n8n-local-rules/no-plain-errors': 'off',
},
};

View file

@ -15,6 +15,7 @@
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-n8n-local-rules": "^1.0.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^51.0.1",
"eslint-plugin-unused-imports": "^3.1.0",
"eslint-plugin-vue": "^9.23.0",

View file

@ -3,11 +3,11 @@ import { mock } from 'jest-mock-extended';
import config from '@/config';
import { generateNanoId } from '@/databases/utils/generators';
import type { RedisClientService } from '@/services/redis/redis-client.service';
import type {
RedisServiceCommandObject,
RedisServiceWorkerResponseObject,
} from '@/services/redis/redis-service-commands';
} from '@/scaling/redis/redis-service-commands';
import type { RedisClientService } from '@/services/redis-client.service';
import { Publisher } from '../pubsub/publisher.service';

View file

@ -2,7 +2,7 @@ import type { Redis as SingleNodeClient } from 'ioredis';
import { mock } from 'jest-mock-extended';
import config from '@/config';
import type { RedisClientService } from '@/services/redis/redis-client.service';
import type { RedisClientService } from '@/services/redis-client.service';
import { Subscriber } from '../pubsub/subscriber.service';

View file

@ -1,3 +1,7 @@
export const QUEUE_NAME = 'jobs';
export const JOB_TYPE_NAME = 'job';
export const COMMAND_PUBSUB_CHANNEL = 'n8n.commands';
export const WORKER_RESPONSE_PUBSUB_CHANNEL = 'n8n.worker-response';

View file

@ -3,11 +3,11 @@ import { Service } from 'typedi';
import config from '@/config';
import { Logger } from '@/logger';
import { RedisClientService } from '@/services/redis/redis-client.service';
import type {
RedisServiceCommandObject,
RedisServiceWorkerResponseObject,
} from '@/services/redis/redis-service-commands';
} from '@/scaling/redis/redis-service-commands';
import { RedisClientService } from '@/services/redis-client.service';
/**
* Responsible for publishing messages into the pubsub channels used by scaling mode.

View file

@ -1,7 +1,8 @@
import type {
COMMAND_REDIS_CHANNEL,
WORKER_RESPONSE_REDIS_CHANNEL,
} from '@/services/redis/redis-constants';
import type { PushType, WorkerStatus } from '@n8n/api-types';
import type { IWorkflowDb } from '@/interfaces';
import type { COMMAND_PUBSUB_CHANNEL, WORKER_RESPONSE_PUBSUB_CHANNEL } from '../constants';
/**
* Pubsub channel used by scaling mode:
@ -10,5 +11,90 @@ import type {
* - `n8n.worker-response` for messages sent by workers in response to commands from main processes
*/
export type ScalingPubSubChannel =
| typeof COMMAND_REDIS_CHANNEL
| typeof WORKER_RESPONSE_REDIS_CHANNEL;
| typeof COMMAND_PUBSUB_CHANNEL
| typeof WORKER_RESPONSE_PUBSUB_CHANNEL;
export type PubSubMessageMap = {
// #region Lifecycle
'reload-license': never;
'restart-event-bus': {
result: 'success' | 'error';
error?: string;
};
'reload-external-secrets-providers': {
result: 'success' | 'error';
error?: string;
};
'stop-worker': never;
// #endregion
// #region Community packages
'community-package-install': {
packageName: string;
packageVersion: string;
};
'community-package-update': {
packageName: string;
packageVersion: string;
};
'community-package-uninstall': {
packageName: string;
packageVersion: string;
};
// #endregion
// #region Worker view
'get-worker-id': never;
'get-worker-status': WorkerStatus;
// #endregion
// #region Multi-main setup
'add-webhooks-triggers-and-pollers': {
workflowId: string;
};
'remove-triggers-and-pollers': {
workflowId: string;
};
'display-workflow-activation': {
workflowId: string;
};
'display-workflow-deactivation': {
workflowId: string;
};
// currently 'workflow-failed-to-activate'
'display-workflow-activation-error': {
workflowId: string;
errorMessage: string;
};
'relay-execution-lifecycle-event': {
type: PushType;
args: Record<string, unknown>;
pushRef: string;
};
'clear-test-webhooks': {
webhookKey: string;
workflowEntity: IWorkflowDb;
pushRef: string;
};
// #endregion
};

View file

@ -3,7 +3,7 @@ import { Service } from 'typedi';
import config from '@/config';
import { Logger } from '@/logger';
import { RedisClientService } from '@/services/redis/redis-client.service';
import { RedisClientService } from '@/services/redis-client.service';
import type { ScalingPubSubChannel } from './pubsub.types';

View file

@ -24,7 +24,7 @@ import type {
JobStatus,
JobId,
QueueRecoveryContext,
PubSubMessage,
JobReport,
} from './scaling.types';
@Service()
@ -46,7 +46,7 @@ export class ScalingService {
async setupQueue() {
const { default: BullQueue } = await import('bull');
const { RedisClientService } = await import('@/services/redis/redis-client.service');
const { RedisClientService } = await import('@/services/redis-client.service');
const service = Container.get(RedisClientService);
const bullPrefix = this.globalConfig.queue.bull.prefix;
@ -265,7 +265,7 @@ export class ScalingService {
}
}
private isPubSubMessage(candidate: unknown): candidate is PubSubMessage {
private isPubSubMessage(candidate: unknown): candidate is JobReport {
return typeof candidate === 'object' && candidate !== null && 'kind' in candidate;
}

View file

@ -23,11 +23,11 @@ export type JobStatus = Bull.JobStatus;
export type JobOptions = Bull.JobOptions;
export type PubSubMessage = MessageToMain | MessageToWorker;
export type JobReport = JobReportToMain | JobReportToWorker;
type MessageToMain = RespondToWebhookMessage;
type JobReportToMain = RespondToWebhookMessage;
type MessageToWorker = AbortJobMessage;
type JobReportToWorker = AbortJobMessage;
type RespondToWebhookMessage = {
kind: 'respond-to-webhook';

View file

@ -9,13 +9,13 @@ import config from '@/config';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { ExternalSecretsManager } from '@/external-secrets/external-secrets-manager.ee';
import { Push } from '@/push';
import type { RedisServiceWorkerResponseObject } from '@/scaling/redis/redis-service-commands';
import * as helpers from '@/services/orchestration/helpers';
import { handleCommandMessageMain } from '@/services/orchestration/main/handle-command-message-main';
import { handleWorkerResponseMessageMain } from '@/services/orchestration/main/handle-worker-response-message-main';
import { OrchestrationHandlerMainService } from '@/services/orchestration/main/orchestration.handler.main.service';
import { OrchestrationService } from '@/services/orchestration.service';
import { RedisClientService } from '@/services/redis/redis-client.service';
import type { RedisServiceWorkerResponseObject } from '@/services/redis/redis-service-commands';
import { RedisClientService } from '@/services/redis-client.service';
import { mockInstance } from '@test/mocking';
import type { MainResponseReceivedHandlerOptions } from '../orchestration/main/types';

View file

@ -36,7 +36,7 @@ export class CacheService extends TypedEmitter<CacheEvents> {
const useRedis = backend === 'redis' || (backend === 'auto' && mode === 'queue');
if (useRedis) {
const { RedisClientService } = await import('../redis/redis-client.service');
const { RedisClientService } = await import('../redis-client.service');
const redisClientService = Container.get(RedisClientService);
const prefixBase = config.getEnv('redis.prefix');

View file

@ -8,7 +8,10 @@ import type { Publisher } from '@/scaling/pubsub/publisher.service';
import type { Subscriber } from '@/scaling/pubsub/subscriber.service';
import { MultiMainSetup } from './orchestration/main/multi-main-setup.ee';
import type { RedisServiceBaseCommand, RedisServiceCommand } from './redis/redis-service-commands';
import type {
RedisServiceBaseCommand,
RedisServiceCommand,
} from '../scaling/redis/redis-service-commands';
@Service()
export class OrchestrationService {

View file

@ -3,9 +3,9 @@ import os from 'node:os';
import { Container } from 'typedi';
import { Logger } from '@/logger';
import { COMMAND_PUBSUB_CHANNEL } from '@/scaling/constants';
import { COMMAND_REDIS_CHANNEL } from '../redis/redis-constants';
import type { RedisServiceCommandObject } from '../redis/redis-service-commands';
import type { RedisServiceCommandObject } from '../../scaling/redis/redis-service-commands';
export interface RedisServiceCommandLastReceived {
[date: string]: Date;
@ -18,7 +18,7 @@ export function messageToRedisServiceCommandObject(messageString: string) {
message = jsonParse<RedisServiceCommandObject>(messageString);
} catch {
Container.get(Logger).debug(
`Received invalid message via channel ${COMMAND_REDIS_CHANNEL}: "${messageString}"`,
`Received invalid message via channel ${COMMAND_PUBSUB_CHANNEL}: "${messageString}"`,
);
return;
}

View file

@ -3,11 +3,11 @@ import { jsonParse } from 'n8n-workflow';
import Container from 'typedi';
import { Logger } from '@/logger';
import { WORKER_RESPONSE_REDIS_CHANNEL } from '@/services/redis/redis-constants';
import { WORKER_RESPONSE_PUBSUB_CHANNEL } from '@/scaling/constants';
import type { MainResponseReceivedHandlerOptions } from './types';
import { Push } from '../../../push';
import type { RedisServiceWorkerResponseObject } from '../../redis/redis-service-commands';
import type { RedisServiceWorkerResponseObject } from '../../../scaling/redis/redis-service-commands';
export async function handleWorkerResponseMessageMain(
messageString: string,
@ -19,7 +19,7 @@ export async function handleWorkerResponseMessageMain(
if (!workerResponse) {
Container.get(Logger).debug(
`Received invalid message via channel ${WORKER_RESPONSE_REDIS_CHANNEL}: "${messageString}"`,
`Received invalid message via channel ${WORKER_RESPONSE_PUBSUB_CHANNEL}: "${messageString}"`,
);
return;
}

View file

@ -6,7 +6,7 @@ import config from '@/config';
import { TIME } from '@/constants';
import { Logger } from '@/logger';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import { RedisClientService } from '@/services/redis/redis-client.service';
import { RedisClientService } from '@/services/redis-client.service';
import { TypedEmitter } from '@/typed-emitter';
type MultiMainEvents = {

View file

@ -1,12 +1,12 @@
import { Service } from 'typedi';
import { COMMAND_PUBSUB_CHANNEL, WORKER_RESPONSE_PUBSUB_CHANNEL } from '@/scaling/constants';
import { Subscriber } from '@/scaling/pubsub/subscriber.service';
import { handleCommandMessageMain } from './handle-command-message-main';
import { handleWorkerResponseMessageMain } from './handle-worker-response-message-main';
import type { MainResponseReceivedHandlerOptions } from './types';
import { OrchestrationHandlerService } from '../../orchestration.handler.base.service';
import { COMMAND_REDIS_CHANNEL, WORKER_RESPONSE_REDIS_CHANNEL } from '../../redis/redis-constants';
@Service()
export class OrchestrationHandlerMainService extends OrchestrationHandlerService {
@ -19,9 +19,9 @@ export class OrchestrationHandlerMainService extends OrchestrationHandlerService
await this.subscriber.subscribe('n8n.worker-response');
this.subscriber.addMessageHandler(async (channel: string, messageString: string) => {
if (channel === WORKER_RESPONSE_REDIS_CHANNEL) {
if (channel === WORKER_RESPONSE_PUBSUB_CHANNEL) {
await handleWorkerResponseMessageMain(messageString, options);
} else if (channel === COMMAND_REDIS_CHANNEL) {
} else if (channel === COMMAND_PUBSUB_CHANNEL) {
await handleCommandMessageMain(messageString);
}
});

View file

@ -1,88 +0,0 @@
import type { PushType, WorkerStatus } from '@n8n/api-types';
import type { IWorkflowDb } from '@/interfaces';
export type PubSubMessageMap = {
// #region Lifecycle
'reload-license': never;
'restart-event-bus': {
result: 'success' | 'error';
error?: string;
};
'reload-external-secrets-providers': {
result: 'success' | 'error';
error?: string;
};
'stop-worker': never;
// #endregion
// #region Community packages
'community-package-install': {
packageName: string;
packageVersion: string;
};
'community-package-update': {
packageName: string;
packageVersion: string;
};
'community-package-uninstall': {
packageName: string;
packageVersion: string;
};
// #endregion
// #region Worker view
'get-worker-id': never;
'get-worker-status': WorkerStatus;
// #endregion
// #region Multi-main setup
'add-webhooks-triggers-and-pollers': {
workflowId: string;
};
'remove-triggers-and-pollers': {
workflowId: string;
};
'display-workflow-activation': {
workflowId: string;
};
'display-workflow-deactivation': {
workflowId: string;
};
// currently 'workflow-failed-to-activate'
'display-workflow-activation-error': {
workflowId: string;
errorMessage: string;
};
'relay-execution-lifecycle-event': {
type: PushType;
args: Record<string, unknown>;
pushRef: string;
};
'clear-test-webhooks': {
webhookKey: string;
workflowEntity: IWorkflowDb;
pushRef: string;
};
// #endregion
};

View file

@ -1,10 +1,10 @@
import { Service } from 'typedi';
import { COMMAND_PUBSUB_CHANNEL } from '@/scaling/constants';
import { Subscriber } from '@/scaling/pubsub/subscriber.service';
import { handleCommandMessageWebhook } from './handle-command-message-webhook';
import { OrchestrationHandlerService } from '../../orchestration.handler.base.service';
import { COMMAND_REDIS_CHANNEL } from '../../redis/redis-constants';
@Service()
export class OrchestrationHandlerWebhookService extends OrchestrationHandlerService {
@ -16,7 +16,7 @@ export class OrchestrationHandlerWebhookService extends OrchestrationHandlerServ
await this.subscriber.subscribe('n8n.commands');
this.subscriber.addMessageHandler(async (channel: string, messageString: string) => {
if (channel === COMMAND_REDIS_CHANNEL) {
if (channel === COMMAND_PUBSUB_CHANNEL) {
await handleCommandMessageWebhook(messageString);
}
});

View file

@ -7,9 +7,9 @@ import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'
import { ExternalSecretsManager } from '@/external-secrets/external-secrets-manager.ee';
import { License } from '@/license';
import { Logger } from '@/logger';
import { COMMAND_PUBSUB_CHANNEL } from '@/scaling/constants';
import type { RedisServiceCommandObject } from '@/scaling/redis/redis-service-commands';
import { CommunityPackagesService } from '@/services/community-packages.service';
import { COMMAND_REDIS_CHANNEL } from '@/services/redis/redis-constants';
import type { RedisServiceCommandObject } from '@/services/redis/redis-service-commands';
import type { WorkerCommandReceivedHandlerOptions } from './types';
import { debounceMessageReceiver, getOsCpuString } from '../helpers';
@ -17,7 +17,7 @@ import { debounceMessageReceiver, getOsCpuString } from '../helpers';
export function getWorkerCommandReceivedHandler(options: WorkerCommandReceivedHandlerOptions) {
// eslint-disable-next-line complexity
return async (channel: string, messageString: string) => {
if (channel === COMMAND_REDIS_CHANNEL) {
if (channel === COMMAND_PUBSUB_CHANNEL) {
if (!messageString) return;
const logger = Container.get(Logger);
let message: RedisServiceCommandObject;
@ -25,7 +25,7 @@ export function getWorkerCommandReceivedHandler(options: WorkerCommandReceivedHa
message = jsonParse<RedisServiceCommandObject>(messageString);
} catch {
logger.debug(
`Received invalid message via channel ${COMMAND_REDIS_CHANNEL}: "${messageString}"`,
`Received invalid message via channel ${COMMAND_PUBSUB_CHANNEL}: "${messageString}"`,
);
return;
}
@ -145,7 +145,7 @@ export function getWorkerCommandReceivedHandler(options: WorkerCommandReceivedHa
}
logger.debug(
`Received unknown command via channel ${COMMAND_REDIS_CHANNEL}: "${message.command}"`,
`Received unknown command via channel ${COMMAND_PUBSUB_CHANNEL}: "${message.command}"`,
);
break;
}

View file

@ -1,5 +1,4 @@
import type { RunningJobSummary } from '@n8n/api-types';
import type { ExecutionStatus, WorkflowExecuteMode } from 'n8n-workflow';
import type { Publisher } from '@/scaling/pubsub/publisher.service';
@ -9,14 +8,3 @@ export interface WorkerCommandReceivedHandlerOptions {
getRunningJobIds: () => Array<string | number>;
getRunningJobsSummary: () => RunningJobSummary[];
}
export interface WorkerJobStatusSummary {
jobId: string;
executionId: string;
retryOf?: string;
startedAt: Date;
mode: WorkflowExecuteMode;
workflowName: string;
workflowId: string;
status: ExecutionStatus;
}

View file

@ -5,7 +5,7 @@ import { Service } from 'typedi';
import { Logger } from '@/logger';
import type { RedisClientType } from './redis.types';
import type { RedisClientType } from '../scaling/redis/redis.types';
@Service()
export class RedisClientService {

View file

@ -1,2 +0,0 @@
export const COMMAND_REDIS_CHANNEL = 'n8n.commands';
export const WORKER_RESPONSE_REDIS_CHANNEL = 'n8n.worker-response';

View file

@ -1,70 +0,0 @@
import type Redis from 'ioredis';
import type { Cluster } from 'ioredis';
import { Service } from 'typedi';
import config from '@/config';
import { Logger } from '@/logger';
import { RedisClientService } from './redis-client.service';
import type { RedisClientType } from './redis.types';
export type RedisServiceMessageHandler =
| ((channel: string, message: string) => void)
| ((stream: string, id: string, message: string[]) => void);
@Service()
class RedisServiceBase {
redisClient: Redis | Cluster | undefined;
isInitialized = false;
constructor(
protected readonly logger: Logger,
private readonly redisClientService: RedisClientService,
) {}
async init(type: RedisClientType): Promise<void> {
if (this.redisClient && this.isInitialized) {
return;
}
this.redisClient = this.redisClientService.createClient({ type });
this.redisClient.on('error', (error) => {
if (!String(error).includes('ECONNREFUSED')) {
this.logger.warn('Error with Redis: ', error);
}
});
this.isInitialized = true;
}
async destroy(): Promise<void> {
if (!this.redisClient) {
return;
}
await this.redisClient.quit();
this.isInitialized = false;
this.redisClient = undefined;
}
}
export abstract class RedisServiceBaseSender extends RedisServiceBase {
senderId: string;
async init(type: RedisClientType): Promise<void> {
await super.init(type);
this.senderId = config.get('redis.queueModeId');
}
}
export abstract class RedisServiceBaseReceiver extends RedisServiceBase {
messageHandlers: Map<string, RedisServiceMessageHandler> = new Map();
addMessageHandler(handlerName: string, handler: RedisServiceMessageHandler): void {
this.messageHandlers.set(handlerName, handler);
}
removeMessageHandler(handlerName: string): void {
this.messageHandlers.delete(handlerName);
}
}

View file

@ -153,11 +153,7 @@ const hasNodeRun = computed(() => {
});
const runTaskData = computed(() => {
if (
!node.value ||
workflowExecution.value === null ||
workflowExecution.value.status === 'waiting'
) {
if (!node.value || workflowExecution.value === null) {
return null;
}
@ -214,6 +210,16 @@ const canPinData = computed(() => {
// Methods
const waitingNodeMessage = (resume: string) => {
if (resume === 'form') {
return i18n.baseText('ndv.output.waitNodeWaitingForFormSubmission');
}
if (resume === 'webhook') {
return i18n.baseText('ndv.output.waitNodeWaitingForWebhook');
}
return i18n.baseText('ndv.output.waitNodeWaiting');
};
const insertTestData = () => {
if (!runDataRef.value) return;
@ -352,6 +358,13 @@ const activatePane = () => {
</n8n-text>
</template>
<template #node-waiting>
<n8n-text :bold="true" color="text-dark" size="large">Waiting for input</n8n-text>
<n8n-text>
{{ waitingNodeMessage(node?.parameters?.resume as string) }}
</n8n-text>
</template>
<template #no-output-data>
<n8n-text :bold="true" color="text-dark" size="large">{{
$locale.baseText('ndv.output.noOutputData.title')

View file

@ -40,6 +40,7 @@ import {
MAX_DISPLAY_ITEMS_AUTO_ALL,
TEST_PIN_DATA,
HTML_NODE_TYPE,
WAIT_NODE_TYPE,
} from '@/constants';
import BinaryDataDisplay from '@/components/BinaryDataDisplay.vue';
@ -218,6 +219,14 @@ export default defineComponent({
isReadOnlyRoute() {
return this.$route?.meta?.readOnlyCanvas === true;
},
isWaitNodeWaiting() {
return (
this.hasNodeRun &&
this.workflowExecution?.status === 'waiting' &&
this.node?.type === WAIT_NODE_TYPE &&
this.workflowExecution?.data?.resultData?.lastNodeExecuted === this.node?.name
);
},
activeNode(): INodeUi | null {
return this.ndvStore.activeNode;
},
@ -1501,6 +1510,10 @@ export default defineComponent({
</n8n-text>
</div>
<div v-else-if="isWaitNodeWaiting" :class="$style.center">
<slot name="node-waiting">xxx</slot>
</div>
<div v-else-if="hasNodeRun && !inputData.length && !search" :class="$style.center">
<slot name="no-output-data">xxx</slot>
</div>

View file

@ -1,153 +1,120 @@
<script lang="ts">
import { defineAsyncComponent, defineComponent, ref } from 'vue';
import type { PropType } from 'vue';
<script setup lang="ts">
import { computed, defineAsyncComponent, ref } from 'vue';
import VueJsonPretty from 'vue-json-pretty';
import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
import type { INodeExecutionData } from 'n8n-workflow';
import Draggable from '@/components/Draggable.vue';
import { executionDataToJson } from '@/utils/nodeTypesUtils';
import { isString } from '@/utils/typeGuards';
import { shorten } from '@/utils/typesUtils';
import type { INodeUi } from '@/Interface';
import { mapStores } from 'pinia';
import { useNDVStore } from '@/stores/ndv.store';
import MappingPill from './MappingPill.vue';
import { getMappedExpression } from '@/utils/mappingUtils';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { nonExistingJsonPath } from '@/constants';
import { useExternalHooks } from '@/composables/useExternalHooks';
import TextWithHighlights from './TextWithHighlights.vue';
import { useTelemetry } from '@/composables/useTelemetry';
import { useElementSize } from '@vueuse/core';
const LazyRunDataJsonActions = defineAsyncComponent(
async () => await import('@/components/RunDataJsonActions.vue'),
);
export default defineComponent({
name: 'RunDataJson',
components: {
VueJsonPretty,
Draggable,
LazyRunDataJsonActions,
MappingPill,
TextWithHighlights,
const props = withDefaults(
defineProps<{
editMode: { enabled?: boolean; value?: string };
pushRef?: string;
paneType?: string;
node: INodeUi;
inputData: INodeExecutionData[];
mappingEnabled?: boolean;
distanceFromActive: number;
runIndex?: number;
totalRuns?: number;
search?: string;
}>(),
{
editMode: () => ({}),
},
props: {
editMode: {
type: Object as PropType<{ enabled?: boolean; value?: string }>,
default: () => ({}),
},
pushRef: {
type: String,
},
paneType: {
type: String,
},
node: {
type: Object as PropType<INodeUi>,
required: true,
},
inputData: {
type: Array as PropType<INodeExecutionData[]>,
required: true,
},
mappingEnabled: {
type: Boolean,
},
distanceFromActive: {
type: Number,
required: true,
},
runIndex: {
type: Number,
},
totalRuns: {
type: Number,
},
search: {
type: String,
},
},
setup() {
const externalHooks = useExternalHooks();
);
const selectedJsonPath = ref(nonExistingJsonPath);
const draggingPath = ref<null | string>(null);
const displayMode = ref('json');
const ndvStore = useNDVStore();
return {
externalHooks,
selectedJsonPath,
draggingPath,
displayMode,
};
},
computed: {
...mapStores(useNDVStore, useWorkflowsStore),
jsonData(): IDataObject[] {
return executionDataToJson(this.inputData);
},
highlight(): boolean {
return this.ndvStore.highlightDraggables;
},
},
methods: {
getShortKey(el: HTMLElement): string {
if (!el) {
return '';
}
const externalHooks = useExternalHooks();
const telemetry = useTelemetry();
return shorten(el.dataset.name || '', 16, 2);
},
getJsonParameterPath(path: string): string {
const subPath = path.replace(/^(\["?\d"?])/, ''); // remove item position
const selectedJsonPath = ref(nonExistingJsonPath);
const draggingPath = ref<null | string>(null);
const displayMode = ref('json');
const jsonDataContainer = ref(null);
return getMappedExpression({
nodeName: this.node.name,
distanceFromActive: this.distanceFromActive,
path: subPath,
});
},
onDragStart(el: HTMLElement) {
if (el?.dataset.path) {
this.draggingPath = el.dataset.path;
}
const { height } = useElementSize(jsonDataContainer);
this.ndvStore.resetMappingTelemetry();
},
onDragEnd(el: HTMLElement) {
this.draggingPath = null;
const mappingTelemetry = this.ndvStore.mappingTelemetry;
const telemetryPayload = {
src_node_type: this.node.type,
src_field_name: el.dataset.name || '',
src_nodes_back: this.distanceFromActive,
src_run_index: this.runIndex,
src_runs_total: this.totalRuns,
src_field_nest_level: el.dataset.depth || 0,
src_view: 'json',
src_element: el,
success: false,
...mappingTelemetry,
};
const jsonData = computed(() => executionDataToJson(props.inputData));
setTimeout(() => {
void this.externalHooks.run('runDataJson.onDragEnd', telemetryPayload);
this.$telemetry.track('User dragged data for mapping', telemetryPayload, {
withPostHog: true,
});
}, 1000); // ensure dest data gets set if drop
},
getContent(value: unknown): string {
return isString(value) ? `"${value}"` : JSON.stringify(value);
},
getListItemName(path: string): string {
return path.replace(/^(\["?\d"?]\.?)/g, '');
},
},
});
const highlight = computed(() => ndvStore.highlightDraggables);
const getShortKey = (el: HTMLElement) => {
if (!el) {
return '';
}
return shorten(el.dataset.name ?? '', 16, 2);
};
const getJsonParameterPath = (path: string) => {
const subPath = path.replace(/^(\["?\d"?])/, ''); // remove item position
return getMappedExpression({
nodeName: props.node.name,
distanceFromActive: props.distanceFromActive,
path: subPath,
});
};
const onDragStart = (el: HTMLElement) => {
if (el?.dataset.path) {
draggingPath.value = el.dataset.path;
}
ndvStore.resetMappingTelemetry();
};
const onDragEnd = (el: HTMLElement) => {
draggingPath.value = null;
const mappingTelemetry = ndvStore.mappingTelemetry;
const telemetryPayload = {
src_node_type: props.node.type,
src_field_name: el.dataset.name ?? '',
src_nodes_back: props.distanceFromActive,
src_run_index: props.runIndex,
src_runs_total: props.totalRuns,
src_field_nest_level: el.dataset.depth ?? 0,
src_view: 'json',
src_element: el,
success: false,
...mappingTelemetry,
};
setTimeout(() => {
void externalHooks.run('runDataJson.onDragEnd', telemetryPayload);
telemetry.track('User dragged data for mapping', telemetryPayload, {
withPostHog: true,
});
}, 1000); // ensure dest data gets set if drop
};
const getContent = (value: unknown) => {
return isString(value) ? `"${value}"` : JSON.stringify(value);
};
const getListItemName = (path: string) => {
return path.replace(/^(\["?\d"?]\.?)/g, '');
};
</script>
<template>
<div :class="[$style.jsonDisplay, { [$style.highlight]: highlight }]">
<div ref="jsonDataContainer" :class="[$style.jsonDisplay, { [$style.highlight]: highlight }]">
<Suspense>
<LazyRunDataJsonActions
v-if="!editMode.enabled"
@ -178,6 +145,8 @@ export default defineComponent({
root-path=""
selectable-type="single"
class="json-data"
:virtual="true"
:height="height"
@update:selected-value="selectedJsonPath = $event"
>
<template #renderNodeKey="{ node }">
@ -229,11 +198,10 @@ export default defineComponent({
left: 0;
padding-left: var(--spacing-s);
right: 0;
overflow-y: auto;
overflow-y: hidden;
line-height: 1.5;
word-break: normal;
height: 100%;
padding-bottom: var(--spacing-3xl);
&:hover {
/* Shows .actionsGroup element from <run-data-json-actions /> child component */
@ -336,4 +304,8 @@ export default defineComponent({
.vjs-tree .vjs-tree__content.has-line {
border-left: 1px dotted var(--color-json-line);
}
.vjs-tree .vjs-tree-list-holder-inner {
padding-bottom: var(--spacing-3xl);
}
</style>

View file

@ -2,6 +2,22 @@ import { createTestingPinia } from '@pinia/testing';
import { screen, cleanup } from '@testing-library/vue';
import RunDataJson from '@/components/RunDataJson.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { useElementSize } from '@vueuse/core'; // Import the composable to mock
vi.mock('@vueuse/core', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const originalModule = await vi.importActual<typeof import('@vueuse/core')>('@vueuse/core');
return {
...originalModule, // Keep all original exports
useElementSize: vi.fn(), // Mock useElementSize
};
});
(useElementSize as jest.Mock).mockReturnValue({
height: 500, // Mocked height value
width: 300, // Mocked width value
});
const renderComponent = createComponentRenderer(RunDataJson, {
props: {
@ -34,18 +50,6 @@ const renderComponent = createComponentRenderer(RunDataJson, {
disabled: false,
},
},
global: {
mocks: {
$locale: {
baseText() {
return '';
},
},
$store: {
getters: {},
},
},
},
});
describe('RunDataJson.vue', () => {

View file

@ -325,13 +325,6 @@ export function usePushConnection({ router }: { router: ReturnType<typeof useRou
// Workflow did start but had been put to wait
titleChange.titleSet(workflow.name as string, 'IDLE');
toast.showToast({
title: 'Workflow started waiting',
message: `${action} <a href="https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.wait/" target="_blank">More info</a>`,
type: 'success',
duration: 0,
dangerouslyUseHTMLString: true,
});
} else if (runDataExecuted.finished !== true) {
titleChange.titleSet(workflow.name as string, 'ERROR');

View file

@ -955,6 +955,9 @@
"ndv.output.run": "Run",
"ndv.output.runNodeHint": "Execute this node to view data",
"ndv.output.runNodeHintSubNode": "Output will appear here once the parent node is run",
"ndv.output.waitNodeWaitingForWebhook": "Execution will continue when webhook is received",
"ndv.output.waitNodeWaitingForFormSubmission": "Execution will continue when form is submitted",
"ndv.output.waitNodeWaiting": "Execution will continue when wait time is over",
"ndv.output.insertTestData": "set mock data",
"ndv.output.staleDataWarning.regular": "Node parameters have changed.<br>Test node again to refresh output.",
"ndv.output.staleDataWarning.pinData": "Node parameter changes will not affect pinned output data.",

View file

@ -356,9 +356,9 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
resetAssistantChat();
chatSessionTask.value = credentialType ? 'credentials' : 'support';
chatSessionCredType.value = credentialType;
chatWindowOpen.value = true;
addUserMessage(userMessage, id);
addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.thinking'));
openChat();
streaming.value = true;
let payload: ChatRequest.InitSupportChat | ChatRequest.InitCredHelp = {

View file

@ -7,8 +7,9 @@ import {
hasDestinationId,
saveDestinationToDb,
sendTestMessageToDestination,
} from '../api/eventbus.ee';
} from '@/api/eventbus.ee';
import { useRootStore } from './root.store';
import { ref } from 'vue';
export interface EventSelectionItem {
selected: boolean;
@ -32,221 +33,242 @@ export interface DestinationSettingsStore {
[key: string]: DestinationStoreItem;
}
export const useLogStreamingStore = defineStore('logStreaming', {
state: () => ({
items: {} as DestinationSettingsStore,
eventNames: new Set<string>(),
}),
getters: {},
actions: {
addDestination(destination: MessageEventBusDestinationOptions) {
if (destination.id && this.items[destination.id]) {
this.items[destination.id].destination = destination;
} else {
this.setSelectionAndBuildItems(destination);
export const useLogStreamingStore = defineStore('logStreaming', () => {
const items = ref<DestinationSettingsStore>({});
const eventNames = ref(new Set<string>());
const rootStore = useRootStore();
const addDestination = (destination: MessageEventBusDestinationOptions) => {
if (destination.id && items.value[destination.id]) {
items.value[destination.id].destination = destination;
} else {
setSelectionAndBuildItems(destination);
}
};
const setSelectionAndBuildItems = (destination: MessageEventBusDestinationOptions) => {
if (destination.id) {
if (!items.value[destination.id]) {
items.value[destination.id] = {
destination,
selectedEvents: new Set<string>(),
eventGroups: [],
isNew: false,
} as DestinationStoreItem;
}
},
getDestination(destinationId: string): MessageEventBusDestinationOptions | undefined {
if (this.items[destinationId]) {
return this.items[destinationId].destination;
} else {
return;
items.value[destination.id]?.selectedEvents?.clear();
if (destination.subscribedEvents) {
for (const eventName of destination.subscribedEvents) {
items.value[destination.id]?.selectedEvents?.add(eventName);
}
}
},
getAllDestinations(): MessageEventBusDestinationOptions[] {
const destinations: MessageEventBusDestinationOptions[] = [];
for (const key of Object.keys(this.items)) {
destinations.push(this.items[key].destination);
}
return destinations;
},
updateDestination(destination: MessageEventBusDestinationOptions) {
if (destination.id && this.items[destination.id]) {
this.$patch((state) => {
if (destination.id && this.items[destination.id]) {
state.items[destination.id].destination = destination;
}
// to trigger refresh
state.items = { ...state.items };
});
}
},
removeDestination(destinationId: string) {
if (!destinationId) return;
delete this.items[destinationId];
if (this.items[destinationId]) {
this.$patch({
items: {
...this.items,
},
});
}
},
clearDestinations() {
this.items = {};
},
addEventName(name: string) {
this.eventNames.add(name);
},
removeEventName(name: string) {
this.eventNames.delete(name);
},
clearEventNames() {
this.eventNames.clear();
},
addSelectedEvent(id: string, name: string) {
this.items[id]?.selectedEvents?.add(name);
this.setSelectedInGroup(id, name, true);
},
removeSelectedEvent(id: string, name: string) {
this.items[id]?.selectedEvents?.delete(name);
this.setSelectedInGroup(id, name, false);
},
getSelectedEvents(destinationId: string): string[] {
const selectedEvents: string[] = [];
if (this.items[destinationId]) {
for (const group of this.items[destinationId].eventGroups) {
if (group.selected) {
selectedEvents.push(group.name);
}
for (const event of group.children) {
if (event.selected) {
selectedEvents.push(event.name);
items.value[destination.id].eventGroups = eventGroupsFromStringList(
eventNames.value,
items.value[destination.id]?.selectedEvents,
);
}
};
const getDestination = (destinationId: string) => {
if (items.value[destinationId]) {
return items.value[destinationId].destination;
} else {
return;
}
};
const getAllDestinations = () => {
const destinations: MessageEventBusDestinationOptions[] = [];
for (const key of Object.keys(items)) {
destinations.push(items.value[key].destination);
}
return destinations;
};
const clearDestinations = () => {
items.value = {};
};
const addEventName = (name: string) => {
eventNames.value.add(name);
};
const removeEventName = (name: string) => {
eventNames.value.delete(name);
};
const clearEventNames = () => {
eventNames.value.clear();
};
const addSelectedEvent = (id: string, name: string) => {
items.value[id]?.selectedEvents?.add(name);
setSelectedInGroup(id, name, true);
};
const removeSelectedEvent = (id: string, name: string) => {
items.value[id]?.selectedEvents?.delete(name);
setSelectedInGroup(id, name, false);
};
const setSelectedInGroup = (destinationId: string, name: string, isSelected: boolean) => {
if (items.value[destinationId]) {
const groupName = eventGroupFromEventName(name);
const groupIndex = items.value[destinationId].eventGroups.findIndex(
(e) => e.name === groupName,
);
if (groupIndex > -1) {
if (groupName === name) {
items.value[destinationId].eventGroups[groupIndex].selected = isSelected;
} else {
const eventIndex = items.value[destinationId].eventGroups[groupIndex].children.findIndex(
(e) => e.name === name,
);
if (eventIndex > -1) {
items.value[destinationId].eventGroups[groupIndex].children[eventIndex].selected =
isSelected;
if (isSelected) {
items.value[destinationId].eventGroups[groupIndex].indeterminate = isSelected;
} else {
let anySelected = false;
for (
let i = 0;
i < items.value[destinationId].eventGroups[groupIndex].children.length;
i++
) {
anySelected =
anySelected ||
items.value[destinationId].eventGroups[groupIndex].children[i].selected;
}
items.value[destinationId].eventGroups[groupIndex].indeterminate = anySelected;
}
}
}
}
return selectedEvents;
},
setSelectedInGroup(destinationId: string, name: string, isSelected: boolean) {
if (this.items[destinationId]) {
const groupName = eventGroupFromEventName(name);
const groupIndex = this.items[destinationId].eventGroups.findIndex(
(e) => e.name === groupName,
);
if (groupIndex > -1) {
if (groupName === name) {
this.$patch((state) => {
state.items[destinationId].eventGroups[groupIndex].selected = isSelected;
});
} else {
const eventIndex = this.items[destinationId].eventGroups[groupIndex].children.findIndex(
(e) => e.name === name,
);
if (eventIndex > -1) {
this.$patch((state) => {
state.items[destinationId].eventGroups[groupIndex].children[eventIndex].selected =
isSelected;
if (isSelected) {
state.items[destinationId].eventGroups[groupIndex].indeterminate = isSelected;
} else {
let anySelected = false;
for (
let i = 0;
i < state.items[destinationId].eventGroups[groupIndex].children.length;
i++
) {
anySelected =
anySelected ||
state.items[destinationId].eventGroups[groupIndex].children[i].selected;
}
state.items[destinationId].eventGroups[groupIndex].indeterminate = anySelected;
}
});
}
}
};
const removeDestinationItemTree = (id: string) => {
delete items.value[id];
};
const updateDestination = (destination: MessageEventBusDestinationOptions) => {
if (destination.id && items.value[destination.id]) {
items.value[destination.id].destination = destination;
}
};
const removeDestination = (destinationId: string) => {
if (!destinationId) return;
delete items.value[destinationId];
};
const getSelectedEvents = (destinationId: string): string[] => {
const selectedEvents: string[] = [];
if (items.value[destinationId]) {
for (const group of items.value[destinationId].eventGroups) {
if (group.selected) {
selectedEvents.push(group.name);
}
for (const event of group.children) {
if (event.selected) {
selectedEvents.push(event.name);
}
}
}
},
removeDestinationItemTree(id: string) {
delete this.items[id];
},
clearDestinationItemTrees() {
this.items = {} as DestinationSettingsStore;
},
setSelectionAndBuildItems(destination: MessageEventBusDestinationOptions) {
if (destination.id) {
if (!this.items[destination.id]) {
this.items[destination.id] = {
destination,
selectedEvents: new Set<string>(),
eventGroups: [],
isNew: false,
} as DestinationStoreItem;
}
this.items[destination.id]?.selectedEvents?.clear();
if (destination.subscribedEvents) {
for (const eventName of destination.subscribedEvents) {
this.items[destination.id]?.selectedEvents?.add(eventName);
}
}
this.items[destination.id].eventGroups = eventGroupsFromStringList(
this.eventNames,
this.items[destination.id]?.selectedEvents,
);
}
},
async saveDestination(destination: MessageEventBusDestinationOptions): Promise<boolean> {
if (!hasDestinationId(destination)) {
return false;
}
}
return selectedEvents;
};
const rootStore = useRootStore();
const selectedEvents = this.getSelectedEvents(destination.id);
try {
await saveDestinationToDb(rootStore.restApiContext, destination, selectedEvents);
this.updateDestination(destination);
return true;
} catch (e) {
return false;
}
},
async sendTestMessage(destination: MessageEventBusDestinationOptions): Promise<boolean> {
if (!hasDestinationId(destination)) {
return false;
}
const saveDestination = async (
destination: MessageEventBusDestinationOptions,
): Promise<boolean> => {
if (!hasDestinationId(destination)) {
return false;
}
const rootStore = useRootStore();
const testResult = await sendTestMessageToDestination(rootStore.restApiContext, destination);
return testResult;
},
async fetchEventNames(): Promise<string[]> {
const rootStore = useRootStore();
return await getEventNamesFromBackend(rootStore.restApiContext);
},
async fetchDestinations(): Promise<MessageEventBusDestinationOptions[]> {
const rootStore = useRootStore();
return await getDestinationsFromBackend(rootStore.restApiContext);
},
async deleteDestination(destinationId: string) {
const rootStore = useRootStore();
await deleteDestinationFromDb(rootStore.restApiContext, destinationId);
this.removeDestination(destinationId);
},
},
const selectedEvents = getSelectedEvents(destination.id);
try {
await saveDestinationToDb(rootStore.restApiContext, destination, selectedEvents);
updateDestination(destination);
return true;
} catch (e) {
return false;
}
};
const sendTestMessage = async (
destination: MessageEventBusDestinationOptions,
): Promise<boolean> => {
if (!hasDestinationId(destination)) {
return false;
}
const testResult = await sendTestMessageToDestination(rootStore.restApiContext, destination);
return testResult;
};
const fetchEventNames = async () => {
return await getEventNamesFromBackend(rootStore.restApiContext);
};
const fetchDestinations = async (): Promise<MessageEventBusDestinationOptions[]> => {
return await getDestinationsFromBackend(rootStore.restApiContext);
};
const deleteDestination = async (destinationId: string) => {
await deleteDestinationFromDb(rootStore.restApiContext, destinationId);
removeDestination(destinationId);
};
return {
addDestination,
setSelectionAndBuildItems,
getDestination,
getAllDestinations,
clearDestinations,
addEventName,
removeEventName,
clearEventNames,
addSelectedEvent,
removeSelectedEvent,
setSelectedInGroup,
removeDestinationItemTree,
updateDestination,
removeDestination,
getSelectedEvents,
saveDestination,
sendTestMessage,
fetchEventNames,
fetchDestinations,
deleteDestination,
items,
};
});
export function eventGroupFromEventName(eventName: string): string | undefined {
export const eventGroupFromEventName = (eventName: string): string | undefined => {
const matches = eventName.match(/^[\w\s]+\.[\w\s]+/);
if (matches && matches?.length > 0) {
return matches[0];
}
return undefined;
}
};
function prettifyEventName(label: string, group = ''): string {
const prettifyEventName = (label: string, group = ''): string => {
label = label.replace(group + '.', '');
if (label.length > 0) {
label = label[0].toUpperCase() + label.substring(1);
label = label.replaceAll('.', ' ');
}
return label;
}
};
export function eventGroupsFromStringList(
export const eventGroupsFromStringList = (
dottedList: Set<string>,
selectionList: Set<string> = new Set(),
) {
) => {
const result = [] as EventSelectionGroup[];
const eventNameArray = Array.from(dottedList.values());
@ -287,4 +309,4 @@ export function eventGroupsFromStringList(
result.push(collection);
}
return result;
}
};

View file

@ -1,44 +1,37 @@
<script lang="ts">
import { type PropType, defineComponent } from 'vue';
import Logo from '@/components/Logo.vue';
<script setup lang="ts">
import SSOLogin from '@/components/SSOLogin.vue';
import type { IFormBoxConfig } from '@/Interface';
export default defineComponent({
name: 'AuthView',
components: {
Logo,
SSOLogin,
withDefaults(
defineProps<{
form: IFormBoxConfig;
formLoading?: boolean;
subtitle?: string;
withSso?: boolean;
}>(),
{
formLoading: false,
withSso: false,
},
props: {
form: {
type: Object as PropType<IFormBoxConfig>,
},
formLoading: {
type: Boolean,
default: false,
},
subtitle: {
type: String,
},
withSso: {
type: Boolean,
default: false,
},
},
methods: {
onUpdate(e: { name: string; value: string }) {
this.$emit('update', e);
},
onSubmit(values: { [key: string]: string }) {
this.$emit('submit', values);
},
onSecondaryClick() {
this.$emit('secondaryClick');
},
},
});
);
const emit = defineEmits<{
update: [{ name: string; value: string }];
submit: [values: { [key: string]: string }];
secondaryClick: [];
}>();
const onUpdate = (e: { name: string; value: string }) => {
emit('update', e);
};
const onSubmit = (values: { [key: string]: string }) => {
emit('submit', values);
};
const onSecondaryClick = () => {
emit('secondaryClick');
};
</script>
<template>

View file

@ -1,163 +1,164 @@
<script lang="ts">
import AuthView from '@/views/AuthView.vue';
import { useToast } from '@/composables/useToast';
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { defineComponent } from 'vue';
import type { IFormBoxConfig } from '@/Interface';
import { MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH, VIEWS } from '@/constants';
import { mapStores } from 'pinia';
import AuthView from '@/views/AuthView.vue';
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import { useUsersStore } from '@/stores/users.store';
export default defineComponent({
name: 'ChangePasswordView',
components: {
AuthView,
},
setup() {
return {
...useToast(),
};
},
data() {
return {
password: '',
loading: false,
config: null as null | IFormBoxConfig,
};
},
computed: {
...mapStores(useUsersStore),
},
async mounted() {
const form: IFormBoxConfig = {
title: this.$locale.baseText('auth.changePassword'),
buttonText: this.$locale.baseText('auth.changePassword'),
redirectText: this.$locale.baseText('auth.signin'),
redirectLink: '/signin',
inputs: [
{
name: 'password',
properties: {
label: this.$locale.baseText('auth.newPassword'),
type: 'password',
required: true,
validationRules: [{ name: 'DEFAULT_PASSWORD_RULES' }],
infoText: this.$locale.baseText('auth.defaultPasswordRequirements'),
autocomplete: 'new-password',
capitalize: true,
},
},
{
name: 'password2',
properties: {
label: this.$locale.baseText('auth.changePassword.reenterNewPassword'),
type: 'password',
required: true,
validators: {
TWO_PASSWORDS_MATCH: {
validate: this.passwordsMatch,
},
},
validationRules: [{ name: 'TWO_PASSWORDS_MATCH' }],
autocomplete: 'new-password',
capitalize: true,
},
},
],
};
import type { IFormBoxConfig } from '@/Interface';
import { MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH, VIEWS } from '@/constants';
const token = this.getResetToken();
const mfaEnabled = this.getMfaEnabled();
const usersStore = useUsersStore();
if (mfaEnabled) {
form.inputs.push({
name: 'mfaToken',
initialValue: '',
properties: {
required: true,
label: this.$locale.baseText('mfa.code.input.label'),
placeholder: this.$locale.baseText('mfa.code.input.placeholder'),
maxlength: MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH,
capitalize: true,
validateOnBlur: true,
},
const locale = useI18n();
const toast = useToast();
const router = useRouter();
const password = ref('');
const loading = ref(false);
const config = ref<IFormBoxConfig | null>(null);
const passwordsMatch = (value: string | number | boolean | null | undefined) => {
if (typeof value !== 'string') {
return false;
}
if (value !== password.value) {
return {
messageKey: 'auth.changePassword.passwordsMustMatchError',
};
}
return false;
};
const getResetToken = () => {
return !router.currentRoute.value.query.token ||
typeof router.currentRoute.value.query.token !== 'string'
? null
: router.currentRoute.value.query.token;
};
const getMfaEnabled = () => {
if (!router.currentRoute.value.query.mfaEnabled) return null;
return router.currentRoute.value.query.mfaEnabled === 'true' ? true : false;
};
const isFormWithMFAToken = (values: { [key: string]: string }): values is { mfaToken: string } => {
return 'mfaToken' in values;
};
const onSubmit = async (values: { [key: string]: string }) => {
if (!isFormWithMFAToken(values)) return;
try {
loading.value = true;
const token = getResetToken();
if (token) {
const changePasswordParameters = {
token,
password: password.value,
...(values.mfaToken && { mfaToken: values.mfaToken }),
};
await usersStore.changePassword(changePasswordParameters);
toast.showMessage({
type: 'success',
title: locale.baseText('auth.changePassword.passwordUpdated'),
message: locale.baseText('auth.changePassword.passwordUpdatedMessage'),
});
await router.push({ name: VIEWS.SIGNIN });
} else {
toast.showError(
new Error(locale.baseText('auth.validation.missingParameters')),
locale.baseText('auth.changePassword.error'),
);
}
} catch (error) {
toast.showError(error, locale.baseText('auth.changePassword.error'));
}
loading.value = false;
};
const onInput = (e: { name: string; value: string }) => {
if (e.name === 'password') {
password.value = e.value;
}
};
onMounted(async () => {
const form: IFormBoxConfig = {
title: locale.baseText('auth.changePassword'),
buttonText: locale.baseText('auth.changePassword'),
redirectText: locale.baseText('auth.signin'),
redirectLink: '/signin',
inputs: [
{
name: 'password',
properties: {
label: locale.baseText('auth.newPassword'),
type: 'password',
required: true,
validationRules: [{ name: 'DEFAULT_PASSWORD_RULES' }],
infoText: locale.baseText('auth.defaultPasswordRequirements'),
autocomplete: 'new-password',
capitalize: true,
},
},
{
name: 'password2',
properties: {
label: locale.baseText('auth.changePassword.reenterNewPassword'),
type: 'password',
required: true,
validators: {
TWO_PASSWORDS_MATCH: {
validate: passwordsMatch,
},
},
validationRules: [{ name: 'TWO_PASSWORDS_MATCH' }],
autocomplete: 'new-password',
capitalize: true,
},
},
],
};
const token = getResetToken();
const mfaEnabled = getMfaEnabled();
if (mfaEnabled) {
form.inputs.push({
name: 'mfaToken',
initialValue: '',
properties: {
required: true,
label: locale.baseText('mfa.code.input.label'),
placeholder: locale.baseText('mfa.code.input.placeholder'),
maxlength: MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH,
capitalize: true,
validateOnBlur: true,
},
});
}
config.value = form;
try {
if (!token) {
throw new Error(locale.baseText('auth.changePassword.missingTokenError'));
}
this.config = form;
try {
if (!token) {
throw new Error(this.$locale.baseText('auth.changePassword.missingTokenError'));
}
await this.usersStore.validatePasswordToken({ token });
} catch (e) {
this.showError(e, this.$locale.baseText('auth.changePassword.tokenValidationError'));
void this.$router.replace({ name: VIEWS.SIGNIN });
}
},
methods: {
passwordsMatch(value: string | number | boolean | null | undefined) {
if (typeof value !== 'string') {
return false;
}
if (value !== this.password) {
return {
messageKey: 'auth.changePassword.passwordsMustMatchError',
};
}
return false;
},
onInput(e: { name: string; value: string }) {
if (e.name === 'password') {
this.password = e.value;
}
},
getResetToken() {
return !this.$route.query.token || typeof this.$route.query.token !== 'string'
? null
: this.$route.query.token;
},
getMfaEnabled() {
if (!this.$route.query.mfaEnabled) return null;
return this.$route.query.mfaEnabled === 'true' ? true : false;
},
async onSubmit(values: { mfaToken: string }) {
try {
this.loading = true;
const token = this.getResetToken();
if (token) {
const changePasswordParameters = {
token,
password: this.password,
...(values.mfaToken && { mfaToken: values.mfaToken }),
};
await this.usersStore.changePassword(changePasswordParameters);
this.showMessage({
type: 'success',
title: this.$locale.baseText('auth.changePassword.passwordUpdated'),
message: this.$locale.baseText('auth.changePassword.passwordUpdatedMessage'),
});
await this.$router.push({ name: VIEWS.SIGNIN });
} else {
this.showError(
new Error(this.$locale.baseText('auth.validation.missingParameters')),
this.$locale.baseText('auth.changePassword.error'),
);
}
} catch (error) {
this.showError(error, this.$locale.baseText('auth.changePassword.error'));
}
this.loading = false;
},
},
await usersStore.validatePasswordToken({ token });
} catch (e) {
toast.showError(e, locale.baseText('auth.changePassword.tokenValidationError'));
void router.replace({ name: VIEWS.SIGNIN });
}
});
</script>

View file

@ -1,108 +1,102 @@
<script lang="ts">
<script setup lang="ts">
import AuthView from './AuthView.vue';
import { useToast } from '@/composables/useToast';
import { defineComponent } from 'vue';
import type { IFormBoxConfig } from '@/Interface';
import { mapStores } from 'pinia';
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { computed, ref } from 'vue';
export default defineComponent({
name: 'ForgotMyPasswordView',
components: {
AuthView,
},
setup() {
return {
...useToast(),
};
},
data() {
return {
loading: false,
};
},
computed: {
...mapStores(useSettingsStore, useUsersStore),
formConfig(): IFormBoxConfig {
const EMAIL_INPUTS: IFormBoxConfig['inputs'] = [
{
name: 'email',
properties: {
label: this.$locale.baseText('auth.email'),
type: 'email',
required: true,
validationRules: [{ name: 'VALID_EMAIL' }],
autocomplete: 'email',
capitalize: true,
},
},
];
const settingsStore = useSettingsStore();
const usersStore = useUsersStore();
const NO_SMTP_INPUTS: IFormBoxConfig['inputs'] = [
{
name: 'no-smtp-warning',
properties: {
label: this.$locale.baseText('forgotPassword.noSMTPToSendEmailWarning'),
type: 'info',
},
},
];
const toast = useToast();
const locale = useI18n();
const DEFAULT_FORM_CONFIG = {
title: this.$locale.baseText('forgotPassword.recoverPassword'),
redirectText: this.$locale.baseText('forgotPassword.returnToSignIn'),
redirectLink: '/signin',
};
const loading = ref(false);
if (this.settingsStore.isSmtpSetup) {
return {
...DEFAULT_FORM_CONFIG,
buttonText: this.$locale.baseText('forgotPassword.getRecoveryLink'),
inputs: EMAIL_INPUTS,
};
}
return {
...DEFAULT_FORM_CONFIG,
inputs: NO_SMTP_INPUTS,
};
const formConfig = computed(() => {
const EMAIL_INPUTS: IFormBoxConfig['inputs'] = [
{
name: 'email',
properties: {
label: locale.baseText('auth.email'),
type: 'email',
required: true,
validationRules: [{ name: 'VALID_EMAIL' }],
autocomplete: 'email',
capitalize: true,
},
},
},
methods: {
async onSubmit(values: { email: string }) {
try {
this.loading = true;
await this.usersStore.sendForgotPasswordEmail(values);
];
this.showMessage({
type: 'success',
title: this.$locale.baseText('forgotPassword.recoveryEmailSent'),
message: this.$locale.baseText('forgotPassword.emailSentIfExists', {
interpolate: { email: values.email },
}),
});
} catch (error) {
let message = this.$locale.baseText('forgotPassword.smtpErrorContactAdministrator');
if (error.httpStatusCode) {
const { httpStatusCode: status } = error;
if (status === 429) {
message = this.$locale.baseText('forgotPassword.tooManyRequests');
} else if (error.httpStatusCode === 422) {
message = this.$locale.baseText(error.message);
}
this.showMessage({
type: 'error',
title: this.$locale.baseText('forgotPassword.sendingEmailError'),
message,
});
}
}
this.loading = false;
const NO_SMTP_INPUTS: IFormBoxConfig['inputs'] = [
{
name: 'no-smtp-warning',
properties: {
label: locale.baseText('forgotPassword.noSMTPToSendEmailWarning'),
type: 'info',
},
},
},
];
const DEFAULT_FORM_CONFIG = {
title: locale.baseText('forgotPassword.recoverPassword'),
redirectText: locale.baseText('forgotPassword.returnToSignIn'),
redirectLink: '/signin',
};
if (settingsStore.isSmtpSetup) {
return {
...DEFAULT_FORM_CONFIG,
buttonText: locale.baseText('forgotPassword.getRecoveryLink'),
inputs: EMAIL_INPUTS,
};
}
return {
...DEFAULT_FORM_CONFIG,
inputs: NO_SMTP_INPUTS,
};
});
const isFormWithEmail = (values: { [key: string]: string }): values is { email: string } => {
return 'email' in values;
};
const onSubmit = async (values: { [key: string]: string }) => {
if (!isFormWithEmail(values)) {
return;
}
try {
loading.value = true;
await usersStore.sendForgotPasswordEmail(values);
toast.showMessage({
type: 'success',
title: locale.baseText('forgotPassword.recoveryEmailSent'),
message: locale.baseText('forgotPassword.emailSentIfExists', {
interpolate: { email: values.email },
}),
});
} catch (error) {
let message = locale.baseText('forgotPassword.smtpErrorContactAdministrator');
if (error.httpStatusCode) {
const { httpStatusCode: status } = error;
if (status === 429) {
message = locale.baseText('forgotPassword.tooManyRequests');
} else if (error.httpStatusCode === 422) {
message = locale.baseText(error.message);
}
toast.showMessage({
type: 'error',
title: locale.baseText('forgotPassword.sendingEmailError'),
message,
});
}
}
loading.value = false;
};
</script>
<template>

View file

@ -1,14 +1,17 @@
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElNotification as Notification } from 'element-plus';
import type { IFormBoxConfig } from 'n8n-design-system';
import AuthView from '@/views/AuthView.vue';
import { i18n as locale } from '@/plugins/i18n';
import { useSSOStore } from '@/stores/sso.store';
import { VIEWS } from '@/constants';
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import { useSSOStore } from '@/stores/sso.store';
const router = useRouter();
const locale = useI18n();
const toast = useToast();
const ssoStore = useSSOStore();
const loading = ref(false);
@ -40,18 +43,22 @@ const FORM_CONFIG: IFormBoxConfig = reactive({
},
],
});
const onSubmit = async (values: { firstName: string; lastName: string }) => {
const isFormWithFirstAndLastName = (values: {
[key: string]: string;
}): values is { firstName: string; lastName: string } => {
return 'firstName' in values && 'lastName' in values;
};
const onSubmit = async (values: { [key: string]: string }) => {
if (!isFormWithFirstAndLastName(values)) return;
try {
loading.value = true;
await ssoStore.updateUser(values);
await router.push({ name: VIEWS.HOMEPAGE });
} catch (error) {
loading.value = false;
Notification.error({
title: 'Error',
message: error.message,
position: 'bottom-right',
});
toast.showError(error, 'Error', error.message);
}
};
</script>

View file

@ -95,7 +95,7 @@ export default defineComponent({
},
async getDestinationDataFromBackend(): Promise<void> {
this.logStreamingStore.clearEventNames();
this.logStreamingStore.clearDestinationItemTrees();
this.logStreamingStore.clearDestinations();
this.allDestinations = [];
const eventNamesData = await this.logStreamingStore.fetchEventNames();
if (eventNamesData) {

View file

@ -1,129 +1,123 @@
<script lang="ts">
import AuthView from './AuthView.vue';
import { defineComponent } from 'vue';
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from '@/composables/useToast';
import { useI18n } from '@/composables/useI18n';
import { usePostHog } from '@/stores/posthog.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
import type { IFormBoxConfig } from '@/Interface';
import { MORE_ONBOARDING_OPTIONS_EXPERIMENT, VIEWS } from '@/constants';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { usePostHog } from '@/stores/posthog.store';
export default defineComponent({
name: 'SetupView',
components: {
AuthView,
},
setup() {
return useToast();
},
data() {
const FORM_CONFIG: IFormBoxConfig = {
title: this.$locale.baseText('auth.setup.setupOwner'),
buttonText: this.$locale.baseText('auth.setup.next'),
inputs: [
{
name: 'email',
properties: {
label: this.$locale.baseText('auth.email'),
type: 'email',
required: true,
validationRules: [{ name: 'VALID_EMAIL' }],
autocomplete: 'email',
capitalize: true,
},
},
{
name: 'firstName',
properties: {
label: this.$locale.baseText('auth.firstName'),
maxlength: 32,
required: true,
autocomplete: 'given-name',
capitalize: true,
},
},
{
name: 'lastName',
properties: {
label: this.$locale.baseText('auth.lastName'),
maxlength: 32,
required: true,
autocomplete: 'family-name',
capitalize: true,
},
},
{
name: 'password',
properties: {
label: this.$locale.baseText('auth.password'),
type: 'password',
required: true,
validationRules: [{ name: 'DEFAULT_PASSWORD_RULES' }],
infoText: this.$locale.baseText('auth.defaultPasswordRequirements'),
autocomplete: 'new-password',
capitalize: true,
},
},
{
name: 'agree',
properties: {
label: this.$locale.baseText('auth.agreement.label'),
type: 'checkbox',
},
},
],
};
import AuthView from '@/views/AuthView.vue';
return {
FORM_CONFIG,
loading: false,
};
},
computed: {
...mapStores(useSettingsStore, useUIStore, useUsersStore, usePostHog),
},
methods: {
async onSubmit(values: { [key: string]: string | boolean }) {
try {
const forceRedirectedHere = this.settingsStore.showSetupPage;
const isPartOfOnboardingExperiment =
this.posthogStore.getVariant(MORE_ONBOARDING_OPTIONS_EXPERIMENT.name) ===
MORE_ONBOARDING_OPTIONS_EXPERIMENT.variant;
this.loading = true;
await this.usersStore.createOwner(
values as { firstName: string; lastName: string; email: string; password: string },
);
const posthogStore = usePostHog();
const settingsStore = useSettingsStore();
const uiStore = useUIStore();
const usersStore = useUsersStore();
if (values.agree === true) {
try {
await this.uiStore.submitContactEmail(values.email.toString(), values.agree);
} catch {}
}
const toast = useToast();
const locale = useI18n();
const router = useRouter();
if (forceRedirectedHere) {
if (isPartOfOnboardingExperiment) {
await this.$router.push({ name: VIEWS.WORKFLOWS });
} else {
await this.$router.push({ name: VIEWS.NEW_WORKFLOW });
}
} else {
await this.$router.push({ name: VIEWS.USERS_SETTINGS });
}
} catch (error) {
this.showError(error, this.$locale.baseText('auth.setup.settingUpOwnerError'));
}
this.loading = false;
const loading = ref(false);
const formConfig: IFormBoxConfig = reactive({
title: locale.baseText('auth.setup.setupOwner'),
buttonText: locale.baseText('auth.setup.next'),
inputs: [
{
name: 'email',
properties: {
label: locale.baseText('auth.email'),
type: 'email',
required: true,
validationRules: [{ name: 'VALID_EMAIL' }],
autocomplete: 'email',
capitalize: true,
},
},
},
{
name: 'firstName',
properties: {
label: locale.baseText('auth.firstName'),
maxlength: 32,
required: true,
autocomplete: 'given-name',
capitalize: true,
},
},
{
name: 'lastName',
properties: {
label: locale.baseText('auth.lastName'),
maxlength: 32,
required: true,
autocomplete: 'family-name',
capitalize: true,
},
},
{
name: 'password',
properties: {
label: locale.baseText('auth.password'),
type: 'password',
required: true,
validationRules: [{ name: 'DEFAULT_PASSWORD_RULES' }],
infoText: locale.baseText('auth.defaultPasswordRequirements'),
autocomplete: 'new-password',
capitalize: true,
},
},
{
name: 'agree',
properties: {
label: locale.baseText('auth.agreement.label'),
type: 'checkbox',
},
},
],
});
const onSubmit = async (values: { [key: string]: string | boolean }) => {
try {
const forceRedirectedHere = settingsStore.showSetupPage;
const isPartOfOnboardingExperiment =
posthogStore.getVariant(MORE_ONBOARDING_OPTIONS_EXPERIMENT.name) ===
MORE_ONBOARDING_OPTIONS_EXPERIMENT.variant;
loading.value = true;
await usersStore.createOwner(
values as { firstName: string; lastName: string; email: string; password: string },
);
if (values.agree === true) {
try {
await uiStore.submitContactEmail(values.email.toString(), values.agree);
} catch {}
}
if (forceRedirectedHere) {
if (isPartOfOnboardingExperiment) {
await router.push({ name: VIEWS.WORKFLOWS });
} else {
await router.push({ name: VIEWS.NEW_WORKFLOW });
}
} else {
await router.push({ name: VIEWS.USERS_SETTINGS });
}
} catch (error) {
toast.showError(error, locale.baseText('auth.setup.settingUpOwnerError'));
}
loading.value = false;
};
</script>
<template>
<AuthView
:form="FORM_CONFIG"
:form="formConfig"
:form-loading="loading"
data-test-id="setup-form"
@submit="onSubmit"

View file

@ -1,191 +1,207 @@
<script lang="ts">
import { defineComponent } from 'vue';
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import AuthView from './AuthView.vue';
import MfaView from './MfaView.vue';
import { useToast } from '@/composables/useToast';
import type { IFormBoxConfig } from '@/Interface';
import { MFA_AUTHENTICATION_REQUIRED_ERROR_CODE, VIEWS, MFA_FORM } from '@/constants';
import { mapStores } from 'pinia';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useUsersStore } from '@/stores/users.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { useUIStore } from '@/stores/ui.store';
export default defineComponent({
name: 'SigninView',
components: {
AuthView,
MfaView,
},
setup() {
return {
...useToast(),
};
},
data() {
return {
FORM_CONFIG: {} as IFormBoxConfig,
loading: false,
showMfaView: false,
email: '',
password: '',
reportError: false,
};
},
computed: {
...mapStores(useUsersStore, useSettingsStore, useUIStore, useCloudPlanStore),
userHasMfaEnabled() {
return !!this.usersStore.currentUser?.mfaEnabled;
},
},
mounted() {
let emailLabel = this.$locale.baseText('auth.email');
const ldapLoginLabel = this.settingsStore.ldapLoginLabel;
const isLdapLoginEnabled = this.settingsStore.isLdapLoginEnabled;
if (isLdapLoginEnabled && ldapLoginLabel) {
emailLabel = ldapLoginLabel;
}
this.FORM_CONFIG = {
title: this.$locale.baseText('auth.signin'),
buttonText: this.$locale.baseText('auth.signin'),
redirectText: this.$locale.baseText('forgotPassword'),
inputs: [
{
name: 'email',
properties: {
label: emailLabel,
type: 'email',
required: true,
...(!isLdapLoginEnabled && { validationRules: [{ name: 'VALID_EMAIL' }] }),
showRequiredAsterisk: false,
validateOnBlur: false,
autocomplete: 'email',
capitalize: true,
},
},
{
name: 'password',
properties: {
label: this.$locale.baseText('auth.password'),
type: 'password',
required: true,
showRequiredAsterisk: false,
validateOnBlur: false,
autocomplete: 'current-password',
capitalize: true,
},
},
],
};
import type { IFormBoxConfig } from '@/Interface';
import { MFA_AUTHENTICATION_REQUIRED_ERROR_CODE, VIEWS, MFA_FORM } from '@/constants';
if (!this.settingsStore.isDesktopDeployment) {
this.FORM_CONFIG.redirectLink = '/forgot-password';
}
},
methods: {
async onMFASubmitted(form: { token?: string; recoveryCode?: string }) {
await this.login({
email: this.email,
password: this.password,
token: form.token,
recoveryCode: form.recoveryCode,
});
},
async onEmailPasswordSubmitted(form: { email: string; password: string }) {
await this.login(form);
},
isRedirectSafe() {
const redirect = this.getRedirectQueryParameter();
return redirect.startsWith('/') || redirect.startsWith(window.location.origin);
},
getRedirectQueryParameter() {
let redirect = '';
if (typeof this.$route.query?.redirect === 'string') {
redirect = decodeURIComponent(this.$route.query?.redirect);
}
return redirect;
},
async login(form: { email: string; password: string; token?: string; recoveryCode?: string }) {
try {
this.loading = true;
await this.usersStore.loginWithCreds({
email: form.email,
password: form.password,
mfaToken: form.token,
mfaRecoveryCode: form.recoveryCode,
});
this.loading = false;
if (this.settingsStore.isCloudDeployment) {
try {
await this.cloudPlanStore.checkForCloudPlanData();
} catch (error) {
console.warn('Failed to check for cloud plan data', error);
}
}
await this.settingsStore.getSettings();
this.clearAllStickyNotifications();
const usersStore = useUsersStore();
const settingsStore = useSettingsStore();
const cloudPlanStore = useCloudPlanStore();
this.$telemetry.track('User attempted to login', {
result: this.showMfaView ? 'mfa_success' : 'success',
});
const route = useRoute();
const router = useRouter();
if (this.isRedirectSafe()) {
const redirect = this.getRedirectQueryParameter();
if (redirect.startsWith('http')) {
window.location.href = redirect;
return;
}
const toast = useToast();
const locale = useI18n();
const telemetry = useTelemetry();
void this.$router.push(redirect);
return;
}
const loading = ref(false);
const showMfaView = ref(false);
const email = ref('');
const password = ref('');
const reportError = ref(false);
await this.$router.push({ name: VIEWS.HOMEPAGE });
} catch (error) {
if (error.errorCode === MFA_AUTHENTICATION_REQUIRED_ERROR_CODE) {
this.showMfaView = true;
this.cacheCredentials(form);
return;
}
this.$telemetry.track('User attempted to login', {
result: this.showMfaView ? 'mfa_token_rejected' : 'credentials_error',
});
if (!this.showMfaView) {
this.showError(error, this.$locale.baseText('auth.signin.error'));
this.loading = false;
return;
}
this.reportError = true;
}
},
onBackClick(fromForm: string) {
this.reportError = false;
if (fromForm === MFA_FORM.MFA_TOKEN) {
this.showMfaView = false;
this.loading = false;
}
},
onFormChanged(toForm: string) {
if (toForm === MFA_FORM.MFA_RECOVERY_CODE) {
this.reportError = false;
}
},
cacheCredentials(form: { email: string; password: string }) {
this.email = form.email;
this.password = form.password;
},
},
const ldapLoginLabel = computed(() => settingsStore.ldapLoginLabel);
const isLdapLoginEnabled = computed(() => settingsStore.isLdapLoginEnabled);
const emailLabel = computed(() => {
let label = locale.baseText('auth.email');
if (isLdapLoginEnabled.value && ldapLoginLabel.value) {
label = ldapLoginLabel.value;
}
return label;
});
const redirectLink = computed(() => {
if (!settingsStore.isDesktopDeployment) {
return '/forgot-password';
}
return undefined;
});
const formConfig: IFormBoxConfig = reactive({
title: locale.baseText('auth.signin'),
buttonText: locale.baseText('auth.signin'),
redirectText: locale.baseText('forgotPassword'),
redirectLink: redirectLink.value,
inputs: [
{
name: 'email',
properties: {
label: emailLabel.value,
type: 'email',
required: true,
...(!isLdapLoginEnabled.value && { validationRules: [{ name: 'VALID_EMAIL' }] }),
showRequiredAsterisk: false,
validateOnBlur: false,
autocomplete: 'email',
capitalize: true,
},
},
{
name: 'password',
properties: {
label: locale.baseText('auth.password'),
type: 'password',
required: true,
showRequiredAsterisk: false,
validateOnBlur: false,
autocomplete: 'current-password',
capitalize: true,
},
},
],
});
const onMFASubmitted = async (form: { token?: string; recoveryCode?: string }) => {
await login({
email: email.value,
password: password.value,
token: form.token,
recoveryCode: form.recoveryCode,
});
};
const isFormWithEmailAndPassword = (values: {
[key: string]: string;
}): values is { email: string; password: string } => {
return 'email' in values && 'password' in values;
};
const onEmailPasswordSubmitted = async (form: { [key: string]: string }) => {
if (!isFormWithEmailAndPassword(form)) return;
await login(form);
};
const isRedirectSafe = () => {
const redirect = getRedirectQueryParameter();
return redirect.startsWith('/') || redirect.startsWith(window.location.origin);
};
const getRedirectQueryParameter = () => {
let redirect = '';
if (typeof route.query?.redirect === 'string') {
redirect = decodeURIComponent(route.query?.redirect);
}
return redirect;
};
const login = async (form: {
email: string;
password: string;
token?: string;
recoveryCode?: string;
}) => {
try {
loading.value = true;
await usersStore.loginWithCreds({
email: form.email,
password: form.password,
mfaToken: form.token,
mfaRecoveryCode: form.recoveryCode,
});
loading.value = false;
if (settingsStore.isCloudDeployment) {
try {
await cloudPlanStore.checkForCloudPlanData();
} catch (error) {
console.warn('Failed to check for cloud plan data', error);
}
}
await settingsStore.getSettings();
toast.clearAllStickyNotifications();
telemetry.track('User attempted to login', {
result: showMfaView.value ? 'mfa_success' : 'success',
});
if (isRedirectSafe()) {
const redirect = getRedirectQueryParameter();
if (redirect.startsWith('http')) {
window.location.href = redirect;
return;
}
void router.push(redirect);
return;
}
await router.push({ name: VIEWS.HOMEPAGE });
} catch (error) {
if (error.errorCode === MFA_AUTHENTICATION_REQUIRED_ERROR_CODE) {
showMfaView.value = true;
cacheCredentials(form);
return;
}
telemetry.track('User attempted to login', {
result: showMfaView.value ? 'mfa_token_rejected' : 'credentials_error',
});
if (!showMfaView.value) {
toast.showError(error, locale.baseText('auth.signin.error'));
loading.value = false;
return;
}
reportError.value = true;
}
};
const onBackClick = (fromForm: string) => {
reportError.value = false;
if (fromForm === MFA_FORM.MFA_TOKEN) {
showMfaView.value = false;
loading.value = false;
}
};
const onFormChanged = (toForm: string) => {
if (toForm === MFA_FORM.MFA_RECOVERY_CODE) {
reportError.value = false;
}
};
const cacheCredentials = (form: { email: string; password: string }) => {
email.value = form.email;
password.value = form.password;
};
</script>
<template>
<div>
<AuthView
v-if="!showMfaView"
:form="FORM_CONFIG"
:form="formConfig"
:form-loading="loading"
:with-sso="true"
data-test-id="signin-form"

View file

@ -1,5 +1,6 @@
import { connect, type IClientOptions, type MqttClient } from 'mqtt';
import { ApplicationError, randomString } from 'n8n-workflow';
import { formatPrivateKey } from '@utils/utilities';
interface BaseMqttCredential {
@ -62,6 +63,10 @@ export const createClient = async (credentials: MqttCredential): Promise<MqttCli
const onError = (error: Error) => {
client.removeListener('connect', onConnect);
client.removeListener('error', onError);
// mqtt client has an automatic reconnect mechanism that will
// keep trying to reconnect until it succeeds unless we
// explicitly close the client
client.end();
reject(new ApplicationError(error.message));
};

View file

@ -8,6 +8,7 @@ import {
type INodeType,
type INodeTypeDescription,
NodeConnectionType,
ensureError,
} from 'n8n-workflow';
import { createClient, type MqttCredential } from './GenericFunctions';
@ -116,10 +117,12 @@ export class Mqtt implements INodeType {
try {
const client = await createClient(credentials);
client.end();
} catch (error) {
} catch (e) {
const error = ensureError(e);
return {
status: 'Error',
message: (error as Error).message,
message: error.message,
};
}
return {

View file

@ -1,19 +1,20 @@
import { MqttClient } from 'mqtt';
import { mock } from 'jest-mock-extended';
import { MqttClient } from 'mqtt';
import { ApplicationError } from 'n8n-workflow';
import { createClient, type MqttCredential } from '../GenericFunctions';
describe('createClient', () => {
const mockConnect = jest.spyOn(MqttClient.prototype, 'connect').mockImplementation(function (
this: MqttClient,
) {
setImmediate(() => this.emit('connect', mock()));
return this;
});
beforeEach(() => jest.clearAllMocks());
it('should create a client with minimal credentials', async () => {
const mockConnect = jest.spyOn(MqttClient.prototype, 'connect').mockImplementation(function (
this: MqttClient,
) {
setImmediate(() => this.emit('connect', mock()));
return this;
});
const credentials = mock<MqttCredential>({
protocol: 'mqtt',
host: 'localhost',
@ -35,4 +36,31 @@ describe('createClient', () => {
clientId: 'testClient',
});
});
it('should reject with ApplicationError on connection error and close connection', async () => {
const mockConnect = jest.spyOn(MqttClient.prototype, 'connect').mockImplementation(function (
this: MqttClient,
) {
setImmediate(() => this.emit('error', new Error('Connection failed')));
return this;
});
const mockEnd = jest.spyOn(MqttClient.prototype, 'end').mockImplementation();
const credentials: MqttCredential = {
protocol: 'mqtt',
host: 'localhost',
port: 1883,
clean: true,
clientId: 'testClientId',
username: 'testUser',
password: 'testPass',
ssl: false,
};
const clientPromise = createClient(credentials);
await expect(clientPromise).rejects.toThrow(ApplicationError);
expect(mockConnect).toBeCalledTimes(1);
expect(mockEnd).toBeCalledTimes(1);
});
});

View file

@ -238,14 +238,6 @@ export class Wait extends Webhook {
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
credentials: credentialsProperty(this.authPropertyName),
hints: [
{
message:
"When testing your workflow using the Editor UI, you can't see the rest of the execution following the Wait node. To inspect the execution results, enable Save Manual Executions in your Workflow settings so you can review the execution results there.",
location: 'outputPane',
whenToDisplay: 'beforeExecution',
},
],
webhooks: [
{
...defaultWebhookDescription,

File diff suppressed because it is too large Load diff