mirror of
https://github.com/n8n-io/n8n.git
synced 2024-09-19 22:37:31 -07:00
Merge branch 'master' of https://github.com/n8n-io/n8n into node-1714-show-result-of-waiting-execution-on-canvas-after-execution
This commit is contained in:
commit
548907e529
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
|
@ -1,2 +0,0 @@
|
|||
export const COMMAND_REDIS_CHANNEL = 'n8n.commands';
|
||||
export const WORKER_RESPONSE_REDIS_CHANNEL = 'n8n.worker-response';
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -1,149 +1,112 @@
|
|||
<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';
|
||||
|
||||
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');
|
||||
|
||||
return getMappedExpression({
|
||||
nodeName: this.node.name,
|
||||
distanceFromActive: this.distanceFromActive,
|
||||
path: subPath,
|
||||
});
|
||||
},
|
||||
onDragStart(el: HTMLElement) {
|
||||
if (el?.dataset.path) {
|
||||
this.draggingPath = el.dataset.path;
|
||||
}
|
||||
const jsonData = computed(() => executionDataToJson(props.inputData));
|
||||
|
||||
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 highlight = computed(() => ndvStore.highlightDraggables);
|
||||
|
||||
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 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>
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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));
|
||||
};
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
3906
pnpm-lock.yaml
3906
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue