Update credentials modal (#2154)

*  Generalize unique entity name generation

*  Standardize variable names

* redo credentials

* revert some changes, replace got with was

* fix v-if order

* fix v-if order

* update linting

* update gulpfile

* update ssh display name

* update height

* update params

* update info tip sizes

* address design comments

* update google button disabled

* update icon size to 28px

* update design issues

* update info tab design

* address design comments

* update tab size

* update run data spacing

* address comments, update logo design

* fix spacing issues

* clean up store

* fix create new bug

* add loading state

* rename prop

* remove unused prop

* fix select bug

* remove label tag

* update word break

* build

* address design comments

* update font family of button

* update menu opacity

* update text

* update title

* address more comments

* update oauth messages

* add oauth validation

* hide disabled state

* update warning modal

* show button on text input

* clean up cred details

* add validation errors

* fix bug when deleting cred

* Frontend hack to display test button

* Created interfaces for testing and endpoint

* Testing slack node credentials working

* Adding test with node to endpoint for credential testing

* Fixed linting and test detectability

* Adding required for slack token

* Added google sheets credential testing

* update message

* Adding suggestions by Ivan and Mutasem

* Address comments

* keep blurred when focused

* update font weight of errors

* add oauth banner

* remove toast

* Fixed code bug and added telegram credential testing

* scroll to top on success

* clean up duplication

* Fixed telegram trigger node and added tests to typeform

* refactor modal

* add more validation support

* refactor info tab

* scroll to bottom on save, handle cred saving

* refactor save button

* save cred on valid

* save cred on valid

* scroll to top if has error

* add targets on input labels

* delete credentails input

* revert fe changes

* update validation logic

* clean interface

* test credentials

* update banner design

* show testing state

* update x position

* fix issues

* fix focus issues

* clean up validation behavior

* make error relative

* update banner component

* update error spacing

* don't close dialog

* rename button

* update how banners behave

* if has unsaved changes first

* move confirm message

* add success banner

* update time state

* disable transitions

* test on open

* clean up banner behavior

* update banner styling

* capitalize

* update error banner styling to handle long texts

* avoid unnessary content jostling

* add loading label

* show validation warnings when opening modal

* retest cred if not all props req

* update scroll to auto

* add error warning

* update color saturation

* set overflow to auto

* fix bug to get credentials when connected

* round down to minutes

* change tab name

* update casing oauth

* disable credential testing if it has expressions

* label same as title

* add more space between close and save

* remove check on making any changes

* hide close on confirm modals

* don't accept clicks outside dialog

* fix build issues

* undo test changes

* fix table scrollbar logs

* rename modals

* fix bug with same name

* refactor modal

* fix tslint issue

* refactor name

* update name behavior

* update monospace font

* remove comment

* refactor inputs

* refactor error handling

* reduce spacing changes

* fix doc url oauth1 oauth2

* build

* hide infotip if no inputs

* address most comments

* rename file

* fix menu alignment

* gst

* update types

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: Omar Ajoue <krynble@gmail.com>
This commit is contained in:
Mutasem Aldmour 2021-09-11 10:15:36 +02:00 committed by GitHub
parent 63e2bd25c9
commit 3d6b40b852
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
109 changed files with 3554 additions and 1933 deletions

View file

@ -8,8 +8,8 @@
"dev": "lerna exec npm run dev --parallel",
"clean:dist": "lerna exec -- rimraf ./dist",
"format": "lerna exec npm run format",
"lint": "lerna exec npm run lint",
"lintfix": "lerna exec npm run lintfix",
"lint": "lerna exec npm run lint",
"lintfix": "lerna exec npm run lintfix",
"optimize-svg": "find ./packages -name '*.svg' ! -name 'pipedrive.svg' -print0 | xargs -0 -P16 -L20 npx svgo",
"start": "run-script-os",
"start:default": "cd packages/cli/bin && ./n8n",

View file

@ -148,6 +148,12 @@ const config = convict({
env: 'CREDENTIALS_OVERWRITE_ENDPOINT',
},
},
defaultName: {
doc: 'Default name for credentials',
format: String,
default: 'My credentials',
env: 'CREDENTIALS_DEFAULT_NAME',
},
},
workflows: {

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable no-param-reassign */
@ -11,7 +12,11 @@ import { IDataObject } from 'n8n-workflow';
import * as config from '../config';
// eslint-disable-next-line import/no-cycle
import { IPackageVersions } from '.';
import { Db, ICredentialsDb, IPackageVersions } from '.';
// eslint-disable-next-line import/order
import { Like } from 'typeorm';
// eslint-disable-next-line import/no-cycle
import { WorkflowEntity } from './databases/entities/WorkflowEntity';
let versionCache: IPackageVersions | undefined;
@ -170,3 +175,56 @@ export function getConfigValueSync(configKey: string): string | boolean | number
return data;
}
/**
* Generate a unique name for a workflow or credentials entity.
*
* - If the name does not yet exist, it returns the requested name.
* - If the name already exists once, it returns the requested name suffixed with 2.
* - If the name already exists more than once with suffixes, it looks for the max suffix
* and returns the requested name with max suffix + 1.
*/
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export async function generateUniqueName(
requestedName: string,
entityType: 'workflow' | 'credentials',
) {
const findConditions = {
select: ['name' as const],
where: {
name: Like(`${requestedName}%`),
},
};
const found: Array<WorkflowEntity | ICredentialsDb> =
entityType === 'workflow'
? await Db.collections.Workflow!.find(findConditions)
: await Db.collections.Credentials!.find(findConditions);
// name is unique
if (found.length === 0) {
return { name: requestedName };
}
const maxSuffix = found.reduce((acc, { name }) => {
const parts = name.split(`${requestedName} `);
if (parts.length > 2) return acc;
const suffix = Number(parts[1]);
// eslint-disable-next-line no-restricted-globals
if (!isNaN(suffix) && Math.ceil(suffix) > acc) {
acc = Math.ceil(suffix);
}
return acc;
}, 0);
// name is duplicate but no numeric suffixes exist yet
if (maxSuffix === 0) {
return { name: `${requestedName} 2` };
}
return { name: `${requestedName} ${maxSuffix + 1}` };
}

View file

@ -121,6 +121,7 @@ export interface ICredentialsBase {
export interface ICredentialsDb extends ICredentialsBase, ICredentialsEncrypted {
id: number | string;
name: string;
}
export interface ICredentialsResponse extends ICredentialsDb {

View file

@ -151,6 +151,14 @@ class LoadNodesAndCredentialsClass {
let tempCredential: ICredentialType;
try {
tempCredential = new tempModule[credentialName]() as ICredentialType;
if (tempCredential.icon && tempCredential.icon.startsWith('file:')) {
// If a file icon gets used add the full path
tempCredential.icon = `file:${path.join(
path.dirname(filePath),
tempCredential.icon.substr(5),
)}`;
}
} catch (e) {
if (e instanceof TypeError) {
throw new Error(

View file

@ -52,9 +52,16 @@ import { createHash, createHmac } from 'crypto';
import { compare } from 'bcryptjs';
import * as promClient from 'prom-client';
import { Credentials, LoadNodeParameterOptions, UserSettings } from 'n8n-core';
import {
Credentials,
ICredentialTestFunctions,
LoadNodeParameterOptions,
NodeExecuteFunctions,
UserSettings,
} from 'n8n-core';
import {
ICredentialsDecrypted,
ICredentialsEncrypted,
ICredentialType,
IDataObject,
@ -66,6 +73,8 @@ import {
IWorkflowBase,
IWorkflowCredentials,
LoggerProxy,
NodeCredentialTestRequest,
NodeCredentialTestResult,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
@ -131,7 +140,7 @@ import * as config from '../config';
import * as TagHelpers from './TagHelpers';
import { TagEntity } from './databases/entities/TagEntity';
import { WorkflowEntity } from './databases/entities/WorkflowEntity';
import { WorkflowNameRequest } from './WorkflowHelpers';
import { NameRequest } from './WorkflowHelpers';
require('body-parser-xml')(bodyParser);
@ -156,6 +165,8 @@ class App {
defaultWorkflowName: string;
defaultCredentialsName: string;
saveDataErrorExecution: string;
saveDataSuccessExecution: string;
@ -196,6 +207,7 @@ class App {
this.endpointWebhookTest = config.get('endpoints.webhookTest') as string;
this.defaultWorkflowName = config.get('workflows.defaultName') as string;
this.defaultCredentialsName = config.get('credentials.defaultName') as string;
this.saveDataErrorExecution = config.get('executions.saveDataOnError') as string;
this.saveDataSuccessExecution = config.get('executions.saveDataOnSuccess') as string;
@ -720,41 +732,11 @@ class App {
this.app.get(
`/${this.restEndpoint}/workflows/new`,
ResponseHelper.send(
async (req: WorkflowNameRequest, res: express.Response): Promise<{ name: string }> => {
const nameToReturn =
async (req: NameRequest, res: express.Response): Promise<{ name: string }> => {
const requestedName =
req.query.name && req.query.name !== '' ? req.query.name : this.defaultWorkflowName;
const workflows = await Db.collections.Workflow!.find({
select: ['name'],
where: { name: Like(`${nameToReturn}%`) },
});
// name is unique
if (workflows.length === 0) {
return { name: nameToReturn };
}
const maxSuffix = workflows.reduce((acc: number, { name }) => {
const parts = name.split(`${nameToReturn} `);
if (parts.length > 2) return acc;
const suffix = Number(parts[1]);
// eslint-disable-next-line no-restricted-globals
if (!isNaN(suffix) && Math.ceil(suffix) > acc) {
acc = Math.ceil(suffix);
}
return acc;
}, 0);
// name is duplicate but no numeric suffixes exist yet
if (maxSuffix === 0) {
return { name: `${nameToReturn} 2` };
}
return { name: `${nameToReturn} ${maxSuffix + 1}` };
return await GenericHelpers.generateUniqueName(requestedName, 'workflow');
},
),
);
@ -1237,6 +1219,18 @@ class App {
// Credentials
// ----------------------------------------
this.app.get(
`/${this.restEndpoint}/credentials/new`,
ResponseHelper.send(
async (req: NameRequest, res: express.Response): Promise<{ name: string }> => {
const requestedName =
req.query.name && req.query.name !== '' ? req.query.name : this.defaultCredentialsName;
return await GenericHelpers.generateUniqueName(requestedName, 'credentials');
},
),
);
// Deletes a specific credential
this.app.delete(
`/${this.restEndpoint}/credentials/:id`,
@ -1323,6 +1317,67 @@ class App {
),
);
// Test credentials
this.app.post(
`/${this.restEndpoint}/credentials-test`,
ResponseHelper.send(
async (req: express.Request, res: express.Response): Promise<NodeCredentialTestResult> => {
const incomingData = req.body as NodeCredentialTestRequest;
const credentialType = incomingData.credentials.type;
// Find nodes that can test this credential.
const nodeTypes = NodeTypes();
const allNodes = nodeTypes.getAll();
let foundTestFunction:
| ((
this: ICredentialTestFunctions,
credential: ICredentialsDecrypted,
) => Promise<NodeCredentialTestResult>)
| undefined;
const nodeThatCanTestThisCredential = allNodes.find((node) => {
if (
incomingData.nodeToTestWith &&
node.description.name !== incomingData.nodeToTestWith
) {
return false;
}
const credentialTestable = node.description.credentials?.find((credential) => {
const testFunctionSearch =
credential.name === credentialType && !!credential.testedBy;
if (testFunctionSearch) {
foundTestFunction = node.methods!.credentialTest![credential.testedBy!];
}
return testFunctionSearch;
});
return !!credentialTestable;
});
if (!nodeThatCanTestThisCredential) {
return Promise.resolve({
status: 'Error',
message: 'There are no nodes that can test this credential.',
});
}
if (foundTestFunction === undefined) {
return Promise.resolve({
status: 'Error',
message: 'No testing function found for this credential.',
});
}
const credentialTestFunctions = NodeExecuteFunctions.getCredentialTestFunctions();
const output = await foundTestFunction.call(
credentialTestFunctions,
incomingData.credentials,
);
return Promise.resolve(output);
},
),
);
// Updates existing credentials
this.app.patch(
`/${this.restEndpoint}/credentials/:id`,
@ -1540,6 +1595,42 @@ class App {
),
);
this.app.get(
`/${this.restEndpoint}/credential-icon/:credentialType`,
async (req: express.Request, res: express.Response): Promise<void> => {
try {
const credentialName = req.params.credentialType;
const credentialType = CredentialTypes().getByName(credentialName);
if (credentialType === undefined) {
res.status(404).send('The credentialType is not known.');
return;
}
if (credentialType.icon === undefined) {
res.status(404).send('No icon found for credential.');
return;
}
if (!credentialType.icon.startsWith('file:')) {
res.status(404).send('Credential does not have a file icon.');
return;
}
const filepath = credentialType.icon.substr(5);
const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days
res.setHeader('Cache-control', `private max-age=${maxAge}`);
res.sendFile(filepath);
} catch (error) {
// Error response
return ResponseHelper.sendErrorResponse(res, error);
}
},
);
// ----------------------------------------
// OAuth1-Credential/Auth
// ----------------------------------------

View file

@ -408,9 +408,8 @@ export function throwDuplicateEntryError(error: Error) {
throw new ResponseHelper.ResponseError(errorMessage, undefined, 400);
}
export type WorkflowNameRequest = Express.Request & {
export type NameRequest = Express.Request & {
query: {
name?: string;
offset?: string;
};
};

View file

@ -2,6 +2,7 @@
import {
IAllExecuteFunctions,
IBinaryData,
ICredentialTestFunctions as ICredentialTestFunctionsBase,
ICredentialType,
IDataObject,
IExecuteFunctions as IExecuteFunctionsBase,
@ -158,6 +159,12 @@ export interface ILoadOptionsFunctions extends ILoadOptionsFunctionsBase {
};
}
export interface ICredentialTestFunctions extends ICredentialTestFunctionsBase {
helpers: {
request: requestPromise.RequestPromiseAPI;
};
}
export interface IHookFunctions extends IHookFunctionsBase {
helpers: {
request: requestPromise.RequestPromiseAPI;

View file

@ -65,6 +65,7 @@ import { lookup } from 'mime-types';
// eslint-disable-next-line import/no-cycle
import {
BINARY_ENCODING,
ICredentialTestFunctions,
IHookFunctions,
ILoadOptionsFunctions,
IResponseError,
@ -1280,6 +1281,14 @@ export function getExecuteSingleFunctions(
})(workflow, runExecutionData, connectionInputData, inputData, node, itemIndex);
}
export function getCredentialTestFunctions(): ICredentialTestFunctions {
return {
helpers: {
request: requestPromiseWithDefaults,
},
};
}
/**
* Returns the execute functions regular nodes have access to in load-options-function.
*

View file

@ -6,7 +6,7 @@
:size="props.size"
:loading="props.loading"
:title="props.title || props.label"
:class="$style[$options.getClass(props)]"
:class="$options.getClass(props, $style)"
:round="!props.circle && props.round"
:circle="props.circle"
:style="$options.styles(props)"
@ -91,6 +91,10 @@ export default {
type: Boolean,
default: false,
},
transparentBackground: {
type: Boolean,
default: false,
},
},
components: {
ElButton,
@ -106,10 +110,16 @@ export default {
...(props.fullWidth ? { width: '100%' } : {}),
};
},
getClass(props: { type: string; theme?: string }): string {
return props.type === 'text'
getClass(props: { type: string; theme?: string, transparentBackground: boolean }, $style: any): string {
const theme = props.type === 'text'
? 'text'
: `${props.type}-${props.theme || 'primary'}`;
if (props.transparentBackground) {
return `${$style[theme]} ${$style['transparent']}`;
}
return $style[theme];
},
};
</script>
@ -289,6 +299,11 @@ $color-danger-shade: lightness(
--button-active-border-color: transparent;
}
.transparent {
--button-background-color: transparent;
--button-active-background-color: transparent;
}
.icon {
display: inline-flex;

View file

@ -0,0 +1,17 @@
import N8nInfoTip from './InfoTip.vue';
export default {
title: 'Atoms/InfoTip',
component: N8nInfoTip,
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nInfoTip,
},
template:
'<n8n-info-tip>Need help doing something? <a href="/docs" target="_blank">Open docs</a></n8n-info-tip>',
});
export const InputLabel = Template.bind({});

View file

@ -0,0 +1,36 @@
<template functional>
<div :class="$style.infotip">
<n8n-icon icon="info-circle" /> <span><slot></slot></span>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import N8nIcon from '../N8nIcon';
Vue.component('N8nIcon', N8nIcon);
export default {
name: 'n8n-info-tip',
props: {
},
};
</script>
<style lang="scss" module>
.infotip {
color: var(--color-text-light);
font-size: var(--font-size-2xs);
font-weight: var(--font-weight-bold);
line-height: var(--font-size-s);
word-break: normal;
display: flex;
align-items: center;
svg {
font-size: var(--font-size-s);
margin-right: var(--spacing-4xs);
}
}
</style>

View file

@ -0,0 +1,3 @@
import InfoTip from './InfoTip.vue';
export default InfoTip;

View file

@ -1,16 +1,18 @@
<template functional>
<div :class="$style.inputLabel">
<label>
<div :class="$style.label">
<span>{{ props.label }}</span>
<span v-if="props.tooltipText" :class="$style.infoIcon">
<n8n-tooltip :content="props.tooltipText" placement="top">
<n8n-icon icon="info-circle" />
</n8n-tooltip>
</span>
</div>
<slot></slot>
</label>
<div :class="$style.label">
<span>
{{ $options.methods.addTargetBlank(props.label) }}
<span v-if="props.required" :class="$style.required">*</span>
</span>
<span :class="$style.infoIcon" v-if="props.tooltipText">
<n8n-tooltip placement="top" :popper-class="$style.tooltipPopper">
<n8n-icon icon="question-circle" />
<div slot="content" v-html="props.tooltipText"></div>
</n8n-tooltip>
</span>
</div>
<slot></slot>
</div>
</template>
@ -20,6 +22,8 @@ import Vue from 'vue';
import N8nTooltip from '../N8nTooltip';
import N8nIcon from '../N8nIcon';
import { addTargetBlank } from '../utils/helpers';
Vue.component('N8nIcon', N8nIcon);
Vue.component('N8nTooltip', N8nTooltip);
@ -33,6 +37,12 @@ export default {
tooltipText: {
type: String,
},
required: {
type: Boolean,
},
},
methods: {
addTargetBlank,
},
};
</script>
@ -48,10 +58,22 @@ export default {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-s);
margin-bottom: var(--spacing-2xs);
* {
margin-right: var(--spacing-4xs);
}
}
.infoIcon {
color: var(--color-text-light);
display: var(--info-icon-display, none);
}
.required {
color: var(--color-primary);
}
.tooltipPopper {
max-width: 400px;
}
</style>

View file

@ -0,0 +1,46 @@
import N8nMenu from './Menu.vue';
import N8nMenuItem from '../N8nMenuItem';
import { action } from '@storybook/addon-actions';
export default {
title: 'Atoms/Menu',
component: N8nMenu,
argTypes: {
type: {
control: 'select',
options: ['primary', 'secondary'],
},
},
parameters: {
backgrounds: { default: '--color-background-xlight' },
},
};
const methods = {
onSelect: action('select'),
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nMenu,
N8nMenuItem,
},
template:
`<n8n-menu v-bind="$props" @select="onSelect">
<n8n-menu-item index="1"> <span slot="title">Item 1</span> </n8n-menu-item>
<n8n-menu-item index="2"> <span slot="title">Item 2</span> </n8n-menu-item>
</n8n-menu>`,
methods,
});
export const Primary = Template.bind({});
Primary.parameters = {
backgrounds: { default: '--color-background-light' },
};
export const Secondary = Template.bind({});
Secondary.args = {
type: 'secondary',
};

View file

@ -0,0 +1,70 @@
<template functional>
<component
:is="$options.components.ElMenu"
:defaultActive="props.defaultActive"
:collapse="props.collapse"
:class="$style[props.type + (props.light ? '-light' : '')]"
@select="listeners.select"
>
<slot></slot>
</component>
</template>
<script lang="ts">
import ElMenu from 'element-ui/lib/menu';
export default {
name: 'n8n-menu',
props: {
type: {
type: String,
default: 'primary',
validator: (value: string): boolean => ['primary', 'secondary'].includes(value),
},
defaultActive: {
type: String,
},
collapse: {
type: Boolean,
},
light: {
type: Boolean,
},
},
components: {
ElMenu,
},
};
</script>
<style lang="scss" module>
.menu {
max-width: 200px;
}
.primary {
composes: menu;
}
.secondary {
composes: menu;
--menu-font-color: var(--color-text-base);
--menu-item-font-color: var(--font-weight-regular);
--menu-background-color: transparent;
--menu-item-hover-font-color: var(--color-primary);
--menu-item-active-font-color: var(--color-text-dark);
--menu-item-active-background-color: var(--color-foreground-base);
--menu-item-border-radius: 4px;
--menu-item-height: 38px;
}
.secondary-light {
composes: secondary;
--menu-item-active-background-color: hsl(
var(--color-foreground-base-h),
var(--color-foreground-base-s),
var(--color-foreground-base-l),
0.7
);
}
</style>

View file

@ -0,0 +1,3 @@
import N8nMenu from './Menu.vue';
export default N8nMenu;

View file

@ -0,0 +1,7 @@
<script lang="ts">
import ElMenuItem from 'element-ui/lib/menu-item';
ElMenuItem.name = 'n8n-menu-item';
export default ElMenuItem;
</script>

View file

@ -0,0 +1,3 @@
import N8nMenuItem from './MenuItem.vue';
export default N8nMenuItem;

View file

@ -2,22 +2,28 @@ import N8nButton from './N8nButton';
import N8nIcon from './N8nIcon';
import N8nIconButton from './N8nIconButton';
import N8nInput from './N8nInput';
import N8nInputLabel from './N8nInputLabel';
import N8nInfoTip from './N8nInfoTip';
import N8nInputNumber from './N8nInputNumber';
import N8nOption from './N8nOption';
import N8nInputLabel from './N8nInputLabel';
import N8nMenu from './N8nMenu';
import N8nMenuItem from './N8nMenuItem';
import N8nSelect from './N8nSelect';
import N8nSpinner from './N8nSpinner';
import N8nTooltip from './N8nTooltip';
import N8nOption from './N8nOption';
export {
N8nButton,
N8nIcon,
N8nIconButton,
N8nInfoTip,
N8nInput,
N8nInputLabel,
N8nInputNumber,
N8nOption,
N8nMenu,
N8nMenuItem,
N8nSelect,
N8nSpinner,
N8nTooltip,
N8nOption,
};

View file

@ -0,0 +1,5 @@
export function addTargetBlank(html: string) {
return html.includes('href=')
? html.replace(/href=/g, 'target="_blank" href=')
: html;
}

View file

@ -4,4 +4,6 @@ declare module 'element-ui/lib/tooltip';
declare module 'element-ui/lib/input-number';
declare module 'element-ui/lib/select';
declare module 'element-ui/lib/option';
declare module 'element-ui/lib/menu';
declare module 'element-ui/lib/menu-item';

View file

@ -53,7 +53,7 @@
);
--color-success-h: 150.4;
--color-success-s: 73.8%;
--color-success-s: 60%;
--color-success-l: 40.4%;
--color-success: hsl(
var(--color-success-h),

View file

@ -5,7 +5,6 @@
@include mixins.b(checkbox) {
color: var.$checkbox-font-color;
font-weight: var.$checkbox-font-weight;
font-size: var.$font-size-base;
position: relative;
cursor: pointer;
@ -156,10 +155,6 @@
transform: rotate(45deg) scaleY(1);
}
}
& + .el-checkbox__label {
color: var.$checkbox-checked-font-color;
}
}
@include mixins.when(focus) {
/*focus时 视觉上区分*/
@ -253,7 +248,6 @@
@include mixins.e(inner) {
display: inline-block;
line-height: 1;
font-weight: var.$checkbox-font-weight;
white-space: nowrap;
vertical-align: middle;
cursor: pointer;

View file

@ -12,16 +12,20 @@
@keyframes v-modal-in {
0% {
opacity: 0;
backdrop-filter: blur(4px) opacity(0);
}
100% {
backdrop-filter: blur(4px) opacity(1);
}
}
@keyframes v-modal-out {
0% {
backdrop-filter: blur(4px) opacity(1);
}
100% {
opacity: 0;
backdrop-filter: blur(4px) opacity(0);
}
}
@ -31,8 +35,8 @@
top: 0;
width: 100%;
height: 100%;
opacity: var.$popup-modal-opacity;
background: var.$popup-modal-background-color;
background-color: var.$popup-modal-background-color;
backdrop-filter: blur(4px) opacity(1);
}
@include mixins.b(popup-parent) {

View file

@ -277,10 +277,8 @@ $icon-color-base: var(--color-info);
-------------------------- */
/// fontSize||Font|1
$checkbox-font-size: 14px;
/// fontWeight||Font|1
$checkbox-font-weight: $font-weight-primary;
/// color||Color|0
$checkbox-font-color: var(--color-text-dark);
$checkbox-font-color: var(--color-text-base);
$checkbox-input-height: 14px;
$checkbox-input-width: 14px;
/// borderRadius||Border|2
@ -761,7 +759,8 @@ $dialog-content-font-size: 14px;
/// fontLineHeight||LineHeight|2
$dialog-font-line-height: $font-line-height-primary;
/// padding||Spacing|3
$dialog-padding-primary: 20px;
$dialog-padding-primary: var(--spacing-l);
$dialog-close-top: var(--dialog-close-top, var(--spacing-l));
/* Table
-------------------------- */
@ -804,9 +803,9 @@ $pagination-hover-color: var(--color-primary);
/* Popup
-------------------------- */
/// color||Color|0
$popup-modal-background-color: $color-black;
$popup-modal-background-color: hsla(247,14%, 70%, 0.75);
/// opacity||Other|1
$popup-modal-opacity: 0.5;
$popup-modal-opacity: 0.65;
/* Popover
-------------------------- */
@ -852,9 +851,9 @@ $tag-warning-color: var(--color-warning);
/// color||Color|0
$tag-danger-color: var(--color-danger);
/// fontSize||Font|1
$tag-font-size: 12px;
$tag-font-size: var(--font-size-s);
$tag-border-radius: 4px;
$tag-padding: 0 10px;
$tag-padding: 16px;
/* Tree
-------------------------- */
@ -924,11 +923,17 @@ $steps-padding: 20px;
--------------------------*/
/// fontSize||Font|1
$menu-item-font-size: $font-size-base;
$menu-item-font-weight: var(--menu-item-font-color, 300);
/// color||Color|0
$menu-item-font-color: var(--color-text-dark);
$menu-item-font-color: var(--menu-font-color, var(--color-text-dark));
/// color||Color|0
$menu-background-color: $color-white;
$menu-item-hover-fill: $color-primary-light-9;
$menu-background-color: var(--menu-background-color, var(--color-background-xlight));
$menu-item-hover-fill: var(--menu-item-hover-fill, transparent);
$menu-item-hover-font-color: var(--menu-item-hover-font-color, var(--color-text-dark));
$menu-item-active-font-color: var(--menu-item-active-font-color, var(--color-primary));
$menu-item-active-background-color: var(--menu-item-active-background-color, transparent);
$menu-item-border-radius: var(--menu-item-border-radius, 0);
$menu-item-height: var(--menu-item-height, 56px);
/* Rate
--------------------------*/

View file

@ -32,12 +32,12 @@
@include mixins.e(header) {
padding: var.$dialog-padding-primary;
padding-bottom: 10px;
padding-bottom: 0px;
}
@include mixins.e(headerbtn) {
position: absolute;
top: var.$dialog-padding-primary;
top: var.$dialog-close-top;
right: var.$dialog-padding-primary;
padding: 0;
background: transparent;
@ -65,8 +65,8 @@
}
@include mixins.e(body) {
padding: (var.$dialog-padding-primary + 10px) var.$dialog-padding-primary;
color: var(--color-text-dark);
padding: var.$dialog-padding-primary;
color: var(--color-text-base);
font-size: var.$dialog-content-font-size;
word-break: break-all;
}

View file

@ -113,8 +113,7 @@ $directions: rtl, ltr, ttb, btt;
background-color: var.$dialog-background-color;
display: flex;
flex-direction: column;
box-shadow: 0 8px 10px -5px rgba(0, 0, 0, 0.2),
0 16px 24px 2px rgba(0, 0, 0, 0.14), 0 6px 30px 5px rgba(0, 0, 0, 0.12);
box-shadow: 0px 6px 16px rgb(68 28 23 / 6%);
overflow: hidden;
outline: 0;
@ -183,10 +182,12 @@ $directions: rtl, ltr, ttb, btt;
&.ltr {
left: 0;
border-right: var(--border-base);
}
&.rtl {
right: 0;
border-left: var(--border-base);
}
&.ttb {

View file

@ -4,10 +4,11 @@
@use "common/transition";
@mixin menu-item {
height: 56px;
line-height: 56px;
height: var.$menu-item-height;
line-height: var.$menu-item-height;
font-size: var.$menu-item-font-size;
color: var.$menu-item-font-color;
font-weight: var.$menu-item-font-weight;
padding: 0 20px;
list-style: none;
cursor: pointer;
@ -15,10 +16,7 @@
transition: border-color 0.3s, background-color 0.3s, color 0.3s;
box-sizing: border-box;
white-space: nowrap;
* {
vertical-align: middle;
}
border-radius: var.$menu-item-border-radius;
i {
color: var(--color-text-light);
@ -28,6 +26,7 @@
&:focus {
outline: none;
background-color: var.$menu-item-hover-fill;
color: var.$menu-item-hover-font-color;
}
@include mixins.when(disabled) {
@ -38,7 +37,6 @@
}
@include mixins.b(menu) {
border-right: solid 1px #e6e6e6;
list-style: none;
position: relative;
margin: 0;
@ -208,7 +206,9 @@
vertical-align: middle;
}
@include mixins.when(active) {
color: var(--color-primary);
color: var.$menu-item-active-font-color;
background-color: var.$menu-item-active-background-color;
i {
color: inherit;
}

View file

@ -226,7 +226,7 @@ hr {
height: 1px;
border: 0;
border-top: 1px solid var(--color-foreground-light);
margin: 1em 0;
margin: 0;
padding: 0;
}

View file

@ -5,6 +5,7 @@
background-color: var.$color-primary-lighter;
border-color: var.$color-primary-light-5;
color: var.$color-primary-light-1;
font-weight: var(--font-weight-regular);
@include mixins.when(hit) {
border-color: var.$tag-primary-color;
@ -37,9 +38,9 @@
}
&.el-tag--success {
background-color: var.$color-success-lighter;
border-color: var.$color-success-light-5;
color: var.$color-success-light-3;
background-color: var(--color-success-tint-2);
border-color: var(--color-success-tint-1);
color: var(--color-success);
@include mixins.when(hit) {
border-color: var.$tag-success-color;
@ -94,9 +95,7 @@
@include mixins.b(tag) {
@include genTheme();
display: inline-block;
height: 32px;
padding: var.$tag-padding;
line-height: 30px;
font-size: var.$tag-font-size;
color: var.$tag-primary-color;
border-width: 1px;
@ -132,8 +131,7 @@
}
@include mixins.m(medium) {
height: 28px;
line-height: 26px;
padding: 12px;
.el-icon-close {
transform: scale(0.8);

View file

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

View file

@ -140,19 +140,10 @@ export interface IRestApi {
getWorkflow(id: string): Promise<IWorkflowDb>;
getWorkflows(filter?: object): Promise<IWorkflowShortResponse[]>;
getWorkflowFromUrl(url: string): Promise<IWorkflowDb>;
createNewCredentials(sendData: ICredentialsDecrypted): Promise<ICredentialsResponse>;
deleteCredentials(id: string): Promise<void>;
updateCredentials(id: string, data: ICredentialsDecrypted): Promise<ICredentialsResponse>;
getAllCredentials(filter?: object): Promise<ICredentialsResponse[]>;
getCredentials(id: string, includeData?: boolean): Promise<ICredentialsDecryptedResponse | ICredentialsResponse | undefined>;
getCredentialTypes(): Promise<ICredentialType[]>;
getExecution(id: string): Promise<IExecutionResponse>;
deleteExecutions(sendData: IExecutionDeleteFilter): Promise<void>;
retryExecution(id: string, loadWorkflow?: boolean): Promise<boolean>;
getTimezones(): Promise<IDataObject>;
oAuth1CredentialAuthorize(sendData: ICredentialsResponse): Promise<string>;
oAuth2CredentialAuthorize(sendData: ICredentialsResponse): Promise<string>;
oAuth2Callback(code: string, state: string): Promise<string>;
}
export interface IBinaryDisplayData {
@ -163,13 +154,6 @@ export interface IBinaryDisplayData {
runIndex: number;
}
export interface ICredentialsCreatedEvent {
data: ICredentialsDecryptedResponse;
options: {
closeDialog: boolean,
};
}
export interface IStartRunData {
workflowData: IWorkflowData;
startNodes?: string[];
@ -585,8 +569,6 @@ export interface IRootState {
activeActions: string[];
activeNode: string | null;
baseUrl: string;
credentials: ICredentialsResponse[] | null;
credentialTypes: ICredentialType[] | null;
endpointWebhook: string;
endpointWebhookTest: string;
executionId: string | null;
@ -618,6 +600,19 @@ export interface IRootState {
instanceId: string;
}
export interface ICredentialTypeMap {
[name: string]: ICredentialType;
}
export interface ICredentialMap {
[name: string]: ICredentialsResponse;
}
export interface ICredentialsState {
credentialTypes: ICredentialTypeMap;
credentials: ICredentialMap;
}
export interface ITagsState {
tags: { [id: string]: ITag };
isLoading: boolean;
@ -627,6 +622,8 @@ export interface ITagsState {
export interface IModalState {
open: boolean;
mode?: string | null;
activeId?: string | null;
}
export interface IUiState {

View file

@ -0,0 +1,53 @@
import { ICredentialsDecryptedResponse, ICredentialsResponse, IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from './helpers';
import {
ICredentialsDecrypted,
ICredentialType,
IDataObject,
NodeCredentialTestRequest,
NodeCredentialTestResult,
} from 'n8n-workflow';
export async function getCredentialTypes(context: IRestApiContext): Promise<ICredentialType[]> {
return await makeRestApiRequest(context, 'GET', '/credential-types');
}
export async function getCredentialsNewName(context: IRestApiContext, name?: string): Promise<{name: string}> {
return await makeRestApiRequest(context, 'GET', '/credentials/new', name ? { name } : {});
}
export async function getAllCredentials(context: IRestApiContext): Promise<ICredentialType[]> {
return await makeRestApiRequest(context, 'GET', '/credentials');
}
export async function createNewCredential(context: IRestApiContext, data: ICredentialsDecrypted): Promise<ICredentialsResponse> {
return makeRestApiRequest(context, 'POST', `/credentials`, data as unknown as IDataObject);
}
export async function deleteCredential(context: IRestApiContext, id: string): Promise<boolean> {
return makeRestApiRequest(context, 'DELETE', `/credentials/${id}`);
}
export async function updateCredential(context: IRestApiContext, id: string, data: ICredentialsDecrypted): Promise<ICredentialsResponse> {
return makeRestApiRequest(context, 'PATCH', `/credentials/${id}`, data as unknown as IDataObject);
}
export async function getCredentialData(context: IRestApiContext, id: string): Promise<ICredentialsDecryptedResponse | ICredentialsResponse | undefined> {
return makeRestApiRequest(context, 'GET', `/credentials/${id}`, {
includeData: true,
});
}
// Get OAuth1 Authorization URL using the stored credentials
export async function oAuth1CredentialAuthorize(context: IRestApiContext, data: ICredentialsResponse): Promise<string> {
return makeRestApiRequest(context, 'GET', `/oauth1-credential/auth`, data as unknown as IDataObject);
}
// Get OAuth2 Authorization URL using the stored credentials
export async function oAuth2CredentialAuthorize(context: IRestApiContext, data: ICredentialsResponse): Promise<string> {
return makeRestApiRequest(context, 'GET', `/oauth2-credential/auth`, data as unknown as IDataObject);
}
export async function testCredential(context: IRestApiContext, data: NodeCredentialTestRequest): Promise<NodeCredentialTestResult> {
return makeRestApiRequest(context, 'POST', '/credentials-test', data as unknown as IDataObject);
}

View file

@ -0,0 +1,147 @@
<template>
<el-tag
:type="theme"
size="medium"
:disable-transitions="true"
:class="$style.container"
>
<font-awesome-icon
:icon="theme === 'success' ? 'check-circle' : 'exclamation-triangle'"
:class="theme === 'success' ? $style.icon : $style.dangerIcon"
/>
<div
:class="$style.banner"
>
<div :class="$style.content">
<div>
<span
:class="theme === 'success' ? $style.message : $style.dangerMessage"
>
{{ message }}&nbsp;
</span>
<a v-if="details && !expanded" :class="$style.expandButton" @click="expand">More details</a>
</div>
</div>
<n8n-button
v-if="buttonLabel"
:label="buttonLoading && buttonLoadingLabel ? buttonLoadingLabel : buttonLabel"
:title="buttonTitle"
:theme="theme"
:loading="buttonLoading"
size="small"
type="outline"
:transparentBackground="true"
@click.stop="onClick"
/>
</div>
<div v-if="expanded" :class="$style.details">
{{details}}
</div>
</el-tag>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'Banner',
data() {
return {
expanded: false,
};
},
props: {
theme: {
type: String,
validator: (value: string): boolean =>
['success', 'danger'].indexOf(value) !== -1,
},
message: {
type: String,
},
buttonLabel: {
type: String,
},
buttonLoadingLabel: {
type: String,
},
buttonTitle: {
type: String,
},
details: {
type: String,
},
buttonLoading: {
type: Boolean,
default: false,
},
},
methods: {
expand() {
this.expanded = true;
},
onClick() {
this.expanded = false;
this.$emit('click');
},
},
});
</script>
<style module lang="scss">
.icon {
position: absolute;
left: 14px;
top: 18px;
}
.dangerIcon {
composes: icon;
color: var(--color-danger);
}
.container {
width: 100%;
position: relative;
padding-left: 40px;
border: none;
}
.message {
white-space: normal;
line-height: var(--font-line-height-regular);
overflow: hidden;
word-break: break-word;
}
.dangerMessage {
composes: message;
color: var(--color-danger);
}
.banner {
display: flex;
align-items: center;
}
.content {
flex-grow: 1;
min-height: 26px;
display: flex;
align-items: center;
}
.expandButton {
font-weight: var(--font-weight-bold);
}
.details {
composes: message;
margin-top: var(--spacing-3xs);
color: var(--color-text-base);
font-size: var(--font-size-2xs);
}
</style>

View file

@ -0,0 +1,96 @@
<template>
<div>
<n8n-input-label :label="label">
<div :class="$style.copyText" @click="copy">
<span>{{ copyContent }}</span>
<div :class="$style.copyButton"><span>{{ copyButtonText }}</span></div>
</div>
</n8n-input-label>
<div :class="$style.subtitle">{{ subtitle }}</div>
</div>
</template>
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { copyPaste } from './mixins/copyPaste';
import { showMessage } from './mixins/showMessage';
export default mixins(copyPaste, showMessage).extend({
props: {
label: {
type: String,
},
subtitle: {
type: String,
},
copyContent: {
type: String,
},
copyButtonText: {
type: String,
},
successMessage: {
type: String,
},
},
methods: {
copy(): void {
this.copyToClipboard(this.$props.copyContent);
this.$showMessage({
title: 'Copied',
message: this.$props.successMessage,
type: 'success',
});
},
},
});
</script>
<style lang="scss" module>
.copyText {
span {
font-family: Monaco, Consolas;
line-height: 1.5;
}
padding: var(--spacing-xs);
background-color: var(--color-background-light);
border: var(--border-base);
border-radius: var(--border-radius-base);
cursor: pointer;
position: relative;
font-weight: var(--font-weight-regular);
&:hover {
--display-copy-button: flex;
width: 100%;
}
}
.copyButton {
display: var(--display-copy-button, none);
position: absolute;
top: 0;
right: 0;
padding: var(--spacing-xs);
background-color: var(--color-background-light);
height: 100%;
align-items: center;
border-radius: var(--border-radius-base);
span {
font-family: unset;
}
}
.subtitle {
margin-top: var(--spacing-2xs);
font-size: var(--font-size-2xs);
line-height: var(--font-line-height-loose);
font-weight: var(--font-weight-regular);
word-break: normal;
}
</style>

View file

@ -0,0 +1,189 @@
<template>
<div :class="$style.container">
<banner
v-show="showValidationWarning"
theme="danger"
message="Please check the errors below"
/>
<banner
v-if="authError && !showValidationWarning"
theme="danger"
message="Couldnt connect with these settings"
:details="authError"
buttonLabel="Retry"
buttonLoadingLabel="Retrying"
buttonTitle="Retry credentials test"
:buttonLoading="isRetesting"
@click="$emit('retest')"
/>
<banner
v-show="showOAuthSuccessBanner && !showValidationWarning"
theme="success"
message="Account connected"
buttonLabel="Reconnect"
buttonTitle="Reconnect OAuth Credentials"
@click="$emit('oauth')"
/>
<banner
v-show="testedSuccessfully && !showValidationWarning"
theme="success"
message="Connection tested successfully"
buttonLabel="Retry"
buttonLoadingLabel="Retrying"
buttonTitle="Retry credentials test"
:buttonLoading="isRetesting"
@click="$emit('retest')"
/>
<n8n-info-tip v-if="documentationUrl && credentialProperties.length">
Need help filling out these fields?
<a :href="documentationUrl" target="_blank">Open docs</a>
</n8n-info-tip>
<CopyInput
v-if="isOAuthType && credentialProperties.length"
label="OAuth Redirect URL"
:copyContent="oAuthCallbackUrl"
copyButtonText="Click to copy"
:subtitle="`In ${appName}, use the URL above when prompted to enter an OAuth callback or redirect URL`"
successMessage="Redirect URL copied to clipboard"
/>
<CredentialInputs
v-if="credentialType"
:credentialData="credentialData"
:credentialProperties="credentialProperties"
:documentationUrl="documentationUrl"
:showValidationWarnings="showValidationWarning"
@change="onDataChange"
/>
<OauthButton
v-if="isOAuthType && requiredPropertiesFilled && !isOAuthConnected"
:isGoogleOAuthType="isGoogleOAuthType"
@click="$emit('oauth')"
/>
</div>
</template>
<script lang="ts">
import { ICredentialType } from 'n8n-workflow';
import { getAppNameFromCredType } from '../helpers';
import Vue from 'vue';
import Banner from '../Banner.vue';
import CopyInput from '../CopyInput.vue';
import CredentialInputs from './CredentialInputs.vue';
import OauthButton from './OauthButton.vue';
export default Vue.extend({
name: 'CredentialConfig',
components: {
Banner,
CopyInput,
CredentialInputs,
OauthButton,
},
props: {
credentialType: {
},
credentialProperties: {
type: Array,
},
parentTypes: {
type: Array,
},
credentialData: {
},
showValidationWarning: {
type: Boolean,
default: false,
},
authError: {
type: String,
},
testedSuccessfully: {
type: Boolean,
},
isOAuthType: {
type: Boolean,
},
isOAuthConnected: {
type: Boolean,
},
isRetesting: {
type: Boolean,
},
requiredPropertiesFilled: {
type: Boolean,
},
},
computed: {
appName(): string {
if (!this.credentialType) {
return '';
}
const appName = getAppNameFromCredType(
(this.credentialType as ICredentialType).displayName,
);
return appName || "the service you're connecting to";
},
credentialTypeName(): string {
return (this.credentialType as ICredentialType).name;
},
documentationUrl(): string {
const type = this.credentialType as ICredentialType;
if (!type || !type.documentationUrl) {
return '';
}
if (type.documentationUrl.startsWith('https://') || type.documentationUrl.startsWith('http://')) {
return type.documentationUrl;
}
return `https://docs.n8n.io/credentials/${type.documentationUrl}/?utm_source=n8n_app&utm_medium=left_nav_menu&utm_campaign=create_new_credentials_modal`;
},
isGoogleOAuthType(): boolean {
return this.credentialTypeName === 'googleOAuth2Api' || this.parentTypes.includes('googleOAuth2Api');
},
oAuthCallbackUrl(): string {
const oauthType =
this.credentialTypeName === 'oAuth2Api' ||
this.parentTypes.includes('oAuth2Api')
? 'oauth2'
: 'oauth1';
return this.$store.getters.oauthCallbackUrls[oauthType];
},
showOAuthSuccessBanner(): boolean {
return this.isOAuthType && this.requiredPropertiesFilled && this.isOAuthConnected && !this.authError;
},
},
methods: {
onDataChange(event: { name: string; value: string | number | boolean | Date | null }): void {
this.$emit('change', event);
},
},
watch: {
showOAuthSuccessBanner(newValue, oldValue) {
if (newValue && !oldValue) {
this.$emit('scrollToTop');
}
},
},
});
</script>
<style lang="scss" module>
.container {
> * {
margin-bottom: var(--spacing-l);
}
}
</style>

View file

@ -0,0 +1,853 @@
<template>
<Modal
:name="modalName"
size="lg"
:customClass="$style.credentialModal"
:eventBus="modalBus"
:loading="loading"
:beforeClose="beforeClose"
>
<template slot="header">
<div v-if="credentialType" :class="$style.header">
<div :class="$style.credInfo">
<div :class="$style.credIcon">
<CredentialIcon :credentialTypeName="credentialTypeName" />
</div>
<InlineNameEdit
:name="credentialName"
:subtitle="credentialType.displayName"
type="Credential"
@input="onNameEdit"
/>
</div>
<div :class="$style.credActions">
<n8n-icon-button
v-if="currentCredential"
size="medium"
title="Delete"
icon="trash"
type="text"
:disabled="isSaving"
:loading="isDeleting"
@click="deleteCredential"
/>
<SaveButton
v-if="hasUnsavedChanges || credentialId"
:saved="!hasUnsavedChanges && !isTesting"
:isSaving="isSaving || isTesting"
:savingLabel="isTesting ? 'Testing' : 'Saving'"
@click="saveCredential"
/>
</div>
</div>
<hr />
</template>
<template slot="content">
<div :class="$style.container">
<div :class="$style.sidebar">
<n8n-menu
type="secondary"
@select="onTabSelect"
defaultActive="connection"
:light="true"
>
<n8n-menu-item index="connection" :class="$style.credTab"
><span slot="title">Connection</span></n8n-menu-item
>
<n8n-menu-item index="details" :class="$style.credTab"
><span slot="title">Details</span></n8n-menu-item
>
</n8n-menu>
</div>
<div v-if="activeTab === 'connection'" :class="$style.mainContent" ref="content">
<CredentialConfig
:credentialType="credentialType"
:credentialProperties="credentialProperties"
:credentialData="credentialData"
:showValidationWarning="showValidationWarning"
:authError="authError"
:testedSuccessfully="testedSuccessfully"
:isOAuthType="isOAuthType"
:isOAuthConnected="isOAuthConnected"
:isRetesting="isRetesting"
:parentTypes="parentTypes"
:requiredPropertiesFilled="requiredPropertiesFilled"
@change="onDataChange"
@oauth="oAuthCredentialAuthorize"
@retest="retestCredential"
@scrollToTop="scrollToTop"
/>
</div>
<div v-if="activeTab === 'details'" :class="$style.mainContent">
<CredentialInfo
:nodeAccess="nodeAccess"
:nodesWithAccess="nodesWithAccess"
:currentCredential="currentCredential"
@accessChange="onNodeAccessChange"
/>
</div>
</div>
</template>
</Modal>
</template>
<script lang="ts">
import Vue from 'vue';
import {
ICredentialsDecryptedResponse,
ICredentialsResponse,
} from '@/Interface';
import {
CredentialInformation,
ICredentialDataDecryptedObject,
ICredentialNodeAccess,
ICredentialsDecrypted,
ICredentialType,
INodeParameters,
INodeProperties,
INodeTypeDescription,
NodeCredentialTestResult,
NodeHelpers,
} from 'n8n-workflow';
import CredentialIcon from '../CredentialIcon.vue';
import mixins from 'vue-typed-mixins';
import { nodeHelpers } from '../mixins/nodeHelpers';
import { showMessage } from '../mixins/showMessage';
import CredentialConfig from './CredentialConfig.vue';
import CredentialInfo from './CredentialInfo.vue';
import SaveButton from '../SaveButton.vue';
import Modal from '../Modal.vue';
import InlineNameEdit from '../InlineNameEdit.vue';
interface NodeAccessMap {
[nodeType: string]: ICredentialNodeAccess | null;
}
export default mixins(showMessage, nodeHelpers).extend({
name: 'CredentialsDetail',
components: {
CredentialConfig,
CredentialIcon,
CredentialInfo,
InlineNameEdit,
Modal,
SaveButton,
},
props: {
modalName: {
type: String,
required: true,
},
activeId: {
type: String,
required: true,
},
mode: {
type: String,
},
},
data() {
return {
activeTab: 'connection',
authError: '',
credentialId: '',
credentialName: '',
credentialData: {} as ICredentialDataDecryptedObject,
modalBus: new Vue(),
nodeAccess: {} as NodeAccessMap,
isDeleting: false,
isSaving: false,
isTesting: false,
hasUnsavedChanges: false,
loading: true,
showValidationWarning: false,
testedSuccessfully: false,
isRetesting: false,
};
},
async mounted() {
this.nodeAccess = this.nodesWithAccess.reduce(
(accu: NodeAccessMap, node: { name: string }) => {
if (this.mode === 'new') {
accu[node.name] = { nodeType: node.name }; // enable all nodes by default
} else {
accu[node.name] = null;
}
return accu;
},
{},
);
if (this.mode === 'new') {
this.credentialName = await this.$store.dispatch(
'credentials/getNewCredentialName',
{ credentialTypeName: this.credentialTypeName },
);
} else {
await this.loadCurrentCredential();
}
if (this.credentialType) {
for (const property of this.credentialType.properties) {
if (!this.credentialData.hasOwnProperty(property.name)) {
this.credentialData[property.name] =
property.default as CredentialInformation;
}
}
}
if (this.credentialId) {
if (!this.requiredPropertiesFilled) {
this.showValidationWarning = true;
}
else {
this.retestCredential();
}
}
this.loading = false;
},
computed: {
currentCredential(): ICredentialsResponse | null {
if (!this.credentialId) {
return null;
}
return this.$store.getters['credentials/getCredentialById'](
this.credentialId,
);
},
credentialTypeName(): string | null {
if (this.mode === 'edit') {
if (this.currentCredential) {
return this.currentCredential.type;
}
return null;
}
return this.activeId;
},
credentialType(): ICredentialType | null {
if (!this.credentialTypeName) {
return null;
}
const type = this.$store.getters['credentials/getCredentialTypeByName'](
this.credentialTypeName,
);
return {
...type,
properties: this.getCredentialProperties(this.credentialTypeName),
};
},
isCredentialTestable (): boolean {
if (this.isOAuthType || !this.requiredPropertiesFilled) {
return false;
}
const hasExpressions = Object.values(this.credentialData).reduce((accu: boolean, value: CredentialInformation) => accu || (typeof value === 'string' && value.startsWith('=')), false);
if (hasExpressions) {
return false;
}
const nodesThatCanTest = this.nodesWithAccess.filter(node => {
if (node.credentials) {
// Returns a list of nodes that can test this credentials
const eligibleTesters = node.credentials.filter(credential => {
return credential.name === this.credentialTypeName && credential.testedBy;
});
// If we have any node that can test, return true.
return !!eligibleTesters.length;
}
return false;
});
return !!nodesThatCanTest.length;
},
nodesWithAccess(): INodeTypeDescription[] {
if (this.credentialTypeName) {
return this.$store.getters['credentials/getNodesWithAccess'](
this.credentialTypeName,
);
}
return [];
},
parentTypes(): string[] {
if (this.credentialTypeName) {
return this.getParentTypes(this.credentialTypeName);
}
return [];
},
isOAuthType(): boolean {
return !!this.credentialTypeName && (
['oAuth1Api', 'oAuth2Api'].includes(this.credentialTypeName) ||
this.parentTypes.includes('oAuth1Api') ||
this.parentTypes.includes('oAuth2Api')
);
},
isOAuthConnected(): boolean {
return this.isOAuthType && !!this.credentialData.oauthTokenData;
},
credentialProperties(): INodeProperties[] {
if (!this.credentialType) {
return [];
}
return this.credentialType.properties.filter(
(propertyData: INodeProperties) => {
if (!this.displayCredentialParameter(propertyData)) {
return false;
}
return (
!this.credentialType!.__overwrittenProperties ||
!this.credentialType!.__overwrittenProperties.includes(
propertyData.name,
)
);
},
);
},
requiredPropertiesFilled(): boolean {
for (const property of this.credentialProperties) {
if (property.required !== true) {
continue;
}
if (!this.credentialData[property.name]) {
return false;
}
}
return true;
},
},
methods: {
async beforeClose(done: () => void) {
let keepEditing = false;
if (this.hasUnsavedChanges) {
const displayName = this.credentialType ? this.credentialType.displayName : '';
keepEditing = await this.confirmMessage(
`Are you sure you want to throw away the changes you made to the ${displayName} credential?`,
'Close without saving?',
null,
'Keep editing',
'Close',
);
}
else if (this.isOAuthType && !this.isOAuthConnected) {
keepEditing = await this.confirmMessage(
`You need to connect your credential for it to work`,
'Close without connecting?',
null,
'Keep editing',
'Close',
);
}
if (!keepEditing) {
done();
return;
}
else if (!this.requiredPropertiesFilled) {
this.showValidationWarning = true;
this.scrollToTop();
}
else if (this.isOAuthType) {
this.scrollToBottom();
}
},
displayCredentialParameter(parameter: INodeProperties): boolean {
if (parameter.type === 'hidden') {
return false;
}
if (parameter.displayOptions === undefined) {
// If it is not defined no need to do a proper check
return true;
}
return this.displayParameter(
this.credentialData as INodeParameters,
parameter,
'',
);
},
getCredentialProperties(name: string): INodeProperties[] {
const credentialsData =
this.$store.getters['credentials/getCredentialTypeByName'](name);
if (!credentialsData) {
throw new Error(`Could not find credentials of type: ${name}`);
}
if (credentialsData.extends === undefined) {
return credentialsData.properties;
}
const combineProperties = [] as INodeProperties[];
for (const credentialsTypeName of credentialsData.extends) {
const mergeCredentialProperties =
this.getCredentialProperties(credentialsTypeName);
NodeHelpers.mergeNodeProperties(
combineProperties,
mergeCredentialProperties,
);
}
// The properties defined on the parent credentials take presidence
NodeHelpers.mergeNodeProperties(
combineProperties,
credentialsData.properties,
);
return combineProperties;
},
async loadCurrentCredential() {
this.credentialId = this.activeId;
try {
const currentCredentials: ICredentialsDecryptedResponse =
await this.$store.dispatch('credentials/getCredentialData', {
id: this.credentialId,
});
if (!currentCredentials) {
throw new Error(
`Could not find the credentials with the id: ${this.credentialId}`,
);
}
this.credentialData = currentCredentials.data || {};
this.credentialName = currentCredentials.name;
currentCredentials.nodesAccess.forEach(
(access: { nodeType: string }) => {
// keep node access structure to keep dates when updating
this.nodeAccess[access.nodeType] = access;
},
);
} catch (e) {
this.$showError(
e,
'Problem loading credentials',
'There was a problem loading the credentials:',
);
this.closeDialog();
return;
}
},
onTabSelect(tab: string) {
this.activeTab = tab;
},
onNodeAccessChange({name, value}: {name: string, value: boolean}) {
this.hasUnsavedChanges = true;
if (value) {
this.nodeAccess = {
...this.nodeAccess,
[name]: {
nodeType: name,
},
};
} else {
this.nodeAccess = {
...this.nodeAccess,
[name]: null,
};
}
},
onDataChange({ name, value }: { name: string; value: any }) { // tslint:disable-line:no-any
this.hasUnsavedChanges = true;
const { oauthTokenData, ...credData } = this.credentialData;
this.credentialData = {
...credData,
[name]: value,
};
},
closeDialog() {
this.modalBus.$emit('close');
},
getParentTypes(name: string): string[] {
const credentialType =
this.$store.getters['credentials/getCredentialTypeByName'](name);
if (
credentialType === undefined ||
credentialType.extends === undefined
) {
return [];
}
const types: string[] = [];
for (const typeName of credentialType.extends) {
types.push(typeName);
types.push.apply(types, this.getParentTypes(typeName));
}
return types;
},
onNameEdit(text: string) {
this.hasUnsavedChanges = true;
this.credentialName = text;
},
scrollToTop() {
setTimeout(() => {
const content = this.$refs.content as Element;
if (content) {
content.scrollTop = 0;
}
}, 0);
},
scrollToBottom() {
setTimeout(() => {
const content = this.$refs.content as Element;
if (content) {
content.scrollTop = content.scrollHeight;
}
}, 0);
},
async retestCredential() {
if (!this.isCredentialTestable) {
this.authError = '';
this.testedSuccessfully = false;
return;
}
const nodesAccess = Object.values(this.nodeAccess).filter(
(access) => !!access,
) as ICredentialNodeAccess[];
// Save only the none default data
const data = NodeHelpers.getNodeParameters(
this.credentialType!.properties,
this.credentialData as INodeParameters,
false,
false,
);
const details: ICredentialsDecrypted = {
name: this.credentialName,
type: this.credentialTypeName!,
data: data as unknown as ICredentialDataDecryptedObject,
nodesAccess,
};
this.isRetesting = true;
await this.testCredential(details);
this.isRetesting = false;
},
async testCredential(credentialDetails: ICredentialsDecrypted) {
const result: NodeCredentialTestResult = await this.$store.dispatch('credentials/testCredential', credentialDetails);
if (result.status === 'Error') {
this.authError = result.message;
this.testedSuccessfully = false;
}
else {
this.authError = '';
this.testedSuccessfully = true;
}
this.scrollToTop();
},
async saveCredential(): Promise<ICredentialsResponse | null> {
if (!this.requiredPropertiesFilled) {
this.showValidationWarning = true;
this.scrollToTop();
}
else {
this.showValidationWarning = false;
}
this.isSaving = true;
const nodesAccess = Object.values(this.nodeAccess).filter(
(access) => !!access,
) as ICredentialNodeAccess[];
// Save only the none default data
const data = NodeHelpers.getNodeParameters(
this.credentialType!.properties,
this.credentialData as INodeParameters,
false,
false,
);
const credentialDetails: ICredentialsDecrypted = {
name: this.credentialName,
type: this.credentialTypeName!,
data: data as unknown as ICredentialDataDecryptedObject,
nodesAccess,
};
let credential;
if (this.mode === 'new' && !this.credentialId) {
credential = await this.createCredential(
credentialDetails,
);
} else {
credential = await this.updateCredential(
credentialDetails,
);
}
this.isSaving = false;
if (credential) {
this.credentialId = credential.id as string;
if (this.isCredentialTestable) {
this.isTesting = true;
await this.testCredential(credentialDetails);
this.isTesting = false;
}
else {
this.authError = '';
this.testedSuccessfully = false;
}
}
return credential;
},
async createCredential(
credentialDetails: ICredentialsDecrypted,
): Promise<ICredentialsResponse | null> {
let credential;
try {
credential = (await this.$store.dispatch(
'credentials/createNewCredential',
credentialDetails,
)) as ICredentialsResponse;
this.hasUnsavedChanges = false;
} catch (error) {
this.$showError(
error,
'Problem creating credentials',
'There was a problem creating the credentials:',
);
return null;
}
this.$externalHooks().run('credentials.create', {
credentialTypeData: this.credentialData,
});
return credential;
},
async updateCredential(
credentialDetails: ICredentialsDecrypted,
): Promise<ICredentialsResponse | null> {
let credential;
try {
credential = (await this.$store.dispatch(
'credentials/updateCredential',
{ id: this.credentialId, data: credentialDetails },
)) as ICredentialsResponse;
this.hasUnsavedChanges = false;
} catch (error) {
this.$showError(
error,
'Problem updating credentials',
'There was a problem updating the credentials:',
);
return null;
}
// Now that the credentials changed check if any nodes use credentials
// which have now a different name
this.updateNodesCredentialsIssues();
return credential;
},
async deleteCredential() {
if (!this.currentCredential) {
return;
}
const savedCredentialName = this.currentCredential.name;
const deleteConfirmed = await this.confirmMessage(
`Are you sure you want to delete "${savedCredentialName}" credentials?`,
'Delete Credentials?',
null,
'Yes, delete!',
);
if (deleteConfirmed === false) {
return;
}
try {
this.isDeleting = true;
await this.$store.dispatch('credentials/deleteCredential', {
id: this.credentialId,
});
this.hasUnsavedChanges = false;
} catch (error) {
this.$showError(
error,
'Problem deleting credentials',
'There was a problem deleting the credentials:',
);
this.isDeleting = false;
return;
}
this.isDeleting = false;
// Now that the credentials were removed check if any nodes used them
this.updateNodesCredentialsIssues();
this.$showMessage({
title: 'Credentials deleted',
message: `The credential "${savedCredentialName}" was deleted!`,
type: 'success',
});
this.closeDialog();
},
async oAuthCredentialAuthorize() {
let url;
const credential = await this.saveCredential();
if (!credential) {
return;
}
const types = this.parentTypes;
try {
if (
this.credentialTypeName === 'oAuth2Api' ||
types.includes('oAuth2Api')
) {
url = (await this.$store.dispatch('credentials/oAuth2Authorize', {
...this.credentialData,
id: credential.id,
})) as string;
} else if (
this.credentialTypeName === 'oAuth1Api' ||
types.includes('oAuth1Api')
) {
url = (await this.$store.dispatch('credentials/oAuth1Authorize', {
...this.credentialData,
id: credential.id,
})) as string;
}
} catch (error) {
this.$showError(
error,
'OAuth Authorization Error',
'Error generating authorization URL:',
);
return;
}
const params = `scrollbars=no,resizable=yes,status=no,titlebar=noe,location=no,toolbar=no,menubar=no,width=500,height=700`;
const oauthPopup = window.open(url, 'OAuth2 Authorization', params);
Vue.set(this.credentialData, 'oauthTokenData', null);
const receiveMessage = (event: MessageEvent) => {
// // TODO: Add check that it came from n8n
// if (event.origin !== 'http://example.org:8080') {
// return;
// }
if (event.data === 'success') {
window.removeEventListener('message', receiveMessage, false);
// Set some kind of data that status changes.
// As data does not get displayed directly it does not matter what data.
Vue.set(this.credentialData, 'oauthTokenData', {});
this.$store.commit('credentials/enableOAuthCredential', credential);
// Close the window
if (oauthPopup) {
oauthPopup.close();
}
}
};
window.addEventListener('message', receiveMessage, false);
},
},
});
</script>
<style module lang="scss">
.credentialModal {
max-width: 900px;
--dialog-close-top: 28px;
}
.mainContent {
flex-grow: 1;
overflow: auto;
padding-bottom: 100px;
}
.sidebar {
max-width: 170px;
min-width: 170px;
margin-right: var(--spacing-l);
flex-grow: 1;
}
.header {
display: flex;
}
.container {
display: flex;
height: 100%;
}
.credInfo {
display: flex;
flex-grow: 1;
margin-bottom: var(--spacing-s);
}
.credTab {
padding-left: 12px !important;
}
.credActions {
margin-right: var(--spacing-xl);
> * {
margin-left: var(--spacing-2xs);
}
}
.credIcon {
display: flex;
align-items: center;
margin-right: var(--spacing-xs);
}
</style>

View file

@ -0,0 +1,91 @@
<template>
<div :class="$style.container">
<el-row>
<el-col :span="8" :class="$style.accessLabel">
<span>Allow use by</span>
</el-col>
<el-col :span="16">
<div
v-for="node in nodesWithAccess"
:key="node.name"
:class="$style.valueLabel"
>
<el-checkbox
:label="node.displayName"
:value="!!nodeAccess[node.name]"
@change="(val) => onNodeAccessChange(node.name, val)"
/>
</div>
</el-col>
</el-row>
<el-row v-if="currentCredential">
<el-col :span="8" :class="$style.label">
<span>Created</span>
</el-col>
<el-col :span="16" :class="$style.valueLabel">
<TimeAgo :date="currentCredential.createdAt" :capitalize="true" />
</el-col>
</el-row>
<el-row v-if="currentCredential">
<el-col :span="8" :class="$style.label">
<span>Last modified</span>
</el-col>
<el-col :span="16" :class="$style.valueLabel">
<TimeAgo :date="currentCredential.updatedAt" :capitalize="true" />
</el-col>
</el-row>
<el-row v-if="currentCredential">
<el-col :span="8" :class="$style.label">
<span>ID</span>
</el-col>
<el-col :span="16" :class="$style.valueLabel">
<span>{{currentCredential.id}}</span>
</el-col>
</el-row>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import TimeAgo from '../TimeAgo.vue';
export default Vue.extend({
name: 'CredentialInfo',
props: ['nodesWithAccess', 'nodeAccess', 'currentCredential'],
components: {
TimeAgo,
},
methods: {
onNodeAccessChange(name: string, value: string) {
this.$emit('accessChange', {
name,
value,
});
},
},
});
</script>
<style lang="scss" module>
.container {
> * {
margin-bottom: var(--spacing-l);
}
}
.label {
font-weight: var(--font-weight-bold);
max-width: 230px;
}
.accessLabel {
composes: label;
margin-top: var(--spacing-5xs);
}
.valueLabel {
font-weight: var(--font-weight-regular);
}
</style>

View file

@ -0,0 +1,52 @@
<template>
<div @keydown.stop :class="$style.container">
<div v-for="parameter in credentialProperties" :key="parameter.name">
<ParameterInputExpanded
:parameter="parameter"
:value="credentialData[parameter.name]"
:documentationUrl="documentationUrl"
:showValidationWarnings="showValidationWarnings"
@change="valueChanged"
/>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { IUpdateInformation } from '../../Interface';
import ParameterInputExpanded from '../ParameterInputExpanded.vue';
export default Vue.extend({
name: 'CredentialsInput',
props: [
'credentialProperties',
'credentialData', // ICredentialsDecryptedResponse
'documentationUrl',
'showValidationWarnings',
],
components: {
ParameterInputExpanded,
},
methods: {
valueChanged(parameterData: IUpdateInformation) {
const name = parameterData.name.split('.').pop();
this.$emit('change', {
name,
value: parameterData.value,
});
},
},
});
</script>
<style lang="scss" module>
.container {
> * {
margin-bottom: var(--spacing-l);
}
}
</style>

View file

@ -0,0 +1,41 @@
<template>
<span>
<img
v-if="isGoogleOAuthType"
:src="basePath + 'google-signin-light.png'"
:class="$style.googleIcon"
alt="Sign in with Google"
@click.stop="$emit('click')"
/>
<n8n-button
v-else
label="Connect my account"
size="large"
@click.stop="$emit('click')"
/>
</span>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
isGoogleOAuthType: {
type: Boolean,
},
},
computed: {
basePath(): string {
return this.$store.getters.getBaseUrl;
},
},
});
</script>
<style module lang="scss">
.googleIcon {
width: 191px;
cursor: pointer;
}
</style>

View file

@ -0,0 +1,74 @@
<template>
<div>
<img v-if="filePath" :class="$style.credIcon" :src="filePath" />
<NodeIcon v-else-if="relevantNode" :nodeType="relevantNode" :size="28" />
</div>
</template>
<script lang="ts">
import { ICredentialType, INodeTypeDescription } from 'n8n-workflow';
import Vue from 'vue';
export default Vue.extend({
props: {
credentialTypeName: {
type: String,
required: true,
},
},
computed: {
credentialWithIcon(): ICredentialType | null {
return this.getCredentialWithIcon(this.credentialTypeName);
},
filePath(): string | null {
if (!this.credentialWithIcon || !this.credentialWithIcon.icon || !this.credentialWithIcon.icon.startsWith('file:')) {
return null;
}
const restUrl = this.$store.getters.getRestUrl;
return `${restUrl}/credential-icon/${this.credentialWithIcon.name}`;
},
relevantNode(): INodeTypeDescription | null {
if (this.credentialWithIcon && this.credentialWithIcon.icon && this.credentialWithIcon.icon.startsWith('node:')) {
const nodeType = this.credentialWithIcon.icon.replace('node:', '');
return this.$store.getters.nodeType(nodeType);
}
const nodesWithAccess = this.$store.getters['credentials/getNodesWithAccess'](this.credentialTypeName);
if (nodesWithAccess.length) {
return nodesWithAccess[0];
}
return null;
},
},
methods: {
getCredentialWithIcon(name: string): ICredentialType | null {
const type = this.$store.getters['credentials/getCredentialTypeByName'](name);
if (type.icon) {
return type;
}
if (type.extends) {
return type.extends.reduce((accu: string | null, type: string) => {
return accu || this.getCredentialWithIcon(type);
}, null);
}
return null;
},
},
});
</script>
<style lang="scss" module>
.credIcon {
height: 26px;
}
</style>

View file

@ -1,388 +0,0 @@
<template>
<div v-if="dialogVisible" @keydown.stop>
<el-dialog :visible="dialogVisible" append-to-body width="75%" class="credentials-edit-wrapper" :title="title" :nodeType="nodeType" :before-close="closeDialog">
<div name="title" class="title-container" slot="title">
<div class="title-left">{{title}}</div>
<div class="title-right">
<div v-if="credentialType && documentationUrl" class="docs-container">
<svg class="help-logo" target="_blank" width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Node Documentation</title>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-1127.000000, -836.000000)" fill-rule="nonzero">
<g transform="translate(1117.000000, 825.000000)">
<g transform="translate(10.000000, 11.000000)">
<g transform="translate(2.250000, 2.250000)" fill="#FF6150">
<path d="M6,11.25 L7.5,11.25 L7.5,9.75 L6,9.75 L6,11.25 M6.75,2.25 C5.09314575,2.25 3.75,3.59314575 3.75,5.25 L5.25,5.25 C5.25,4.42157288 5.92157288,3.75 6.75,3.75 C7.57842712,3.75 8.25,4.42157288 8.25,5.25 C8.25,6.75 6,6.5625 6,9 L7.5,9 C7.5,7.3125 9.75,7.125 9.75,5.25 C9.75,3.59314575 8.40685425,2.25 6.75,2.25 M1.5,0 L12,0 C12.8284271,0 13.5,0.671572875 13.5,1.5 L13.5,12 C13.5,12.8284271 12.8284271,13.5 12,13.5 L1.5,13.5 C0.671572875,13.5 0,12.8284271 0,12 L0,1.5 C0,0.671572875 0.671572875,0 1.5,0 Z"></path>
</g>
<rect x="0" y="0" width="18" height="18"></rect>
</g>
</g>
</g>
</g>
</svg>
<span class="doc-link-text">Need help? <a class="doc-hyperlink" :href="documentationUrl" target="_blank">Open credential docs</a></span>
</div>
</div>
</div>
<div class="credential-type-item">
<el-row v-if="!setCredentialType">
<el-col :span="6">
Credential type:
</el-col>
<el-col :span="18">
<n8n-select v-model="credentialType" filterable placeholder="Select Type" size="medium" ref="credentialsDropdown">
<n8n-option
v-for="item in credentialTypes"
:key="item.name"
:label="item.displayName"
:value="item.name">
</n8n-option>
</n8n-select>
</el-col>
</el-row>
</div>
<credentials-input v-if="credentialType" @credentialsCreated="credentialsCreated" @credentialsUpdated="credentialsUpdated" :credentialTypeData="getCredentialTypeData(credentialType)" :credentialData="credentialData" :nodesInit="nodesInit"></credentials-input>
</el-dialog>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { externalHooks } from '@/components/mixins/externalHooks';
import { restApi } from '@/components/mixins/restApi';
import { showMessage } from '@/components/mixins/showMessage';
import CredentialsInput from '@/components/CredentialsInput.vue';
import {
ICredentialsCreatedEvent,
ICredentialsDecryptedResponse,
} from '@/Interface';
import {
NodeHelpers,
ICredentialType,
INodeProperties,
INodeTypeDescription,
} from 'n8n-workflow';
import mixins from 'vue-typed-mixins';
import { INodeUi } from '../Interface';
export default mixins(
restApi,
showMessage,
externalHooks,
).extend({
name: 'CredentialsEdit',
props: [
'dialogVisible', // Boolean
'editCredentials',
'setCredentialType', // String
'nodesInit', // Array
],
components: {
CredentialsInput,
},
data () {
return {
credentialData: null as ICredentialsDecryptedResponse | null,
credentialType: null as string | null,
};
},
computed: {
credentialTypes (): ICredentialType[] {
const credentialTypes = this.$store.getters.allCredentialTypes;
if (credentialTypes === null) {
return [];
}
return credentialTypes;
},
title (): string {
if (this.editCredentials) {
const credentialType = this.$store.getters.credentialType(this.editCredentials.type);
return `Edit Credentials: "${credentialType.displayName}"`;
} else {
if (this.credentialType) {
const credentialType = this.$store.getters.credentialType(this.credentialType);
return `Create New Credentials: "${credentialType.displayName}"`;
} else {
return `Create New Credentials`;
}
}
},
documentationUrl (): string | undefined {
let credentialTypeName = '';
if (this.editCredentials) {
credentialTypeName = this.editCredentials.type as string;
} else {
credentialTypeName = this.credentialType as string;
}
const credentialType = this.$store.getters.credentialType(credentialTypeName);
if (credentialType.documentationUrl !== undefined) {
if (credentialType.documentationUrl.startsWith('http')) {
return credentialType.documentationUrl;
} else {
return 'https://docs.n8n.io/credentials/' + credentialType.documentationUrl + '/?utm_source=n8n_app&utm_medium=left_nav_menu&utm_campaign=create_new_credentials_modal';
}
}
return undefined;
},
node (): INodeUi {
return this.$store.getters.activeNode;
},
nodeType (): INodeTypeDescription | null {
const activeNode = this.node;
if (this.node) {
return this.$store.getters.nodeType(this.node.type);
}
return null;
},
},
watch: {
async dialogVisible (newValue, oldValue): Promise<void> {
if (newValue) {
if (this.editCredentials) {
// Credentials which should be edited are given
const credentialType = this.$store.getters.credentialType(this.editCredentials.type);
if (credentialType === null) {
this.$showMessage({
title: 'Credential type not known',
message: `Credentials of type "${this.editCredentials.type}" are not known.`,
type: 'error',
duration: 0,
});
this.closeDialog();
return;
}
if (this.editCredentials.id === undefined) {
this.$showMessage({
title: 'Credential ID missing',
message: 'The ID of the credentials which should be edited is missing!',
type: 'error',
});
this.closeDialog();
return;
}
let currentCredentials: ICredentialsDecryptedResponse | undefined;
try {
currentCredentials = await this.restApi().getCredentials(this.editCredentials.id as string, true) as ICredentialsDecryptedResponse | undefined;
} catch (error) {
this.$showError(error, 'Problem loading credentials', 'There was a problem loading the credentials:');
this.closeDialog();
return;
}
if (currentCredentials === undefined) {
this.$showMessage({
title: 'Credentials not found',
message: `Could not find the credentials with the id: ${this.editCredentials.id}`,
type: 'error',
duration: 0,
});
this.closeDialog();
return;
}
if (currentCredentials === undefined) {
this.$showMessage({
title: 'Problem loading credentials',
message: 'No credentials could be loaded!',
type: 'error',
});
return;
}
this.credentialData = currentCredentials;
} else {
Vue.nextTick(() => {
(this.$refs.credentialsDropdown as HTMLDivElement).focus();
});
if (this.credentialType || this.setCredentialType) {
const credentialType = this.$store.getters.credentialType(this.credentialType || this.setCredentialType);
if (credentialType === null) {
this.$showMessage({
title: 'Credential type not known',
message: `Credentials of type "${this.credentialType || this.setCredentialType}" are not known.`,
type: 'error',
duration: 0,
});
this.closeDialog();
return;
}
}
this.credentialData = null;
}
if (this.setCredentialType || (this.credentialData && this.credentialData.type)) {
this.credentialType = this.setCredentialType || (this.credentialData && this.credentialData.type);
}
} else {
// Make sure that it gets always reset else it uses by default
// again the last selection from when it was open the previous time.
this.credentialType = null;
}
},
async credentialType (newValue, oldValue) {
this.$externalHooks().run('credentialsEdit.credentialTypeChanged', { newValue, oldValue, editCredentials: !!this.editCredentials, credentialType: this.credentialType, setCredentialType: this.setCredentialType });
},
},
methods: {
getCredentialProperties (name: string): INodeProperties[] {
const credentialsData = this.$store.getters.credentialType(name);
if (credentialsData === null) {
throw new Error(`Could not find credentials of type: ${name}`);
}
if (credentialsData.extends === undefined) {
return credentialsData.properties;
}
const combineProperties = [] as INodeProperties[];
for (const credentialsTypeName of credentialsData.extends) {
const mergeCredentialProperties = this.getCredentialProperties(credentialsTypeName);
NodeHelpers.mergeNodeProperties(combineProperties, mergeCredentialProperties);
}
// The properties defined on the parent credentials take presidence
NodeHelpers.mergeNodeProperties(combineProperties, credentialsData.properties);
return combineProperties;
},
getCredentialTypeData (name: string): ICredentialType | null {
let credentialData = this.$store.getters.credentialType(name);
if (credentialData === null || credentialData.extends === undefined) {
return credentialData;
}
// Credentials extends another one. So get the properties of the one it
// extends and add them.
credentialData = JSON.parse(JSON.stringify(credentialData));
credentialData.properties = this.getCredentialProperties(credentialData.name);
return credentialData;
},
credentialsCreated (eventData: ICredentialsCreatedEvent): void {
this.$emit('credentialsCreated', eventData);
this.$showMessage({
title: 'Credentials created',
message: `"${eventData.data.name}" credentials were successfully created!`,
type: 'success',
});
if (eventData.options.closeDialog === true) {
this.closeDialog();
}
},
credentialsUpdated (eventData: ICredentialsCreatedEvent): void {
this.$emit('credentialsUpdated', eventData);
this.$showMessage({
title: 'Credentials updated',
message: `"${eventData.data.name}" credentials were successfully updated!`,
type: 'success',
});
if (eventData.options.closeDialog === true) {
this.closeDialog();
}
},
closeDialog (): void {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('closeDialog');
},
},
});
</script>
<style lang="scss">
.credentials-edit-wrapper {
.credential-type-item {
> .el-row {
display: flex;
align-items: center;
}
padding-bottom: 8px;
}
@media (min-width: 1200px){
.title-container {
display: flex;
flex-direction: row;
max-width: 100%;
line-height: 17px;
}
.docs-container {
margin-left: auto;
margin-right: 0;
}
}
@media (max-width: 1199px){
.title-container {
display: flex;
flex-direction: column;
max-width: 100%;
line-height: 17px;
}
.docs-container {
margin-top: 10px;
margin-left: 0;
margin-right: auto;
}
}
.title-left {
flex: 7;
font-size: 16px;
font-weight: bold;
color: #7a7a7a;
vertical-align:middle;
}
.title-right {
vertical-align: middle;
flex: 3;
font-family: "Open Sans";
color: #666666;
font-size: 12px;
font-weight: 510;
letter-spacing: 0;
display: flex;
flex-direction: row;
min-width: 40%;
}
.help-logo {
flex: 1;
}
.doc-link-text {
margin-left: 2px;
float: right;
word-break: break-word;
flex: 9;
}
.doc-hyperlink,
.doc-hyperlink:visited,
.doc-hyperlink:focus,
.doc-hyperlink:active {
text-decoration: none;
color: #FF6150;
}
}
</style>

View file

@ -1,618 +0,0 @@
<template>
<div @keydown.stop class="credentials-input-wrapper">
<el-row class="credential-name-wrapper">
<el-col :span="6" class="headline-regular">
Credentials Name:
<n8n-tooltip class="credentials-info" placement="top" >
<div slot="content" v-html="helpTexts.credentialsName"></div>
<font-awesome-icon icon="question-circle" />
</n8n-tooltip>
</el-col>
<el-col :span="18">
<n8n-input v-model="name" type="text" size="medium"></n8n-input>
</el-col>
</el-row>
<br />
<div class="headline" v-if="credentialProperties.length">
Credential Data:
<n8n-tooltip class="credentials-info" placement="top" >
<div slot="content" v-html="helpTexts.credentialsData"></div>
<font-awesome-icon icon="question-circle" />
</n8n-tooltip>
</div>
<div v-for="parameter in credentialProperties" :key="parameter.name">
<el-row class="parameter-wrapper">
<el-col :span="6" class="parameter-name">
{{parameter.displayName}}:
<n8n-tooltip placement="top" class="parameter-info" v-if="parameter.description" >
<div slot="content" v-html="addTargetBlank(parameter.description)"></div>
<font-awesome-icon icon="question-circle"/>
</n8n-tooltip>
</el-col>
<el-col :span="18">
<parameter-input :parameter="parameter" :value="propertyValue[parameter.name]" :path="parameter.name" :isCredential="true" :displayOptions="true" @valueChanged="valueChanged" inputSize="medium" />
</el-col>
</el-row>
</div>
<el-row v-if="isOAuthType" class="oauth-information">
<el-col :span="6" class="headline">
OAuth
</el-col>
<el-col :span="18">
<span v-if="requiredPropertiesFilled === false">
<n8n-icon-button title="Connect OAuth Credentials" icon="redo" :disabled="true" size="large" />
Enter all required properties
</span>
<span v-else-if="isOAuthConnected === true">
<n8n-icon-button title="Reconnect OAuth Credentials" @click.stop="oAuthCredentialAuthorize()" icon="redo" size="large" />
Connected
</span>
<span v-else>
<span v-if="isGoogleOAuthType">
<img :src="basePath + 'google-signin.png'" class="google-icon clickable" alt="Sign in with Google" @click.stop="oAuthCredentialAuthorize()" />
</span>
<span v-else>
<n8n-icon-button title="Connect OAuth Credentials" @click.stop="oAuthCredentialAuthorize()" icon="sign-in-alt" size="large" />
Not connected
</span>
</span>
<div v-if="credentialProperties.length">
<div class="clickable oauth-callback-headline" :class="{expanded: !isMinimized}" @click="isMinimized=!isMinimized" :title="isMinimized ? 'Click to display Webhook URLs' : 'Click to hide Webhook URLs'">
<font-awesome-icon icon="angle-up" class="minimize-button minimize-icon" />
OAuth Callback URL
</div>
<n8n-tooltip v-if="!isMinimized" class="item" content="Click to copy Callback URL" placement="right">
<div class="callback-url left-ellipsis clickable" @click="copyCallbackUrl">
{{oAuthCallbackUrl}}
</div>
</n8n-tooltip>
</div>
</el-col>
</el-row>
<el-row class="nodes-access-wrapper">
<el-col :span="6" class="headline">
Nodes with access:
<n8n-tooltip class="credentials-info" placement="top" >
<div slot="content" v-html="helpTexts.nodesWithAccess"></div>
<font-awesome-icon icon="question-circle" />
</n8n-tooltip>
</el-col>
<el-col :span="18">
<el-transfer
:titles="['No Access', 'Access ']"
v-model="nodesAccess"
:data="allNodesRequestingAccess">
</el-transfer>
<div v-if="nodesAccess.length === 0" class="no-nodes-access">
<strong>
Important
</strong><br />
Add at least one node which has access to the credentials!
</div>
</el-col>
</el-row>
<div class="action-buttons">
<n8n-button type="success" @click="updateCredentials(true)" label="Save" size="large" v-if="credentialDataDynamic" />
<n8n-button @click="createCredentials(true)" label="Create" size="large" v-else />
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { copyPaste } from '@/components/mixins/copyPaste';
import { externalHooks } from '@/components/mixins/externalHooks';
import { restApi } from '@/components/mixins/restApi';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import {
ICredentialsDecryptedResponse,
ICredentialsResponse,
IUpdateInformation,
} from '@/Interface';
import {
CredentialInformation,
ICredentialDataDecryptedObject,
ICredentialsDecrypted,
ICredentialType,
ICredentialNodeAccess,
INodeCredentialDescription,
INodeParameters,
INodeProperties,
INodeTypeDescription,
NodeHelpers,
} from 'n8n-workflow';
import ParameterInput from '@/components/ParameterInput.vue';
import mixins from 'vue-typed-mixins';
import { addTargetBlank } from './helpers';
export default mixins(
copyPaste,
externalHooks,
nodeHelpers,
restApi,
showMessage,
).extend({
name: 'CredentialsInput',
props: [
'credentialTypeData', // ICredentialType
'credentialData', // ICredentialsDecryptedResponse
'nodesInit', // {
// type: Array,
// default: () => { [] },
// }
],
components: {
ParameterInput,
},
data () {
return {
basePath: this.$store.getters.getBaseUrl,
isMinimized: true,
helpTexts: {
credentialsData: 'The credentials to set.',
credentialsName: 'A recognizable label for the credentials. Descriptive names work <br />best here, so you can easily select it from a list later.',
nodesWithAccess: 'Nodes with access to these credentials.',
},
credentialDataTemp: null as ICredentialsDecryptedResponse | null,
nodesAccess: [] as string[],
name: '',
propertyValue: {} as ICredentialDataDecryptedObject,
};
},
computed: {
allNodesRequestingAccess (): Array<{key: string, label: string}> {
const returnNodeTypes: string[] = [];
const nodeTypes: INodeTypeDescription[] = this.$store.getters.allNodeTypes;
let nodeType: INodeTypeDescription;
let credentialTypeDescription: INodeCredentialDescription;
// Find the node types which need the credentials
for (nodeType of nodeTypes) {
if (!nodeType.credentials) {
continue;
}
for (credentialTypeDescription of nodeType.credentials) {
if (credentialTypeDescription.name === (this.credentialTypeData as ICredentialType).name && !returnNodeTypes.includes(credentialTypeDescription.name)) {
returnNodeTypes.push(nodeType.name);
break;
}
}
}
// Return the data in the correct format el-transfer expects
return returnNodeTypes.map((nodeTypeName: string) => {
return {
key: nodeTypeName,
label: this.$store.getters.nodeType(nodeTypeName).displayName as string,
};
});
},
credentialProperties (): INodeProperties[] {
return this.credentialTypeData.properties.filter((propertyData: INodeProperties) => {
if (!this.displayCredentialParameter(propertyData)) {
return false;
}
return !this.credentialTypeData.__overwrittenProperties || !this.credentialTypeData.__overwrittenProperties.includes(propertyData.name);
});
},
credentialDataDynamic (): ICredentialsDecryptedResponse | null {
if (this.credentialData) {
return this.credentialData;
}
return this.credentialDataTemp;
},
isGoogleOAuthType (): boolean {
if (this.credentialTypeData.name === 'googleOAuth2Api') {
return true;
}
const types = this.parentTypes(this.credentialTypeData.name);
return types.includes('googleOAuth2Api');
},
isOAuthType (): boolean {
if (['oAuth1Api', 'oAuth2Api'].includes(this.credentialTypeData.name)) {
return true;
}
const types = this.parentTypes(this.credentialTypeData.name);
return types.includes('oAuth1Api') || types.includes('oAuth2Api');
},
isOAuthConnected (): boolean {
if (this.isOAuthType === false) {
return false;
}
return this.credentialDataDynamic !== null && !!this.credentialDataDynamic.data!.oauthTokenData;
},
oAuthCallbackUrl (): string {
const types = this.parentTypes(this.credentialTypeData.name);
const oauthType = (this.credentialTypeData.name === 'oAuth2Api' || types.includes('oAuth2Api')) ? 'oauth2' : 'oauth1';
return this.$store.getters.oauthCallbackUrls[oauthType];
},
requiredPropertiesFilled (): boolean {
for (const property of this.credentialProperties) {
if (property.required !== true) {
continue;
}
if (!this.propertyValue[property.name]) {
return false;
}
}
return true;
},
},
methods: {
addTargetBlank,
copyCallbackUrl (): void {
this.copyToClipboard(this.oAuthCallbackUrl);
this.$showMessage({
title: 'Copied',
message: `Callback URL was successfully copied!`,
type: 'success',
});
},
parentTypes (name: string): string[] {
const credentialType = this.$store.getters.credentialType(name);
if (credentialType === undefined || credentialType.extends === undefined) {
return [];
}
const types: string[] = [];
for (const typeName of credentialType.extends) {
types.push(typeName);
types.push.apply(types, this.parentTypes(typeName));
}
return types;
},
valueChanged (parameterData: IUpdateInformation) {
const name = parameterData.name.split('.').pop() as string;
// For a currently for me unknown reason can In not simply just
// set the value and it has to be this way.
const tempValue = JSON.parse(JSON.stringify(this.propertyValue));
tempValue[name] = parameterData.value;
Vue.set(this, 'propertyValue', tempValue);
},
displayCredentialParameter (parameter: INodeProperties): boolean {
if (parameter.type === 'hidden') {
return false;
}
if (parameter.displayOptions === undefined) {
// If it is not defined no need to do a proper check
return true;
}
return this.displayParameter(this.propertyValue as INodeParameters, parameter, '');
},
async createCredentials (closeDialog: boolean): Promise<ICredentialsResponse | null> {
const nodesAccess = this.nodesAccess.map((nodeType) => {
return {
nodeType,
};
});
const newCredentials = {
name: this.name,
type: (this.credentialTypeData as ICredentialType).name,
nodesAccess,
// Save only the none default data
data: NodeHelpers.getNodeParameters(this.credentialTypeData.properties as INodeProperties[], this.propertyValue as INodeParameters, false, false),
} as ICredentialsDecrypted;
let result;
try {
result = await this.restApi().createNewCredentials(newCredentials);
} catch (error) {
this.$showError(error, 'Problem Creating Credentials', 'There was a problem creating the credentials:');
return null;
}
// Add also to local store
this.$store.commit('addCredentials', result);
this.$emit('credentialsCreated', {data: result, options: { closeDialog }});
this.$externalHooks().run('credentials.create', { credentialTypeData: this.credentialTypeData });
return result;
},
async oAuthCredentialAuthorize () {
let url;
let credentialData = this.credentialDataDynamic;
let newCredentials = false;
if (!credentialData) {
// Credentials did not get created yet. So create first before
// doing oauth authorize
credentialData = await this.createCredentials(false) as ICredentialsDecryptedResponse;
newCredentials = true;
if (credentialData === null) {
return;
}
// Set the internal data directly so that even if it fails it displays a "Save" instead
// of the "Create" button. If that would not be done, people could not retry after a
// connect issue as it woult try to create credentials again which would fail as they
// exist already.
Vue.set(this, 'credentialDataTemp', credentialData);
} else {
// Exists already but got maybe changed. So save first
credentialData = await this.updateCredentials(false) as ICredentialsDecryptedResponse;
if (credentialData === null) {
return;
}
}
const types = this.parentTypes(this.credentialTypeData.name);
try {
if (this.credentialTypeData.name === 'oAuth2Api' || types.includes('oAuth2Api')) {
url = await this.restApi().oAuth2CredentialAuthorize(credentialData as ICredentialsResponse) as string;
} else if (this.credentialTypeData.name === 'oAuth1Api' || types.includes('oAuth1Api')) {
url = await this.restApi().oAuth1CredentialAuthorize(credentialData as ICredentialsResponse) as string;
}
} catch (error) {
this.$showError(error, 'OAuth Authorization Error', 'Error generating authorization URL:');
return;
}
const params = `scrollbars=no,resizable=yes,status=no,titlebar=noe,location=no,toolbar=no,menubar=no,width=500,height=700`;
const oauthPopup = window.open(url, 'OAuth2 Authorization', params);
const receiveMessage = (event: MessageEvent) => {
// // TODO: Add check that it came from n8n
// if (event.origin !== 'http://example.org:8080') {
// return;
// }
if (event.data === 'success') {
// Set some kind of data that status changes.
// As data does not get displayed directly it does not matter what data.
if (this.credentialData === null) {
// Are new credentials so did not get send via "credentialData"
Vue.set(this, 'credentialDataTemp', credentialData);
Vue.set(this.credentialDataTemp!.data!, 'oauthTokenData', {});
} else {
// Credentials did already exist so can be set directly
Vue.set(this.credentialData.data, 'oauthTokenData', {});
}
// Save that OAuth got authorized locally
this.$store.commit('updateCredentials', this.credentialDataDynamic);
// Close the window
if (oauthPopup) {
oauthPopup.close();
}
if (newCredentials === true) {
this.$emit('credentialsCreated', {data: credentialData, options: { closeDialog: false }});
}
this.$showMessage({
title: 'Connected',
message: 'Connected successfully!',
type: 'success',
});
// Make sure that the event gets removed again
window.removeEventListener('message', receiveMessage, false);
}
};
window.addEventListener('message', receiveMessage, false);
},
async updateCredentials (closeDialog: boolean): Promise<ICredentialsResponse | null> {
const nodesAccess: ICredentialNodeAccess[] = [];
const addedNodeTypes: string[] = [];
// Add Node-type which already had access to keep the original added date
let nodeAccessData: ICredentialNodeAccess;
for (nodeAccessData of (this.credentialDataDynamic as ICredentialsDecryptedResponse).nodesAccess) {
if (this.nodesAccess.includes((nodeAccessData.nodeType))) {
nodesAccess.push(nodeAccessData);
addedNodeTypes.push(nodeAccessData.nodeType);
}
}
// Add Node-type which did not have access before
for (const nodeType of this.nodesAccess) {
if (!addedNodeTypes.includes(nodeType)) {
nodesAccess.push({
nodeType,
});
}
}
const newCredentials = {
name: this.name,
type: (this.credentialTypeData as ICredentialType).name,
nodesAccess,
// Save only the none default data
data: NodeHelpers.getNodeParameters(this.credentialTypeData.properties as INodeProperties[], this.propertyValue as INodeParameters, false, false),
} as ICredentialsDecrypted;
let result;
try {
result = await this.restApi().updateCredentials((this.credentialDataDynamic as ICredentialsDecryptedResponse).id as string, newCredentials);
} catch (error) {
this.$showError(error, 'Problem Updating Credentials', 'There was a problem updating the credentials:');
return null;
}
// Update also in local store
this.$store.commit('updateCredentials', result);
// Now that the credentials changed check if any nodes use credentials
// which have now a different name
this.updateNodesCredentialsIssues();
this.$emit('credentialsUpdated', {data: result, options: { closeDialog }});
return result;
},
init () {
if (this.credentialData) {
// Initialize with the given data
this.name = (this.credentialData as ICredentialsDecryptedResponse).name;
this.propertyValue = (this.credentialData as ICredentialsDecryptedResponse).data as ICredentialDataDecryptedObject;
const nodesAccess = (this.credentialData as ICredentialsDecryptedResponse).nodesAccess.map((nodeAccess) => {
return nodeAccess.nodeType;
});
Vue.set(this, 'nodesAccess', nodesAccess);
} else {
// No data supplied so init empty
this.name = '';
this.propertyValue = {} as ICredentialDataDecryptedObject;
const nodesAccess = [] as string[];
nodesAccess.push.apply(nodesAccess, this.nodesInit);
Vue.set(this, 'nodesAccess', nodesAccess);
}
// Set default values
for (const property of (this.credentialTypeData as ICredentialType).properties) {
if (!this.propertyValue.hasOwnProperty(property.name)) {
this.propertyValue[property.name] = property.default as CredentialInformation;
}
}
},
},
watch: {
credentialData () {
this.init();
},
credentialTypeData () {
this.init();
},
},
mounted () {
this.init();
},
});
</script>
<style lang="scss">
.credentials-input-wrapper {
.credential-name-wrapper {
display: flex;
align-items: center;
}
.action-buttons {
margin-top: 2em;
text-align: right;
}
.headline {
font-weight: 600;
color: $--color-primary;
margin-bottom: 1em;
line-height: 1.5;
}
.headline-regular {
line-height: 1.5;
}
.nodes-access-wrapper {
margin-top: 1em;
}
.no-nodes-access {
margin: 1em 0;
color: $--color-primary;
line-height: 1.75em;
}
.oauth-information {
line-height: 2.5em;
margin: 2em 0;
.google-icon {
width: 191px;
}
}
.parameter-wrapper {
display: flex;
align-items: center;
margin: 8px 0;
.parameter-name {
position: relative;
&:hover {
.parameter-info {
display: inline;
}
}
.parameter-info {
display: none;
}
}
}
.credentials-info {
display: none;
}
.callback-url {
position: relative;
top: 0;
width: 100%;
font-size: 0.9em;
white-space: normal;
overflow: visible;
text-overflow: initial;
color: #404040;
text-align: left;
direction: ltr;
word-break: break-all;
}
.headline:hover,
.headline-regular:hover {
.credentials-info {
display: inline;
}
}
.expanded .minimize-button {
-webkit-transform: rotate(180deg);
-moz-transform: rotate(180deg);
-o-transform: rotate(180deg);
transform: rotate(180deg);
}
.oauth-callback-headline {
padding-top: 1em;
font-weight: 500;
}
}
</style>

View file

@ -1,7 +1,5 @@
<template>
<div v-if="dialogVisible">
<credentials-edit :dialogVisible="credentialEditDialogVisible" @closeDialog="closeCredentialEditDialog" @credentialsUpdated="reloadCredentialList" @credentialsCreated="reloadCredentialList" :setCredentialType="editCredentials && editCredentials.type" :editCredentials="editCredentials"></credentials-edit>
<el-dialog :visible="dialogVisible" append-to-body width="80%" title="Credentials" :before-close="closeDialog">
<div class="text-very-light">
Your saved credentials:
@ -17,13 +15,9 @@
/>
</div>
<el-table :data="credentials" :default-sort = "{prop: 'name', order: 'ascending'}" stripe @row-click="editCredential" max-height="450" v-loading="isDataLoading">
<el-table :data="credentialsToDisplay" :default-sort = "{prop: 'name', order: 'ascending'}" stripe max-height="450" @row-click="editCredential">
<el-table-column property="name" label="Name" class-name="clickable" sortable></el-table-column>
<el-table-column property="type" label="Type" class-name="clickable" sortable>
<template slot-scope="scope">
{{credentialTypeDisplayNames[scope.row.type]}}
</template>
</el-table-column>
<el-table-column property="type" label="Type" class-name="clickable" sortable></el-table-column>
<el-table-column property="createdAt" label="Created" class-name="clickable" sortable></el-table-column>
<el-table-column property="updatedAt" label="Updated" class-name="clickable" sortable></el-table-column>
<el-table-column
@ -43,136 +37,89 @@
<script lang="ts">
import { externalHooks } from '@/components/mixins/externalHooks';
import { restApi } from '@/components/mixins/restApi';
import { ICredentialsResponse } from '@/Interface';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import CredentialsEdit from '@/components/CredentialsEdit.vue';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { mapGetters } from "vuex";
import mixins from 'vue-typed-mixins';
import { convertToDisplayDate } from './helpers';
export default mixins(
externalHooks,
genericHelpers,
nodeHelpers,
restApi,
showMessage,
).extend({
name: 'CredentialsList',
props: [
'dialogVisible',
],
components: {
CredentialsEdit,
},
data () {
return {
credentialEditDialogVisible: false,
credentialTypeDisplayNames: {} as { [key: string]: string; },
credentials: [] as ICredentialsResponse[],
displayAddCredentials: false,
editCredentials: null as ICredentialsResponse | null,
isDataLoading: false,
};
computed: {
...mapGetters('credentials', ['allCredentials']),
credentialsToDisplay() {
return this.allCredentials.reduce((accu: ICredentialsResponse[], cred: ICredentialsResponse) => {
const type = this.$store.getters['credentials/getCredentialTypeByName'](cred.type);
if (type) {
accu.push({
...cred,
type: type.displayName,
createdAt: convertToDisplayDate(cred.createdAt as number),
updatedAt: convertToDisplayDate(cred.updatedAt as number),
});
}
return accu;
}, []);
},
},
watch: {
dialogVisible (newValue) {
if (newValue) {
this.loadCredentials();
this.loadCredentialTypes();
}
this.$externalHooks().run('credentialsList.dialogVisibleChanged', { dialogVisible: newValue });
},
},
methods: {
closeCredentialEditDialog () {
this.credentialEditDialogVisible = false;
},
closeDialog () {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('closeDialog');
return false;
},
createCredential () {
this.editCredentials = null;
this.credentialEditDialogVisible = true;
this.$store.dispatch('ui/openCredentialsSelectModal');
},
editCredential (credential: ICredentialsResponse) {
const editCredentials = {
id: credential.id,
name: credential.name,
type: credential.type,
} as ICredentialsResponse;
this.editCredentials = editCredentials;
this.credentialEditDialogVisible = true;
},
reloadCredentialList () {
this.loadCredentials();
},
loadCredentialTypes () {
if (Object.keys(this.credentialTypeDisplayNames).length !== 0) {
// Data is already loaded
return;
}
if (this.$store.getters.allCredentialTypes === null) {
// Data is not ready yet to be loaded
return;
}
for (const credentialType of this.$store.getters.allCredentialTypes) {
this.credentialTypeDisplayNames[credentialType.name] = credentialType.displayName;
}
},
loadCredentials () {
this.isDataLoading = true;
try {
this.credentials = JSON.parse(JSON.stringify(this.$store.getters.allCredentials));
} catch (error) {
this.$showError(error, 'Problem loading credentials', 'There was a problem loading the credentials:');
this.isDataLoading = false;
return;
}
this.credentials.forEach((credentialData: ICredentialsResponse) => {
credentialData.createdAt = this.convertToDisplayDate(credentialData.createdAt as number);
credentialData.updatedAt = this.convertToDisplayDate(credentialData.updatedAt as number);
});
this.isDataLoading = false;
this.$store.dispatch('ui/openExisitngCredential', { id: credential.id});
},
async deleteCredential (credential: ICredentialsResponse) {
const deleteConfirmed = await this.confirmMessage(`Are you sure you want to delete "${credential.name}" credentials?`, 'Delete Credentials?', 'warning', 'Yes, delete!');
const deleteConfirmed = await this.confirmMessage(`Are you sure you want to delete "${credential.name}" credentials?`, 'Delete Credentials?', null, 'Yes, delete!');
if (deleteConfirmed === false) {
return;
}
try {
await this.restApi().deleteCredentials(credential.id!);
await this.$store.dispatch('credentials/deleteCredential', {id: credential.id});
} catch (error) {
this.$showError(error, 'Problem deleting credentials', 'There was a problem deleting the credentials:');
return;
}
// Remove also from local store
this.$store.commit('removeCredentials', credential);
// Now that the credentials got removed check if any nodes used them
this.updateNodesCredentialsIssues();
this.$showMessage({
title: 'Credentials deleted',
message: `The credential "${credential.name}" got deleted!`,
message: `The credential "${credential.name}" was deleted!`,
type: 'success',
});
// Refresh list
this.loadCredentials();
},
},
});

View file

@ -0,0 +1,107 @@
<template>
<Modal
:name="modalName"
:eventBus="modalBus"
size="sm"
>
<template slot="header">
<h2 :class="$style.title">Add new credential</h2>
</template>
<template slot="content">
<div :class="$style.container">
<div :class="$style.subtitle">Select an app or service to connect to</div>
<n8n-select
filterable
defaultFirstOption
placeholder="Search for app..."
size="xlarge"
ref="select"
:value="selected"
@change="onSelect"
>
<font-awesome-icon icon="search" slot="prefix" />
<n8n-option
v-for="credential in allCredentialTypes"
:value="credential.name"
:key="credential.name"
:label="credential.displayName"
filterable
/>
</n8n-select>
</div>
</template>
<template slot="footer">
<div :class="$style.footer">
<n8n-button
label="Continue"
float="right"
size="large"
:disabled="!selected"
@click="openCredentialType"
/>
</div>
</template>
</Modal>
</template>
<script lang="ts">
import Vue from 'vue';
import { mapGetters } from "vuex";
import Modal from './Modal.vue';
export default Vue.extend({
name: 'CredentialsSelectModal',
components: {
Modal,
},
mounted() {
setTimeout(() => {
const element = this.$refs.select as HTMLSelectElement;
if (element) {
element.focus();
}
}, 0);
},
data() {
return {
modalBus: new Vue(),
selected: '',
};
},
computed: {
...mapGetters('credentials', ['allCredentialTypes']),
},
props: {
modalName: {
type: String,
},
},
methods: {
onSelect(type: string) {
this.selected = type;
},
openCredentialType () {
this.modalBus.$emit('close');
this.$store.dispatch('ui/openNewCredential', { type: this.selected });
},
},
});
</script>
<style module lang="scss">
.container {
margin-bottom: var(--spacing-l);
}
.title {
font-size: var(--font-size-xl);
line-height: var(--font-line-height-regular);
}
.subtitle {
margin-bottom: var(--spacing-s);
font-size: var(--font-size-m);
line-height: var(--font-line-height-xloose);
}
</style>

View file

@ -1,39 +1,41 @@
<template>
<transition name="el-fade-in" @after-enter="showDocumentHelp = true">
<div class="data-display-wrapper close-on-click" v-show="node" @click="close">
<div class="data-display" >
<NodeSettings @valueChanged="valueChanged" />
<RunData />
<div class="close-button clickable close-on-click" title="Close">
<i class="el-icon-close close-on-click"></i>
</div>
<transition name="fade">
<div v-if="showDocumentHelp && nodeType" class="doc-help-wrapper">
<svg id="help-logo" v-if="showDocumentHelp && nodeType" :href="documentationUrl" target="_blank" width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Node Documentation</title>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-1127.000000, -836.000000)" fill-rule="nonzero">
<g transform="translate(1117.000000, 825.000000)">
<g transform="translate(10.000000, 11.000000)">
<g transform="translate(2.250000, 2.250000)" fill="#FF6150">
<path d="M6,11.25 L7.5,11.25 L7.5,9.75 L6,9.75 L6,11.25 M6.75,2.25 C5.09314575,2.25 3.75,3.59314575 3.75,5.25 L5.25,5.25 C5.25,4.42157288 5.92157288,3.75 6.75,3.75 C7.57842712,3.75 8.25,4.42157288 8.25,5.25 C8.25,6.75 6,6.5625 6,9 L7.5,9 C7.5,7.3125 9.75,7.125 9.75,5.25 C9.75,3.59314575 8.40685425,2.25 6.75,2.25 M1.5,0 L12,0 C12.8284271,0 13.5,0.671572875 13.5,1.5 L13.5,12 C13.5,12.8284271 12.8284271,13.5 12,13.5 L1.5,13.5 C0.671572875,13.5 0,12.8284271 0,12 L0,1.5 C0,0.671572875 0.671572875,0 1.5,0 Z"></path>
</g>
<rect x="0" y="0" width="18" height="18"></rect>
</g>
<el-dialog
:visible="!!node"
:before-close="close"
:custom-class="`classic data-display-wrapper`"
width="80%"
append-to-body
@opened="showDocumentHelp = true"
>
<div class="data-display" >
<NodeSettings @valueChanged="valueChanged" />
<RunData />
</div>
<transition name="fade">
<div v-if="nodeType && showDocumentHelp" class="doc-help-wrapper">
<svg id="help-logo" :href="documentationUrl" target="_blank" width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Node Documentation</title>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-1127.000000, -836.000000)" fill-rule="nonzero">
<g transform="translate(1117.000000, 825.000000)">
<g transform="translate(10.000000, 11.000000)">
<g transform="translate(2.250000, 2.250000)" fill="#FF6150">
<path d="M6,11.25 L7.5,11.25 L7.5,9.75 L6,9.75 L6,11.25 M6.75,2.25 C5.09314575,2.25 3.75,3.59314575 3.75,5.25 L5.25,5.25 C5.25,4.42157288 5.92157288,3.75 6.75,3.75 C7.57842712,3.75 8.25,4.42157288 8.25,5.25 C8.25,6.75 6,6.5625 6,9 L7.5,9 C7.5,7.3125 9.75,7.125 9.75,5.25 C9.75,3.59314575 8.40685425,2.25 6.75,2.25 M1.5,0 L12,0 C12.8284271,0 13.5,0.671572875 13.5,1.5 L13.5,12 C13.5,12.8284271 12.8284271,13.5 12,13.5 L1.5,13.5 C0.671572875,13.5 0,12.8284271 0,12 L0,1.5 C0,0.671572875 0.671572875,0 1.5,0 Z"></path>
</g>
<rect x="0" y="0" width="18" height="18"></rect>
</g>
</g>
</svg>
</g>
</g>
</svg>
<div v-if="showDocumentHelp && nodeType" class="text">
Need help? <a id="doc-hyperlink" v-if="showDocumentHelp && nodeType" :href="documentationUrl" target="_blank" @click="onDocumentationUrlClick">Open {{nodeType.displayName}} documentation</a>
</div>
<div class="text">
Need help? <a id="doc-hyperlink" :href="documentationUrl" target="_blank" @click="onDocumentationUrlClick">Open {{nodeType.displayName}} documentation</a>
</div>
</transition>
</div>
</div>
</transition>
</transition>
</el-dialog>
</template>
<script lang="ts">
@ -102,13 +104,10 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
nodeTypeSelected (nodeTypeName: string) {
this.$emit('nodeTypeSelected', nodeTypeName);
},
close (e: MouseEvent) {
// @ts-ignore
if (e.target.className && e.target.className.includes && e.target.className.includes('close-on-click')) {
this.$externalHooks().run('dataDisplay.nodeEditingFinished');
this.showDocumentHelp = false;
this.$store.commit('setActiveNode', null);
}
close () {
this.$externalHooks().run('dataDisplay.nodeEditingFinished');
this.showDocumentHelp = false;
this.$store.commit('setActiveNode', null);
},
onDocumentationUrlClick () {
this.$externalHooks().run('dataDisplay.onDocumentationUrlClick', { nodeType: this.nodeType, documentationUrl: this.documentationUrl });
@ -119,105 +118,69 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
</script>
<style lang="scss">
.data-display-wrapper {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 20;
background-color: #9d8d9dd8;
height: 85%;
.close-button {
position: absolute;
top: 0;
right: -50px;
color: #fff;
background-color: $--custom-header-background;
border-radius: 0 18px 18px 0;
z-index: 110;
font-size: 1.7em;
text-align: center;
line-height: 50px;
height: 50px;
width: 50px;
.close-on-click {
color: #fff;
font-weight: 400;
}
.close-on-click:hover {
transform: scale(1.2);
}
.el-dialog__header {
padding: 0 !important;
}
.data-display {
position: relative;
width: 80%;
height: 80%;
margin: 3em auto;
background-color: #fff;
border-radius: 2px;
@media (max-height: 720px) {
margin: 1em auto;
height: 95%;
}
.fade-enter-active, .fade-enter-to, .fade-leave-active {
transition: all .75s ease;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
.doc-help-wrapper {
transition-delay: 2s;
background-color: #fff;
margin-top: 1%;
box-sizing: border-box;
border: 1px solid #DCDFE6;
border-radius: 4px;
background-color: #FFFFFF;
box-shadow: 0 2px 7px 0 rgba(0,0,0,0.15);
min-width: 319px;
height: 40px;
float: right;
padding: 5px;
display: flex;
flex-direction: row;
padding-top: 10px;
padding-right: 12px;
#help-logo {
flex: 1;
}
.text {
margin-left: 5px;
flex: 9;
font-family: "Open Sans";
color: #666666;
font-size: 12px;
font-weight: 600;
letter-spacing: 0;
line-height: 17px;
white-space: nowrap;
}
#doc-hyperlink, #doc-hyperlink:visited, #doc-hyperlink:focus, #doc-hyperlink:active {
text-decoration: none;
color: #FF6150;
}
}
.el-dialog__body {
padding: 0 !important;
min-height: 400px;
overflow: hidden;
border-radius: 8px;
}
}
.data-display {
background-color: #fff;
border-radius: 8px;
display: flex;
height: 100%;
}
.doc-help-wrapper {
position: absolute;
right: 0;
transition-delay: 2s;
background-color: #fff;
margin-top: 1%;
box-sizing: border-box;
border: 1px solid #DCDFE6;
border-radius: 4px;
background-color: #FFFFFF;
box-shadow: 0 2px 7px 0 rgba(0,0,0,0.15);
min-width: 319px;
height: 40px;
float: right;
padding: 5px;
display: flex;
flex-direction: row;
padding-top: 10px;
padding-right: 12px;
#help-logo {
flex: 1;
}
.text {
margin-left: 5px;
flex: 9;
font-family: "Open Sans";
font-size: 12px;
font-weight: 600;
line-height: 17px;
white-space: nowrap;
}
}
.fade-enter-active, .fade-enter-to, .fade-leave-active {
transition: all .75s ease;
opacity: 1;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
</style>

View file

@ -173,6 +173,10 @@ import {
IWorkflowShortResponse,
} from '@/Interface';
import {
convertToDisplayDate,
} from './helpers';
import {
IDataObject,
} from 'n8n-workflow';
@ -319,6 +323,7 @@ export default mixins(
}
return false;
},
convertToDisplayDate,
displayExecution (execution: IExecutionShortResponse) {
this.$router.push({
name: 'ExecutionById',
@ -380,7 +385,7 @@ export default mixins(
this.$showMessage({
title: 'Execution deleted',
message: 'The executions got deleted!',
message: 'The executions were deleted!',
type: 'success',
});

View file

@ -1,6 +1,6 @@
<template>
<div v-if="dialogVisible" @keydown.stop>
<el-dialog :visible="dialogVisible" custom-class="expression-dialog" append-to-body width="80%" title="Edit Expression" :before-close="closeDialog">
<el-dialog :visible="dialogVisible" custom-class="expression-dialog classic" append-to-body width="80%" title="Edit Expression" :before-close="closeDialog">
<el-row>
<el-col :span="8">
<div class="header-side-menu">
@ -145,11 +145,14 @@ export default mixins(
.right-side {
background-color: #f9f9f9;
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
}
}
.header-side-menu {
padding: 1em 0 0.5em 1.8em;
border-top-left-radius: 8px;
background-color: $--custom-window-sidebar-top;
color: #555;

View file

@ -0,0 +1,124 @@
<template>
<div :class="$style.container">
<div
:class="$style.headline"
@keydown.stop
@click="enableNameEdit"
v-click-outside="disableNameEdit"
>
<div v-if="!isNameEdit">
<span>{{ name }}</span>
<i><font-awesome-icon icon="pen" /></i>
</div>
<div v-else :class="$style.nameInput">
<n8n-input
:value="name"
size="xlarge"
ref="nameInput"
@input="onNameEdit"
@change="disableNameEdit"
:maxlength="64"
/>
</div>
</div>
<div :class="$style.subtitle" v-if="!isNameEdit">{{ subtitle }}</div>
</div>
</template>
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { showMessage } from './mixins/showMessage';
export default mixins(showMessage).extend({
name: 'InlineNameEdit',
props: {
name: {
type: String,
},
subtitle: {
type: String,
},
type: {
type: String,
},
},
data() {
return {
isNameEdit: false,
};
},
methods: {
onNameEdit(value: string) {
this.$emit('input', value);
},
enableNameEdit() {
this.isNameEdit = true;
setTimeout(() => {
const input = this.$refs.nameInput as HTMLInputElement;
if (input) {
input.focus();
}
}, 0);
},
disableNameEdit() {
if (!this.name) {
this.$emit('input', `Untitled ${this.type}`);
this.$showWarning('Error', `${this.type} name cannot be empty`);
}
this.isNameEdit = false;
},
},
});
</script>
<style module lang="scss">
.container {
min-height: 36px;
}
.headline {
font-size: var(--font-size-m);
line-height: 1.4;
margin-bottom: var(--spacing-5xs);
display: inline-block;
cursor: pointer;
padding: 0 var(--spacing-4xs);
border-radius: var(--border-radius-base);
position: relative;
min-height: 22px;
max-height: 22px;
font-weight: 400;
i {
display: var(--headline-icon-display, none);
font-size: 0.75em;
margin-left: 8px;
color: var(--color-text-base);
}
&:hover {
background-color: var(--color-background-base);
--headline-icon-display: inline-flex;
}
}
.nameInput {
z-index: 1;
position: absolute;
top: -13px;
left: -9px;
width: 400px;
}
.subtitle {
font-size: var(--font-size-2xs);
color: var(--color-text-light);
margin-left: 4px;
font-weight: 400;
}
</style>

View file

@ -46,24 +46,6 @@ export default mixins(
</script>
<style lang="scss">
.el-menu--horizontal>.el-menu-item,
.el-menu--horizontal>.el-submenu .el-submenu__title,
.el-menu-item {
height: 65px;
line-height: 65px;
}
.el-submenu .el-submenu__title,
.el-menu--horizontal>.el-menu-item,
.el-menu.el-menu--horizontal {
border: none !important;
}
.el-menu--popup-bottom-start {
margin-top: 0px;
border-top: 1px solid #464646;
border-radius: 0 0 2px 2px;
}
.main-header {
position: fixed;
top: 0;

View file

@ -65,7 +65,11 @@
<span>Active:</span>
<WorkflowActivator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflowId" :disabled="!currentWorkflowId"/>
</span>
<SaveWorkflowButton />
<SaveButton
:saved="!this.isDirty && !this.isNewWorkflow"
:disabled="isWorkflowSaving"
@click="saveCurrentWorkflow"
/>
</template>
</PushConnectionTracker>
</div>
@ -82,7 +86,7 @@ import TagsContainer from "@/components/TagsContainer.vue";
import PushConnectionTracker from "@/components/PushConnectionTracker.vue";
import WorkflowActivator from "@/components/WorkflowActivator.vue";
import { workflowHelpers } from "@/components/mixins/workflowHelpers";
import SaveWorkflowButton from "@/components/SaveWorkflowButton.vue";
import SaveButton from "@/components/SaveButton.vue";
import TagsDropdown from "@/components/TagsDropdown.vue";
import InlineTextEdit from "@/components/InlineTextEdit.vue";
import BreakpointsObserver from "@/components/BreakpointsObserver.vue";
@ -103,7 +107,7 @@ export default mixins(workflowHelpers).extend({
PushConnectionTracker,
WorkflowNameShort,
WorkflowActivator,
SaveWorkflowButton,
SaveButton,
TagsDropdown,
InlineTextEdit,
BreakpointsObserver,
@ -125,6 +129,9 @@ export default mixins(workflowHelpers).extend({
isDirty: "getStateIsDirty",
currentWorkflowTagIds: "workflowTags",
}),
isNewWorkflow(): boolean {
return !this.$route.params.name;
},
isWorkflowSaving(): boolean {
return this.$store.getters.isActionActive("workflowSaving");
},

View file

@ -3,7 +3,6 @@
<about :dialogVisible="aboutDialogVisible" @closeDialog="closeAboutDialog"></about>
<executions-list :dialogVisible="executionsListDialogVisible" @closeDialog="closeExecutionsListOpenDialog"></executions-list>
<credentials-list :dialogVisible="credentialOpenDialogVisible" @closeDialog="closeCredentialOpenDialog"></credentials-list>
<credentials-edit :dialogVisible="credentialNewDialogVisible" @closeDialog="closeCredentialNewDialog"></credentials-edit>
<workflow-settings :dialogVisible="workflowSettingsDialogVisible" @closeDialog="closeWorkflowSettingsDialog"></workflow-settings>
<input type="file" ref="importFile" style="display: none" v-on:change="handleFileImport()">
@ -11,105 +10,105 @@
<div id="collapse-change-button" class="clickable" @click="toggleCollapse">
<font-awesome-icon icon="angle-right" class="icon" />
</div>
<el-menu default-active="workflow" @select="handleSelect" :collapse="isCollapsed">
<n8n-menu default-active="workflow" @select="handleSelect" :collapse="isCollapsed">
<el-menu-item index="logo" class="logo-item">
<n8n-menu-item index="logo" class="logo-item">
<a href="https://n8n.io" target="_blank" class="logo">
<img :src="basePath + 'n8n-icon-small.png'" class="icon" alt="n8n.io"/>
<span class="logo-text" slot="title">n8n.io</span>
</a>
</el-menu-item>
</n8n-menu-item>
<MenuItemsIterator :items="sidebarMenuTopItems" :root="true"/>
<el-submenu index="workflow" title="Workflow">
<el-submenu index="workflow" title="Workflow" popperClass="sidebar-popper">
<template slot="title">
<font-awesome-icon icon="network-wired"/>&nbsp;
<span slot="title" class="item-title-root">Workflows</span>
</template>
<el-menu-item index="workflow-new">
<n8n-menu-item index="workflow-new">
<template slot="title">
<font-awesome-icon icon="file"/>&nbsp;
<span slot="title" class="item-title">New</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-open">
</n8n-menu-item>
<n8n-menu-item index="workflow-open">
<template slot="title">
<font-awesome-icon icon="folder-open"/>&nbsp;
<span slot="title" class="item-title">Open</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-save">
</n8n-menu-item>
<n8n-menu-item index="workflow-save">
<template slot="title">
<font-awesome-icon icon="save"/>
<span slot="title" class="item-title">Save</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-duplicate" :disabled="!currentWorkflow">
</n8n-menu-item>
<n8n-menu-item index="workflow-duplicate" :disabled="!currentWorkflow">
<template slot="title">
<font-awesome-icon icon="copy"/>
<span slot="title" class="item-title">Duplicate</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-delete" :disabled="!currentWorkflow">
</n8n-menu-item>
<n8n-menu-item index="workflow-delete" :disabled="!currentWorkflow">
<template slot="title">
<font-awesome-icon icon="trash"/>
<span slot="title" class="item-title">Delete</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-download">
</n8n-menu-item>
<n8n-menu-item index="workflow-download">
<template slot="title">
<font-awesome-icon icon="file-download"/>
<span slot="title" class="item-title">Download</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-import-url">
</n8n-menu-item>
<n8n-menu-item index="workflow-import-url">
<template slot="title">
<font-awesome-icon icon="cloud"/>
<span slot="title" class="item-title">Import from URL</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-import-file">
</n8n-menu-item>
<n8n-menu-item index="workflow-import-file">
<template slot="title">
<font-awesome-icon icon="hdd"/>
<span slot="title" class="item-title">Import from File</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-settings" :disabled="!currentWorkflow">
</n8n-menu-item>
<n8n-menu-item index="workflow-settings" :disabled="!currentWorkflow">
<template slot="title">
<font-awesome-icon icon="cog"/>
<span slot="title" class="item-title">Settings</span>
</template>
</el-menu-item>
</n8n-menu-item>
</el-submenu>
<el-submenu index="credentials" title="Credentials">
<el-submenu index="credentials" title="Credentials" popperClass="sidebar-popper">
<template slot="title">
<font-awesome-icon icon="key"/>&nbsp;
<span slot="title" class="item-title-root">Credentials</span>
</template>
<el-menu-item index="credentials-new">
<n8n-menu-item index="credentials-new">
<template slot="title">
<font-awesome-icon icon="file"/>
<span slot="title" class="item-title">New</span>
</template>
</el-menu-item>
<el-menu-item index="credentials-open">
</n8n-menu-item>
<n8n-menu-item index="credentials-open">
<template slot="title">
<font-awesome-icon icon="folder-open"/>
<span slot="title" class="item-title">Open</span>
</template>
</el-menu-item>
</n8n-menu-item>
</el-submenu>
<el-menu-item index="executions">
<n8n-menu-item index="executions">
<font-awesome-icon icon="tasks"/>&nbsp;
<span slot="title" class="item-title-root">Executions</span>
</el-menu-item>
</n8n-menu-item>
<el-submenu index="help" class="help-menu" title="Help">
<el-submenu index="help" class="help-menu" title="Help" popperClass="sidebar-popper">
<template slot="title">
<font-awesome-icon icon="question"/>&nbsp;
<span slot="title" class="item-title-root">Help</span>
@ -117,25 +116,25 @@
<MenuItemsIterator :items="helpMenuItems" />
<el-menu-item index="help-about">
<n8n-menu-item index="help-about">
<template slot="title">
<font-awesome-icon class="about-icon" icon="info"/>
<span slot="title" class="item-title">About n8n</span>
</template>
</el-menu-item>
</n8n-menu-item>
</el-submenu>
<MenuItemsIterator :items="sidebarMenuBottomItems" :root="true"/>
<div class="footer-menu-items">
<el-menu-item index="updates" class="updates" v-if="hasVersionUpdates" @click="openUpdatesPanel">
<n8n-menu-item index="updates" class="updates" v-if="hasVersionUpdates" @click="openUpdatesPanel">
<div class="gift-container">
<GiftNotificationIcon />
</div>
<span slot="title" class="item-title-root">{{nextVersions.length > 99 ? '99+' : nextVersions.length}} update{{nextVersions.length > 1 ? 's' : ''}} available</span>
</el-menu-item>
</n8n-menu-item>
</div>
</el-menu>
</n8n-menu>
</div>
</div>
@ -153,7 +152,6 @@ import {
} from '../Interface';
import About from '@/components/About.vue';
import CredentialsEdit from '@/components/CredentialsEdit.vue';
import CredentialsList from '@/components/CredentialsList.vue';
import ExecutionsList from '@/components/ExecutionsList.vue';
import GiftNotificationIcon from './GiftNotificationIcon.vue';
@ -217,7 +215,6 @@ export default mixins(
name: 'MainHeader',
components: {
About,
CredentialsEdit,
CredentialsList,
ExecutionsList,
GiftNotificationIcon,
@ -229,7 +226,6 @@ export default mixins(
aboutDialogVisible: false,
// @ts-ignore
basePath: this.$store.getters.getBaseUrl,
credentialNewDialogVisible: false,
credentialOpenDialogVisible: false,
executionsListDialogVisible: false,
stopExecutionInProgress: false,
@ -318,9 +314,6 @@ export default mixins(
closeCredentialOpenDialog () {
this.credentialOpenDialogVisible = false;
},
closeCredentialNewDialog () {
this.credentialNewDialogVisible = false;
},
openTagManager() {
this.$store.dispatch('ui/openTagsManagerModal');
},
@ -414,8 +407,8 @@ export default mixins(
// Reset tab title since workflow is deleted.
this.$titleReset();
this.$showMessage({
title: 'Workflow got deleted',
message: `The workflow "${this.workflowName}" got deleted!`,
title: 'Workflow was deleted',
message: `The workflow "${this.workflowName}" was deleted!`,
type: 'success',
});
@ -477,7 +470,7 @@ export default mixins(
} else if (key === 'credentials-open') {
this.credentialOpenDialogVisible = true;
} else if (key === 'credentials-new') {
this.credentialNewDialogVisible = true;
this.$store.dispatch('ui/openCredentialsSelectModal');
} else if (key === 'execution-open-workflow') {
if (this.workflowExecution !== null) {
this.openWorkflow(this.workflowExecution.workflowId as string);
@ -491,6 +484,103 @@ export default mixins(
</script>
<style lang="scss">
.sidebar-popper{
.el-menu-item {
font-size: 0.9em;
height: 35px;
line-height: 35px;
color: $--custom-dialog-text-color;
--menu-item-hover-fill: #fff0ef;
.item-title {
position: absolute;
left: 55px;
}
.svg-inline--fa {
position: relative;
right: -3px;
}
}
}
#side-menu {
// Menu
.el-menu--vertical,
.el-menu {
border: none;
font-size: 14px;
--menu-item-hover-fill: #fff0ef;
.el-menu--collapse {
width: 75px;
}
.el-menu--popup,
.el-menu--inline {
font-size: 0.9em;
li.el-menu-item {
height: 35px;
line-height: 35px;
color: $--custom-dialog-text-color;
}
}
.el-menu-item,
.el-submenu__title {
color: $--color-primary;
font-size: 1.2em;
.el-submenu__icon-arrow {
color: $--color-primary;
font-weight: 800;
font-size: 1em;
}
.svg-inline--fa {
position: relative;
right: -3px;
}
.item-title {
position: absolute;
left: 73px;
}
.item-title-root {
position: absolute;
left: 60px;
top: 1px;
}
}
}
.el-menu-item {
a {
color: #666;
&.primary-item {
color: $--color-primary;
vertical-align: baseline;
}
}
&.logo-item {
background-color: $--color-primary !important;
height: $--header-height;
line-height: $--header-height;
* {
vertical-align: middle;
}
.icon {
position: relative;
height: 23px;
left: -10px;
top: -2px;
}
}
}
}
.about-icon {
margin-left: 5px;
}
@ -530,29 +620,6 @@ export default mixins(
transform: scale(1.1);
}
.el-menu-item {
a {
color: #666;
&.primary-item {
color: $--color-primary;
vertical-align: baseline;
}
}
&.logo-item {
background-color: $--color-primary !important;
height: $--header-height;
.icon {
position: relative;
height: 23px;
left: -10px;
top: -2px;
}
}
}
a.logo {
text-decoration: none;
}
@ -605,7 +672,7 @@ a.logo {
}
.el-menu-item.updates {
color: $--sidebar-inactive-color;
color: $--sidebar-inactive-color !important;
.item-title-root {
font-size: 13px;
top: 0 !important;

View file

@ -1,6 +1,6 @@
<template>
<div>
<el-menu-item
<n8n-menu-item
v-for="item in items"
:key="item.id"
:index="item.id"
@ -8,7 +8,7 @@
>
<font-awesome-icon :icon="item.properties.icon" />
<span slot="title" :class="{'item-title-root': root, 'item-title': !root}">{{ item.properties.title }}</span>
</el-menu-item>
</n8n-menu-item>
</div>
</template>
@ -41,4 +41,4 @@ export default Vue.extend({
},
},
});
</script>
</script>

View file

@ -21,15 +21,20 @@
:title="title"
:class="{ 'dialog-wrapper': true, [size]: true }"
:width="width"
:show-close="showClose"
:custom-class="getCustomClass()"
append-to-body
>
<template v-slot:title>
<slot name="header" />
<slot name="header" v-if="!loading" />
</template>
<div class="modal-content" @keydown.stop @keydown.enter="handleEnter" @keydown.esc="closeDialog">
<slot name="content"/>
<slot v-if="!loading" name="content"/>
<div class="loader" v-else>
<n8n-spinner />
</div>
</div>
<el-row class="modal-footer">
<el-row v-if="!loading" class="modal-footer">
<slot name="footer" :close="closeDialog" />
</el-row>
</el-dialog>
@ -41,13 +46,14 @@ import Vue from "vue";
const sizeMap: {[size: string]: string} = {
xl: '80%',
lg: '70%',
m: '50%',
default: '50%',
};
export default Vue.extend({
name: "Modal",
props: ['name', 'title', 'eventBus', 'size', 'drawer', 'drawerDirection', 'drawerWidth', 'visible'],
props: ['name', 'title', 'eventBus', 'size', 'drawer', 'drawerDirection', 'drawerWidth', 'visible', 'showClose', 'loading', 'classic', 'beforeClose', 'customClass'],
data() {
return {
visibleDrawer: this.drawer,
@ -86,6 +92,17 @@ export default Vue.extend({
}
},
closeDialog(callback?: () => void) {
if (this.beforeClose) {
this.beforeClose(() => {
this.$store.commit('ui/closeTopModal');
if (typeof callback === 'function') {
callback();
}
});
return;
}
this.$store.commit('ui/closeTopModal');
if (typeof callback === 'function') {
callback();
@ -98,6 +115,15 @@ export default Vue.extend({
this.visibleDrawer = true;
}, 300); // delayed for closing animation to take effect
},
getCustomClass() {
let classes = this.$props.customClass || '';
if (this.$props.classic) {
classes = `${classes} classic`;
}
return classes;
},
},
computed: {
width(): string {
@ -123,22 +149,31 @@ export default Vue.extend({
overflow: hidden;
}
.dialog-wrapper {
* {
box-sizing: border-box;
}
.el-dialog__body {
height: 100%;
}
.dialog-wrapper {
&.xl > div, &.md > div {
min-width: 620px;
}
&.lg > div {
height: 80%;
overflow: hidden;
.modal-content {
height: 100%;
}
}
&.sm {
display: flex;
align-items: center;
justify-content: center;
> div {
max-width: 420px;
max-width: 460px;
}
}
}
@ -146,4 +181,13 @@ export default Vue.extend({
.modal-content > .el-row {
margin-bottom: 15px;
}
.loader {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-primary-tint-1);
font-size: 30px;
height: 80%;
}
</style>

View file

@ -2,7 +2,13 @@
<div
v-if="isOpen(name) || keepAlive"
>
<slot :modalName="name" :active="isActive(name)" :open="isOpen(name)"></slot>
<slot
:modalName="name"
:active="isActive(name)"
:open="isOpen(name)"
:activeId="getActiveId(name)"
:mode="getMode(name)"
></slot>
</div>
</template>
@ -19,6 +25,12 @@ export default Vue.extend({
isOpen(name: string) {
return this.$store.getters['ui/isModalOpen'](name);
},
getMode(name: string) {
return this.$store.getters['ui/getModalMode'](name);
},
getActiveId(name: string) {
return this.$store.getters['ui/getModalActiveId'](name);
},
},
});
</script>

View file

@ -24,6 +24,7 @@
/>
</template>
</ModalRoot>
<ModalRoot :name="VERSIONS_MODAL_KEY" :keepAlive="true">
<template v-slot="{ modalName, open }">
<UpdatesPanel
@ -32,33 +33,57 @@
/>
</template>
</ModalRoot>
<ModalRoot :name="CREDENTIAL_EDIT_MODAL_KEY">
<template v-slot="{ modalName, activeId, mode }">
<CredentialEdit
:modalName="modalName"
:mode="mode"
:activeId="activeId"
/>
</template>
</ModalRoot>
<ModalRoot :name="CREDENTIAL_SELECT_MODAL_KEY">
<template v-slot="{ modalName }">
<CredentialsSelectModal
:modalName="modalName"
/>
</template>
</ModalRoot>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import { DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, WORKLOW_OPEN_MODAL_KEY, VERSIONS_MODAL_KEY } from '@/constants';
import { DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, WORKLOW_OPEN_MODAL_KEY, VERSIONS_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY } from '@/constants';
import TagsManager from "@/components/TagsManager/TagsManager.vue";
import CredentialEdit from "./CredentialEdit/CredentialEdit.vue";
import DuplicateWorkflowDialog from "@/components/DuplicateWorkflowDialog.vue";
import WorkflowOpen from "@/components/WorkflowOpen.vue";
import ModalRoot from "./ModalRoot.vue";
import CredentialsSelectModal from "./CredentialsSelectModal.vue";
import UpdatesPanel from "./UpdatesPanel.vue";
import TagsManager from "@/components/TagsManager/TagsManager.vue";
export default Vue.extend({
name: "Modals",
components: {
TagsManager,
CredentialEdit,
DuplicateWorkflowDialog,
WorkflowOpen,
ModalRoot,
CredentialsSelectModal,
UpdatesPanel,
TagsManager,
WorkflowOpen,
},
data: () => ({
DUPLICATE_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
WORKLOW_OPEN_MODAL_KEY,
VERSIONS_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY,
}),
});
</script>

View file

@ -21,7 +21,7 @@
<collection-parameter :parameter="parameter" :values="value" :nodeValues="nodeValues" :path="getPath(index)" :hideDelete="hideDelete" @valueChanged="valueChanged" />
</div>
<div v-else>
<parameter-input class="duplicate-parameter-input-item" :parameter="parameter" :value="value" :displayOptions="true" :path="getPath(index)" @valueChanged="valueChanged" inputSize="small" />
<parameter-input class="duplicate-parameter-input-item" :parameter="parameter" :value="value" :displayOptions="true" :path="getPath(index)" @valueChanged="valueChanged" inputSize="small" :isReadOnly="isReadOnly" />
</div>
</div>
@ -75,6 +75,7 @@ export default mixins(genericHelpers)
},
},
methods: {
addTargetBlank,
addItem () {
const name = this.getPath();
let currentValue = get(this.nodeValues, name);
@ -92,7 +93,6 @@ export default mixins(genericHelpers)
this.$emit('valueChanged', parameterData);
},
addTargetBlank,
deleteItem (index: number) {
const parameterData = {
name: this.getPath(index),

View file

@ -37,7 +37,7 @@
</div>
</div>
<NodeIcon class="node-icon" :nodeType="nodeType" size="60" :shrink="true" :disabled="this.data.disabled"/>
<NodeIcon class="node-icon" :nodeType="nodeType" size="60" :circle="true" :shrink="true" :disabled="this.data.disabled"/>
</div>
<div class="node-description">
<div class="node-name" :title="data.name">

View file

@ -1,27 +1,30 @@
<template>
<div v-if="credentialTypesNodeDescriptionDisplayed.length" class="node-credentials">
<credentials-edit :dialogVisible="credentialNewDialogVisible" :editCredentials="editCredentials" :setCredentialType="addType" :nodesInit="nodesInit" :node="node" @closeDialog="closeCredentialNewDialog" @credentialsCreated="credentialsCreated" @credentialsUpdated="credentialsUpdated"></credentials-edit>
<div class="headline">
Credentials
</div>
<div v-for="credentialTypeDescription in credentialTypesNodeDescriptionDisplayed" :key="credentialTypeDescription.name" class="credential-data">
<el-row v-if="displayCredentials(credentialTypeDescription)" class="credential-parameter-wrapper">
<el-row class="credential-parameter-wrapper">
<el-col :span="10" class="parameter-name">
{{credentialTypeNames[credentialTypeDescription.name]}}:
</el-col>
<el-col :span="12" class="parameter-value" :class="getIssues(credentialTypeDescription.name).length?'has-issues':''">
<div :style="credentialInputWrapperStyle(credentialTypeDescription.name)">
<n8n-select v-model="credentials[credentialTypeDescription.name]" :disabled="isReadOnly" @change="credentialSelected(credentialTypeDescription.name)" placeholder="Select Credential" size="small">
<n8n-select :value="selected[credentialTypeDescription.name]" :disabled="isReadOnly" @change="(value) => credentialSelected(credentialTypeDescription.name, value)" placeholder="Select Credential" size="small">
<n8n-option
v-for="(item, index) in credentialOptions[credentialTypeDescription.name]"
:key="item.name + '_' + index"
v-for="(item) in credentialOptions[credentialTypeDescription.name]"
:key="item.id"
:label="item.name"
:value="item.name">
</n8n-option>
<n8n-option
:key="NEW_CREDENTIALS_TEXT"
:value="NEW_CREDENTIALS_TEXT"
:label="NEW_CREDENTIALS_TEXT"
>
</n8n-option>
</n8n-select>
</div>
@ -34,7 +37,7 @@
</el-col>
<el-col :span="2" class="parameter-value credential-action">
<font-awesome-icon v-if="credentials[credentialTypeDescription.name]" icon="pen" @click="updateCredentials(credentialTypeDescription.name)" class="update-credentials clickable" title="Update Credentials" />
<font-awesome-icon v-if="selected[credentialTypeDescription.name] && isCredentialValid(credentialTypeDescription.name)" icon="pen" @click="editCredential(credentialTypeDescription.name)" class="update-credentials clickable" title="Update Credentials" />
</el-col>
</el-row>
@ -44,12 +47,8 @@
</template>
<script lang="ts">
import Vue from 'vue';
import { restApi } from '@/components/mixins/restApi';
import {
ICredentialsCreatedEvent,
ICredentialsResponse,
INodeUi,
INodeUpdatePropertiesInformation,
} from '@/Interface';
@ -59,15 +58,16 @@ import {
INodeTypeDescription,
} from 'n8n-workflow';
import CredentialsEdit from '@/components/CredentialsEdit.vue';
import ParameterInput from '@/components/ParameterInput.vue';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import { mapGetters } from "vuex";
import mixins from 'vue-typed-mixins';
const NEW_CREDENTIALS_TEXT = '- Create New -';
export default mixins(
genericHelpers,
nodeHelpers,
@ -78,11 +78,16 @@ export default mixins(
props: [
'node', // INodeUi
],
components: {
CredentialsEdit,
ParameterInput,
data () {
return {
NEW_CREDENTIALS_TEXT,
newCredentialUnsubscribe: null as null | (() => void),
};
},
computed: {
...mapGetters('credentials', {
credentialOptions: 'allCredentialsByType',
}),
credentialTypesNode (): string[] {
return this.credentialTypesNodeDescription
.map((credentialTypeDescription) => credentialTypeDescription.name);
@ -109,52 +114,16 @@ export default mixins(
} = {};
let credentialType: ICredentialType | null;
for (const credentialTypeName of this.credentialTypesNode) {
credentialType = this.$store.getters.credentialType(credentialTypeName);
credentialType = this.$store.getters['credentials/getCredentialTypeByName'](credentialTypeName);
returnData[credentialTypeName] = credentialType !== null ? credentialType.displayName : credentialTypeName;
}
return returnData;
},
},
data () {
return {
addType: undefined as string | undefined,
credentialNewDialogVisible: false,
credentialOptions: {} as { [key: string]: ICredentialsResponse[]; },
credentials: {} as {
[key: string]: string | undefined
},
editCredentials: null as object | null, // Credentials filter
newCredentialText: '- Create New -',
nodesInit: undefined as string[] | undefined,
};
},
watch: {
node () {
this.init();
selected(): {[type: string]: string} {
return this.node.credentials || {};
},
},
methods: {
closeCredentialNewDialog () {
this.credentialNewDialogVisible = false;
},
async credentialsCreated (eventData: ICredentialsCreatedEvent) {
await this.credentialsUpdated(eventData);
},
credentialsUpdated (eventData: ICredentialsCreatedEvent) {
if (!this.credentialTypesNode.includes(eventData.data.type)) {
return;
}
this.init();
Vue.set(this.credentials, eventData.data.type, eventData.data.name);
// Makes sure that it does also get set correctly on the node not just the UI
this.credentialSelected(eventData.data.type);
if (eventData.options.closeDialog === true) {
this.closeCredentialNewDialog();
}
},
credentialInputWrapperStyle (credentialType: string) {
let deductWidth = 0;
const styles = {
@ -170,29 +139,54 @@ export default mixins(
return styles;
},
credentialSelected (credentialType: string) {
const credential = this.credentials[credentialType];
if (credential === this.newCredentialText) {
// New credentials should be created
this.addType = credentialType;
this.editCredentials = null;
this.nodesInit = [ (this.node as INodeUi).type ];
this.credentialNewDialogVisible = true;
this.credentials[credentialType] = undefined;
listenForNewCredentials(credentialType: string) {
this.stopListeningForNewCredentials();
this.newCredentialUnsubscribe = this.$store.subscribe((mutation, state) => {
if (mutation.type === 'credentials/upsertCredential' || mutation.type === 'credentials/enableOAuthCredential'){
this.credentialSelected(credentialType, mutation.payload.name);
}
if (mutation.type === 'credentials/deleteCredential') {
this.credentialSelected(credentialType, mutation.payload.name);
this.stopListeningForNewCredentials();
}
});
},
stopListeningForNewCredentials() {
if (this.newCredentialUnsubscribe) {
this.newCredentialUnsubscribe();
}
},
credentialSelected (credentialType: string, credentialName: string) {
let selected = undefined;
if (credentialName === NEW_CREDENTIALS_TEXT) {
this.listenForNewCredentials(credentialType);
this.$store.dispatch('ui/openNewCredential', { type: credentialType });
}
else {
selected = credentialName;
}
const node = this.node as INodeUi;
const node: INodeUi = this.node;
const credentials = {
...(node.credentials || {}),
[credentialType]: selected,
};
const updateInformation: INodeUpdatePropertiesInformation = {
name: node.name,
name: this.node.name,
properties: {
credentials: JSON.parse(JSON.stringify(this.credentials)),
credentials,
},
};
this.$emit('credentialSelected', updateInformation);
},
displayCredentials (credentialTypeDescription: INodeCredentialDescription): boolean {
if (credentialTypeDescription.displayOptions === undefined) {
// If it is not defined no need to do a proper check
@ -200,6 +194,7 @@ export default mixins(
}
return this.displayParameter(this.node.parameters, credentialTypeDescription, '');
},
getIssues (credentialTypeName: string): string[] {
const node = this.node as INodeUi;
@ -213,56 +208,25 @@ export default mixins(
return node.issues.credentials[credentialTypeName];
},
updateCredentials (credentialType: string): void {
const name = this.credentials[credentialType];
const credentialData = this.credentialOptions[credentialType].find((optionData: ICredentialsResponse) => optionData.name === name);
if (credentialData === undefined) {
this.$showMessage({
title: 'Credentials not found',
message: `The credentials named "${name}" of type "${credentialType}" could not be found!`,
type: 'error',
});
return;
}
const editCredentials = {
id: credentialData.id,
name,
type: credentialType,
};
isCredentialValid(credentialType: string): boolean {
const name = this.node.credentials[credentialType];
const options = this.credentialOptions[credentialType];
this.editCredentials = editCredentials;
this.addType = credentialType;
this.credentialNewDialogVisible = true;
return options.find((option: ICredentialType) => option.name === name);
},
init () {
const node = this.node as INodeUi;
editCredential(credentialType: string): void {
const name = this.node.credentials[credentialType];
const options = this.credentialOptions[credentialType];
const selected = options.find((option: ICredentialType) => option.name === name);
this.$store.dispatch('ui/openExisitngCredential', { id: selected.id });
const newOption = {
name: this.newCredentialText,
};
let options = [];
// Get the available credentials for each type
for (const credentialType of this.credentialTypesNode) {
options = this.$store.getters.credentialsByType(credentialType);
options.push(newOption as ICredentialsResponse);
Vue.set(this.credentialOptions, credentialType, options);
}
// Set the current node credentials
if (node.credentials) {
Vue.set(this, 'credentials', JSON.parse(JSON.stringify(node.credentials)));
} else {
Vue.set(this, 'credentials', {});
}
this.listenForNewCredentials(credentialType);
},
},
mounted () {
this.init();
beforeDestroy () {
this.stopListeningForNewCredentials();
},
});
</script>
@ -317,6 +281,7 @@ export default mixins(
display: flex;
justify-content: center;
align-items: center;
color: var(--color-text-base);
}
}

View file

@ -27,6 +27,7 @@ export default Vue.extend({
'size',
'shrink',
'disabled',
'circle',
],
computed: {
iconStyleData (): object {
@ -43,7 +44,7 @@ export default Vue.extend({
height: size + 'px',
'font-size': Math.floor(parseInt(this.size, 10) * 0.6) + 'px',
'line-height': size + 'px',
'border-radius': Math.ceil(size / 2) + 'px',
'border-radius': this.circle ? '50%': '4px',
};
},
isSvgIcon (): boolean {

View file

@ -511,24 +511,17 @@ export default mixins(
<style lang="scss">
.node-settings {
position: absolute;
left: 0;
width: 350px;
height: 100%;
border: none;
z-index: 200;
font-size: 0.8em;
color: #555;
border-radius: 2px 0 0 2px;
overflow: hidden;
min-width: 350px;
max-width: 350px;
font-size: var(--font-size-s);
.header-side-menu {
padding: 1em 0 1em 1.8em;
font-size: 1.35em;
font-size: var(--font-size-l);
background-color: $--custom-window-sidebar-top;
color: #555;
.node-info {
color: #555;
display: none;
padding-left: 0.5em;
font-size: 0.8em;
@ -546,18 +539,19 @@ export default mixins(
}
.node-parameters-wrapper {
height: calc(100% - 110px);
height: 100%;
font-size: .9em;
.el-tabs__header {
background-color: #fff5f2;
line-height: 2em;
}
.el-tabs {
height: 100%;
.el-tabs__content {
height: calc(100% - 17px);
overflow-y: auto;
height: 100%;
padding-bottom: 180px;
.el-tab-pane {
margin: 0 1em;

View file

@ -29,9 +29,11 @@
:rows="getArgument('rows')"
:value="displayValue"
:disabled="isReadOnly"
@input="onTextInputChange"
@change="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
:title="displayTitle"
:placeholder="isValueExpression?'':parameter.placeholder"
>
@ -48,6 +50,7 @@
:value="displayValue"
:disabled="isReadOnly"
@focus="setFocus"
@blur="onBlur"
@change="valueChanged"
:title="displayTitle"
:show-alpha="getArgument('showAlpha')"
@ -61,6 +64,7 @@
@change="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
:title="displayTitle"
/>
</div>
@ -78,6 +82,7 @@
:picker-options="dateTimePickerOptions"
@change="valueChanged"
@focus="setFocus"
@blur="onBlur"
@keydown.stop
/>
@ -92,7 +97,9 @@
:step="getArgument('numberStepSize')"
:disabled="isReadOnly"
@change="valueChanged"
@input="onTextInputChange"
@focus="setFocus"
@blur="onBlur"
@keydown.stop
:title="displayTitle"
:placeholder="parameter.placeholder"
@ -107,9 +114,11 @@
:loading="remoteParameterOptionsLoading"
:disabled="isReadOnly || remoteParameterOptionsLoading"
:title="displayTitle"
:popper-append-to-body="true"
@change="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
>
<n8n-option
v-for="option in parameterOptions"
@ -136,6 +145,7 @@
@change="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
:title="displayTitle"
>
<n8n-option v-for="option in parameterOptions" :value="option.value" :key="option.value" :label="option.name" >
@ -177,7 +187,6 @@
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
</template>
@ -201,7 +210,6 @@ import ExpressionEdit from '@/components/ExpressionEdit.vue';
import PrismEditor from 'vue-prism-editor';
import TextEdit from '@/components/TextEdit.vue';
import { externalHooks } from '@/components/mixins/externalHooks';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
@ -210,7 +218,6 @@ import mixins from 'vue-typed-mixins';
export default mixins(
externalHooks,
genericHelpers,
nodeHelpers,
showMessage,
workflowHelpers,
@ -225,11 +232,14 @@ export default mixins(
},
props: [
'displayOptions', // boolean
'inputSize',
'isReadOnly',
'documentationUrl',
'parameter', // NodeProperties
'path', // string
'value',
'isCredential', // boolean
'inputSize',
'hideIssues', // boolean
'errorHighlight',
],
data () {
return {
@ -431,7 +441,7 @@ export default mixins(
return 'text';
},
getIssues (): string[] {
if (this.isCredential === true || this.node === null) {
if (this.hideIssues === true || this.node === null) {
return [];
}
@ -512,7 +522,7 @@ export default mixins(
if (this.isValueExpression) {
classes.push('expression');
}
if (this.getIssues.length) {
if (this.getIssues.length || this.errorHighlight) {
classes.push('has-issues');
}
return classes;
@ -602,8 +612,12 @@ export default mixins(
openExpressionEdit() {
if (this.isValueExpression) {
this.expressionEditDialogVisible = true;
return;
}
},
onBlur () {
this.$emit('blur');
},
setFocus () {
if (this.isValueExpression) {
this.expressionEditDialogVisible = true;
@ -644,6 +658,15 @@ export default mixins(
const [r, g, b, a] = valueMatch.splice(1, 4).map(v => Number(v));
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1) + ((1 << 8) + Math.floor((1-a)*255)).toString(16).slice(1);
},
onTextInputChange (value: string) {
const parameterData = {
node: this.node !== null ? this.node.name : this.nodeName,
name: this.path,
value,
};
this.$emit('textInput', parameterData);
},
valueChanged (value: string | number | boolean | Date | null) {
if (value instanceof Date) {
value = value.toISOString();
@ -840,4 +863,16 @@ export default mixins(
align-items: center;
}
.errors {
margin-top: var(--spacing-2xs);
color: var(--color-danger);
font-size: var(--font-size-2xs);
font-weight: var(--font-weight-regular);
a {
color: var(--color-danger);
text-decoration: underline;
}
}
</style>

View file

@ -0,0 +1,68 @@
<template>
<n8n-input-label
:label="parameter.displayName"
:tooltipText="parameter.description"
:required="parameter.required"
>
<parameter-input
:parameter="parameter"
:value="value"
:path="parameter.name"
:hideIssues="true"
:displayOptions="true"
:documentationUrl="documentationUrl"
:errorHighlight="showRequiredErrors"
@blur="onBlur"
@textInput="valueChanged"
@valueChanged="valueChanged"
inputSize="large"
/>
<div class="errors" v-if="showRequiredErrors">
This field is required. <a v-if="documentationUrl" :href="documentationUrl" target="_blank">Open docs</a>
</div>
</n8n-input-label>
</template>
<script lang="ts">
import { IUpdateInformation } from '@/Interface';
import ParameterInput from './ParameterInput.vue';
import Vue from 'vue';
export default Vue.extend({
name: 'ParameterInputExpanded',
components: {
ParameterInput,
},
props: {
parameter: {
},
value: {
},
showValidationWarnings: {
type: Boolean,
},
documentationUrl: {
type: String,
},
},
data() {
return {
blurred: false,
};
},
computed: {
showRequiredErrors(): boolean {
return this.$props.parameter.type !== 'boolean' && !this.value && this.$props.parameter.required && (this.blurred || this.showValidationWarnings);
},
},
methods: {
onBlur() {
this.blurred = true;
},
valueChanged(parameterData: IUpdateInformation) {
this.$emit('change', parameterData);
},
},
});
</script>

View file

@ -8,7 +8,7 @@
</n8n-tooltip>
</el-col>
<el-col :span="isMultiLineParameter ? 24 : 14" class="parameter-value">
<parameter-input :parameter="parameter" :value="value" :displayOptions="displayOptions" :path="path" @valueChanged="valueChanged" inputSize="small" />
<parameter-input :parameter="parameter" :value="value" :displayOptions="displayOptions" :path="path" :isReadOnly="isReadOnly" @valueChanged="valueChanged" inputSize="small" />
</el-col>
</el-row>
</template>
@ -47,6 +47,7 @@ export default Vue
},
props: [
'displayOptions',
'isReadOnly',
'parameter',
'path',
'value',

View file

@ -70,6 +70,7 @@
:value="getParameterValue(nodeValues, parameter.name, path)"
:displayOptions="true"
:path="getPath(parameter.name)"
:isReadOnly="isReadOnly"
@valueChanged="valueChanged"
/>
</div>

View file

@ -639,14 +639,8 @@ export default mixins(
.run-data-view {
position: relative;
bottom: 0;
left: 0;
margin-left: 350px;
width: calc(100% - 350px);
width: 100%;
height: 100%;
z-index: 100;
color: #555;
font-size: 14px;
background-color: #f9f9f9;
.data-display-content {
@ -657,6 +651,7 @@ export default mixins(
right: 0;
overflow-y: auto;
line-height: 1.5;
word-break: normal;
.binary-data-row {
display: inline-flex;

View file

@ -0,0 +1,58 @@
<template>
<span :class="$style.container">
<span :class="$style.saved" v-if="saved">{{ savedLabel }}</span>
<n8n-button
v-else
:label="isSaving ? savingLabel : saveLabel"
:loading="isSaving"
:disabled="disabled"
@click="$emit('click')"
/>
</span>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: "SaveButton",
props: {
saved: {
type: Boolean,
},
isSaving: {
type: Boolean,
},
disabled: {
type: Boolean,
},
saveLabel: {
type: String,
default: 'Save',
},
savingLabel: {
type: String,
default: 'Saving',
},
savedLabel: {
type: String,
default: 'Saved',
},
},
});
</script>
<style lang="scss" module>
.container {
width: 65px;
}
.saved {
color: $--custom-font-very-light;
font-size: 12px;
font-weight: 600;
line-height: 12px;
text-align: center;
padding: var(--spacing-2xs) var(--spacing-xs);
}
</style>

View file

@ -1,51 +0,0 @@
<template>
<span :class="$style.container">
<n8n-button v-if="isDirty || isNewWorkflow" label="Save" :disabled="isWorkflowSaving" @click="save" />
<span :class="$style.saved" v-else>Saved</span>
</span>
</template>
<script lang="ts">
import mixins from "vue-typed-mixins";
import { mapGetters } from "vuex";
import { workflowHelpers } from "@/components/mixins/workflowHelpers";
export default mixins(workflowHelpers).extend({
name: "SaveWorkflowButton",
computed: {
...mapGetters({
isDirty: "getStateIsDirty",
}),
isWorkflowSaving(): boolean {
return this.$store.getters.isActionActive("workflowSaving");
},
isNewWorkflow(): boolean {
return !this.$route.params.name;
},
isSaved(): boolean {
return !this.isWorkflowSaving && !this.isDirty && !this.isNewWorkflow;
},
},
methods: {
save() {
this.saveCurrentWorkflow();
},
},
});
</script>
<style lang="scss" module>
.container {
width: 65px;
}
.saved {
color: $--custom-font-very-light;
font-size: 12px;
font-weight: 600;
line-height: 12px;
text-align: center;
padding: var(--spacing-2xs) var(--spacing-xs);
}
</style>

View file

@ -1,15 +1,59 @@
<template functional>
<span>
{{$options.format(props.date)}}
<span :title="$options.methods.convertToHumanReadableDate($props)">
{{$options.methods.format(props)}}
</span>
</template>
<script lang="ts">
import { format } from 'timeago.js';
import { format, LocaleFunc, register } from 'timeago.js';
import { convertToHumanReadableDate } from './helpers';
const localeFunc = (num: number, index: number, totalSec: number): [string, string] => {
// number: the timeago / timein number;
// index: the index of array below;
// totalSec: total seconds between date to be formatted and today's date;
return [
['Just now', 'Right now'],
['Just now', 'Right now'], // ['%s seconds ago', 'in %s seconds'],
['1 minute ago', 'in 1 minute'],
['%s minutes ago', 'in %s minutes'],
['1 hour ago', 'in 1 hour'],
['%s hours ago', 'in %s hours'],
['1 day ago', 'in 1 day'],
['%s days ago', 'in %s days'],
['1 week ago', 'in 1 week'],
['%s weeks ago', 'in %s weeks'],
['1 month ago', 'in 1 month'],
['%s months ago', 'in %s months'],
['1 year ago', 'in 1 year'],
['%s years ago', 'in %s years'],
][index] as [string, string];
};
register('main', localeFunc as LocaleFunc);
export default {
name: 'UpdatesPanel',
props: ['date'],
format,
props: {
date: {
type: String,
},
capitalize: {
type: Boolean,
default: false,
},
},
methods: {
format(props: {date: string, capitalize: boolean}) {
const text = format(props.date, 'main');
if (!props.capitalize) {
return text.toLowerCase();
}
return text;
},
convertToHumanReadableDate,
},
};
</script>
</script>

View file

@ -2,6 +2,7 @@
<Modal
:name="modalName"
size="xl"
:classic="true"
>
<template v-slot:header>
<div class="workflows-header">
@ -63,6 +64,7 @@ import Modal from '@/components/Modal.vue';
import TagsContainer from '@/components/TagsContainer.vue';
import TagsDropdown from '@/components/TagsDropdown.vue';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
import { convertToDisplayDate } from './helpers';
export default mixins(
genericHelpers,
@ -176,8 +178,8 @@ export default mixins(
this.workflows = data;
this.workflows.forEach((workflowData: IWorkflowShortResponse) => {
workflowData.createdAt = this.convertToDisplayDate(workflowData.createdAt as number);
workflowData.updatedAt = this.convertToDisplayDate(workflowData.updatedAt as number);
workflowData.createdAt = convertToDisplayDate(workflowData.createdAt as number);
workflowData.updatedAt = convertToDisplayDate(workflowData.updatedAt as number);
});
this.isDataLoading = false;
},
@ -215,7 +217,6 @@ export default mixins(
flex-grow: 1;
h1 {
font-weight: 600;
line-height: 24px;
font-size: 18px;
}

View file

@ -1,6 +1,6 @@
<template>
<span>
<el-dialog class="workflow-settings" custom-class="classic" :visible="dialogVisible" append-to-body width="65%" title="Workflow Settings" :before-close="closeDialog">
<el-dialog class="workflow-settings" :visible="dialogVisible" append-to-body width="65%" title="Workflow Settings" :before-close="closeDialog">
<div v-loading="isLoading">
<el-row>
<el-col :span="10" class="setting-name">

View file

@ -1,5 +1,21 @@
import dateformat from 'dateformat';
const KEYWORDS_TO_FILTER = ['API', 'OAuth1', 'OAuth2'];
export function addTargetBlank(html: string) {
return html.includes('href=')
? html.replace(/href=/g, 'target="_blank" href=')
: html;
}
export function convertToDisplayDate (epochTime: number) {
return dateformat(epochTime, 'yyyy-mm-dd HH:MM:ss');
}
export function convertToHumanReadableDate (epochTime: number) {
return dateformat(epochTime, 'd mmmm, yyyy @ HH:MM Z');
}
export function getAppNameFromCredType(name: string) {
return name.split(' ').filter((word) => !KEYWORDS_TO_FILTER.includes(word)).join(' ');
}

View file

@ -1,7 +1,4 @@
import dateformat from 'dateformat';
import { showMessage } from '@/components/mixins/showMessage';
import { MessageType } from '@/Interface';
import { debounce } from 'lodash';
import mixins from 'vue-typed-mixins';
@ -22,9 +19,6 @@ export const genericHelpers = mixins(showMessage).extend({
},
},
methods: {
convertToDisplayDate (epochTime: number) {
return dateformat(epochTime, 'yyyy-mm-dd HH:MM:ss');
},
displayTimer (msPassed: number, showMs = false): string {
if (msPassed < 60000) {
if (showMs === false) {
@ -91,20 +85,5 @@ export const genericHelpers = mixins(showMessage).extend({
// @ts-ignore
await this.debouncedFunctions[functionName].apply(this, inputParameters);
},
async confirmMessage (message: string, headline: string, type = 'warning' as MessageType, confirmButtonText = 'OK', cancelButtonText = 'Cancel'): Promise<boolean> {
try {
await this.$confirm(message, headline, {
confirmButtonText,
cancelButtonText,
type,
dangerouslyUseHTMLString: true,
});
return true;
} catch (e) {
return false;
}
},
},
});

View file

@ -204,7 +204,7 @@ export const nodeHelpers = mixins(
}
// Get the display name of the credential type
credentialType = this.$store.getters.credentialType(credentialTypeDescription.name);
credentialType = this.$store.getters['credentials/getCredentialTypeByName'](credentialTypeDescription.name);
if (credentialType === null) {
credentialDisplayName = credentialTypeDescription.name;
} else {
@ -219,7 +219,7 @@ export const nodeHelpers = mixins(
} else {
// If they are set check if the value is valid
selectedCredentials = node.credentials[credentialTypeDescription.name];
userCredentials = this.$store.getters.credentialsByType(credentialTypeDescription.name);
userCredentials = this.$store.getters['credentials/getCredentialsByType'](credentialTypeDescription.name);
if (userCredentials === null) {
userCredentials = [];

View file

@ -292,7 +292,7 @@ export const pushConnection = mixins(
const pushData = receivedData.data;
this.$store.commit('setExecutingNode', pushData.nodeName);
} else if (receivedData.type === 'testWebhookDeleted') {
// A test-webhook got deleted
// A test-webhook was deleted
const pushData = receivedData.data;
if (pushData.workflowId === this.$store.getters.workflowId) {

View file

@ -1,11 +1,9 @@
import Vue from 'vue';
import { parse } from 'flatted';
import axios, { AxiosRequestConfig, Method } from 'axios';
import { Method } from 'axios';
import {
IActivationError,
ICredentialsDecryptedResponse,
ICredentialsResponse,
IExecutionsCurrentSummaryExtended,
IExecutionDeleteFilter,
IExecutionPushResponse,
@ -18,12 +16,9 @@ import {
IWorkflowDb,
IWorkflowShortResponse,
IRestApi,
IWorkflowData,
IWorkflowDataUpdate,
} from '@/Interface';
import {
ICredentialsDecrypted,
ICredentialType,
IDataObject,
INodeCredentials,
INodeParameters,
@ -153,69 +148,6 @@ export const restApi = Vue.extend({
return self.restApi().makeRestApiRequest('GET', `/workflows/from-url`, { url });
},
// Creates a new workflow
createNewCredentials: (sendData: ICredentialsDecrypted): Promise<ICredentialsResponse> => {
return self.restApi().makeRestApiRequest('POST', `/credentials`, sendData);
},
// Deletes a credentials
deleteCredentials: (id: string): Promise<void> => {
return self.restApi().makeRestApiRequest('DELETE', `/credentials/${id}`);
},
// Updates existing credentials
updateCredentials: (id: string, data: ICredentialsDecrypted): Promise<ICredentialsResponse> => {
return self.restApi().makeRestApiRequest('PATCH', `/credentials/${id}`, data);
},
// Returns the credentials with the given id
getCredentials: (id: string, includeData?: boolean): Promise<ICredentialsDecryptedResponse | ICredentialsResponse | undefined> => {
let sendData;
if (includeData) {
sendData = {
includeData,
};
}
return self.restApi().makeRestApiRequest('GET', `/credentials/${id}`, sendData);
},
// Returns all saved credentials
getAllCredentials: (filter?: object): Promise<ICredentialsResponse[]> => {
let sendData;
if (filter) {
sendData = {
filter,
};
}
return self.restApi().makeRestApiRequest('GET', `/credentials`, sendData);
},
// Returns all credential types
getCredentialTypes: (): Promise<ICredentialType[]> => {
return self.restApi().makeRestApiRequest('GET', `/credential-types`);
},
// Get OAuth1 Authorization URL using the stored credentials
oAuth1CredentialAuthorize: (sendData: ICredentialsResponse): Promise<string> => {
return self.restApi().makeRestApiRequest('GET', `/oauth1-credential/auth`, sendData);
},
// Get OAuth2 Authorization URL using the stored credentials
oAuth2CredentialAuthorize: (sendData: ICredentialsResponse): Promise<string> => {
return self.restApi().makeRestApiRequest('GET', `/oauth2-credential/auth`, sendData);
},
// Verify OAuth2 provider callback and kick off token generation
oAuth2Callback: (code: string, state: string): Promise<string> => {
const sendData = {
'code': code,
'state': state,
};
return self.restApi().makeRestApiRequest('POST', `/oauth2-credential/callback`, sendData);
},
// Returns the execution with the given name
getExecution: async (id: string): Promise<IExecutionResponse> => {
const response = await self.restApi().makeRestApiRequest('GET', `/executions/${id}`);

View file

@ -4,6 +4,8 @@ import mixins from 'vue-typed-mixins';
import { externalHooks } from '@/components/mixins/externalHooks';
import { ExecutionError } from 'n8n-workflow';
import { ElMessageBoxOptions } from 'element-ui/types/message-box';
import { MessageType } from 'element-ui/types/message';
export const showMessage = mixins(externalHooks).extend({
methods: {
@ -81,6 +83,22 @@ export const showMessage = mixins(externalHooks).extend({
});
},
async confirmMessage (message: string, headline: string, type: MessageType | null = 'warning', confirmButtonText = 'OK', cancelButtonText = 'Cancel'): Promise<boolean> {
try {
const options: ElMessageBoxOptions = {
confirmButtonText,
cancelButtonText,
dangerouslyUseHTMLString: true,
...(type && { type }),
};
await this.$confirm(message, headline, options);
return true;
} catch (e) {
return false;
}
},
// @ts-ignore
collapsableDetails({ description, node }: Error) {
if (!description) return '';

View file

@ -13,10 +13,14 @@ export const DUPLICATE_POSTFFIX = ' copy';
// tags
export const MAX_TAG_NAME_LENGTH = 24;
// modals
export const DUPLICATE_MODAL_KEY = 'duplicate';
export const TAGS_MANAGER_MODAL_KEY = 'tagsManager';
export const WORKLOW_OPEN_MODAL_KEY = 'workflowOpen';
export const VERSIONS_MODAL_KEY = 'versions';
export const CREDENTIAL_EDIT_MODAL_KEY = 'editCredential';
export const CREDENTIAL_SELECT_MODAL_KEY = 'selectCredential';
// breakpoints
export const BREAKPOINT_SM = 768;

View file

@ -0,0 +1,177 @@
import { getCredentialTypes,
getCredentialsNewName,
getAllCredentials,
deleteCredential,
getCredentialData,
createNewCredential,
updateCredential,
oAuth2CredentialAuthorize,
oAuth1CredentialAuthorize,
testCredential,
} from '@/api/credentials';
import Vue from 'vue';
import { ActionContext, Module } from 'vuex';
import {
ICredentialMap,
ICredentialsResponse,
ICredentialsState,
ICredentialTypeMap,
IRootState,
} from '../Interface';
import {
ICredentialType,
ICredentialsDecrypted,
NodeCredentialTestResult,
INodeTypeDescription,
} from 'n8n-workflow';
import { getAppNameFromCredType } from '@/components/helpers';
const DEFAULT_CREDENTIAL_NAME = 'Unnamed credential';
const DEFAULT_CREDENTIAL_POSTFIX = 'account';
const TYPES_WITH_DEFAULT_NAME = ['httpBasicAuth', 'oAuth2Api', 'httpDigestAuth', 'oAuth1Api'];
const module: Module<ICredentialsState, IRootState> = {
namespaced: true,
state: {
credentialTypes: {},
credentials: {},
},
mutations: {
setCredentialTypes: (state: ICredentialsState, credentialTypes: ICredentialType[]) => {
state.credentialTypes = credentialTypes.reduce((accu: ICredentialTypeMap, cred: ICredentialType) => {
accu[cred.name] = cred;
return accu;
}, {});
},
setCredentials: (state: ICredentialsState, credentials: ICredentialsResponse[]) => {
state.credentials = credentials.reduce((accu: ICredentialMap, cred: ICredentialsResponse) => {
if (cred.id) {
accu[cred.id] = cred;
}
return accu;
}, {});
},
upsertCredential(state: ICredentialsState, credential: ICredentialsResponse) {
if (credential.id) {
Vue.set(state.credentials, credential.id, credential);
}
},
deleteCredential(state: ICredentialsState, id: string) {
Vue.delete(state.credentials, id);
},
enableOAuthCredential(state: ICredentialsState, credential: ICredentialsResponse) {
// enable oauth event to track change between modals
},
},
getters: {
allCredentialTypes(state: ICredentialsState): ICredentialType[] {
return Object.values(state.credentialTypes)
.sort((a, b) => a.displayName.localeCompare(b.displayName));
},
allCredentials(state: ICredentialsState): ICredentialsResponse[] {
return Object.values(state.credentials)
.sort((a, b) => a.name.localeCompare(b.name));
},
allCredentialsByType(state: ICredentialsState, getters: any): {[type: string]: ICredentialsResponse[]} { // tslint:disable-line:no-any
const credentials = getters.allCredentials as ICredentialsResponse[];
const types = getters.allCredentialTypes as ICredentialType[];
return types.reduce((accu: {[type: string]: ICredentialsResponse[]}, type: ICredentialType) => {
accu[type.name] = credentials.filter((cred: ICredentialsResponse) => cred.type === type.name);
return accu;
}, {});
},
getCredentialTypeByName: (state: ICredentialsState) => {
return (type: string) => state.credentialTypes[type];
},
getCredentialById: (state: ICredentialsState) => {
return (id: string) => state.credentials[id];
},
getCredentialsByType: (state: ICredentialsState, getters: any) => { // tslint:disable-line:no-any
return (credentialType: string): ICredentialsResponse[] => {
return getters.allCredentials.filter((credentialData: ICredentialsResponse) => credentialData.type === credentialType);
};
},
getNodesWithAccess (state: ICredentialsState, getters: any, rootState: IRootState, rootGetters: any) { // tslint:disable-line:no-any
return (credentialTypeName: string) => {
const nodeTypes: INodeTypeDescription[] = rootGetters.allNodeTypes;
return nodeTypes.filter((nodeType: INodeTypeDescription) => {
if (!nodeType.credentials) {
return false;
}
for (const credentialTypeDescription of nodeType.credentials) {
if (credentialTypeDescription.name === credentialTypeName ) {
return true;
}
}
return false;
});
};
},
},
actions: {
fetchCredentialTypes: async (context: ActionContext<ICredentialsState, IRootState>) => {
const credentialTypes = await getCredentialTypes(context.rootGetters.getRestApiContext);
context.commit('setCredentialTypes', credentialTypes);
},
fetchAllCredentials: async (context: ActionContext<ICredentialsState, IRootState>) => {
const credentials = await getAllCredentials(context.rootGetters.getRestApiContext);
context.commit('setCredentials', credentials);
},
getCredentialData: async (context: ActionContext<ICredentialsState, IRootState>, { id }: {id: string}) => {
return await getCredentialData(context.rootGetters.getRestApiContext, id);
},
createNewCredential: async (context: ActionContext<ICredentialsState, IRootState>, data: ICredentialsDecrypted) => {
const credential = await createNewCredential(context.rootGetters.getRestApiContext, data);
context.commit('upsertCredential', credential);
return credential;
},
updateCredential: async (context: ActionContext<ICredentialsState, IRootState>, params: {data: ICredentialsDecrypted, id: string}) => {
const { id, data } = params;
const credential = await updateCredential(context.rootGetters.getRestApiContext, id, data);
context.commit('upsertCredential', credential);
return credential;
},
deleteCredential: async (context: ActionContext<ICredentialsState, IRootState>, { id }: {id: string}) => {
const deleted = await deleteCredential(context.rootGetters.getRestApiContext, id);
if (deleted) {
context.commit('deleteCredential', id);
}
},
oAuth2Authorize: async (context: ActionContext<ICredentialsState, IRootState>, data: ICredentialsResponse) => {
return oAuth2CredentialAuthorize(context.rootGetters.getRestApiContext, data);
},
oAuth1Authorize: async (context: ActionContext<ICredentialsState, IRootState>, data: ICredentialsResponse) => {
return oAuth1CredentialAuthorize(context.rootGetters.getRestApiContext, data);
},
testCredential: async (context: ActionContext<ICredentialsState, IRootState>, data: ICredentialsDecrypted): Promise<NodeCredentialTestResult> => {
return testCredential(context.rootGetters.getRestApiContext, { credentials: data });
},
getNewCredentialName: async (context: ActionContext<ICredentialsState, IRootState>, params: { credentialTypeName: string }) => {
try {
const { credentialTypeName } = params;
let newName = DEFAULT_CREDENTIAL_NAME;
if (!TYPES_WITH_DEFAULT_NAME.includes(credentialTypeName)) {
const { displayName } = context.getters.getCredentialTypeByName(credentialTypeName);
newName = getAppNameFromCredType(displayName);
newName = newName.length > 0 ? `${newName} ${DEFAULT_CREDENTIAL_POSTFIX}` : DEFAULT_CREDENTIAL_NAME;
}
const res = await getCredentialsNewName(context.rootGetters.getRestApiContext, newName);
return res.name;
} catch (e) {
return DEFAULT_CREDENTIAL_NAME;
}
},
},
};
export default module;

View file

@ -1,4 +1,4 @@
import { DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, VERSIONS_MODAL_KEY, WORKLOW_OPEN_MODAL_KEY } from '@/constants';
import { CREDENTIAL_EDIT_MODAL_KEY, DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, VERSIONS_MODAL_KEY, WORKLOW_OPEN_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY } from '@/constants';
import Vue from 'vue';
import { ActionContext, Module } from 'vuex';
import {
@ -10,6 +10,11 @@ const module: Module<IUiState, IRootState> = {
namespaced: true,
state: {
modals: {
[CREDENTIAL_EDIT_MODAL_KEY]: {
open: false,
mode: '',
activeId: null,
},
[DUPLICATE_MODAL_KEY]: {
open: false,
},
@ -22,6 +27,9 @@ const module: Module<IUiState, IRootState> = {
[VERSIONS_MODAL_KEY]: {
open: false,
},
[CREDENTIAL_SELECT_MODAL_KEY]: {
open: false,
},
},
modalStack: [],
sidebarMenuCollapsed: true,
@ -37,9 +45,23 @@ const module: Module<IUiState, IRootState> = {
isModalActive: (state: IUiState) => {
return (name: string) => state.modalStack.length > 0 && name === state.modalStack[0];
},
getModalActiveId: (state: IUiState) => {
return (name: string) => state.modals[name].activeId;
},
getModalMode: (state: IUiState) => {
return (name: string) => state.modals[name].mode;
},
sidebarMenuCollapsed: (state: IUiState): boolean => state.sidebarMenuCollapsed,
},
mutations: {
setMode: (state: IUiState, params: {name: string, mode: string}) => {
const { name, mode } = params;
Vue.set(state.modals[name], 'mode', mode);
},
setActiveId: (state: IUiState, params: {name: string, id: string}) => {
const { name, id } = params;
Vue.set(state.modals[name], 'activeId', id);
},
openModal: (state: IUiState, name: string) => {
Vue.set(state.modals[name], 'open', true);
state.modalStack = [name].concat(state.modalStack);
@ -47,6 +69,12 @@ const module: Module<IUiState, IRootState> = {
closeTopModal: (state: IUiState) => {
const name = state.modalStack[0];
Vue.set(state.modals[name], 'open', false);
if (state.modals.mode) {
Vue.set(state.modals[name], 'mode', '');
}
if (state.modals.activeId) {
Vue.set(state.modals[name], 'activeId', '');
}
state.modalStack = state.modalStack.slice(1);
},
@ -67,7 +95,20 @@ const module: Module<IUiState, IRootState> = {
openUpdatesPanel: async (context: ActionContext<IUiState, IRootState>) => {
context.commit('openModal', VERSIONS_MODAL_KEY);
},
openExisitngCredential: async (context: ActionContext<IUiState, IRootState>, { id }: {id: string}) => {
context.commit('setActiveId', {name: CREDENTIAL_EDIT_MODAL_KEY, id});
context.commit('setMode', {name: CREDENTIAL_EDIT_MODAL_KEY, mode: 'edit'});
context.commit('openModal', CREDENTIAL_EDIT_MODAL_KEY);
},
openNewCredential: async (context: ActionContext<IUiState, IRootState>, { type }: {type: string}) => {
context.commit('setActiveId', {name: CREDENTIAL_EDIT_MODAL_KEY, id: type});
context.commit('setMode', {name: CREDENTIAL_EDIT_MODAL_KEY, mode: 'new'});
context.commit('openModal', CREDENTIAL_EDIT_MODAL_KEY);
},
openCredentialsSelectModal: async (context: ActionContext<IUiState, IRootState>) => {
context.commit('openModal', CREDENTIAL_SELECT_MODAL_KEY);
},
},
};
export default module;
export default module;

View file

@ -21,17 +21,10 @@ body {
font-weight: 400;
}
// Dialog
.v-modal {
opacity: .85;
background-color: lighten($--custom-table-background-main, 55% );
}
.el-dialog {
background-color: $--custom-dialog-background;
-webkit-box-shadow: none;
box-shadow: none;
border: var(--border-base);
box-shadow: 0px 6px 16px rgb(68 28 23 / 6%);
border-radius: 8px;
@media (max-height: 1050px) {
margin: 4em auto !important;
@ -40,42 +33,42 @@ body {
@media (max-height: 930px) {
margin: 1em auto !important;
}
&.classic {
.el-dialog__header {
padding: 15px 20px;
}
.el-dialog__header {
padding: 15px 20px;
.el-dialog__title {
font-weight: 600;
}
}
.el-dialog__headerbtn {
position: absolute;
top: 0;
right: -50px;
color: #fff;
background-color: $--custom-table-background-main;
border-radius: 0 18px 18px 0;
z-index: 110;
font-size: 1.7em;
text-align: center;
line-height: 30px;
height: 50px;
width: 50px;
.el-dialog__close {
.el-dialog__headerbtn {
position: absolute;
top: 0;
right: -50px;
color: #fff;
font-weight: 400;
background-color: $--custom-table-background-main;
border-radius: 0 18px 18px 0;
z-index: 110;
font-size: 1.7em;
text-align: center;
line-height: 30px;
height: 50px;
width: 50px;
.el-dialog__close {
color: #fff;
font-weight: 400;
}
.el-dialog__close:hover {
transform: scale(1.2);
}
}
.el-dialog__close:hover {
transform: scale(1.2);
.el-dialog__body {
color: $--custom-dialog-text-color;
padding: 0 20px 20px 20px;
}
.el-dialog__title {
color: $--custom-dialog-text-color;
}
}
.el-dialog__body {
color: $--custom-dialog-text-color;
padding: 0 20px 20px 20px;
}
.el-dialog__title {
color: $--custom-dialog-text-color;
}
}
.el-message-box {
background-color: $--custom-dialog-background;
border: none;
@ -90,71 +83,11 @@ body {
}
}
// Menu
.el-menu--vertical,
.el-menu {
border: none;
font-size: 14px;
.el-menu--collapse {
width: 75px;
}
.el-menu--popup,
.el-menu--inline {
font-size: 0.9em;
li.el-menu-item {
height: 35px;
line-height: 35px;
color: $--custom-dialog-text-color;
}
}
.el-menu-item,
.el-submenu__title {
color: $--color-primary;
font-size: 1.2em;
.el-submenu__icon-arrow {
color: $--color-primary;
font-weight: 800;
font-size: 1em;
}
.svg-inline--fa {
position: relative;
right: -3px;
}
.item-title {
position: absolute;
left: 73px;
}
.item-title-root {
position: absolute;
left: 60px;
top: 1px;
}
&:hover, &:focus {
background-color: #fff0ef;
}
}
}
.el-menu--vertical {
.el-menu-item {
.item-title {
position: absolute;
left: 55px;
}
}
}
// Notification Message
.el-message p {
line-height: 1.5em;
}
// Table
.el-table {
thead th {

View file

@ -10,9 +10,7 @@ import Dialog from 'element-ui/lib/dialog';
import Dropdown from 'element-ui/lib/dropdown';
import DropdownMenu from 'element-ui/lib/dropdown-menu';
import DropdownItem from 'element-ui/lib/dropdown-item';
import Menu from 'element-ui/lib/menu';
import Submenu from 'element-ui/lib/submenu';
import MenuItem from 'element-ui/lib/menu-item';
import Radio from 'element-ui/lib/radio';
import RadioGroup from 'element-ui/lib/radio-group';
import RadioButton from 'element-ui/lib/radio-button';
@ -49,10 +47,14 @@ import locale from 'element-ui/lib/locale';
import {
N8nIconButton,
N8nButton,
N8nInfoTip,
N8nInput,
N8nInputLabel,
N8nInputNumber,
N8nMenu,
N8nMenuItem,
N8nSelect,
N8nSpinner,
N8nOption,
} from 'n8n-design-system';
import { ElMessageBoxOptions } from "element-ui/types/message-box";
@ -62,10 +64,14 @@ Vue.use(Fragment.Plugin);
// n8n design system
Vue.use(N8nButton);
Vue.use(N8nIconButton);
Vue.use(N8nInfoTip);
Vue.use(N8nInput);
Vue.use(N8nInputLabel);
Vue.use(N8nInputNumber);
Vue.use(N8nMenu);
Vue.use(N8nMenuItem);
Vue.use(N8nSelect);
Vue.use(N8nSpinner);
Vue.use(N8nOption);
// element io
@ -76,9 +82,7 @@ Vue.use(Drawer);
Vue.use(Dropdown);
Vue.use(DropdownMenu);
Vue.use(DropdownItem);
Vue.use(Menu);
Vue.use(Submenu);
Vue.use(MenuItem);
Vue.use(Radio);
Vue.use(RadioGroup);
Vue.use(RadioButton);
@ -131,6 +135,8 @@ Vue.prototype.$confirm = async (message: string, configOrTitle: string | ElMessa
roundButton: true,
cancelButtonClass: 'btn--cancel',
confirmButtonClass: 'btn--confirm',
showClose: false,
closeOnClickModal: false,
};
if (typeof configOrTitle === 'string') {

View file

@ -13,6 +13,7 @@ import {
faBug,
faCalendar,
faCheck,
faCheckCircle,
faChevronDown,
faChevronUp,
faCode,
@ -97,6 +98,7 @@ library.add(faBook);
library.add(faBug);
library.add(faCalendar);
library.add(faCheck);
library.add(faCheckCircle);
library.add(faChevronDown);
library.add(faChevronUp);
library.add(faCode);

View file

@ -1,3 +1,3 @@
import './icons';
import './directives';
import './compontents';
import './components';

View file

@ -20,12 +20,6 @@ export default new Router({
sidebar: MainSidebar,
},
},
{
path: '/oauth2/callback',
name: 'oAuth2Callback',
components: {
},
},
{
path: '/workflow',
name: 'NodeViewNew',

View file

@ -33,6 +33,7 @@ import {
IRestApiContext,
} from './Interface';
import credentials from './modules/credentials';
import tags from './modules/tags';
import ui from './modules/ui';
import workflows from './modules/workflows';
@ -47,8 +48,6 @@ const state: IRootState = {
activeNode: null,
// @ts-ignore
baseUrl: process.env.VUE_APP_URL_BASE_API ? process.env.VUE_APP_URL_BASE_API : (window.BASE_PATH === '/%BASE_PATH%/' ? '/' : window.BASE_PATH),
credentials: null,
credentialTypes: null,
endpointWebhook: 'webhook',
endpointWebhookTest: 'webhook-test',
executionId: null,
@ -91,10 +90,11 @@ const state: IRootState = {
};
const modules = {
credentials,
tags,
ui,
workflows,
versions,
ui,
};
export const store = new Vuex.Store({
@ -309,43 +309,6 @@ export const store = new Vuex.Store({
}
},
// Credentials
addCredentials (state, credentialData: ICredentialsResponse) {
if (state.credentials !== null) {
state.credentials.push(credentialData);
}
},
removeCredentials (state, credentialData: ICredentialsResponse) {
if (state.credentials === null) {
return;
}
for (let i = 0; i < state.credentials.length; i++) {
if (state.credentials[i].id === credentialData.id) {
state.credentials.splice(i, 1);
return;
}
}
},
updateCredentials (state, credentialData: ICredentialsResponse) {
if (state.credentials === null) {
return;
}
for (let i = 0; i < state.credentials.length; i++) {
if (state.credentials[i].id === credentialData.id) {
state.credentials[i] = credentialData;
return;
}
}
},
setCredentials (state, credentials: ICredentialsResponse[]) {
Vue.set(state, 'credentials', credentials);
},
setCredentialTypes (state, credentialTypes: ICredentialType[]) {
Vue.set(state, 'credentialTypes', credentialTypes);
},
renameNodeSelectedAndExecution (state, nameData) {
state.stateIsDirty = true;
// If node has any WorkflowResultData rename also that one that the data
@ -788,32 +751,6 @@ export const store = new Vuex.Store({
}
return false;
},
allCredentialTypes: (state): ICredentialType[] | null => {
return state.credentialTypes;
},
allCredentials: (state): ICredentialsResponse[] | null => {
return state.credentials;
},
credentialsByType: (state) => (credentialType: string): ICredentialsResponse[] | null => {
if (state.credentials === null) {
return null;
}
return state.credentials.filter((credentialData) => credentialData.type === credentialType);
},
credentialType: (state) => (credentialType: string): ICredentialType | null => {
if (state.credentialTypes === null) {
return null;
}
const foundType = state.credentialTypes.find(credentialData => {
return credentialData.name === credentialType;
});
if (foundType === undefined) {
return null;
}
return foundType;
},
allNodeTypes: (state): INodeTypeDescription[] => {
return state.nodeTypes;
},

View file

@ -2201,12 +2201,10 @@ export default mixins(
this.$store.commit('setNodeTypes', nodeTypes);
},
async loadCredentialTypes (): Promise<void> {
const credentialTypes = await this.restApi().getCredentialTypes();
this.$store.commit('setCredentialTypes', credentialTypes);
await this.$store.dispatch('credentials/fetchCredentialTypes');
},
async loadCredentials (): Promise<void> {
const credentials = await this.restApi().getAllCredentials();
this.$store.commit('setCredentials', credentials);
await this.$store.dispatch('credentials/fetchAllCredentials');
},
async loadNodesProperties(nodeNames: string[]): Promise<void> {
const allNodes = this.$store.getters.allNodeTypes;

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 333334 199332" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd"><defs><style>.fil1{fill:#f90}</style></defs><g id="Layer_x0020_1"><g id="amazon-web-services-2.svg"><path d="M93937 72393c0 4102 443 7428 1219 9867 887 2439 1996 5100 3548 7982 554 887 776 1774 776 2550 0 1109-665 2217-2106 3326l-6985 4656c-998 665-1995 998-2882 998-1109 0-2217-554-3326-1552-1552-1663-2882-3437-3991-5211-1109-1885-2217-3991-3437-6541-8648 10200-19512 15299-32594 15299-9312 0-16740-2661-22172-7982-5432-5322-8204-12417-8204-21286 0-9424 3326-17073 10089-22838s15743-8647 27161-8647c3769 0 7650 332 11752 887 4102 554 8315 1441 12749 2439v-8093c0-8426-1774-14301-5211-17738-3548-3437-9534-5100-18071-5100-3880 0-7871 443-11973 1441s-8093 2217-11973 3769c-1774 776-3104 1219-3880 1441s-1330 332-1774 332c-1552 0-2328-1109-2328-3437v-5432c0-1774 222-3104 776-3880s1552-1552 3104-2328c3880-1996 8537-3659 13969-4989C43606 885 49370 220 55468 220c13193 0 22838 2993 29046 8980 6098 5987 9202 15077 9202 27272v35920h222zM48926 89244c3659 0 7428-665 11419-1995s7539-3769 10532-7095c1774-2106 3104-4435 3770-7095 665-2661 1108-5876 1108-9645v-4656c-3215-776-6652-1441-10199-1885-3548-443-6984-665-10421-665-7428 0-12860 1441-16519 4435-3659 2993-5432 7206-5432 12749 0 5211 1330 9091 4102 11751 2661 2772 6541 4102 11641 4102zm89023 11973c-1996 0-3326-332-4213-1109-887-665-1663-2217-2328-4324l-26053-85697c-665-2217-998-3658-998-4434 0-1774 887-2772 2661-2772h10865c2106 0 3548 333 4324 1109 887 665 1552 2217 2217 4324l18625 73391 17295-73391c554-2217 1219-3659 2106-4324s2439-1109 4435-1109h8869c2106 0 3548 333 4435 1109 887 665 1663 2217 2106 4324l17516 74278 19180-74278c665-2217 1441-3659 2217-4324 887-665 2328-1109 4324-1109h10310c1774 0 2772 887 2772 2772 0 554-111 1109-222 1774s-333 1552-776 2772l-26718 85697c-665 2217-1441 3658-2328 4324-887 665-2328 1109-4213 1109h-9534c-2107 0-3548-333-4435-1109s-1663-2217-2106-4435l-17184-71507-17073 71396c-554 2217-1220 3658-2107 4434s-2439 1109-4434 1109h-9534zm142459 2993c-5765 0-11530-665-17073-1995s-9867-2772-12749-4435c-1774-998-2993-2106-3437-3104-443-998-665-2106-665-3104v-5654c0-2328 887-3437 2550-3437 665 0 1330 111 1995 333s1663 665 2772 1109c3769 1663 7871 2993 12195 3880 4435 887 8758 1330 13193 1330 6984 0 12417-1220 16186-3659s5765-5987 5765-10532c0-3104-998-5654-2993-7760-1996-2107-5765-3991-11197-5765l-16075-4989c-8093-2550-14080-6319-17738-11308-3658-4878-5543-10310-5543-16075 0-4656 998-8758 2993-12306s4656-6652 7982-9091c3326-2550 7095-4434 11530-5765S279190-2 284068-2c2439 0 4989 111 7428 443 2550 333 4878 776 7206 1219 2217 554 4324 1109 6319 1774s3548 1330 4656 1996c1552 887 2661 1774 3326 2771 665 887 998 2107 998 3659v5211c0 2328-887 3548-2550 3548-887 0-2328-444-4213-1331-6319-2882-13415-4324-21286-4324-6319 0-11308 998-14745 3104s-5211 5321-5211 9867c0 3104 1109 5765 3326 7871s6319 4213 12195 6097l15743 4989c7982 2550 13747 6098 17184 10643s5100 9756 5100 15521c0 4767-998 9091-2882 12860-1996 3770-4656 7095-8093 9756-3437 2771-7539 4767-12306 6208-4989 1552-10199 2328-15854 2328z" fill="#252f3e"/><path class="fil1" d="M301362 158091c-36474 26940-89467 41241-135031 41241-63858 0-121395-23614-164854-62859-3437-3104-332-7317 3770-4878 47006 27272 104988 43791 164964 43791 40465 0 84921-8426 125830-25721 6097-2772 11308 3991 5321 8426z"/><path class="fil1" d="M316550 140796c-4656-5987-30820-2883-42682-1441-3548 443-4102-2661-887-4989 20842-14634 55099-10421 59090-5543 3991 4989-1109 39246-20620 55653-2993 2550-5876 1220-4545-2106 4435-10976 14301-35698 9645-41574z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -8,6 +8,7 @@ export class Aws implements ICredentialType {
name = 'aws';
displayName = 'AWS';
documentationUrl = 'aws';
icon = 'file:AWS.svg';
properties: INodeProperties[] = [
{
displayName: 'Region',

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48"><defs><path id="a" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></defs><clipPath id="b"><use xlink:href="#a" overflow="visible"/></clipPath><path clip-path="url(#b)" fill="#FBBC05" d="M0 37V11l17 13z"/><path clip-path="url(#b)" fill="#EA4335" d="M0 11l17 13 7-6.1L48 14V0H0z"/><path clip-path="url(#b)" fill="#34A853" d="M0 37l30-23 7.9 1L48 0v48H0z"/><path clip-path="url(#b)" fill="#4285F4" d="M48 48L17 24l-4-3 35-10z"/></svg>

After

Width:  |  Height:  |  Size: 688 B

View file

@ -8,6 +8,7 @@ export class GoogleApi implements ICredentialType {
name = 'googleApi';
displayName = 'Google API';
documentationUrl = 'google';
icon = 'file:Google.svg';
properties: INodeProperties[] = [
{
displayName: 'Service Account Email',
@ -15,6 +16,7 @@ export class GoogleApi implements ICredentialType {
type: 'string',
default: '',
description: 'The Google Service account similar to user-808@project.iam.gserviceaccount.com.',
required: true,
},
{
@ -23,6 +25,7 @@ export class GoogleApi implements ICredentialType {
type: 'string',
default: '',
description: 'Use the multiline editor. Make sure there are exactly 3 lines.<br />-----BEGIN PRIVATE KEY-----<br />KEY IN A SINGLE LINE<br />-----END PRIVATE KEY-----',
required: true,
},
{
displayName: ' Impersonate a User',

View file

@ -10,6 +10,7 @@ export class GoogleOAuth2Api implements ICredentialType {
];
displayName = 'Google OAuth2 API';
documentationUrl = 'google';
icon = 'file:Google.svg';
properties: INodeProperties[] = [
{
displayName: 'Authorization URL',

View file

@ -8,6 +8,7 @@ export class HttpBasicAuth implements ICredentialType {
name = 'httpBasicAuth';
displayName = 'Basic Auth';
documentationUrl = 'httpRequest';
icon = 'node:n8n-nodes-base.httpRequest';
properties: INodeProperties[] = [
{
displayName: 'User',

View file

@ -8,6 +8,7 @@ export class HttpDigestAuth implements ICredentialType {
name = 'httpDigestAuth';
displayName = 'Digest Auth';
documentationUrl = 'httpRequest';
icon = 'node:n8n-nodes-base.httpRequest';
properties: INodeProperties[] = [
{
displayName: 'User',

View file

@ -8,6 +8,7 @@ export class HttpHeaderAuth implements ICredentialType {
name = 'httpHeaderAuth';
displayName = 'Header Auth';
documentationUrl = 'httpRequest';
icon = 'node:n8n-nodes-base.httpRequest';
properties: INodeProperties[] = [
{
displayName: 'Name',

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23"><path fill="#f3f3f3" d="M0 0h23v23H0z"/><path fill="#f35325" d="M1 1h10v10H1z"/><path fill="#81bc06" d="M12 1h10v10H12z"/><path fill="#05a6f0" d="M1 12h10v10H1z"/><path fill="#ffba08" d="M12 12h10v10H12z"/></svg>

After

Width:  |  Height:  |  Size: 272 B

View file

@ -8,6 +8,7 @@ export class MicrosoftOAuth2Api implements ICredentialType {
extends = [
'oAuth2Api',
];
icon = 'file:Microsoft.svg';
displayName = 'Microsoft OAuth2 API';
documentationUrl = 'microsoft';
properties: INodeProperties[] = [

View file

@ -14,6 +14,7 @@ export class SlackApi implements ICredentialType {
name: 'accessToken',
type: 'string',
default: '',
required: true,
},
];
}

View file

@ -5,7 +5,7 @@ import {
export class SshPassword implements ICredentialType {
name = 'sshPassword';
displayName = 'SSH';
displayName = 'SSH Password';
properties: INodeProperties[] = [
{
displayName: 'Host',

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