Change credentials structure (#2139)

*  change FE to handle new object type

* 🚸 improve UX of handling invalid credentials

* 🚧 WIP

* 🎨 fix typescript issues

* 🐘 add migrations for all supported dbs

* ✏️ add description to migrations

*  add credential update on import

*  resolve after merge issues

* 👕 fix lint issues

*  check credentials on workflow create/update

* update interface

* 👕 fix ts issues

*  adaption to new credentials UI

* 🐛 intialize cache on BE for credentials check

* 🐛 fix undefined oldCredentials

* 🐛 fix deleting credential

* 🐛 fix check for undefined keys

* 🐛 fix disabling edit in execution

* 🎨 just show credential name on execution view

* ✏️  remove TODO

*  implement review suggestions

*  add cache to getCredentialsByType

*  use getter instead of cache

* ✏️ fix variable name typo

* 🐘 include waiting nodes to migrations

* 🐛 fix reverting migrations command

*  update typeorm command

*  create db:revert command

* 👕 fix lint error

Co-authored-by: Mutasem <mutdmour@gmail.com>
This commit is contained in:
Ben Hesseldieck
2021-10-14 00:21:00 +02:00
committed by GitHub
parent 1e34aca8bd
commit 3137de2585
36 changed files with 1318 additions and 251 deletions

View File

@@ -249,7 +249,7 @@ export interface IActivationError {
}
export interface ICredentialsResponse extends ICredentialsEncrypted {
id?: string;
id: string;
createdAt: number | string;
updatedAt: number | string;
}

View File

@@ -557,6 +557,7 @@ export default mixins(showMessage, nodeHelpers).extend({
);
const details: ICredentialsDecrypted = {
id: this.credentialId,
name: this.credentialName,
type: this.credentialTypeName!,
data: data as unknown as ICredentialDataDecryptedObject,
@@ -605,6 +606,7 @@ export default mixins(showMessage, nodeHelpers).extend({
);
const credentialDetails: ICredentialsDecrypted = {
id: this.credentialId,
name: this.credentialName,
type: this.credentialTypeName!,
data: data as unknown as ICredentialDataDecryptedObject,

View File

@@ -9,15 +9,15 @@
<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':''">
<el-col v-if="!isReadOnly" :span="12" class="parameter-value" :class="getIssues(credentialTypeDescription.name).length?'has-issues':''">
<div :style="credentialInputWrapperStyle(credentialTypeDescription.name)">
<n8n-select :value="selected[credentialTypeDescription.name]" :disabled="isReadOnly" @change="(value) => credentialSelected(credentialTypeDescription.name, value)" placeholder="Select Credential" size="small">
<n8n-select :value="getSelectedId(credentialTypeDescription.name)" @change="(value) => onCredentialSelected(credentialTypeDescription.name, value)" placeholder="Select Credential" size="small">
<n8n-option
v-for="(item) in credentialOptions[credentialTypeDescription.name]"
:key="item.id"
:label="item.name"
:value="item.name">
:value="item.id">
</n8n-option>
<n8n-option
:key="NEW_CREDENTIALS_TEXT"
@@ -34,10 +34,13 @@
<font-awesome-icon icon="exclamation-triangle" />
</n8n-tooltip>
</div>
</el-col>
<el-col :span="2" class="parameter-value credential-action">
<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 v-if="!isReadOnly" :span="2" class="parameter-value credential-action">
<font-awesome-icon v-if="isCredentialExisting(credentialTypeDescription.name)" icon="pen" @click="editCredential(credentialTypeDescription.name)" class="update-credentials clickable" title="Update Credentials" />
</el-col>
<el-col v-if="isReadOnly" :span="14" class="readonly-container" >
<n8n-input disabled :value="selected && selected[credentialTypeDescription.name] && selected[credentialTypeDescription.name].name" size="small" />
</el-col>
</el-row>
@@ -49,12 +52,14 @@
<script lang="ts">
import { restApi } from '@/components/mixins/restApi';
import {
ICredentialsResponse,
INodeUi,
INodeUpdatePropertiesInformation,
} from '@/Interface';
import {
ICredentialType,
INodeCredentialDescription,
INodeCredentialsDetails,
INodeTypeDescription,
} from 'n8n-workflow';
@@ -119,11 +124,17 @@ export default mixins(
}
return returnData;
},
selected(): {[type: string]: string} {
selected(): {[type: string]: INodeCredentialsDetails} {
return this.node.credentials || {};
},
},
methods: {
getSelectedId(type: string) {
if (this.isCredentialExisting(type)) {
return this.selected[type].id;
}
return undefined;
},
credentialInputWrapperStyle (credentialType: string) {
let deductWidth = 0;
const styles = {
@@ -145,10 +156,10 @@ export default mixins(
this.newCredentialUnsubscribe = this.$store.subscribe((mutation, state) => {
if (mutation.type === 'credentials/upsertCredential' || mutation.type === 'credentials/enableOAuthCredential'){
this.credentialSelected(credentialType, mutation.payload.name);
this.onCredentialSelected(credentialType, mutation.payload.id);
}
if (mutation.type === 'credentials/deleteCredential') {
this.credentialSelected(credentialType, mutation.payload.name);
this.clearSelectedCredential(credentialType);
this.stopListeningForNewCredentials();
}
});
@@ -160,14 +171,52 @@ export default mixins(
}
},
credentialSelected (credentialType: string, credentialName: string) {
clearSelectedCredential(credentialType: string) {
const node: INodeUi = this.node;
const credentials = {
...(node.credentials || {}),
};
delete credentials[credentialType];
const updateInformation: INodeUpdatePropertiesInformation = {
name: this.node.name,
properties: {
credentials,
},
};
this.$emit('credentialSelected', updateInformation);
},
onCredentialSelected (credentialType: string, credentialId: string | null | undefined) {
let selected = undefined;
if (credentialName === NEW_CREDENTIALS_TEXT) {
if (credentialId === NEW_CREDENTIALS_TEXT) {
this.listenForNewCredentials(credentialType);
this.$store.dispatch('ui/openNewCredential', { type: credentialType });
return;
}
else {
selected = credentialName;
const selectedCredentials = this.$store.getters['credentials/getCredentialById'](credentialId);
const oldCredentials = this.node.credentials && this.node.credentials[credentialType] ? this.node.credentials[credentialType] : {};
selected = { id: selectedCredentials.id, name: selectedCredentials.name };
// if credentials has been string or neither id matched nor name matched uniquely
if (oldCredentials.id === null || (oldCredentials.id && !this.$store.getters['credentials/getCredentialByIdAndType'](oldCredentials.id, credentialType))) {
// update all nodes in the workflow with the same old/invalid credentials
this.$store.commit('replaceInvalidWorkflowCredentials', {
credentials: selected,
invalid: oldCredentials,
type: credentialType,
});
this.updateNodesCredentialsIssues();
this.$showMessage({
title: 'Node credentials updated',
message: `Nodes that used credentials "${oldCredentials.name}" have been updated to use "${selected.name}"`,
type: 'success',
});
}
const node: INodeUi = this.node;
@@ -209,18 +258,19 @@ export default mixins(
return node.issues.credentials[credentialTypeName];
},
isCredentialValid(credentialType: string): boolean {
const name = this.node.credentials[credentialType];
isCredentialExisting(credentialType: string): boolean {
if (!this.node.credentials || !this.node.credentials[credentialType] || !this.node.credentials[credentialType].id) {
return false;
}
const { id } = this.node.credentials[credentialType];
const options = this.credentialOptions[credentialType];
return options.find((option: ICredentialType) => option.name === name);
return !!options.find((option: ICredentialsResponse) => option.id === id);
},
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 { id } = this.node.credentials[credentialType];
this.$store.dispatch('ui/openExisitngCredential', { id });
this.listenForNewCredentials(credentialType);
},
@@ -283,6 +333,10 @@ export default mixins(
align-items: center;
color: var(--color-text-base);
}
.readonly-container {
padding-right: 0.5em;
}
}
</style>

View File

@@ -7,11 +7,12 @@ import {
ICredentialType,
INodeCredentialDescription,
NodeHelpers,
INodeParameters,
INodeCredentialsDetails,
INodeExecutionData,
INodeIssues,
INodeIssueData,
INodeIssueObjectProperty,
INodeParameters,
INodeProperties,
INodeTypeDescription,
IRunData,
@@ -196,7 +197,7 @@ export const nodeHelpers = mixins(
let userCredentials: ICredentialsResponse[] | null;
let credentialType: ICredentialType | null;
let credentialDisplayName: string;
let selectedCredentials: string;
let selectedCredentials: INodeCredentialsDetails;
for (const credentialTypeDescription of nodeType!.credentials!) {
// Check if credentials should be displayed else ignore
if (this.displayParameter(node.parameters, credentialTypeDescription, '') !== true) {
@@ -218,15 +219,35 @@ export const nodeHelpers = mixins(
}
} else {
// If they are set check if the value is valid
selectedCredentials = node.credentials[credentialTypeDescription.name];
selectedCredentials = node.credentials[credentialTypeDescription.name] as INodeCredentialsDetails;
if (typeof selectedCredentials === 'string') {
selectedCredentials = {
id: null,
name: selectedCredentials,
};
}
userCredentials = this.$store.getters['credentials/getCredentialsByType'](credentialTypeDescription.name);
if (userCredentials === null) {
userCredentials = [];
}
if (userCredentials.find((credentialData) => credentialData.name === selectedCredentials) === undefined) {
foundIssues[credentialTypeDescription.name] = [`Credentials with name "${selectedCredentials}" do not exist for "${credentialDisplayName}".`];
if (selectedCredentials.id) {
const idMatch = userCredentials.find((credentialData) => credentialData.id === selectedCredentials.id);
if (idMatch) {
continue;
}
}
const nameMatches = userCredentials.filter((credentialData) => credentialData.name === selectedCredentials.name);
if (nameMatches.length > 1) {
foundIssues[credentialTypeDescription.name] = [`Credentials with name "${selectedCredentials.name}" exist for "${credentialDisplayName}"`, "Credentials are not clearly identified. Please select the correct credentials."];
continue;
}
if (nameMatches.length === 0) {
foundIssues[credentialTypeDescription.name] = [`Credentials with name "${selectedCredentials.name}" do not exist for "${credentialDisplayName}".`, "You can create credentials with the exact name and then they get auto-selected on refresh."];
}
}
}

View File

@@ -90,9 +90,15 @@ const module: Module<ICredentialsState, IRootState> = {
getCredentialById: (state: ICredentialsState) => {
return (id: string) => state.credentials[id];
},
getCredentialByIdAndType: (state: ICredentialsState) => {
return (id: string, type: string) => {
const credential = state.credentials[id];
return !credential || credential.type !== type ? undefined : credential;
};
},
getCredentialsByType: (state: ICredentialsState, getters: any) => { // tslint:disable-line:no-any
return (credentialType: string): ICredentialsResponse[] => {
return getters.allCredentials.filter((credentialData: ICredentialsResponse) => credentialData.type === credentialType);
return getters.allCredentialsByType[credentialType];
};
},
getNodesWithAccess (state: ICredentialsState, getters: any, rootState: IRootState, rootGetters: any) { // tslint:disable-line:no-any

View File

@@ -375,6 +375,32 @@ export const store = new Vuex.Store({
state.workflow.name = data.newName;
},
// replace invalid credentials in workflow
replaceInvalidWorkflowCredentials(state, {credentials, invalid, type }) {
state.workflow.nodes.forEach((node) => {
if (!node.credentials || !node.credentials[type]) {
return;
}
const nodeCredentials = node.credentials[type];
if (typeof nodeCredentials === 'string' && nodeCredentials === invalid.name) {
node.credentials[type] = credentials;
return;
}
if (nodeCredentials.id === null) {
if (nodeCredentials.name === invalid.name){
node.credentials[type] = credentials;
}
return;
}
if (nodeCredentials.id === invalid.id) {
node.credentials[type] = credentials;
}
});
},
// Nodes
addNode (state, nodeData: INodeUi) {
if (!nodeData.hasOwnProperty('name')) {

View File

@@ -147,9 +147,11 @@ import {
NodeHelpers,
Workflow,
IRun,
INodeCredentialsDetails,
} from 'n8n-workflow';
import {
IConnectionsUi,
ICredentialsResponse,
IExecutionResponse,
IN8nUISettings,
IWorkflowDb,
@@ -327,6 +329,7 @@ export default mixins(
ctrlKeyPressed: false,
stopExecutionInProgress: false,
blankRedirect: false,
credentialsUpdated: false,
};
},
beforeDestroy () {
@@ -495,8 +498,10 @@ export default mixins(
this.$store.commit('setWorkflowTagIds', tagIds || []);
await this.addNodes(data.nodes, data.connections);
if (!this.credentialsUpdated) {
this.$store.commit('setStateDirty', false);
}
this.$store.commit('setStateDirty', false);
this.zoomToFit();
this.$externalHooks().run('workflow.open', { workflowId, workflowName: data.name });
@@ -1871,6 +1876,47 @@ export default mixins(
this.deselectAllNodes();
this.nodeSelectedByName(newName);
},
matchCredentials(node: INodeUi) {
if (!node.credentials) {
return;
}
Object.entries(node.credentials).forEach(([nodeCredentialType, nodeCredentials]: [string, INodeCredentialsDetails]) => {
const credentialOptions = this.$store.getters['credentials/getCredentialsByType'](nodeCredentialType) as ICredentialsResponse[];
// Check if workflows applies old credentials style
if (typeof nodeCredentials === 'string') {
nodeCredentials = {
id: null,
name: nodeCredentials,
};
this.credentialsUpdated = true;
}
if (nodeCredentials.id) {
// Check whether the id is matching with a credential
const credentialsForId = credentialOptions.find((optionData: ICredentialsResponse) => optionData.id === nodeCredentials.id);
if (credentialsForId) {
if (credentialsForId.name !== nodeCredentials.name) {
node.credentials![nodeCredentialType].name = credentialsForId.name;
this.credentialsUpdated = true;
}
return;
}
}
// No match for id found or old credentials type used
node.credentials![nodeCredentialType] = nodeCredentials;
// check if only one option with the name would exist
const credentialsForName = credentialOptions.filter((optionData: ICredentialsResponse) => optionData.name === nodeCredentials.name);
// only one option exists for the name, take it
if (credentialsForName.length === 1) {
node.credentials![nodeCredentialType].id = credentialsForName[0].id;
this.credentialsUpdated = true;
}
});
},
async addNodes (nodes: INodeUi[], connections?: IConnections) {
if (!nodes || !nodes.length) {
return;
@@ -1920,6 +1966,9 @@ export default mixins(
}
}
// check and match credentials, apply new format if old is used
this.matchCredentials(node);
foundNodeIssues = this.getNodeIssues(nodeType, node);
if (foundNodeIssues !== null) {