diff --git a/packages/nodes-base/credentials/AsanaApi.credentials.ts b/packages/nodes-base/credentials/AsanaApi.credentials.ts index 56b42c8b77..c9096ed8a4 100644 --- a/packages/nodes-base/credentials/AsanaApi.credentials.ts +++ b/packages/nodes-base/credentials/AsanaApi.credentials.ts @@ -3,7 +3,6 @@ import { NodePropertyTypes, } from 'n8n-workflow'; - export class AsanaApi implements ICredentialType { name = 'asanaApi'; displayName = 'Asana API'; diff --git a/packages/nodes-base/credentials/AsanaOAuth2Api.credentials.ts b/packages/nodes-base/credentials/AsanaOAuth2Api.credentials.ts new file mode 100644 index 0000000000..7a4be04706 --- /dev/null +++ b/packages/nodes-base/credentials/AsanaOAuth2Api.credentials.ts @@ -0,0 +1,47 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class AsanaOAuth2Api implements ICredentialType { + name = 'asanaOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Asana OAuth2 API'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://app.asana.com/-/oauth_authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://app.asana.com/-/oauth_token', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'body', + description: 'Resource to consume.', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Asana/Asana.node.ts b/packages/nodes-base/nodes/Asana/Asana.node.ts index 8b26999c6f..66fa27d1d1 100644 --- a/packages/nodes-base/nodes/Asana/Asana.node.ts +++ b/packages/nodes-base/nodes/Asana/Asana.node.ts @@ -14,6 +14,7 @@ import { import { asanaApiRequest, asanaApiRequestAllItems, + getWorkspaces, } from './GenericFunctions'; export class Asana implements INodeType { @@ -35,9 +36,44 @@ export class Asana implements INodeType { { name: 'asanaApi', required: true, + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + }, + }, + }, + { + name: 'asanaOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: 'The resource to operate on.', + }, { displayName: 'Resource', name: 'resource', @@ -1004,32 +1040,7 @@ export class Asana implements INodeType { loadOptions: { // Get all the available workspaces to display them to user so that he can // select them easily - async getWorkspaces(this: ILoadOptionsFunctions): Promise { - const endpoint = '/workspaces'; - const responseData = await asanaApiRequestAllItems.call(this, 'GET', endpoint, {}); - - const returnData: INodePropertyOptions[] = []; - for (const workspaceData of responseData) { - if (workspaceData.resource_type !== 'workspace') { - // Not sure if for some reason also ever other resources - // get returned but just in case filter them out - continue; - } - - returnData.push({ - name: workspaceData.name, - value: workspaceData.gid, - }); - } - - returnData.sort((a, b) => { - if (a.name < b.name) { return -1; } - if (a.name > b.name) { return 1; } - return 0; - }); - - return returnData; - }, + getWorkspaces, // Get all the available projects to display them to user so that they can be // selected easily @@ -1215,12 +1226,6 @@ export class Asana implements INodeType { const items = this.getInputData(); const returnData: IDataObject[] = []; - const credentials = this.getCredentials('asanaApi'); - - if (credentials === undefined) { - throw new Error('No credentials got returned!'); - } - const resource = this.getNodeParameter('resource', 0) as string; const operation = this.getNodeParameter('operation', 0) as string; diff --git a/packages/nodes-base/nodes/Asana/AsanaTrigger.node.ts b/packages/nodes-base/nodes/Asana/AsanaTrigger.node.ts index b4e481d168..2aad0cc9ec 100644 --- a/packages/nodes-base/nodes/Asana/AsanaTrigger.node.ts +++ b/packages/nodes-base/nodes/Asana/AsanaTrigger.node.ts @@ -5,6 +5,8 @@ import { import { IDataObject, + ILoadOptionsFunctions, + INodePropertyOptions, INodeTypeDescription, INodeType, IWebhookResponseData, @@ -12,9 +14,12 @@ import { import { asanaApiRequest, + getWorkspaces, } from './GenericFunctions'; -import { createHmac } from 'crypto'; +import { + createHmac, +} from 'crypto'; export class AsanaTrigger implements INodeType { description: INodeTypeDescription = { @@ -26,7 +31,7 @@ export class AsanaTrigger implements INodeType { description: 'Starts the workflow when Asana events occure.', defaults: { name: 'Asana-Trigger', - color: '#559922', + color: '#FC636B', }, inputs: [], outputs: ['main'], @@ -34,7 +39,25 @@ export class AsanaTrigger implements INodeType { { name: 'asanaApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + }, + }, + }, + { + name: 'asanaOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], webhooks: [ { @@ -45,6 +68,23 @@ export class AsanaTrigger implements INodeType { }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: 'The resource to operate on.', + }, { displayName: 'Resource', name: 'resource', @@ -56,13 +96,31 @@ export class AsanaTrigger implements INodeType { { displayName: 'Workspace', name: 'workspace', - type: 'string', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getWorkspaces', + }, + options: [], default: '', required: false, description: 'The workspace ID the resource is registered under. This is only required if you want to allow overriding existing webhooks.', }, ], + }; + methods = { + loadOptions: { + // Get all the available workspaces to display them to user so that he can + // select them easily + async getWorkspaces(this: ILoadOptionsFunctions): Promise { + const workspaces = await getWorkspaces.call(this); + workspaces.unshift({ + name: '', + value: '', + }); + return workspaces; + }, + }, }; // @ts-ignore (because of request) @@ -71,32 +129,29 @@ export class AsanaTrigger implements INodeType { async checkExists(this: IHookFunctions): Promise { const webhookData = this.getWorkflowStaticData('node'); - if (webhookData.webhookId === undefined) { - // No webhook id is set so no webhook can exist - return false; - } + const webhookUrl = this.getNodeWebhookUrl('default') as string; - // Webhook got created before so check if it still exists - const endpoint = `webhooks/${webhookData.webhookId}`; + const resource = this.getNodeParameter('resource') as string; - try { - await asanaApiRequest.call(this, 'GET', endpoint, {}); - } catch (e) { - if (e.statusCode === 404) { - // Webhook does not exist - delete webhookData.webhookId; + const workspace = this.getNodeParameter('workspace') as string; - return false; + const endpoint = '/webhooks'; + + const { data } = await asanaApiRequest.call(this, 'GET', endpoint, {}, { workspace }); + + for (const webhook of data) { + if (webhook.resource.gid === resource && webhook.target === webhookUrl) { + webhookData.webhookId = webhook.gid; + return true; } - - // Some error occured - throw e; } // If it did not error then the webhook exists - return true; + return false; }, async create(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + const webhookUrl = this.getNodeWebhookUrl('default') as string; if (webhookUrl.includes('%20')) { @@ -105,9 +160,7 @@ export class AsanaTrigger implements INodeType { const resource = this.getNodeParameter('resource') as string; - const workspace = this.getNodeParameter('workspace') as string; - - const endpoint = `webhooks`; + const endpoint = `/webhooks`; const body = { resource, @@ -115,29 +168,15 @@ export class AsanaTrigger implements INodeType { }; let responseData; - try { - responseData = await asanaApiRequest.call(this, 'POST', endpoint, body); - } catch(error) { - // delete webhook if it already exists - if (error.statusCode === 403) { - const webhookData = await asanaApiRequest.call(this, 'GET', endpoint, {}, { workspace }); - const webhook = webhookData.data.find((webhook: any) => { // tslint:disable-line:no-any - return webhook.target === webhookUrl && webhook.resource.gid === resource; - }); - await asanaApiRequest.call(this, 'DELETE', `${endpoint}/${webhook.gid}`, {}); - responseData = await asanaApiRequest.call(this, 'POST', endpoint, body); - } else { - throw error; - } - } - if (responseData.data === undefined || responseData.data.id === undefined) { + responseData = await asanaApiRequest.call(this, 'POST', endpoint, body); + + if (responseData.data === undefined || responseData.data.gid === undefined) { // Required data is missing so was not successful return false; } - const webhookData = this.getWorkflowStaticData('node'); - webhookData.webhookId = responseData.data.id as string; + webhookData.webhookId = responseData.data.gid as string; return true; }, @@ -145,7 +184,7 @@ export class AsanaTrigger implements INodeType { const webhookData = this.getWorkflowStaticData('node'); if (webhookData.webhookId !== undefined) { - const endpoint = `webhooks/${webhookData.webhookId}`; + const endpoint = `/webhooks/${webhookData.webhookId}`; const body = {}; try { @@ -165,15 +204,12 @@ export class AsanaTrigger implements INodeType { }, }; - - async webhook(this: IWebhookFunctions): Promise { const bodyData = this.getBodyData() as IDataObject; const headerData = this.getHeaderData() as IDataObject; const req = this.getRequestObject(); - const webhookData = this.getWorkflowStaticData('node') as IDataObject; - + const webhookData = this.getWorkflowStaticData('node'); if (headerData['x-hook-secret'] !== undefined) { // Is a create webhook confirmation request @@ -182,6 +218,7 @@ export class AsanaTrigger implements INodeType { const res = this.getResponseObject(); res.set('X-Hook-Secret', webhookData.hookSecret as string); res.status(200).end(); + return { noWebhookResponse: true, }; @@ -198,7 +235,7 @@ export class AsanaTrigger implements INodeType { // Check if the request is valid // (if the signature matches to data and hookSecret) - const computedSignature = createHmac("sha256", webhookData.hookSecret as string).update(JSON.stringify(req.body)).digest("hex"); + const computedSignature = createHmac('sha256', webhookData.hookSecret as string).update(JSON.stringify(req.body)).digest('hex'); if (headerData['x-hook-signature'] !== computedSignature) { // Signature is not valid so ignore call return {}; @@ -206,7 +243,7 @@ export class AsanaTrigger implements INodeType { return { workflowData: [ - this.helpers.returnJsonArray(req.body) + this.helpers.returnJsonArray(req.body.events) ], }; } diff --git a/packages/nodes-base/nodes/Asana/GenericFunctions.ts b/packages/nodes-base/nodes/Asana/GenericFunctions.ts index c8a0beb1b4..44a04123e6 100644 --- a/packages/nodes-base/nodes/Asana/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Asana/GenericFunctions.ts @@ -10,6 +10,7 @@ import { import { IDataObject, + INodePropertyOptions, } from 'n8n-workflow'; import { @@ -26,16 +27,10 @@ import { * @returns {Promise} */ export async function asanaApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: object, query?: object, uri?: string | undefined): Promise { // tslint:disable-line:no-any - const credentials = this.getCredentials('asanaApi'); - - if (credentials === undefined) { - throw new Error('No credentials got returned!'); - } + const authenticationMethod = this.getNodeParameter('authentication', 0); const options: OptionsWithUri = { - headers: { - Authorization: `Bearer ${credentials.accessToken}`, - }, + headers: {}, method, body: { data: body }, qs: query, @@ -44,13 +39,30 @@ export async function asanaApiRequest(this: IHookFunctions | IExecuteFunctions | }; try { - return await this.helpers.request!(options); + if (authenticationMethod === 'accessToken') { + const credentials = this.getCredentials('asanaApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + options.headers!['Authorization'] = `Bearer ${credentials.accessToken}`; + + return await this.helpers.request!(options); + } else { + //@ts-ignore + return await this.helpers.requestOAuth2.call(this, 'asanaOAuth2Api', options); + } } catch (error) { if (error.statusCode === 401) { // Return a clear error throw new Error('The Asana credentials are not valid!'); } + if (error.statusCode === 403) { + throw error; + } + if (error.response && error.response.body && error.response.body.errors) { // Try to return the error prettier const errorMessages = error.response.body.errors.map((errorData: { message: string }) => { @@ -82,3 +94,30 @@ export async function asanaApiRequestAllItems(this: IExecuteFunctions | ILoadOpt return returnData; } + +export async function getWorkspaces(this: ILoadOptionsFunctions): Promise < INodePropertyOptions[] > { + const endpoint = '/workspaces'; + const responseData = await asanaApiRequestAllItems.call(this, 'GET', endpoint, {}); + + const returnData: INodePropertyOptions[] = []; + for(const workspaceData of responseData) { + if (workspaceData.resource_type !== 'workspace') { + // Not sure if for some reason also ever other resources + // get returned but just in case filter them out + continue; + } + + returnData.push({ + name: workspaceData.name, + value: workspaceData.gid, + }); + } + + returnData.sort((a, b) => { + if (a.name < b.name) { return -1; } + if (a.name > b.name) { return 1; } + return 0; + }); + + return returnData; +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 762c7e0424..500c3f9942 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -33,6 +33,7 @@ "dist/credentials/AirtableApi.credentials.js", "dist/credentials/Amqp.credentials.js", "dist/credentials/AsanaApi.credentials.js", + "dist/credentials/AsanaOAuth2Api.credentials.js", "dist/credentials/Aws.credentials.js", "dist/credentials/AffinityApi.credentials.js", "dist/credentials/BannerbearApi.credentials.js",