diff --git a/packages/nodes-base/credentials/CalendlyApi.credentials.ts b/packages/nodes-base/credentials/CalendlyApi.credentials.ts index d6418e4579..e0cc4df481 100644 --- a/packages/nodes-base/credentials/CalendlyApi.credentials.ts +++ b/packages/nodes-base/credentials/CalendlyApi.credentials.ts @@ -1,5 +1,8 @@ import { + ICredentialDataDecryptedObject, + ICredentialTestRequest, ICredentialType, + IHttpRequestOptions, INodeProperties, } from 'n8n-workflow'; @@ -8,11 +11,39 @@ export class CalendlyApi implements ICredentialType { displayName = 'Calendly API'; documentationUrl = 'calendly'; properties: INodeProperties[] = [ + // Change name to Personal Access Token once API Keys + // are deprecated { - displayName: 'API Key', + displayName: 'API Key or Personal Access Token', name: 'apiKey', type: 'string', default: '', }, ]; + async authenticate(credentials: ICredentialDataDecryptedObject, requestOptions: IHttpRequestOptions): Promise { + //check whether the token is an API Key or an access token + const { apiKey } = credentials as { apiKey: string } ; + const tokenType = getAuthenticationType(apiKey); + // remove condition once v1 is deprecated + // and only inject credentials as an access token + if (tokenType === 'accessToken') { + requestOptions.headers!['Authorization'] = `Bearer ${apiKey}`; + } else { + requestOptions.headers!['X-TOKEN'] = apiKey; + } + return requestOptions; + } + + test: ICredentialTestRequest = { + request: { + baseURL: 'https://calendly.com', + url: '/api/v1/users/me', + }, + }; } + + const getAuthenticationType = (data: string): 'accessToken' | 'apiKey' => { + // The access token is a JWT, so it will always include dots to separate + // header, payoload and signature. + return data.includes('.') ? 'accessToken' : 'apiKey'; +}; diff --git a/packages/nodes-base/nodes/Calendly/CalendlyTrigger.node.ts b/packages/nodes-base/nodes/Calendly/CalendlyTrigger.node.ts index 4407c299f4..8feec84819 100644 --- a/packages/nodes-base/nodes/Calendly/CalendlyTrigger.node.ts +++ b/packages/nodes-base/nodes/Calendly/CalendlyTrigger.node.ts @@ -4,6 +4,7 @@ import { } from 'n8n-core'; import { + IDataObject, INodeType, INodeTypeDescription, IWebhookResponseData, @@ -11,6 +12,7 @@ import { import { calendlyApiRequest, + getAuthenticationType, } from './GenericFunctions'; export class CalendlyTrigger implements INodeType { @@ -41,6 +43,26 @@ export class CalendlyTrigger implements INodeType { }, ], properties: [ + { + displayName: 'Scope', + name: 'scope', + type: 'options', + default: 'user', + required: true, + hint: 'Ignored if you are using an API Key', + options: [ + { + name: 'Organization', + value: 'organization', + description: 'Triggers the webhook for all subscribed events within the organization', + }, + { + name: 'User', + value: 'user', + description: 'Triggers the webhook for subscribed events that belong to the current user', + }, + ], + }, { displayName: 'Events', name: 'events', @@ -71,64 +93,154 @@ export class CalendlyTrigger implements INodeType { const webhookUrl = this.getNodeWebhookUrl('default'); const webhookData = this.getWorkflowStaticData('node'); const events = this.getNodeParameter('events') as string; + const { apiKey } = await this.getCredentials('calendlyApi') as { apiKey: string }; - // Check all the webhooks which exist already if it is identical to the - // one that is supposed to get created. - const endpoint = '/hooks'; - const { data } = await calendlyApiRequest.call(this, 'GET', endpoint, {}); + const authenticationType = getAuthenticationType(apiKey); - for (const webhook of data) { - if (webhook.attributes.url === webhookUrl) { - for (const event of events) { - if (!webhook.attributes.events.includes(event)) { - return false; + // remove condition once API Keys are deprecated + if (authenticationType === 'apiKey') { + const endpoint = '/hooks'; + const { data } = await calendlyApiRequest.call(this, 'GET', endpoint, {}); + + for (const webhook of data) { + if (webhook.attributes.url === webhookUrl) { + for (const event of events) { + if (!webhook.attributes.events.includes(event)) { + return false; + } } } + // Set webhook-id to be sure that it can be deleted + webhookData.webhookId = webhook.id as string; + return true; } - // Set webhook-id to be sure that it can be deleted - webhookData.webhookId = webhook.id as string; - return true; } + + if (authenticationType === 'accessToken') { + const scope = this.getNodeParameter('scope', 0) as string; + const { resource } = await calendlyApiRequest.call(this, 'GET', '/users/me'); + + const qs: IDataObject = {}; + + if (scope === 'user') { + qs.scope = 'user'; + qs.organization = resource.current_organization; + qs.user = resource.uri; + } + + if (scope === 'organization') { + qs.scope = 'organization'; + qs.organization = resource.current_organization; + } + + const endpoint = '/webhook_subscriptions'; + const { collection } = await calendlyApiRequest.call(this, 'GET', endpoint, {}, qs); + + for (const webhook of collection) { + if (webhook.callback_url === webhookUrl) { + for (const event of events) { + if (!webhook.events.includes(event)) { + return false; + } + } + } + + webhookData.webhookURI = webhook.uri; + return true; + } + } + return false; }, async create(this: IHookFunctions): Promise { const webhookData = this.getWorkflowStaticData('node'); const webhookUrl = this.getNodeWebhookUrl('default'); const events = this.getNodeParameter('events') as string; + const { apiKey } = await this.getCredentials('calendlyApi') as { apiKey: string }; - const endpoint = '/hooks'; + const authenticationType = getAuthenticationType(apiKey); - const body = { - url: webhookUrl, - events, - }; + // remove condition once API Keys are deprecated + if (authenticationType === 'apiKey') { + const endpoint = '/hooks'; - const responseData = await calendlyApiRequest.call(this, 'POST', endpoint, body); + const body = { + url: webhookUrl, + events, + }; - if (responseData.id === undefined) { - // Required data is missing so was not successful - return false; + const responseData = await calendlyApiRequest.call(this, 'POST', endpoint, body); + + if (responseData.id === undefined) { + // Required data is missing so was not successful + return false; + } + + webhookData.webhookId = responseData.id as string; + } + + if (authenticationType === 'accessToken') { + const scope = this.getNodeParameter('scope', 0) as string; + const { resource } = await calendlyApiRequest.call(this, 'GET', '/users/me'); + + const body: IDataObject = { + url: webhookUrl, + events, + organization: resource.current_organization, + scope, + }; + + if ( scope === 'user') { + body.user = resource.uri; + } + + const endpoint = '/webhook_subscriptions'; + const responseData = await calendlyApiRequest.call(this, 'POST', endpoint, body); + + if (responseData?.resource === undefined || responseData?.resource?.uri === undefined) { + return false; + } + + webhookData.webhookURI = responseData.resource.uri; } - webhookData.webhookId = responseData.id as string; return true; }, async delete(this: IHookFunctions): Promise { const webhookData = this.getWorkflowStaticData('node'); - if (webhookData.webhookId !== undefined) { + const { apiKey } = await this.getCredentials('calendlyApi') as { apiKey: string }; + const authenticationType = getAuthenticationType(apiKey); - const endpoint = `/hooks/${webhookData.webhookId}`; + // remove condition once API Keys are deprecated + if (authenticationType === 'apiKey') { + if (webhookData.webhookId !== undefined) { - try { - await calendlyApiRequest.call(this, 'DELETE', endpoint); - } catch (error) { - return false; + const endpoint = `/hooks/${webhookData.webhookId}`; + + try { + await calendlyApiRequest.call(this, 'DELETE', endpoint); + } catch (error) { + return false; + } + + // Remove from the static workflow data so that it is clear + // that no webhooks are registred anymore + delete webhookData.webhookId; } - - // Remove from the static workflow data so that it is clear - // that no webhooks are registred anymore - delete webhookData.webhookId; } + + if (authenticationType === 'accessToken') { + if (webhookData.webhookURI !== undefined) { + try { + await calendlyApiRequest.call(this, 'DELETE', '', {}, {}, webhookData.webhookURI as string); + } catch (error) { + return false; + } + + delete webhookData.webhookURI; + } + } + return true; }, }, diff --git a/packages/nodes-base/nodes/Calendly/GenericFunctions.ts b/packages/nodes-base/nodes/Calendly/GenericFunctions.ts index 02f11ad0d0..a801c3aa3e 100644 --- a/packages/nodes-base/nodes/Calendly/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Calendly/GenericFunctions.ts @@ -6,30 +6,40 @@ import { } from 'n8n-core'; import { + ICredentialDataDecryptedObject, + ICredentialTestFunctions, IDataObject, IHookFunctions, IWebhookFunctions, NodeApiError, - NodeOperationError, } from 'n8n-workflow'; export async function calendlyApiRequest(this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, query: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any - const credentials = await this.getCredentials('calendlyApi'); + const { apiKey } = await this.getCredentials('calendlyApi') as { apiKey: string }; - const endpoint = 'https://calendly.com/api/v1'; + const authenticationType = getAuthenticationType(apiKey); + + const headers: IDataObject = { + 'Content-Type': 'application/json', + }; + + let endpoint = 'https://api.calendly.com'; + + // remove once API key is deprecated + if (authenticationType === 'apiKey') { + endpoint = 'https://calendly.com/api/v1'; + } let options: OptionsWithUri = { - headers: { - 'Content-Type': 'application/json', - 'X-TOKEN': credentials.apiKey, - }, + headers, method, body, qs: query, uri: uri || `${endpoint}${resource}`, json: true, }; + if (!Object.keys(body).length) { delete options.form; } @@ -38,8 +48,37 @@ export async function calendlyApiRequest(this: IExecuteFunctions | IWebhookFunct } options = Object.assign({}, options, option); try { - return await this.helpers.request!(options); + return await this.helpers.requestWithAuthentication.call(this, 'calendlyApi', options); } catch (error) { throw new NodeApiError(this.getNode(), error); } } + +export function getAuthenticationType(data: string): 'accessToken' | 'apiKey' { + // The access token is a JWT, so it will always include dots to separate + // header, payoload and signature. + return data.includes('.') ? 'accessToken' : 'apiKey'; +} + +export async function validateCredentials(this: ICredentialTestFunctions, decryptedCredentials: ICredentialDataDecryptedObject): Promise { // tslint:disable-line:no-any + const credentials = decryptedCredentials; + + const { apiKey } = credentials as { + apiKey: string, + }; + + const authenticationType = getAuthenticationType(apiKey); + + const options: OptionsWithUri = { + method: 'GET', + uri: '', + json: true, + }; + + if (authenticationType === 'accessToken') { + Object.assign(options, { headers: { 'Authorization': `Bearer ${apiKey}` }, uri: 'https://api.calendly.com/users/me' }); + } else { + Object.assign(options, { headers: { 'X-TOKEN': apiKey }, uri: 'https://calendly.com/api/v1/users/me' }); + } + return this.helpers.request!(options); +}