From 665f158b3e9e0b1b18a6120fcc594663aaef98c9 Mon Sep 17 00:00:00 2001 From: Hassan Wari Date: Tue, 17 Dec 2024 16:04:09 +0300 Subject: [PATCH] feat(lastpass): add integrations for lastpass --- flows.yaml | 55 +++++++++++++ integrations/lastpass/actions/create-user.md | 58 ++++++++++++++ integrations/lastpass/actions/create-user.ts | 63 +++++++++++++++ integrations/lastpass/actions/delete-user.md | 46 +++++++++++ integrations/lastpass/actions/delete-user.ts | 35 +++++++++ .../lastpass/helpers/get-credentials.ts | 19 +++++ integrations/lastpass/helpers/paginate.ts | 77 +++++++++++++++++++ integrations/lastpass/mappers/to-user.ts | 11 +++ integrations/lastpass/nango.yaml | 54 +++++++++++++ integrations/lastpass/syncs/users.md | 49 ++++++++++++ integrations/lastpass/syncs/users.ts | 24 ++++++ integrations/lastpass/types.ts | 53 +++++++++++++ .../ramp/tests/ramp-disable-user.test.ts | 22 +++--- integrations/ramp/tests/ramp-users.test.ts | 60 +++++++-------- 14 files changed, 585 insertions(+), 41 deletions(-) create mode 100644 integrations/lastpass/actions/create-user.md create mode 100644 integrations/lastpass/actions/create-user.ts create mode 100644 integrations/lastpass/actions/delete-user.md create mode 100644 integrations/lastpass/actions/delete-user.ts create mode 100644 integrations/lastpass/helpers/get-credentials.ts create mode 100644 integrations/lastpass/helpers/paginate.ts create mode 100644 integrations/lastpass/mappers/to-user.ts create mode 100644 integrations/lastpass/nango.yaml create mode 100644 integrations/lastpass/syncs/users.md create mode 100644 integrations/lastpass/syncs/users.ts create mode 100644 integrations/lastpass/types.ts diff --git a/flows.yaml b/flows.yaml index eba21470..83391315 100644 --- a/flows.yaml +++ b/flows.yaml @@ -6334,6 +6334,61 @@ integrations: attributes: object relationships: object links: object + lastpass: + actions: + create-user: + description: Creates a user in Lastpass. + output: User + endpoint: + method: POST + path: /users + group: Users + input: LastPassCreateUser + delete-user: + description: Deletes a user in Lastpass. + endpoint: + method: DELETE + path: /users + group: Users + output: SuccessResponse + input: EmailEntity + syncs: + users: + runs: every day + description: | + Fetches a list of users from Lastpass. + output: User + track_deletes: true + sync_type: full + endpoint: + method: GET + path: /users + group: Users + models: + EmailEntity: + email: string + SuccessResponse: + success: boolean + ActionResponseError: + message: string + CreateUser: + firstName: string + lastName: string + email: string + LastPassCreateUser: + firstName: string + lastName: string + email: string + groups?: string[] + duousername?: string + securidusername?: string + password?: string + password_reset_required?: boolean + User: + id: string + firstName: string + lastName: string + email: string lever: actions: create-note: diff --git a/integrations/lastpass/actions/create-user.md b/integrations/lastpass/actions/create-user.md new file mode 100644 index 00000000..dbdac444 --- /dev/null +++ b/integrations/lastpass/actions/create-user.md @@ -0,0 +1,58 @@ + +# Create User + +## General Information + +- **Description:** Creates a user in Lastpass. +- **Version:** 0.0.1 +- **Group:** Others +- **Scopes:** _None_ +- **Endpoint Type:** Action +- **Code:** [github.com](https://github.com/NangoHQ/integration-templates/tree/main/integrations/lastpass/actions/create-user.ts) + + +## Endpoint Reference + +### Request Endpoint + +`POST /users` + +### Request Query Parameters + +_No request parameters_ + +### Request Body + +```json +{ + "firstName": "", + "lastName": "", + "email": "", + "groups?": [ + "" + ], + "duousername?": "", + "securidusername?": "", + "password?": "", + "password_reset_required?": "" +} +``` + +### Request Response + +```json +{ + "id": "", + "firstName": "", + "lastName": "", + "email": "" +} +``` + +## Changelog + +- [Script History](https://github.com/NangoHQ/integration-templates/commits/main/integrations/lastpass/actions/create-user.ts) +- [Documentation History](https://github.com/NangoHQ/integration-templates/commits/main/integrations/lastpass/actions/create-user.md) + + + diff --git a/integrations/lastpass/actions/create-user.ts b/integrations/lastpass/actions/create-user.ts new file mode 100644 index 00000000..93e9871b --- /dev/null +++ b/integrations/lastpass/actions/create-user.ts @@ -0,0 +1,63 @@ +import type { NangoAction, User, LastPassCreateUser, ProxyConfiguration, ActionResponseError } from '../../models'; +import type { LastPassBody, LastPassCreateNewUser, LastPassResponse } from '../types'; +import { getCredentials } from '../helpers/get-credentials.js'; +import { lastPassCreateUserSchema } from '../../schema.zod.js'; + +export default async function runAction(nango: NangoAction, input: LastPassCreateUser): Promise { + const parsedInput = lastPassCreateUserSchema.safeParse(input); + if (!parsedInput.success) { + for (const error of parsedInput.error.errors) { + await nango.log(`Invalid input provided to create a user: ${error.message} at path ${error.path.join('.')}`, { level: 'error' }); + } + throw new nango.ActionError({ + message: 'Invalid input provided to create a user' + }); + } + + const createUser: LastPassCreateNewUser = { + username: input.email, + fullname: `${input.firstName} ${input.lastName}`, + ...(input.groups && { groups: input.groups }), + ...(input.duousername && { duousername: input.duousername }), + ...(input.securidusername && { securidusername: input.securidusername }), + ...(input.password && { password: input.password }), + ...(input.password_reset_required !== undefined && { + password_reset_required: input.password_reset_required + }) + }; + + const credentials = await getCredentials(nango); + const lastPassInput: LastPassBody = { + cid: credentials.cid, + provhash: credentials.provhash, + cmd: 'batchadd', + data: [createUser] + }; + + const config: ProxyConfiguration = { + // https://support.lastpass.com/s/document-item?language=en_US&bundleId=lastpass&topicId=LastPass/api_add_users.html&_LANG=enus + endpoint: `/enterpriseapi.php`, + data: lastPassInput, + retries: 10 + }; + const response = await nango.post(config); + + const isSuccess = response?.data?.status === 'OK'; + + // we dont have an Id present in the user's object, so we will use the email as the id + const user: User = { + id: input.email, + firstName: input.firstName, + lastName: input.lastName, + email: input.email + }; + + if (isSuccess) { + return user; + } else { + const errorMessages = response?.data?.error?.join(', ') || 'Unknown error'; + throw new nango.ActionError({ + message: `Failed to create user in LastPass: ${errorMessages}` + }); + } +} diff --git a/integrations/lastpass/actions/delete-user.md b/integrations/lastpass/actions/delete-user.md new file mode 100644 index 00000000..3f87c740 --- /dev/null +++ b/integrations/lastpass/actions/delete-user.md @@ -0,0 +1,46 @@ + +# Delete User + +## General Information + +- **Description:** Deletes a user in Lastpass. +- **Version:** 0.0.1 +- **Group:** Others +- **Scopes:** _None_ +- **Endpoint Type:** Action +- **Code:** [github.com](https://github.com/NangoHQ/integration-templates/tree/main/integrations/lastpass/actions/delete-user.ts) + + +## Endpoint Reference + +### Request Endpoint + +`DELETE /users` + +### Request Query Parameters + +_No request parameters_ + +### Request Body + +```json +{ + "email": "" +} +``` + +### Request Response + +```json +{ + "success": "" +} +``` + +## Changelog + +- [Script History](https://github.com/NangoHQ/integration-templates/commits/main/integrations/lastpass/actions/delete-user.ts) +- [Documentation History](https://github.com/NangoHQ/integration-templates/commits/main/integrations/lastpass/actions/delete-user.md) + + + diff --git a/integrations/lastpass/actions/delete-user.ts b/integrations/lastpass/actions/delete-user.ts new file mode 100644 index 00000000..166b0305 --- /dev/null +++ b/integrations/lastpass/actions/delete-user.ts @@ -0,0 +1,35 @@ +import type { NangoAction, ProxyConfiguration, SuccessResponse, EmailEntity } from '../../models'; +import type { LastPassBody } from '../types'; +import { getCredentials } from '../helpers/get-credentials.js'; + +export default async function runAction(nango: NangoAction, input: EmailEntity): Promise { + if (!input.email) { + throw new nango.ActionError({ + message: 'Email is required to delete a user' + }); + } + const credentials = await getCredentials(nango); + const data: LastPassBody = { + cid: credentials.cid, + provhash: credentials.provhash, + cmd: 'deluser', + data: { + username: input.email, + deleteaction: 2 // Delete user. Deletes the account entirely. + } + }; + const config: ProxyConfiguration = { + // https://support.lastpass.com/s/document-item?language=en_US&bundleId=lastpass&topicId=LastPass/api_delete_user.html&_LANG=enus + endpoint: `/enterpriseapi.php`, + retries: 10, + data: data + }; + + const res = await nango.post(config); + + const isSuccess = res?.data?.status === 'OK'; + + return { + success: isSuccess + }; +} diff --git a/integrations/lastpass/helpers/get-credentials.ts b/integrations/lastpass/helpers/get-credentials.ts new file mode 100644 index 00000000..ed7d9681 --- /dev/null +++ b/integrations/lastpass/helpers/get-credentials.ts @@ -0,0 +1,19 @@ +import type { NangoSync, NangoAction } from '../../models'; + +export async function getCredentials(nango: NangoSync | NangoAction): Promise<{ cid: number; provhash: string }> { + const connection = await nango.getConnection(); + + if ('username' in connection.credentials && 'password' in connection.credentials) { + const cid = connection.credentials['username']; + const provhash = connection.credentials['password']; + + return { + cid, + provhash + }; + } else { + throw new nango.ActionError({ + message: `Credentials (username, password) are incomplete` + }); + } +} diff --git a/integrations/lastpass/helpers/paginate.ts b/integrations/lastpass/helpers/paginate.ts new file mode 100644 index 00000000..620b1636 --- /dev/null +++ b/integrations/lastpass/helpers/paginate.ts @@ -0,0 +1,77 @@ +import type { NangoSync, ProxyConfiguration } from '../../models'; + +export interface LastPassPaginationParams { + endpoint: string; + cid: number; + provhash: string; + cmd: string; + pageSize?: number; +} + +export interface LastPassPaginationResponse { + results: T[]; +} + +export async function* paginate( + nango: NangoSync, + { endpoint, cid, provhash, cmd, pageSize = 100 }: LastPassPaginationParams +): AsyncGenerator, void, undefined> { + let pageIndex = 0; + + while (true) { + const body = { + cid, + provhash, + cmd, + data: { + pagesize: pageSize, + pageindex: pageIndex + } + }; + + const config: ProxyConfiguration = { + // eslint-disable-next-line @nangohq/custom-integrations-linting/include-docs-for-endpoints + endpoint, + retries: 10, + data: body + }; + const response = await nango.post<{ + total: number; + count: number; + Users: Record; + invited: string[]; + }>(config); + + const users = Object.values(response.data.Users ?? {}).map((user) => ({ + username: user.username, + fullname: user.fullname, + mpstrength: user.mpstrength, + created: user.created, + last_pw_change: user.last_pw_change, + last_login: user.last_login, + neverloggedin: user.neverloggedin, + disabled: user.disabled, + admin: user.admin, + totalscore: user.totalscore, + legacytotalscore: user.legacytotalscore, + hasSharingKeys: user.hasSharingKeys, + duousername: user.duousername, + sites: user.sites, + notes: user.notes, + formfills: user.formfills, + applications: user.applications, + attachments: user.attachments, + password_reset_required: user.password_reset_required + })); + + if (users.length === 0 || users.length < pageSize) { + // eslint-disable-next-line @nangohq/custom-integrations-linting/no-object-casting + yield { results: users as T[] }; + break; + } + // eslint-disable-next-line @nangohq/custom-integrations-linting/no-object-casting + yield { results: users as T[] }; + + pageIndex += 1; + } +} diff --git a/integrations/lastpass/mappers/to-user.ts b/integrations/lastpass/mappers/to-user.ts new file mode 100644 index 00000000..841b49ea --- /dev/null +++ b/integrations/lastpass/mappers/to-user.ts @@ -0,0 +1,11 @@ +import type { ReturnedUser } from '../types'; +import type { User } from '../../models'; + +export function toUser(users: ReturnedUser[]): User[] { + return users.map((user) => ({ + id: user.username, + firstName: user.fullname?.split(' ')[0] || '', + lastName: user.fullname?.split(' ')[1] || '', + email: user.username + })); +} diff --git a/integrations/lastpass/nango.yaml b/integrations/lastpass/nango.yaml new file mode 100644 index 00000000..166e1309 --- /dev/null +++ b/integrations/lastpass/nango.yaml @@ -0,0 +1,54 @@ +integrations: + lastpass: + actions: + create-user: + description: Creates a user in Lastpass. + output: User + endpoint: + method: POST + path: /users + group: Users + input: LastPassCreateUser + delete-user: + description: Deletes a user in Lastpass. + endpoint: + method: DELETE + path: /users + group: Users + output: SuccessResponse + input: EmailEntity + syncs: + users: + runs: every day + description: | + Fetches a list of users from Lastpass. + output: User + track_deletes: true + sync_type: full + endpoint: + method: GET + path: /users + group: Users +models: + EmailEntity: + email: string + SuccessResponse: + success: boolean + ActionResponseError: + message: string + CreateUser: + firstName: string + lastName: string + email: string + LastPassCreateUser: + __extends: CreateUser + groups?: string[] + duousername?: string + securidusername?: string + password?: string + password_reset_required?: boolean + User: + id: string + firstName: string + lastName: string + email: string diff --git a/integrations/lastpass/syncs/users.md b/integrations/lastpass/syncs/users.md new file mode 100644 index 00000000..f3c91d9d --- /dev/null +++ b/integrations/lastpass/syncs/users.md @@ -0,0 +1,49 @@ + +# Users + +## General Information + +- **Description:** Fetches a list of users from Lastpass. + +- **Version:** 0.0.1 +- **Group:** Others +- **Scopes:** _None_ +- **Endpoint Type:** Sync +- **Code:** [github.com](https://github.com/NangoHQ/integration-templates/tree/main/integrations/lastpass/syncs/users.ts) + + +## Endpoint Reference + +### Request Endpoint + +`GET /users` + +### Request Query Parameters + +- **modified_after:** `(optional, string)` A timestamp (e.g., `2023-05-31T11:46:13.390Z`) used to fetch records modified after this date and time. If not provided, all records are returned. The modified_after parameter is less precise than cursor, as multiple records may share the same modification timestamp. +- **limit:** `(optional, integer)` The maximum number of records to return per page. Defaults to 100. +- **cursor:** `(optional, string)` A marker used to fetch records modified after a specific point in time.If not provided, all records are returned.Each record includes a cursor value found in _nango_metadata.cursor.Save the cursor from the last record retrieved to track your sync progress.Use the cursor parameter together with the limit parameter to paginate through records.The cursor is more precise than modified_after, as it can differentiate between records with the same modification timestamp. +- **filter:** `(optional, added | updated | deleted)` Filter to only show results that have been added or updated or deleted. + +### Request Body + +_No request body_ + +### Request Response + +```json +{ + "id": "", + "firstName": "", + "lastName": "", + "email": "" +} +``` + +## Changelog + +- [Script History](https://github.com/NangoHQ/integration-templates/commits/main/integrations/lastpass/syncs/users.ts) +- [Documentation History](https://github.com/NangoHQ/integration-templates/commits/main/integrations/lastpass/syncs/users.md) + + + diff --git a/integrations/lastpass/syncs/users.ts b/integrations/lastpass/syncs/users.ts new file mode 100644 index 00000000..148e283d --- /dev/null +++ b/integrations/lastpass/syncs/users.ts @@ -0,0 +1,24 @@ +// eslint-disable-next-line @nangohq/custom-integrations-linting/enforce-proxy-configuration-type +import type { NangoSync, User } from '../../models'; +import { paginate } from '../helpers/paginate.js'; +import { getCredentials } from '../helpers/get-credentials.js'; +import type { ReturnedUser } from '../types'; +import { toUser } from '../mappers/to-user.js'; + +export default async function fetchData(nango: NangoSync) { + const credentials = await getCredentials(nango); + const paginationParams = { + // https://support.lastpass.com/s/document-item?language=en_US&bundleId=lastpass&topicId=LastPass%2Fapi_get_user_data.html&_LANG=enus + endpoint: '/enterpriseapi.php', + cid: credentials.cid, + provhash: credentials.provhash, + cmd: 'getuserdata', + pageSize: 100 + }; + + const generator = paginate(nango, paginationParams); + for await (const { results } of generator) { + const users: User[] = toUser(results); + await nango.batchSave(users, 'User'); + } +} diff --git a/integrations/lastpass/types.ts b/integrations/lastpass/types.ts new file mode 100644 index 00000000..477934b4 --- /dev/null +++ b/integrations/lastpass/types.ts @@ -0,0 +1,53 @@ +export interface LastPassBody { + cid: number; + provhash: string; + cmd: string; + data: object | object[]; +} + +export interface LastPassCreateNewUser { + username: string; + fullname: string; + groups?: string[]; + duousername?: string; + securidusername?: string; + password?: string; + password_reset_required?: boolean; +} + +export interface LastPassResponse { + status: string; + error?: string[]; +} + +export interface ReturnedUser { + username: string; + fullname: string; + mpstrength: string; + created: string; + last_pw_change: string; + last_login: string; + neverloggedin: boolean; + disabled: boolean; + admin: boolean; + totalscore: number | null; + legacytotalscore: number | null; + hasSharingKeys: boolean; + duousername: string | null; + sites: string | null; + notes: string | null; + formfills: string | null; + applications: string | null; + attachments: string | null; + password_reset_required: boolean; +} + +type Users = Record; + +export interface Response { + total: number; + count: number; + Users: Users; + Groups: any; + invited?: string[]; +} diff --git a/integrations/ramp/tests/ramp-disable-user.test.ts b/integrations/ramp/tests/ramp-disable-user.test.ts index c729239b..ae654b43 100644 --- a/integrations/ramp/tests/ramp-disable-user.test.ts +++ b/integrations/ramp/tests/ramp-disable-user.test.ts @@ -3,17 +3,17 @@ import { vi, expect, it, describe } from 'vitest'; import runAction from '../actions/disable-user.js'; describe('ramp disable-user tests', () => { - const nangoMock = new global.vitest.NangoActionMock({ - dirname: __dirname, - name: "disable-user", - Model: "SuccessResponse" - }); + const nangoMock = new global.vitest.NangoActionMock({ + dirname: __dirname, + name: 'disable-user', + Model: 'SuccessResponse' + }); - it('should output the action output that is expected', async () => { - const input = await nangoMock.getInput(); - const response = await runAction(nangoMock, input); - const output = await nangoMock.getOutput(); + it('should output the action output that is expected', async () => { + const input = await nangoMock.getInput(); + const response = await runAction(nangoMock, input); + const output = await nangoMock.getOutput(); - expect(response).toEqual(output); - }); + expect(response).toEqual(output); + }); }); diff --git a/integrations/ramp/tests/ramp-users.test.ts b/integrations/ramp/tests/ramp-users.test.ts index b25fefa4..9e1b0a75 100644 --- a/integrations/ramp/tests/ramp-users.test.ts +++ b/integrations/ramp/tests/ramp-users.test.ts @@ -3,43 +3,43 @@ import { vi, expect, it, describe } from 'vitest'; import fetchData from '../syncs/users.js'; describe('ramp users tests', () => { - const nangoMock = new global.vitest.NangoSyncMock({ - dirname: __dirname, - name: "users", - Model: "User" - }); + const nangoMock = new global.vitest.NangoSyncMock({ + dirname: __dirname, + name: 'users', + Model: 'User' + }); - const models = 'User'.split(','); - const batchSaveSpy = vi.spyOn(nangoMock, 'batchSave'); + const models = 'User'.split(','); + const batchSaveSpy = vi.spyOn(nangoMock, 'batchSave'); - it('should get, map correctly the data and batchSave the result', async () => { - await fetchData(nangoMock); + it('should get, map correctly the data and batchSave the result', async () => { + await fetchData(nangoMock); - for (const model of models) { - const expectedBatchSaveData = await nangoMock.getBatchSaveData(model); + for (const model of models) { + const expectedBatchSaveData = await nangoMock.getBatchSaveData(model); - const spiedData = batchSaveSpy.mock.calls.flatMap(call => { - if (call[1] === model) { - return call[0]; - } + const spiedData = batchSaveSpy.mock.calls.flatMap((call) => { + if (call[1] === model) { + return call[0]; + } - return []; - }); + return []; + }); - const spied = JSON.parse(JSON.stringify(spiedData)); + const spied = JSON.parse(JSON.stringify(spiedData)); - expect(spied).toStrictEqual(expectedBatchSaveData); - } - }); + expect(spied).toStrictEqual(expectedBatchSaveData); + } + }); - it('should get, map correctly the data and batchDelete the result', async () => { - await fetchData(nangoMock); + it('should get, map correctly the data and batchDelete the result', async () => { + await fetchData(nangoMock); - for (const model of models) { - const batchDeleteData = await nangoMock.getBatchDeleteData(model); - if (batchDeleteData && batchDeleteData.length > 0) { - expect(nangoMock.batchDelete).toHaveBeenCalledWith(batchDeleteData, model); - } - } - }); + for (const model of models) { + const batchDeleteData = await nangoMock.getBatchDeleteData(model); + if (batchDeleteData && batchDeleteData.length > 0) { + expect(nangoMock.batchDelete).toHaveBeenCalledWith(batchDeleteData, model); + } + } + }); });