From 43971c2a07c2ad7d9f313785caafdf3ffd74d6fe Mon Sep 17 00:00:00 2001 From: Shireen Missi Date: Wed, 31 Jul 2024 17:30:37 +0100 Subject: [PATCH] Add Okta node --- .../nodes-base/nodes/Okta/GenericFunctions.ts | 67 +++ packages/nodes-base/nodes/Okta/Okta.dark.svg | 3 + packages/nodes-base/nodes/Okta/Okta.node.ts | 56 ++ packages/nodes-base/nodes/Okta/Okta.svg | 3 + .../nodes-base/nodes/Okta/UserDescription.ts | 524 ++++++++++++++++++ .../nodes/Okta/test/GenericFunctions.test.ts | 100 ++++ packages/nodes-base/package.json | 1 + 7 files changed, 754 insertions(+) create mode 100644 packages/nodes-base/nodes/Okta/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Okta/Okta.dark.svg create mode 100644 packages/nodes-base/nodes/Okta/Okta.node.ts create mode 100644 packages/nodes-base/nodes/Okta/Okta.svg create mode 100644 packages/nodes-base/nodes/Okta/UserDescription.ts create mode 100644 packages/nodes-base/nodes/Okta/test/GenericFunctions.test.ts diff --git a/packages/nodes-base/nodes/Okta/GenericFunctions.ts b/packages/nodes-base/nodes/Okta/GenericFunctions.ts new file mode 100644 index 0000000000000..ceda4672b051e --- /dev/null +++ b/packages/nodes-base/nodes/Okta/GenericFunctions.ts @@ -0,0 +1,67 @@ +import type { + IDataObject, + IExecuteFunctions, + IExecuteSingleFunctions, + IHookFunctions, + IHttpRequestMethods, + IHttpRequestOptions, + ILoadOptionsFunctions, + INodeListSearchResult, + INodePropertyOptions, +} from 'n8n-workflow'; + +type OktaUser = { + profile: { + firstName: string; + lastName: string; + }; + id: string; +}; + +export async function oktaApiRequest( + this: IExecuteFunctions | IExecuteSingleFunctions | IHookFunctions | ILoadOptionsFunctions, + method: IHttpRequestMethods, + resource: string, + body: IDataObject = {}, + qs: IDataObject = {}, + url?: string, + option: IDataObject = {}, +): Promise { + const credentials = await this.getCredentials('oktaApi'); + const baseUrl = `${credentials.url as string}/api/v1/${resource}`; + const options: IHttpRequestOptions = { + headers: { + 'Content-Type': 'application/json', + }, + method, + body: Object.keys(body).length ? body : undefined, + qs: Object.keys(qs).length ? qs : undefined, + url: url ?? baseUrl, + json: true, + ...option, + }; + return await (this.helpers.httpRequestWithAuthentication.call( + this, + 'oktaApi', + options, + ) as Promise); +} + +export async function getUsers( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + const responseData: OktaUser[] = await oktaApiRequest.call(this, 'GET', '/users/'); + const filteredUsers = responseData.filter((user) => { + if (!filter) return true; + const fullName = `${user.profile.firstName} ${user.profile.lastName}`.toLowerCase(); + return fullName.includes(filter.toLowerCase()); + }); + const users: INodePropertyOptions[] = filteredUsers.map((user) => ({ + name: `${user.profile.firstName} ${user.profile.lastName}`, + value: user.id, + })); + return { + results: users, + }; +} diff --git a/packages/nodes-base/nodes/Okta/Okta.dark.svg b/packages/nodes-base/nodes/Okta/Okta.dark.svg new file mode 100644 index 0000000000000..b6f1fd7e2b477 --- /dev/null +++ b/packages/nodes-base/nodes/Okta/Okta.dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/nodes-base/nodes/Okta/Okta.node.ts b/packages/nodes-base/nodes/Okta/Okta.node.ts new file mode 100644 index 0000000000000..c7e655c0a3918 --- /dev/null +++ b/packages/nodes-base/nodes/Okta/Okta.node.ts @@ -0,0 +1,56 @@ +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; +import { userFields, userOperations } from './UserDescription'; +import { getUsers } from './GenericFunctions'; + +export class Okta implements INodeType { + description: INodeTypeDescription = { + displayName: 'Okta', + name: 'okta', + icon: { light: 'file:Okta.svg', dark: 'file:Okta.dark.svg' }, + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Use the Okta API', + defaults: { + name: 'Okta', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'oktaApi', + required: true, + }, + ], + requestDefaults: { + returnFullResponse: true, + baseURL: '={{$credentials.url.replace(new RegExp("/$"), "")}}', + headers: {}, + }, + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'User', + value: 'user', + }, + ], + default: 'user', + }, + + // USER + ...userOperations, + ...userFields, + ], + }; + + methods = { + listSearch: { + getUsers, + }, + }; +} diff --git a/packages/nodes-base/nodes/Okta/Okta.svg b/packages/nodes-base/nodes/Okta/Okta.svg new file mode 100644 index 0000000000000..4ba579621f863 --- /dev/null +++ b/packages/nodes-base/nodes/Okta/Okta.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/nodes-base/nodes/Okta/UserDescription.ts b/packages/nodes-base/nodes/Okta/UserDescription.ts new file mode 100644 index 0000000000000..39affede66660 --- /dev/null +++ b/packages/nodes-base/nodes/Okta/UserDescription.ts @@ -0,0 +1,524 @@ +import type { INodeProperties } from 'n8n-workflow'; +const BASE_API_URL = '/api/v1/users/'; +export const userOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['user'], + }, + }, + options: [ + // Create Operation + { + name: 'Create', + value: 'create', + description: 'Create a new user', + routing: { + request: { + method: 'POST', + url: BASE_API_URL, + qs: { activate: '={{$parameter["activate"]}}' }, + returnFullResponse: true, + }, + }, + action: 'Create a new User', + }, + // Delete Operation + { + name: 'Delete', + value: 'delete', + description: 'Delete an empty Bucket', + routing: { + request: { + method: 'DELETE', + url: '={{"/api/v1/users/" + $parameter["userId"]}}', + returnFullResponse: true, + }, + }, + action: 'Delete a user', + }, + // Get Operation + { + name: 'Get', + value: 'get', + description: 'Get a user', + routing: { + request: { + method: 'GET', + url: '={{"/api/v1/users/" + $parameter["userId"]}}', + returnFullResponse: true, + qs: {}, + }, + }, + action: 'Get a user', + }, + // Get All Operation + { + name: 'Get Many', + value: 'getAll', + description: 'Get many users', + routing: { + request: { + method: 'GET', + url: BASE_API_URL, + qs: { q: '={{$parameter["searchQuery"]}}' }, + }, + send: { + paginate: true, + }, + }, + action: 'Get many users', + }, + // Update Operation + { + name: 'Update', + value: 'update', + description: 'Update users', + routing: { + request: { + method: 'POST', + url: '={{"/api/v1/users/" + $parameter["userId"]}}', + qs: { + project: '={{$parameter["projectId"]}}', + }, + returnFullResponse: true, + }, + }, + action: 'Update a user', + }, + ], + default: 'getAll', + }, +]; + +export const userFields: INodeProperties[] = [ + // Fields for 'create' and 'update' operations + { + displayName: 'First Name', + name: 'firstName', + type: 'string', + required: true, + placeholder: 'First Name', + displayOptions: { + show: { + resource: ['user'], + operation: ['create', 'update'], + }, + }, + default: '', + routing: { + send: { + property: 'profile.firstName', + type: 'body', + }, + }, + }, + { + displayName: 'Last Name', + name: 'lastName', + type: 'string', + required: true, + placeholder: 'Last Name', + displayOptions: { + show: { + resource: ['user'], + operation: ['create', 'update'], + }, + }, + default: '', + routing: { + send: { + property: 'profile.lastName', + type: 'body', + }, + }, + }, + { + displayName: 'Email', + name: 'email', + type: 'string', + required: true, + placeholder: 'Email', + displayOptions: { + show: { + resource: ['user'], + operation: ['create', 'update'], + }, + }, + default: '', + routing: { + send: { + property: 'profile.email', + type: 'body', + }, + }, + }, + { + displayName: 'Login', + name: 'login', + type: 'string', + required: true, + placeholder: 'Login', + description: 'Unique identifier for the user (username)', + displayOptions: { + show: { + resource: ['user'], + operation: ['create', 'update'], + }, + }, + default: '', + routing: { + send: { + property: 'profile.login', + type: 'body', + }, + }, + }, + { + displayName: 'Credentials', + name: 'getCredentials', + type: 'collection', + displayOptions: { + show: { + resource: ['user'], + operation: ['create', 'update'], + }, + }, + default: {}, + placeholder: 'Add credential field', + options: [ + { + displayName: 'Password', + name: 'password', + type: 'string', + typeOptions: { password: true }, + default: '', + routing: { + send: { + property: 'credentials.password.value', + type: 'body', + }, + }, + }, + { + displayName: 'Recovery Question Question', + name: 'recoveryQuestionQuestion', + type: 'string', + default: '', + routing: { + send: { + property: 'credentials.recovery_question.question', + type: 'body', + }, + }, + }, + { + displayName: 'Recovery Question Answer', + name: 'recoveryQuestionAnswer', + type: 'string', + default: '', + routing: { + send: { + property: 'credentials.recovery_question.answer', + type: 'body', + }, + }, + }, + ], + }, + { + displayName: 'Additional Fields', + name: 'getFields', + type: 'collection', + displayOptions: { + show: { + resource: ['user'], + operation: ['create', 'update'], + }, + }, + default: {}, + routing: { + send: { + property: 'profile', + type: 'body', + }, + }, + placeholder: 'Add additional field', + options: [ + { + displayName: 'Second Email', + name: 'secondEmail', + type: 'string', + typeOptions: { email: true }, + default: '', + }, + { + displayName: 'Middle Name', + name: 'middleName', + type: 'string', + default: '', + }, + { + displayName: 'Honorific Prefix', + name: 'honorificPrefix', + type: 'string', + default: '', + }, + { + displayName: 'Honorific Suffix', + name: 'honorificSuffix', + type: 'string', + default: '', + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + }, + { + displayName: 'Display Name', + name: 'displayName', + type: 'string', + default: '', + }, + { + displayName: 'Nick Name', + name: 'nickName', + type: 'string', + default: '', + }, + { + displayName: 'Profile Url', + name: 'profileUrl', + type: 'string', + default: '', + }, + { + displayName: 'Primary Phone', + name: 'primaryPhone', + type: 'string', + default: '', + }, + { + displayName: 'Mobile Phone', + name: 'mobilePhone', + type: 'string', + default: '', + }, + { + displayName: 'Street Address', + name: 'streetAddress', + type: 'string', + default: '', + }, + { + displayName: 'City', + name: 'city', + type: 'string', + default: '', + }, + { + displayName: 'State', + name: 'state', + type: 'string', + default: '', + }, + { + displayName: 'Zip Code', + name: 'zipCode', + type: 'string', + default: '', + }, + { + displayName: 'Country Code', + name: 'countryCode', + type: 'string', + default: '', + }, + { + displayName: 'Postal Address', + name: 'postalAddress', + type: 'string', + default: '', + }, + { + displayName: 'Preferred Language', + name: 'preferredLanguage', + type: 'string', + default: '', + }, + { + displayName: 'Locale', + name: 'locale', + type: 'string', + default: '', + }, + { + displayName: 'Timezone', + name: 'timezone', + type: 'string', + default: '', + }, + { + displayName: 'User Type', + name: 'userType', + type: 'string', + default: '', + }, + { + displayName: 'Employee Number', + name: 'employeeNumber', + type: 'string', + default: '', + }, + { + displayName: 'Cost Center', + name: 'costCenter', + type: 'string', + default: '', + }, + { + displayName: 'Organization', + name: 'organization', + type: 'string', + default: '', + }, + { + displayName: 'Division', + name: 'division', + type: 'string', + default: '', + }, + { + displayName: 'Department', + name: 'department', + type: 'string', + default: '', + }, + { + displayName: 'ManagerId', + name: 'managerId', + type: 'string', + default: '', + }, + { + displayName: 'Manager', + name: 'manager', + type: 'string', + default: '', + }, + ], + }, + // Fields specific to 'create' operation + { + displayName: 'Activate', + name: 'activate', + type: 'boolean', + displayOptions: { + show: { + resource: ['user'], + operation: ['create'], + }, + }, + default: false, + description: 'Whether to activate the user', + }, + // Fields specific to 'getAll' operation + { + displayName: 'Search Query', + name: 'searchQuery', + type: 'string', + placeholder: 'Filter Users by email, last name, or first name', + displayOptions: { + show: { + resource: ['user'], + operation: ['getAll'], + }, + }, + default: '', + routing: { + request: { + qs: { + prefix: '={{$value}}', + }, + }, + }, + }, + { + displayName: 'User ID', + name: 'userId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user...', + typeOptions: { + searchListMethod: 'getUsers', + searchable: true, + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: '00u1abcd2345EfGHIjk6', + }, + ], + displayOptions: { + show: { + resource: ['user'], + operation: ['get', 'update', 'delete'], + }, + }, + description: 'The user you want to operate on. Choose from the list, or specify an ID.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: ['user'], + operation: ['getAll'], + returnAll: [false], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 200, + }, + default: 20, + routing: { + send: { + type: 'query', + property: 'limit', + }, + output: { + maxResults: '={{$value}}', // Set maxResults to the value of current parameter + }, + }, + description: 'Max number of results to return', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: ['user'], + operation: ['getAll'], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, +]; diff --git a/packages/nodes-base/nodes/Okta/test/GenericFunctions.test.ts b/packages/nodes-base/nodes/Okta/test/GenericFunctions.test.ts new file mode 100644 index 0000000000000..6642446a306d4 --- /dev/null +++ b/packages/nodes-base/nodes/Okta/test/GenericFunctions.test.ts @@ -0,0 +1,100 @@ +import type { IExecuteFunctions, ILoadOptionsFunctions } from 'n8n-workflow'; +import { getUsers, oktaApiRequest } from '../GenericFunctions'; + +describe('oktaApiRequest', () => { + const mockGetCredentials = jest.fn(); + const mockHttpRequestWithAuthentication = jest.fn(); + + const mockContext = { + getCredentials: mockGetCredentials, + helpers: { + httpRequestWithAuthentication: mockHttpRequestWithAuthentication, + }, + } as unknown as IExecuteFunctions; + + beforeEach(() => { + mockGetCredentials.mockClear(); + mockHttpRequestWithAuthentication.mockClear(); + }); + + it('should make a GET request and return data', async () => { + mockGetCredentials.mockResolvedValue({ url: 'https://okta.example.com' }); + mockHttpRequestWithAuthentication.mockResolvedValue([ + { profile: { firstName: 'John', lastName: 'Doe' }, id: '1' }, + ]); + + const response = await oktaApiRequest.call(mockContext, 'GET', 'users'); + + expect(mockGetCredentials).toHaveBeenCalledWith('oktaApi'); + expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith('oktaApi', { + headers: { 'Content-Type': 'application/json' }, + method: 'GET', + body: undefined, + qs: undefined, + url: 'https://okta.example.com/api/v1/users', + json: true, + }); + expect(response).toEqual([{ profile: { firstName: 'John', lastName: 'Doe' }, id: '1' }]); + }); + + // Tests for error handling + it('should handle errors from oktaApiRequest', async () => { + mockHttpRequestWithAuthentication.mockRejectedValue(new Error('Network error')); + + await expect(oktaApiRequest.call(mockContext, 'GET', 'users')).rejects.toThrow('Network error'); + }); +}); + +describe('getUsers', () => { + const mockOktaApiRequest = jest.fn(); + const mockContext = { + getCredentials: jest.fn().mockResolvedValue({ url: 'https://okta.example.com' }), + helpers: { + httpRequestWithAuthentication: mockOktaApiRequest, + }, + } as unknown as ILoadOptionsFunctions; + + beforeEach(() => { + mockOktaApiRequest.mockClear(); + }); + + it('should return users with filtering', async () => { + mockOktaApiRequest.mockResolvedValue([ + { profile: { firstName: 'John', lastName: 'Doe' }, id: '1' }, + { profile: { firstName: 'Jane', lastName: 'Doe' }, id: '2' }, + ]); + + const response = await getUsers.call(mockContext, 'john'); + + expect(response).toEqual({ + results: [{ name: 'John Doe', value: '1' }], + }); + }); + + it('should return all users when no filter is applied', async () => { + mockOktaApiRequest.mockResolvedValue([ + { profile: { firstName: 'John', lastName: 'Doe' }, id: '1' }, + { profile: { firstName: 'Jane', lastName: 'Doe' }, id: '2' }, + ]); + + const response = await getUsers.call(mockContext); + + expect(response).toEqual({ + results: [ + { name: 'John Doe', value: '1' }, + { name: 'Jane Doe', value: '2' }, + ], + }); + }); + + // Tests for empty results + it('should handle empty results from oktaApiRequest', async () => { + mockOktaApiRequest.mockResolvedValue([]); + + const response = await getUsers.call(mockContext); + + expect(response).toEqual({ + results: [], + }); + }); +}); diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index e512fb4ef89bc..3148abbfd25f6 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -646,6 +646,7 @@ "dist/nodes/Notion/NotionTrigger.node.js", "dist/nodes/Npm/Npm.node.js", "dist/nodes/Odoo/Odoo.node.js", + "dist/nodes/Okta/Okta.node.js", "dist/nodes/OneSimpleApi/OneSimpleApi.node.js", "dist/nodes/OpenAi/OpenAi.node.js", "dist/nodes/OpenThesaurus/OpenThesaurus.node.js",