diff --git a/packages/nodes-base/credentials/GSuiteAdminOAuth2Api.credentials.ts b/packages/nodes-base/credentials/GSuiteAdminOAuth2Api.credentials.ts index 4a7720de0c832..523227220ac34 100644 --- a/packages/nodes-base/credentials/GSuiteAdminOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/GSuiteAdminOAuth2Api.credentials.ts @@ -5,6 +5,8 @@ const scopes = [ 'https://www.googleapis.com/auth/admin.directory.user', 'https://www.googleapis.com/auth/admin.directory.domain.readonly', 'https://www.googleapis.com/auth/admin.directory.userschema.readonly', + 'https://www.googleapis.com/auth/admin.directory.device.chromeos', + 'https://www.googleapis.com/auth/admin.directory.orgunit.readonly', ]; export class GSuiteAdminOAuth2Api implements ICredentialType { diff --git a/packages/nodes-base/nodes/Google/GSuiteAdmin/DeviceDescription.ts b/packages/nodes-base/nodes/Google/GSuiteAdmin/DeviceDescription.ts new file mode 100644 index 0000000000000..5883f49963830 --- /dev/null +++ b/packages/nodes-base/nodes/Google/GSuiteAdmin/DeviceDescription.ts @@ -0,0 +1,355 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const deviceOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['device'], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get a ChromeOS device', + action: 'Get ChromeOS device', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Get many ChromeOS devices', + action: 'Get many ChromeOS devices', + }, + { + name: 'Update', + value: 'update', + description: 'Update a ChromeOS device', + action: 'Update ChromeOS device', + }, + { + name: 'Change Status', + value: 'changeStatus', + description: 'Change the status of a ChromeOS device', + action: 'Change status of ChromeOS device', + }, + ], + default: 'get', + }, +]; + +export const deviceFields: INodeProperties[] = [ + /* -------------------------------------------------------------------------- */ + /* device:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Device', + name: 'deviceId', + type: 'resourceLocator', + required: true, + displayOptions: { + show: { + operation: ['get', 'update', 'changeStatus'], + resource: ['device'], + }, + }, + default: { + mode: 'list', + value: '', + }, + description: 'Select the device you want to retrieve', + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchDevices', + }, + }, + { + displayName: 'By ID', + name: 'deviceId', + type: 'string', + hint: 'Enter the device id', + placeholder: 'e.g. 123e4567-e89b-12d3-a456-426614174000', + }, + ], + }, + /* -------------------------------------------------------------------------- */ + /* device:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + operation: ['getAll'], + resource: ['device'], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: ['getAll'], + resource: ['device'], + returnAll: [false], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'Max number of results to return', + }, + { + displayName: 'Output', + name: 'projection', + type: 'options', + required: true, + options: [ + { + name: 'Basic', + value: 'basic', + description: 'Do not include any custom fields for the user', + }, + { + name: 'Full', + value: 'full', + description: 'Include all fields associated with this user', + }, + ], + displayOptions: { + show: { + operation: ['get', 'getAll'], + resource: ['device'], + }, + }, + default: 'basic', + description: 'What subset of fields to fetch for this device', + }, + { + displayName: 'Include Children', + name: 'includeChildOrgunits', + type: 'boolean', + default: false, + description: + 'Whether include devices from organizational units below your specified organizational unit', + displayOptions: { + show: { + operation: ['getAll'], + resource: ['device'], + }, + }, + }, + { + displayName: 'Filter', + name: 'filter', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + operation: ['getAll'], + resource: ['device'], + }, + }, + options: [ + { + displayName: 'Organizational Unit Name or ID', + name: 'orgUnitPath', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getOrgUnits', + }, + default: [], + description: + 'Specify the organizational unit name or ID. Choose from the list or use an expression. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Query', + name: 'query', + type: 'string', + placeholder: 'e.g. name:contact* email:contact*', + default: '', + description: "Use Google's querying syntax to filter results", + }, + ], + }, + { + displayName: 'Sort', + name: 'sort', + type: 'fixedCollection', + placeholder: 'Add Sort Rule', + default: {}, + displayOptions: { + show: { + operation: ['getAll'], + resource: ['device'], + }, + }, + options: [ + { + name: 'sortRules', + displayName: 'Sort Rules', + values: [ + { + displayName: 'Order By', + name: 'orderBy', + type: 'options', + options: [ + { + name: 'Annotated Location', + value: 'annotatedLocation', + }, + { + name: 'Annotated User', + value: 'annotatedUser', + }, + { + name: 'Last Sync', + value: 'lastSync', + }, + { + name: 'Notes', + value: 'notes', + }, + { + name: 'Serial Number', + value: 'serialNumber', + }, + { + name: 'Status', + value: 'status', + }, + ], + default: '', + description: 'Field to sort the results by', + }, + { + displayName: 'Sort Order', + name: 'sortBy', + type: 'options', + options: [ + { + name: 'Ascending', + value: 'ascending', + }, + { + name: 'Descending', + value: 'descending', + }, + ], + default: '', + description: 'Sort order direction', + }, + ], + }, + ], + description: 'Define sorting rules for the results', + }, + /* -------------------------------------------------------------------------- */ + /* device:update...... */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Update Fields', + name: 'updateOptions', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + operation: ['update'], + resource: ['device'], + }, + }, + options: [ + { + displayName: 'Move to Organizational Unit Name or ID', + name: 'orgUnitPath', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getOrgUnits', + }, + default: [], + description: + 'A comma-separated list of schema names. All fields from these schemas are fetched. This should only be set when projection=custom. Choose from the list, or specify IDs using an expression. Choose from the list, or specify an ID using an expression. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Annotated User', + name: 'annotatedUser', + type: 'string', + default: '', + description: 'The annotated User of the device', + placeholder: 'e.g. help desk', + }, + { + displayName: 'Annotated Location', + name: 'annotatedLocation', + type: 'string', + default: '', + description: 'The annotated Location of the device', + placeholder: 'e.g. Mountain View help desk Chromebook', + }, + { + displayName: 'Annotated Asset ID', + name: 'annotatedAssetId', + type: 'string', + default: '', + description: 'The annotated Asset ID of a device', + placeholder: 'e.g. 1234567890', + }, + { + displayName: 'Notes', + name: 'notes', + type: 'string', + default: '', + description: 'Add notes to a device', + placeholder: 'e.g. Loaned from support', + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* device:changeStatus */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Status', + name: 'action', + type: 'options', + required: true, + options: [ + { + name: 'Enabled', + value: 'reenable', + description: 'Re-enable a disabled chromebook', + action: 'Enable a device', + }, + { + name: 'Disabled', + value: 'disable', + description: 'Disable a chromebook', + action: 'Disable a device', + }, + ], + displayOptions: { + show: { + operation: ['changeStatus'], + resource: ['device'], + }, + }, + default: 'Enable', + description: 'Set the status of a device', + }, +]; diff --git a/packages/nodes-base/nodes/Google/GSuiteAdmin/GSuiteAdmin.node.ts b/packages/nodes-base/nodes/Google/GSuiteAdmin/GSuiteAdmin.node.ts index cb2593c83cd79..a6a65e8ca081c 100644 --- a/packages/nodes-base/nodes/Google/GSuiteAdmin/GSuiteAdmin.node.ts +++ b/packages/nodes-base/nodes/Google/GSuiteAdmin/GSuiteAdmin.node.ts @@ -7,10 +7,12 @@ import type { INodeType, INodeTypeDescription, } from 'n8n-workflow'; -import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { ApplicationError, NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { deviceFields, deviceOperations } from './DeviceDescription'; import { googleApiRequest, googleApiRequestAllItems } from './GenericFunctions'; import { groupFields, groupOperations } from './GroupDescripion'; +import { searchDevices, searchGroups, searchUsers } from './SearchFunctions'; import { userFields, userOperations } from './UserDescription'; export class GSuiteAdmin implements INodeType { @@ -49,6 +51,10 @@ export class GSuiteAdmin implements INodeType { name: 'User', value: 'user', }, + { + name: 'ChromeOS Device', + value: 'device', + }, ], default: 'user', }, @@ -56,13 +62,14 @@ export class GSuiteAdmin implements INodeType { ...groupFields, ...userOperations, ...userFields, + ...deviceOperations, + ...deviceFields, ], }; methods = { loadOptions: { - // Get all the domains to display them to user so that they can - // select them easily + // Get all the domains async getDomains(this: ILoadOptionsFunctions): Promise { const returnData: INodePropertyOptions[] = []; const domains = await googleApiRequestAllItems.call( @@ -81,27 +88,92 @@ export class GSuiteAdmin implements INodeType { } return returnData; }, - // Get all the schemas to display them to user so that they can - // select them easily + //Get all the schemas async getSchemas(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; const schemas = await googleApiRequestAllItems.call( this, 'schemas', 'GET', '/directory/v1/customer/my_customer/schemas', ); - for (const schema of schemas) { - const schemaName = schema.displayName; - const schemaId = schema.schemaName; - returnData.push({ - name: schemaName, - value: schemaId, - }); + return schemas.map((schema: { schemaName: string; displayName: string }) => ({ + name: schema.displayName || schema.schemaName, + value: schema.schemaName, + })); + }, + // async getSchemaFields(this: ILoadOptionsFunctions): Promise { + // const additionalFields = this.getNodeParameter('additionalFields', {}) as IDataObject; + // this.getExecutionId(); + // if (additionalFields.customFields) { + // } else { + // return [ + // { + // name: 'Invalid Schema: Missing schemaName.', + // value: '', + // }, + // ]; + // } + // const customFields = (additionalFields.customFields as IDataObject) + // .fieldValues as IDataObject[]; + // const schemaName = customFields?.[0]?.schemaName as string; + + // try { + // const schema = await googleApiRequest.call( + // this, + // 'GET', + // `/directory/v1/customer/my_customer/schemas/${schemaName}`, + // ); + + // if (schema.fields && Array.isArray(schema.fields)) { + // return schema.fields.map((field: { fieldName: string; displayName: string }) => ({ + // name: field.displayName || field.fieldName, + // value: field.fieldName, + // })); + // } else { + // } + // } catch (error) { + // console.error('Error fetching schema fields:', error); + // } + + // return []; + // }, + async getOrgUnits(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const orgUnits = await googleApiRequest.call( + this, + 'GET', + '/directory/v1/customer/my_customer/orgunits', + {}, + { orgUnitPath: '/', type: 'all' }, + ); + + // Check if organizationUnits exist and are iterable + if (orgUnits.organizationUnits && Array.isArray(orgUnits.organizationUnits)) { + if (orgUnits.organizationUnits.length === 0) { + throw new ApplicationError( + 'No organizational units found. Please create organizational units in the Google Admin Console under "Directory > Organizational units".', + ); + } + + for (const unit of orgUnits.organizationUnits) { + returnData.push({ + name: unit.name, + value: unit.orgUnitPath, + }); + } + } else { + throw new ApplicationError( + 'Failed to retrieve organizational units. Ensure your account has organizational units configured.', + ); } return returnData; }, }, + listSearch: { + searchGroups, + searchUsers, + searchDevices, + }, }; async execute(this: IExecuteFunctions): Promise { @@ -117,11 +189,12 @@ export class GSuiteAdmin implements INodeType { if (resource === 'group') { //https://developers.google.com/admin-sdk/directory/v1/reference/groups/insert if (operation === 'create') { + const name = this.getNodeParameter('name', i) as string; const email = this.getNodeParameter('email', i) as string; - const additionalFields = this.getNodeParameter('additionalFields', i); const body: IDataObject = { + name, email, }; @@ -132,8 +205,14 @@ export class GSuiteAdmin implements INodeType { //https://developers.google.com/admin-sdk/directory/v1/reference/groups/delete if (operation === 'delete') { - const groupId = this.getNodeParameter('groupId', i) as string; - + const groupId = (this.getNodeParameter('groupId', i) as IDataObject).value; + if (!groupId) { + throw new NodeOperationError( + this.getNode(), + 'Group ID is required but was not provided.', + { itemIndex: i }, + ); + } responseData = await googleApiRequest.call( this, 'DELETE', @@ -146,8 +225,15 @@ export class GSuiteAdmin implements INodeType { //https://developers.google.com/admin-sdk/directory/v1/reference/groups/get if (operation === 'get') { - const groupId = this.getNodeParameter('groupId', i) as string; + const groupId = (this.getNodeParameter('groupId', i) as IDataObject).value; + if (!groupId) { + throw new NodeOperationError( + this.getNode(), + 'Group ID is required but was not provided.', + { itemIndex: i }, + ); + } responseData = await googleApiRequest.call( this, 'GET', @@ -155,19 +241,57 @@ export class GSuiteAdmin implements INodeType { {}, ); } - //https://developers.google.com/admin-sdk/directory/v1/reference/groups/list if (operation === 'getAll') { const returnAll = this.getNodeParameter('returnAll', i); + const filter = this.getNodeParameter('filter', i, {}) as IDataObject; + const sort = this.getNodeParameter('sort', i, {}) as IDataObject; - const options = this.getNodeParameter('options', i); + if (filter.customer) { + qs.customer = filter.customer as string; + } - Object.assign(qs, options); + if (filter.domain) { + qs.domain = filter.domain as string; + } - if (qs.customer === undefined) { + if (filter.query && typeof filter.query === 'string') { + const query = filter.query.trim(); + + // Validate the query format + const regex = /^(name|email):\S+$/; + if (!regex.test(query)) { + throw new NodeOperationError( + this.getNode(), + 'Invalid query format. Query must follow the format "displayName:" or "email:".', + ); + } + + qs.query = query; + } + + if (filter.userId) { + qs.userId = filter.userId as string; + } + + // Handle sort options + if (sort.sortRules) { + const { orderBy, sortOrder } = sort.sortRules as { + orderBy?: string; + sortOrder?: string; + }; + if (orderBy) { + qs.orderBy = orderBy; + } + if (sortOrder) { + qs.sortOrder = sortOrder; + } + } + if (!qs.customer) { qs.customer = 'my_customer'; } + // Fetch all or limited results if (returnAll) { responseData = await googleApiRequestAllItems.call( this, @@ -188,13 +312,21 @@ export class GSuiteAdmin implements INodeType { qs, ); - responseData = responseData.groups; + responseData = responseData.groups || []; } } //https://developers.google.com/admin-sdk/directory/v1/reference/groups/update if (operation === 'update') { - const groupId = this.getNodeParameter('groupId', i) as string; + const groupId = (this.getNodeParameter('groupId', i) as IDataObject).value; + + if (!groupId) { + throw new NodeOperationError( + this.getNode(), + 'Group ID is required but was not provided.', + { itemIndex: i }, + ); + } const updateFields = this.getNodeParameter('updateFields', i); @@ -212,22 +344,75 @@ export class GSuiteAdmin implements INodeType { } if (resource === 'user') { + //https://developers.google.com/admin-sdk/directory/reference/rest/v1/members/insert + if (operation === 'addToGroup') { + const groupId = (this.getNodeParameter('groupId', i) as IDataObject).value; + const userIdParam = this.getNodeParameter('userId', i); + const userId = + typeof userIdParam === 'string' ? userIdParam : (userIdParam as IDataObject)?.value; + + if (!userId || typeof userId !== 'string') { + throw new NodeOperationError( + this.getNode(), + 'Invalid or missing user ID. Please provide a valid user ID.', + { itemIndex: i }, + ); + } + + let userEmail: string | undefined; + + // If the user ID is not already an email, fetch the user details + if (!userId.includes('@')) { + console.log('User ID is not an email; fetching user details...'); + const userDetails = await googleApiRequest.call( + this, + 'GET', + `/directory/v1/users/${userId}`, + ); + userEmail = userDetails.primaryEmail; + } else { + userEmail = userId; + } + + if (!userEmail) { + throw new NodeOperationError( + this.getNode(), + 'Unable to determine the user email for adding to the group.', + { itemIndex: i }, + ); + } + + const body: IDataObject = { + email: userEmail, + role: 'MEMBER', + }; + + responseData = await googleApiRequest.call( + this, + 'POST', + `/directory/v1/groups/${groupId}/members`, + body, + ); + + responseData = { added: true }; + } + //https://developers.google.com/admin-sdk/directory/v1/reference/users/insert if (operation === 'create') { const domain = this.getNodeParameter('domain', i) as string; - const firstName = this.getNodeParameter('firstName', i) as string; - const lastName = this.getNodeParameter('lastName', i) as string; - const password = this.getNodeParameter('password', i) as string; - const username = this.getNodeParameter('username', i) as string; - const makeAdmin = this.getNodeParameter('makeAdmin', i) as boolean; - const additionalFields = this.getNodeParameter('additionalFields', i); + if (!username) { + throw new NodeOperationError(this.getNode(), 'The parameter ‘Username’ is empty', { + itemIndex: i, + description: 'Please fill in the ‘Username’ parameter to create the user', + }); + } const body: IDataObject = { name: { familyName: lastName, @@ -241,20 +426,61 @@ export class GSuiteAdmin implements INodeType { if (additionalFields.phoneUi) { const phones = (additionalFields.phoneUi as IDataObject).phoneValues as IDataObject[]; - body.phones = phones; - delete body.phoneUi; } if (additionalFields.emailUi) { const emails = (additionalFields.emailUi as IDataObject).emailValues as IDataObject[]; - body.emails = emails; - delete body.emailUi; } + if (additionalFields.roles) { + const roles = additionalFields.roles as string[]; + body.roles = { + superAdmin: roles.includes('superAdmin'), + groupsAdmin: roles.includes('groupsAdmin'), + groupsReader: roles.includes('groupsReader'), + groupsEditor: roles.includes('groupsEditor'), + userManagement: roles.includes('userManagement'), + helpDeskAdmin: roles.includes('helpDeskAdmin'), + servicesAdmin: roles.includes('servicesAdmin'), + inventoryReportingAdmin: roles.includes('inventoryReportingAdmin'), + storageAdmin: roles.includes('storageAdmin'), + directorySyncAdmin: roles.includes('directorySyncAdmin'), + mobileAdmin: roles.includes('mobileAdmin'), + }; + } + + if (additionalFields.customFields) { + const customFields = (additionalFields.customFields as IDataObject) + .fieldValues as IDataObject[]; + const customSchemas: IDataObject = {}; + customFields.forEach((field) => { + const { schemaName, fieldName, value } = field as { + schemaName: string; + fieldName: string; + value: any; + }; + + if (!schemaName || !fieldName || value === undefined || value === '') { + console.error('Missing schemaName, fieldName, or value in customFields:', field); + return; + } + + if (!customSchemas[schemaName]) { + customSchemas[schemaName] = {}; + } + + (customSchemas[schemaName] as IDataObject)[fieldName] = value; + }); + + if (Object.keys(customSchemas).length > 0) { + body.customSchemas = customSchemas; + } + } + // Send the final request to create the user responseData = await googleApiRequest.call( this, 'POST', @@ -262,22 +488,18 @@ export class GSuiteAdmin implements INodeType { body, qs, ); - - if (makeAdmin) { - await googleApiRequest.call( - this, - 'POST', - `/directory/v1/users/${responseData.id}/makeAdmin`, - { status: true }, - ); - - responseData.isAdmin = true; - } } //https://developers.google.com/admin-sdk/directory/v1/reference/users/delete if (operation === 'delete') { - const userId = this.getNodeParameter('userId', i) as string; + const userId = (this.getNodeParameter('userId', i) as IDataObject).value; + if (!userId) { + throw new NodeOperationError( + this.getNode(), + 'User ID is required but was not provided.', + { itemIndex: i }, + ); + } responseData = await googleApiRequest.call( this, @@ -286,33 +508,40 @@ export class GSuiteAdmin implements INodeType { {}, ); - responseData = { success: true }; + responseData = { deleted: true }; } //https://developers.google.com/admin-sdk/directory/v1/reference/users/get if (operation === 'get') { - const userId = this.getNodeParameter('userId', i) as string; - - const projection = this.getNodeParameter('projection', i) as string; + const userId = (this.getNodeParameter('userId', i) as IDataObject).value; - const options = this.getNodeParameter('options', i); + const output = this.getNodeParameter('output', i); + const projection = this.getNodeParameter('projection', i); + const fields = this.getNodeParameter('fields', i, []) as string[]; - qs.projection = projection; - - Object.assign(qs, options); - - if (qs.customFieldMask) { - qs.customFieldMask = (qs.customFieldMask as string[]).join(' '); - } - - if (qs.projection === 'custom' && qs.customFieldMask === undefined) { + // Validate User ID + if (!userId) { throw new NodeOperationError( this.getNode(), - 'When projection is set to custom, the custom schemas field must be defined', + 'User ID is required but was not provided.', { itemIndex: i }, ); } + if (projection) { + qs.projection = projection; + } + qs.projection = projection; + if (projection === 'custom' && qs.customFieldMask) { + qs.customFieldMask = (qs.customFieldMask as string[]).join(','); + } + if (output === 'select') { + if (!fields.includes('id')) { + fields.push('id'); + } + qs.fields = fields.join(','); + } + responseData = await googleApiRequest.call( this, 'GET', @@ -320,34 +549,76 @@ export class GSuiteAdmin implements INodeType { {}, qs, ); + + if (output === 'simplified') { + responseData = { + kind: responseData.kind, + id: responseData.id, + primaryEmail: responseData.primaryEmail, + name: responseData.name, + isAdmin: responseData.isAdmin, + lastLoginTime: responseData.lastLoginTime, + creationTime: responseData.creationTime, + suspended: responseData.suspended, + }; + } } //https://developers.google.com/admin-sdk/directory/v1/reference/users/list if (operation === 'getAll') { const returnAll = this.getNodeParameter('returnAll', i); - + const output = this.getNodeParameter('output', i); + const fields = this.getNodeParameter('fields', i, []) as string[]; const projection = this.getNodeParameter('projection', i) as string; + const filter = this.getNodeParameter('filter', i, {}) as IDataObject; + const sort = this.getNodeParameter('sort', i, {}) as IDataObject; + + if (filter.customer) { + qs.customer = filter.customer as string; + } - const options = this.getNodeParameter('options', i); + if (filter.domain) { + qs.domain = filter.domain as string; + } - qs.projection = projection; + if (filter.query && typeof filter.query === 'string') { + const query = filter.query.trim(); + if (query) { + qs.query = query; + } + } - Object.assign(qs, options); + if (filter.showDeleted) { + qs.showDeleted = filter.showDeleted === true ? 'true' : 'false'; + } - if (qs.customer === undefined) { - qs.customer = 'my_customer'; + if (sort.sortRules) { + const { orderBy, sortOrder } = sort.sortRules as { + orderBy?: string; + sortOrder?: string; + }; + if (orderBy) { + qs.orderBy = orderBy; + } + if (sortOrder) { + qs.sortOrder = sortOrder; + } } - if (qs.customFieldMask) { - qs.customFieldMask = (qs.customFieldMask as string[]).join(' '); + qs.projection = projection; + if (projection === 'custom' && qs.customFieldMask) { + qs.customFieldMask = (qs.customFieldMask as string[]).join(','); } - if (qs.projection === 'custom' && qs.customFieldMask === undefined) { - throw new NodeOperationError( - this.getNode(), - 'When projection is set to custom, the custom schemas field must be defined', - { itemIndex: i }, - ); + if (output === 'select') { + if (!fields.includes('id')) { + fields.push('id'); + } + qs.fields = `users(${fields.join(',')})`; + } + + if (!qs.customer) { + qs.customer = 'my_customer'; } if (returnAll) { @@ -361,7 +632,6 @@ export class GSuiteAdmin implements INodeType { ); } else { qs.maxResults = this.getNodeParameter('limit', i); - responseData = await googleApiRequest.call( this, 'GET', @@ -372,55 +642,131 @@ export class GSuiteAdmin implements INodeType { responseData = responseData.users; } + + if (output === 'simplified') { + responseData = responseData.map((user: any) => ({ + kind: user.kind, + id: user.id, + primaryEmail: user.primaryEmail, + name: user.name, + isAdmin: user.isAdmin, + lastLoginTime: user.lastLoginTime, + creationTime: user.creationTime, + suspended: user.suspended, + })); + } + } + + //https://developers.google.com/admin-sdk/directory/reference/rest/v1/members/delete + if (operation === 'removeFromGroup') { + const groupId = (this.getNodeParameter('groupId', i) as IDataObject).value; + const userId = (this.getNodeParameter('userId', i) as IDataObject).value; + + await googleApiRequest.call( + this, + 'DELETE', + `/directory/v1/groups/${groupId}/members/${userId}`, + ); + + responseData = { removed: true }; } //https://developers.google.com/admin-sdk/directory/v1/reference/users/update if (operation === 'update') { - const userId = this.getNodeParameter('userId', i) as string; - + const userId = (this.getNodeParameter('userId', i) as IDataObject).value; const updateFields = this.getNodeParameter('updateFields', i); + // Validate User ID + if (!userId) { + throw new NodeOperationError( + this.getNode(), + 'User ID is required but was not provided.', + { itemIndex: i }, + ); + } + const body: { - name: { givenName?: string; familyName?: string }; + name?: { givenName?: string; familyName?: string }; emails?: IDataObject[]; + primaryEmail?: string; phones?: IDataObject[]; - } = { name: {} }; - - Object.assign(body, updateFields); + suspended?: boolean; + roles?: { [key: string]: boolean }; + customSchemas?: IDataObject; + } = {}; if (updateFields.firstName) { + body.name = body.name || {}; body.name.givenName = updateFields.firstName as string; - //@ts-ignore - delete body.firstName; } if (updateFields.lastName) { + body.name = body.name || {}; body.name.familyName = updateFields.lastName as string; - //@ts-ignore - delete body.lastName; - } - - if (Object.keys(body.name).length === 0) { - //@ts-ignore - delete body.name; } if (updateFields.phoneUi) { const phones = (updateFields.phoneUi as IDataObject).phoneValues as IDataObject[]; - body.phones = phones; - - //@ts-ignore - delete body.phoneUi; } if (updateFields.emailUi) { const emails = (updateFields.emailUi as IDataObject).emailValues as IDataObject[]; - body.emails = emails; + } - //@ts-ignore - delete body.emailUi; + if (updateFields.primaryEmail) { + body.primaryEmail = updateFields.primaryEmail as string; + } + + if (typeof updateFields.suspendUi === 'boolean') { + body.suspended = updateFields.suspendUi; + } + + if (updateFields.roles) { + const roles = updateFields.roles as string[]; + + body.roles = { + superAdmin: roles.includes('superAdmin'), + groupsAdmin: roles.includes('groupsAdmin'), + groupsReader: roles.includes('groupsReader'), + groupsEditor: roles.includes('groupsEditor'), + userManagement: roles.includes('userManagement'), + helpDeskAdmin: roles.includes('helpDeskAdmin'), + servicesAdmin: roles.includes('servicesAdmin'), + inventoryReportingAdmin: roles.includes('inventoryReportingAdmin'), + storageAdmin: roles.includes('storageAdmin'), + directorySyncAdmin: roles.includes('directorySyncAdmin'), + mobileAdmin: roles.includes('mobileAdmin'), + }; + } + + if (updateFields.customFields) { + const customFields = (updateFields.customFields as IDataObject) + .fieldValues as IDataObject[]; + const customSchemas: IDataObject = {}; + customFields.forEach((field) => { + const { schemaName, fieldName, value } = field as { + schemaName: string; + fieldName: string; + value: any; + }; + + if (!schemaName || !fieldName || value === undefined || value === '') { + console.error('Missing schemaName, fieldName, or value in customFields:', field); + return; + } + + if (!customSchemas[schemaName]) { + customSchemas[schemaName] = {}; + } + + (customSchemas[schemaName] as IDataObject)[fieldName] = value; + }); + + if (Object.keys(customSchemas).length > 0) { + body.customSchemas = customSchemas; + } } responseData = await googleApiRequest.call( @@ -433,6 +779,122 @@ export class GSuiteAdmin implements INodeType { } } + if (resource === 'device') { + //https://developers.google.com/admin-sdk/directory/v1/customer/my_customer/devices/chromeos/deviceId + if (operation === 'get') { + const deviceIdObject = this.getNodeParameter('deviceId', i) as IDataObject; + const deviceId = deviceIdObject.value as string; + const output = this.getNodeParameter('projection', 1); + + // Validate deviceId + if (!deviceId) { + throw new NodeOperationError( + this.getNode(), + 'deviceId is required but was not provided.', + { itemIndex: i }, + ); + } + + responseData = await googleApiRequest.call( + this, + 'GET', + `/directory/v1/customer/my_customer/devices/chromeos/${deviceId}?projection=${output}`, + {}, + ); + } + + //https://developers.google.com/admin-sdk/directory/reference/rest/v1/chromeosdevices/list + if (operation === 'getAll') { + const returnAll = this.getNodeParameter('returnAll', i); + const output = this.getNodeParameter('projection', 1); + const includeChildren = this.getNodeParameter('includeChildOrgunits', i); + const filter = this.getNodeParameter('filter', i, {}) as IDataObject; + const sort = this.getNodeParameter('sort', i, {}) as IDataObject; + + qs.projection = output; + + qs.includeChildOrgunits = includeChildren; + if (qs.customer === undefined) { + qs.customer = 'my_customer'; + } + + if (filter.orgUnitPath) { + qs.orgUnitPath = filter.orgUnitPath as string; + } + if (filter.query && typeof filter.query === 'string') { + const query = filter.query.trim(); + if (query) { + qs.query = query; + } + } + if (sort.sortRules) { + const { orderBy, sortOrder } = sort.sortRules as { + orderBy?: string; + sortOrder?: string; + }; + if (orderBy) { + qs.orderBy = orderBy; + } + if (sortOrder) { + qs.sortOrder = sortOrder; + } + } + + if (!returnAll) { + qs.maxResults = this.getNodeParameter('limit', i); + } + + responseData = await googleApiRequest.call( + this, + 'GET', + `/directory/v1/customer/${qs.customer}/devices/chromeos/`, + {}, + qs, + ); + if (!responseData || responseData.length === 0) { + return [this.helpers.returnJsonArray({})]; + } + + return [this.helpers.returnJsonArray(responseData)]; + } + + if (operation === 'update') { + const deviceIdObject = this.getNodeParameter('deviceId', i) as IDataObject; + const deviceId = deviceIdObject.value as string; + const updateOptions = this.getNodeParameter('updateOptions', 1); + + // Validate deviceId + if (!deviceId) { + throw new NodeOperationError( + this.getNode(), + 'deviceId is required but was not provided.', + { itemIndex: i }, + ); + } + Object.assign(qs, updateOptions); + responseData = await googleApiRequest.call( + this, + 'PUT', + `/directory/v1/customer/my_customer/devices/chromeos/${deviceId}`, + qs, + ); + } + + if (operation === 'changeStatus') { + const deviceIdObject = this.getNodeParameter('deviceId', i) as IDataObject; + const deviceId = deviceIdObject.value as string; + const action = this.getNodeParameter('action', 1); + + qs.action = action; + responseData = await googleApiRequest.call( + this, + 'POST', + `/directory/v1/customer/my_customer/devices/chromeos/${deviceId}/action`, + qs, + ); + } + } + const executionData = this.helpers.constructExecutionMetaData( this.helpers.returnJsonArray(responseData as IDataObject[]), { itemData: { item: i } }, @@ -440,15 +902,29 @@ export class GSuiteAdmin implements INodeType { returnData.push(...executionData); } catch (error) { + if (error instanceof NodeOperationError) { + throw error; + } if (this.continueOnFail()) { const executionErrorData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray({ error: error.message }), + this.helpers.returnJsonArray({ + message: `Operation "${operation}" failed for resource "${resource}".`, + description: error.message, + }), { itemData: { item: i } }, ); returnData.push(...executionErrorData); continue; } - throw error; + throw new NodeOperationError( + this.getNode(), + `Operation "${operation}" failed for resource "${resource}".`, + + { + description: `Please check the input parameters and ensure the API request is correctly formatted. Details: ${error.description}`, + itemIndex: i, + }, + ); } } return [returnData]; diff --git a/packages/nodes-base/nodes/Google/GSuiteAdmin/GroupDescripion.ts b/packages/nodes-base/nodes/Google/GSuiteAdmin/GroupDescripion.ts index 3646800f9abc8..4174c55cc92a5 100644 --- a/packages/nodes-base/nodes/Google/GSuiteAdmin/GroupDescripion.ts +++ b/packages/nodes-base/nodes/Google/GSuiteAdmin/GroupDescripion.ts @@ -51,11 +51,26 @@ export const groupFields: INodeProperties[] = [ /* -------------------------------------------------------------------------- */ /* group:create */ /* -------------------------------------------------------------------------- */ + + { + displayName: 'Group Name', + name: 'name', + displayOptions: { + show: { + operation: ['create'], + resource: ['group'], + }, + }, + default: '', + description: "The group's display name", + placeholder: 'e.g. Sales', + type: 'string', + }, { - displayName: 'Email', + displayName: 'Group Email', name: 'email', type: 'string', - placeholder: 'name@email.com', + placeholder: 'e.g. sales@example.com', required: true, displayOptions: { show: { @@ -88,40 +103,51 @@ export const groupFields: INodeProperties[] = [ description: 'An extended description to help users determine the purpose of a group. For example, you can include information about who should join the group, the types of messages to send to the group, links to FAQs about the group, or related groups.', }, - { - displayName: 'Name', - name: 'name', - type: 'string', - default: '', - description: "The group's display name", - }, ], }, /* -------------------------------------------------------------------------- */ /* group:delete */ /* -------------------------------------------------------------------------- */ { - displayName: 'Group ID', + displayName: 'Group', name: 'groupId', - type: 'string', - required: true, + default: { + mode: 'list', + value: '', + }, + description: 'Select the group you want to delete', displayOptions: { show: { operation: ['delete'], resource: ['group'], }, }, - default: '', - description: - "Identifies the group in the API request. The value can be the group's email address, group alias, or the unique group ID.", + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchGroups', + }, + }, + { + displayName: 'By ID', + name: 'GroupId', + type: 'string', + placeholder: 'e.g. 0123kx3o1habcdf', + }, + ], + required: true, + type: 'resourceLocator', }, /* -------------------------------------------------------------------------- */ /* group:get */ /* -------------------------------------------------------------------------- */ { - displayName: 'Group ID', + displayName: 'Group', name: 'groupId', - type: 'string', + type: 'resourceLocator', required: true, displayOptions: { show: { @@ -129,13 +155,32 @@ export const groupFields: INodeProperties[] = [ resource: ['group'], }, }, - default: '', - description: - "Identifies the group in the API request. The value can be the group's email address, group alias, or the unique group ID.", + default: { + mode: 'list', + value: '', + }, + description: 'Select the group you want to retrieve', + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchGroups', + }, + }, + { + displayName: 'By ID', + name: 'groupId', + type: 'string', + placeholder: 'e.g. 0123kx3o1habcdf', + }, + ], }, /* -------------------------------------------------------------------------- */ /* group:getAll */ /* -------------------------------------------------------------------------- */ + /* -------------------------------------------------------------------------- */ { displayName: 'Return All', name: 'returnAll', @@ -168,10 +213,10 @@ export const groupFields: INodeProperties[] = [ description: 'Max number of results to return', }, { - displayName: 'Options', - name: 'options', + displayName: 'Filter', + name: 'filter', type: 'collection', - placeholder: 'Add option', + placeholder: 'Add Filter', default: {}, displayOptions: { show: { @@ -185,61 +230,81 @@ export const groupFields: INodeProperties[] = [ name: 'customer', type: 'string', default: '', - description: - "The unique ID for the customer's Google Workspace account. In case of a multi-domain account, to fetch all groups for a customer, fill this field instead of domain.", + description: "The unique ID for the customer's Google Workspace account", }, { displayName: 'Domain', name: 'domain', type: 'string', default: '', - description: 'The domain name. Use this field to get fields from only one domain.', - }, - { - displayName: 'Order By', - name: 'orderBy', - type: 'options', - options: [ - { - name: 'Email', - value: 'email', - }, - ], - default: '', - description: 'Property to use for sorting results', + description: 'The domain name. Use this field to get groups from a specific domain.', }, { displayName: 'Query', name: 'query', type: 'string', + placeholder: 'e.g. name:contact* email:contact*', default: '', description: - 'Query string search. Complete documentation is at.', + 'Query string to filter the results. Follow Google Admin SDK documentation. More info.', + }, + { + displayName: 'User ID', + name: 'userId', + type: 'string', + default: '', + description: 'Email or immutable ID of a user to list groups they are a member of', }, + ], + }, + { + displayName: 'Sort', + name: 'sort', + type: 'fixedCollection', + placeholder: 'Add Sort Rule', + default: {}, + displayOptions: { + show: { + operation: ['getAll'], + resource: ['group'], + }, + }, + options: [ { - displayName: 'Sort Order', - name: 'sortOrder', - type: 'options', - options: [ + name: 'sortRules', + displayName: 'Sort Rules', + values: [ { - name: 'Ascending', - value: 'ASCENDING', + displayName: 'Order By', + name: 'orderBy', + type: 'options', + options: [ + { + name: 'Email', + value: 'email', + }, + ], + default: 'email', + description: 'Field to sort the results by', }, { - name: 'Descending', - value: 'DESCENDING', + displayName: 'Sort Order', + name: 'sortOrder', + type: 'options', + options: [ + { + name: 'Ascending', + value: 'ASCENDING', + }, + { + name: 'Descending', + value: 'DESCENDING', + }, + ], + default: 'ASCENDING', + description: 'Sort order direction', }, ], - default: '', - description: 'Whether to return results in ascending or descending order', - }, - { - displayName: 'User ID', - name: 'userId', - type: 'string', - default: '', - description: - "Email or immutable ID of the user if only those groups are to be listed, the given user is a member of. If it's an ID, it should match with the ID of the user object.", }, ], }, @@ -247,19 +312,37 @@ export const groupFields: INodeProperties[] = [ /* group:update */ /* -------------------------------------------------------------------------- */ { - displayName: 'Group ID', + displayName: 'Group', name: 'groupId', - type: 'string', - required: true, displayOptions: { show: { operation: ['update'], resource: ['group'], }, }, - default: '', - description: - "Identifies the group in the API request. The value can be the group's email address, group alias, or the unique group ID.", + default: { + mode: 'list', + value: '', + }, + description: 'Select the group you want to update', + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchGroups', + }, + }, + { + displayName: 'By ID', + name: 'groupId', + type: 'string', + placeholder: 'e.g. 0123kx3o1habcdf', + }, + ], + required: true, + type: 'resourceLocator', }, { displayName: 'Update Fields', @@ -279,6 +362,9 @@ export const groupFields: INodeProperties[] = [ name: 'description', type: 'string', default: '', + typeOptions: { + rows: 2, + }, description: 'An extended description to help users determine the purpose of a group. For example, you can include information about who should join the group, the types of messages to send to the group, links to FAQs about the group, or related groups.', }, @@ -286,7 +372,7 @@ export const groupFields: INodeProperties[] = [ displayName: 'Email', name: 'email', type: 'string', - placeholder: 'name@email.com', + placeholder: 'e.g. sales@example.com', default: '', description: "The group's email address. If your account has multiple domains, select the appropriate domain for the email address. The email must be unique.", @@ -295,6 +381,7 @@ export const groupFields: INodeProperties[] = [ displayName: 'Name', name: 'name', type: 'string', + placeholder: 'e.g. Sales', default: '', description: "The group's display name", }, diff --git a/packages/nodes-base/nodes/Google/GSuiteAdmin/SearchFunctions.ts b/packages/nodes-base/nodes/Google/GSuiteAdmin/SearchFunctions.ts new file mode 100644 index 0000000000000..eac0623490446 --- /dev/null +++ b/packages/nodes-base/nodes/Google/GSuiteAdmin/SearchFunctions.ts @@ -0,0 +1,98 @@ +import type { + ILoadOptionsFunctions, + IDataObject, + INodeListSearchResult, + INodeListSearchItems, +} from 'n8n-workflow'; + +import { googleApiRequest, googleApiRequestAllItems } from './GenericFunctions'; + +export async function searchUsers(this: ILoadOptionsFunctions): Promise { + const qs: IDataObject = { + customer: 'my_customer', + }; + + // Perform the API request to list all users + const responseData = await googleApiRequestAllItems.call( + this, + 'users', + 'GET', + '/directory/v1/users', + {}, + qs, + ); + + // Handle cases where no users are found + if (!responseData || responseData.length === 0) { + return { results: [] }; + } + + //Map the API response + const results: INodeListSearchItems[] = responseData.map( + (user: { name?: { fullName?: string }; id?: string }) => ({ + name: user.name?.fullName, + value: user.id, + }), + ); + + return { results }; +} + +export async function searchGroups(this: ILoadOptionsFunctions): Promise { + const qs: IDataObject = { + customer: 'my_customer', + }; + + // Perform the API request to list all groups + const responseData = await googleApiRequestAllItems.call( + this, + 'groups', + 'GET', + '/directory/v1/groups', + {}, + qs, + ); + + if (!responseData || responseData.length === 0) { + return { results: [] }; + } + + //Map the API response + const results: INodeListSearchItems[] = responseData.map( + (group: { name?: string; email?: string; id?: string }) => ({ + name: group.name || group.email || 'Unnamed Group', + value: group.id, + }), + ); + + return { results }; +} + +export async function searchDevices(this: ILoadOptionsFunctions): Promise { + const qs: IDataObject = { + customerId: 'my_customer', + }; + + // Perform the API request to list all ChromeOS devices + const responseData = await googleApiRequest.call( + this, + 'GET', + '/directory/v1/customer/my_customer/devices/chromeos/', + {}, + qs, + ); + + if (!responseData || !responseData.chromeosdevices || responseData.chromeosdevices.length === 0) { + return { results: [] }; + } + + // Map the API response + const results: INodeListSearchItems[] = responseData.chromeosdevices.map( + (device: { deviceId?: string; serialNumber?: string }) => ({ + name: device.serialNumber || 'Unknown Device', + value: device.deviceId, + }), + ); + + return { results }; +} diff --git a/packages/nodes-base/nodes/Google/GSuiteAdmin/UserDescription.ts b/packages/nodes-base/nodes/Google/GSuiteAdmin/UserDescription.ts index f4dccd8fe72d7..5386a6f652536 100644 --- a/packages/nodes-base/nodes/Google/GSuiteAdmin/UserDescription.ts +++ b/packages/nodes-base/nodes/Google/GSuiteAdmin/UserDescription.ts @@ -12,6 +12,12 @@ export const userOperations: INodeProperties[] = [ }, }, options: [ + { + name: 'Add to Group', + value: 'addToGroup', + description: 'Add an existing user to a group', + action: 'Add user to group', + }, { name: 'Create', value: 'create', @@ -36,6 +42,12 @@ export const userOperations: INodeProperties[] = [ description: 'Get many users', action: 'Get many users', }, + { + name: 'Remove From Group', + value: 'removeFromGroup', + description: 'Remove a user from a group', + action: 'Remove user from group', + }, { name: 'Update', value: 'update', @@ -48,12 +60,99 @@ export const userOperations: INodeProperties[] = [ ]; export const userFields: INodeProperties[] = [ + /* -------------------------------------------------------------------------- */ + /* user:addToGroup */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'User', + name: 'userId', + required: true, + type: 'resourceLocator', + default: { + mode: 'list', + value: '', + }, + description: 'Select the user you want to add to the group', + displayOptions: { + show: { + resource: ['user'], + operation: ['addToGroup'], + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchUsers', + }, + }, + { + displayName: 'By Email', + name: 'userEmail', + type: 'string', + hint: 'Enter the user email', + placeholder: 'e.g. sales@example.com', + validation: [ + { + type: 'regex', + properties: { + regex: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$', + errorMessage: 'Please enter a valid email address.', + }, + }, + ], + }, + { + displayName: 'By ID', + name: 'userId', + type: 'string', + hint: 'Enter the user id', + placeholder: 'e.g. 123456789879230471055', + }, + ], + }, + { + displayName: 'Group', + name: 'groupId', + required: true, + type: 'resourceLocator', + default: { + mode: 'list', + value: '', + }, + description: 'Select the group you want to add the user to', + displayOptions: { + show: { + resource: ['user'], + operation: ['addToGroup'], + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchGroups', + }, + }, + { + displayName: 'By ID', + name: 'groupId', + type: 'string', + placeholder: 'e.g. 0123kx3o1habcdf', + }, + ], + }, /* -------------------------------------------------------------------------- */ /* user:create */ /* -------------------------------------------------------------------------- */ { displayName: 'First Name', name: 'firstName', + placeholder: 'e.g. Nathan', type: 'string', required: true, displayOptions: { @@ -69,6 +168,7 @@ export const userFields: INodeProperties[] = [ name: 'lastName', type: 'string', required: true, + placeholder: 'e.g. Smith', displayOptions: { show: { operation: ['create'], @@ -95,28 +195,11 @@ export const userFields: INodeProperties[] = [ description: 'Stores the password for the user account. A minimum of 8 characters is required. The maximum length is 100 characters.', }, - { - displayName: 'Domain Name or ID', - name: 'domain', - type: 'options', - description: - 'Choose from the list, or specify an ID using an expression', - typeOptions: { - loadOptionsMethod: 'getDomains', - }, - required: true, - displayOptions: { - show: { - operation: ['create'], - resource: ['user'], - }, - }, - default: '', - }, { displayName: 'Username', name: 'username', type: 'string', + placeholder: 'e.g. n.smith', displayOptions: { show: { operation: ['create'], @@ -125,12 +208,17 @@ export const userFields: INodeProperties[] = [ }, default: '', description: - "The username that will be set to the user. Example: If you domain is example.com and you set the username to jhon then the user's final email address will be jhon@example.com.", + "The username that will be set to the user. Example: If you domain is example.com and you set the username to n.smith then the user's final email address will be n.smith@example.com.", }, { - displayName: 'Make Admin', - name: 'makeAdmin', - type: 'boolean', + displayName: 'Domain Name or ID', + name: 'domain', + type: 'options', + description: + 'Choose from the list, or specify an ID using an expression', + typeOptions: { + loadOptionsMethod: 'getDomains', + }, required: true, displayOptions: { show: { @@ -138,8 +226,7 @@ export const userFields: INodeProperties[] = [ resource: ['user'], }, }, - default: false, - description: 'Whether to make a user a super administrator', + default: '', }, { displayName: 'Additional Fields', @@ -155,9 +242,8 @@ export const userFields: INodeProperties[] = [ }, options: [ { - displayName: 'Change Password At Next Login', + displayName: 'Change Password at Next Login', name: 'changePasswordAtNextLogin', - type: 'boolean', default: false, description: 'Whether the user is forced to change their password at next login', @@ -282,8 +368,7 @@ export const userFields: INodeProperties[] = [ name: 'primary', type: 'boolean', default: false, - description: - "Whether this is the user's primary phone number. A user may only have one primary phone number.", + description: "Whether this is the user's primary phone number", }, ], }, @@ -334,33 +419,186 @@ export const userFields: INodeProperties[] = [ }, ], }, + { + displayName: 'Roles', + name: 'roles', + type: 'multiOptions', + default: [], + description: 'Select the roles you want to assign to the user', + options: [ + { + name: 'Directory Sync Admin', + value: 'directorySyncAdmin', + description: 'Whether to assign the Directory Sync Admin role', + }, + { + name: 'Groups Admin', + value: 'groupsAdmin', + description: 'Whether to assign the Groups Admin role', + }, + { + name: 'Groups Editor', + value: 'groupsEditor', + description: 'Whether to assign the Groups Editor role', + }, + { + name: 'Groups Reader', + value: 'groupsReader', + description: 'Whether to assign the Groups Reader role', + }, + { + name: 'Help Desk Admin', + value: 'helpDeskAdmin', + description: 'Whether to assign the Help Desk Admin role', + }, + { + name: 'Inventory Reporting Admin', + value: 'inventoryReportingAdmin', + description: 'Whether to assign the Inventory Reporting Admin role', + }, + { + name: 'Mobile Admin', + value: 'mobileAdmin', + description: 'Whether to assign the Mobile Admin role', + }, + { + name: 'Services Admin', + value: 'servicesAdmin', + description: 'Whether to assign the Services Admin role', + }, + { + name: 'Storage Admin', + value: 'storageAdmin', + description: 'Whether to assign the Storage Admin role', + }, + { + name: 'Super Admin', + value: 'superAdmin', + description: 'Whether to assign the Super Admin role', + }, + { + name: 'User Management', + value: 'userManagement', + description: 'Whether to assign the User Management role', + }, + ], + }, + { + displayName: 'Custom Fields', + name: 'customFields', + placeholder: 'Add or Edit Custom Fields', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + description: 'Allows editing and adding of custom fields', + options: [ + { + name: 'fieldValues', + displayName: 'Field', + values: [ + { + displayName: 'Schema Name or ID', + name: 'schemaName', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSchemas', + }, + default: '', + description: + 'Select the schema to use for custom fields. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Field Name or ID', + name: 'fieldName', + type: 'string', + // type: 'options', + // typeOptions: { + // loadOptionsDependsOn: [ + // 'additionalFields.test', + // 'additionalFields.customFields.fieldValues[0].schemaName', + // ], + // loadOptionsMethod: 'getSchemaFields', + // }, + default: '', + required: true, + description: 'Enter a field name from the selected schema', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + required: true, + description: 'Provide a value for the selected field', + }, + ], + }, + ], + }, ], }, /* -------------------------------------------------------------------------- */ /* user:delete */ /* -------------------------------------------------------------------------- */ { - displayName: 'User ID', + displayName: 'User', name: 'userId', - type: 'string', - required: true, + default: { + mode: 'list', + value: '', + }, displayOptions: { show: { operation: ['delete'], resource: ['user'], }, }, - default: '', - description: - "The value can be the user's primary email address, alias email address, or unique user ID", + description: 'Select the user you want to delete', + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchUsers', + }, + }, + { + displayName: 'By Email', + name: 'userEmail', + type: 'string', + hint: 'Enter the user email', + placeholder: 'e.g. sales@example.com', + validation: [ + { + type: 'regex', + properties: { + regex: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$', + errorMessage: 'Please enter a valid email address.', + }, + }, + ], + }, + { + displayName: 'By ID', + name: 'userId', + type: 'string', + hint: 'Enter the user id', + placeholder: 'e.g. 123456789879230471055', + }, + ], + required: true, + type: 'resourceLocator', }, /* -------------------------------------------------------------------------- */ /* user:get */ /* -------------------------------------------------------------------------- */ { - displayName: 'User ID', + displayName: 'User', name: 'userId', - type: 'string', + type: 'resourceLocator', required: true, displayOptions: { show: { @@ -368,93 +606,148 @@ export const userFields: INodeProperties[] = [ resource: ['user'], }, }, - default: '', - description: - "The value can be the user's primary email address, alias email address, or unique user ID", + default: { + mode: 'list', + value: '', + }, + description: 'Select the user you want to retrieve', + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchUsers', + }, + }, + { + displayName: 'By Email', + name: 'userEmail', + type: 'string', + hint: 'Enter the user email', + placeholder: 'e.g. sales@example.com', + validation: [ + { + type: 'regex', + properties: { + regex: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$', + errorMessage: 'Please enter a valid email address.', + }, + }, + ], + }, + { + displayName: 'By ID', + name: 'groupId', + type: 'string', + hint: 'Enter the user id', + placeholder: 'e.g. 0123kx3o1habcdf', + }, + ], + }, + { + displayName: 'Output', + name: 'output', + type: 'options', + required: true, + default: 'simplified', + displayOptions: { + show: { + operation: ['get'], + resource: ['user'], + }, + }, + options: [ + { + name: 'Simplified', + value: 'simplified', + description: + 'Only return specific fields: kind, ID, primaryEmail, name (with subfields), isAdmin, lastLoginTime, creationTime, and suspended', + }, + { + name: 'Raw', + value: 'raw', + description: 'Return all fields from the API response', + }, + { + name: 'Select Included Fields', + value: 'select', + description: 'Choose specific fields to include', + }, + ], }, { - displayName: 'Projection', + displayName: 'Fields', + name: 'fields', + type: 'multiOptions', + default: [], + displayOptions: { + show: { + output: ['select'], + operation: ['get'], + resource: ['user'], + }, + }, + options: [ + { name: 'creationTime', value: 'creationTime' }, + { name: 'isAdmin', value: 'isAdmin' }, + { name: 'Kind', value: 'kind' }, + { name: 'lastLoginTime', value: 'lastLoginTime' }, + { name: 'Name', value: 'name' }, + { name: 'primaryEmail', value: 'primaryEmail' }, + { name: 'Suspended', value: 'suspended' }, + ], + description: 'Fields to include in the response when "Select Included Fields" is chosen', + }, + { + displayName: 'Custom Fields', name: 'projection', type: 'options', required: true, options: [ { - name: 'Basic', + name: 'Don’t Include', value: 'basic', description: 'Do not include any custom fields for the user', }, { name: 'Custom', value: 'custom', - description: 'Include custom fields from schemas requested in customField', + description: 'Include custom fields from schemas requested in Custom Schema Names or IDs', }, { - name: 'Full', + name: 'Include All', value: 'full', description: 'Include all fields associated with this user', }, ], + default: 'basic', displayOptions: { show: { operation: ['get'], resource: ['user'], }, }, - default: 'basic', description: 'What subset of fields to fetch for this user', }, { - displayName: 'Options', - name: 'options', - type: 'collection', - placeholder: 'Add option', - default: {}, + displayName: 'Custom Schema Names or IDs', + name: 'customFieldMask', + type: 'multiOptions', + required: true, displayOptions: { show: { operation: ['get'], resource: ['user'], + '/projection': ['custom'], }, }, - options: [ - { - displayName: 'Custom Schema Names or IDs', - name: 'customFieldMask', - type: 'multiOptions', - displayOptions: { - show: { - '/projection': ['custom'], - }, - }, - typeOptions: { - loadOptionsMethod: 'getSchemas', - }, - default: [], - description: - 'A comma-separated list of schema names. All fields from these schemas are fetched. This should only be set when projection=custom. Choose from the list, or specify IDs using an expression.', - }, - { - displayName: 'View Type', - name: 'viewType', - type: 'options', - options: [ - { - name: 'Admin View', - value: 'admin_view', - description: - 'Results include both administrator-only and domain-public fields for the user', - }, - { - name: 'Descending', - value: 'DESCENDING', - description: - 'Results only include fields for the user that are publicly visible to other users in the domain', - }, - ], - default: 'admin_view', - description: - 'Whether to fetch the administrator-only or domain-wide public view of the user. For more information, see Retrieve a user as a non-administrator.', - }, - ], + typeOptions: { + loadOptionsMethod: 'getSchemas', + }, + default: [], + description: + 'A comma-separated list of schema names. All fields from these schemas are fetched. Choose from the list, or specify IDs using an expression.', }, /* -------------------------------------------------------------------------- */ /* user:getAll */ @@ -491,41 +784,114 @@ export const userFields: INodeProperties[] = [ description: 'Max number of results to return', }, { - displayName: 'Projection', + displayName: 'Output', + name: 'output', + type: 'options', + required: true, + default: 'simplified', + displayOptions: { + show: { + operation: ['getAll'], + resource: ['user'], + }, + }, + options: [ + { + name: 'Simplified', + value: 'simplified', + description: + 'Only return specific fields: kind, ID, primaryEmail, name (with subfields), isAdmin, lastLoginTime, creationTime, and suspended', + }, + { + name: 'Raw', + value: 'raw', + description: 'Return all fields from the API response', + }, + { + name: 'Select Included Fields', + value: 'select', + description: 'Choose specific fields to include', + }, + ], + }, + { + displayName: 'Fields', + name: 'fields', + type: 'multiOptions', + default: [], + displayOptions: { + show: { + output: ['select'], + operation: ['getAll'], + resource: ['user'], + }, + }, + options: [ + { name: 'creationTime', value: 'creationTime' }, + { name: 'isAdmin', value: 'isAdmin' }, + { name: 'Kind', value: 'kind' }, + { name: 'lastLoginTime', value: 'lastLoginTime' }, + { name: 'Name', value: 'name' }, + { name: 'primaryEmail', value: 'primaryEmail' }, + { name: 'Suspended', value: 'suspended' }, + ], + description: 'Fields to include in the response when "Select Included Fields" is chosen', + }, + { + displayName: 'Custom Fields', name: 'projection', type: 'options', required: true, + displayOptions: { + show: { + operation: ['getAll'], + resource: ['user'], + }, + }, options: [ { - name: 'Basic', + name: 'Don’t Include', value: 'basic', description: 'Do not include any custom fields for the user', }, { name: 'Custom', value: 'custom', - description: 'Include custom fields from schemas requested in customField', + description: 'Include custom fields from schemas requested in Custom Schema Names or IDs', }, { - name: 'Full', + name: 'Include All', value: 'full', description: 'Include all fields associated with this user', }, ], + default: 'basic', + description: 'What subset of fields to fetch for this user', + }, + { + displayName: 'Custom Schema Names or IDs', + name: 'customFieldMask', + type: 'multiOptions', + required: true, displayOptions: { show: { operation: ['getAll'], resource: ['user'], + '/projection': ['custom'], }, }, - default: 'basic', - description: 'What subset of fields to fetch for this user', + typeOptions: { + loadOptionsMethod: 'getSchemas', + }, + default: [], + description: + 'A comma-separated list of schema names. All fields from these schemas are fetched. Choose from the list, or specify IDs using an expression.', }, { - displayName: 'Options', - name: 'options', + displayName: 'Filter', + name: 'filter', type: 'collection', - placeholder: 'Add option', + placeholder: 'Add Filter', default: {}, displayOptions: { show: { @@ -534,111 +900,183 @@ export const userFields: INodeProperties[] = [ }, }, options: [ - { - displayName: 'Custom Schema Names or IDs', - name: 'customFieldMask', - type: 'multiOptions', - displayOptions: { - show: { - '/projection': ['custom'], - }, - }, - typeOptions: { - loadOptionsMethod: 'getSchemas', - }, - default: [], - description: - 'A comma-separated list of schema names. All fields from these schemas are fetched. This should only be set when projection=custom. Choose from the list, or specify IDs using an expression.', - }, { displayName: 'Customer', name: 'customer', type: 'string', default: '', - description: - "The unique ID for the customer's Google Workspace account. In case of a multi-domain account, to fetch all groups for a customer, fill this field instead of domain.", + description: "The unique ID for the customer's Google Workspace account", }, { displayName: 'Domain', name: 'domain', type: 'string', default: '', - description: 'The domain name. Use this field to get fields from only one domain.', - }, - { - displayName: 'Order By', - name: 'orderBy', - type: 'options', - options: [ - { - name: 'Email', - value: 'email', - }, - { - name: 'Family Name', - value: 'familyName', - }, - { - name: 'Given Name', - value: 'givenName', - }, - ], - default: '', - description: 'Property to use for sorting results', + description: 'The domain name. Use this field to get groups from a specific domain.', }, { displayName: 'Query', name: 'query', type: 'string', + placeholder: 'e.g. name:contact* email:contact*', default: '', description: - 'Free text search terms to find users that match these terms in any field, except for extended properties. For more information on constructing user queries, see Search for Users.', + 'Query string to filter the results. Follow Google Admin SDK documentation. More info.', }, { displayName: 'Show Deleted', name: 'showDeleted', type: 'boolean', default: false, - description: 'Whether to retrieve the list of deleted users', + description: 'Whether retrieve the list of deleted users', + }, + ], + }, + { + displayName: 'Sort', + name: 'sort', + type: 'fixedCollection', + placeholder: 'Add Sort Rule', + default: {}, + displayOptions: { + show: { + operation: ['getAll'], + resource: ['user'], }, + }, + options: [ { - displayName: 'Sort Order', - name: 'sortOrder', - type: 'options', - options: [ + name: 'sortRules', + displayName: 'Sort Rules', + values: [ { - name: 'Ascending', - value: 'ASCENDING', + displayName: 'Order By', + name: 'orderBy', + type: 'options', + options: [ + { + name: 'Email', + value: 'email', + }, + { + name: 'Family Name', + value: 'familyName', + }, + { + name: 'Given Name', + value: 'givenName', + }, + ], + default: '', + description: 'Field to sort the results by', }, { - name: 'Descending', - value: 'DESCENDING', + displayName: 'Sort Order', + name: 'sortOrder', + type: 'options', + options: [ + { + name: 'Ascending', + value: 'ASCENDING', + }, + { + name: 'Descending', + value: 'DESCENDING', + }, + ], + default: 'ASCENDING', + description: 'Sort order direction', }, ], - default: '', - description: 'Whether to return results in ascending or descending order', }, + ], + description: 'Define sorting rules for the results', + }, + + /* -------------------------------------------------------------------------- */ + /* user:removeFromGroup */ + /* -------------------------------------------------------------------------- */ + + { + displayName: 'User', + name: 'userId', + required: true, + type: 'resourceLocator', + default: { + mode: 'list', + value: '', + }, + description: 'Select the user you want to remove from the group', + displayOptions: { + show: { + resource: ['user'], + operation: ['removeFromGroup'], + }, + }, + modes: [ { - displayName: 'View Type', - name: 'viewType', - type: 'options', - options: [ - { - name: 'Admin View', - value: 'admin_view', - description: - 'Results include both administrator-only and domain-public fields for the user', - }, + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchUsers', + }, + }, + { + displayName: 'By Email', + name: 'userEmail', + type: 'string', + hint: 'Enter the user email', + placeholder: 'e.g. sales@example.com', + validation: [ { - name: 'Descending', - value: 'DESCENDING', - description: - 'Results only include fields for the user that are publicly visible to other users in the domain', + type: 'regex', + properties: { + regex: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$', + errorMessage: 'Please enter a valid email address.', + }, }, ], - default: 'admin_view', - description: - 'Whether to fetch the administrator-only or domain-wide public view of the user. For more information, see Retrieve a user as a non-administrator.', + }, + { + displayName: 'By ID', + name: 'userId', + type: 'string', + hint: 'Enter the user id', + placeholder: 'e.g. 123456789879230471055', + }, + ], + }, + { + displayName: 'Group', + name: 'groupId', + required: true, + type: 'resourceLocator', + default: { + mode: 'list', + value: '', + }, + description: 'Select the group you want to remove the user from', + displayOptions: { + show: { + resource: ['user'], + operation: ['removeFromGroup'], + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchGroups', + }, + }, + { + displayName: 'By ID', + name: 'groupId', + type: 'string', + placeholder: 'e.g. 0123kx3o1habcdf', }, ], }, @@ -646,9 +1084,9 @@ export const userFields: INodeProperties[] = [ /* user:update */ /* -------------------------------------------------------------------------- */ { - displayName: 'User ID', + displayName: 'User', name: 'userId', - type: 'string', + type: 'resourceLocator', required: true, displayOptions: { show: { @@ -656,9 +1094,44 @@ export const userFields: INodeProperties[] = [ resource: ['user'], }, }, - default: '', - description: - "The value can be the user's primary email address, alias email address, or unique user ID", + default: { + mode: 'list', + value: '', + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchUsers', + }, + }, + { + displayName: 'By Email', + name: 'userEmail', + type: 'string', + hint: 'Enter the user email', + placeholder: 'e.g. sales@example.com', + validation: [ + { + type: 'regex', + properties: { + regex: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$', + errorMessage: 'Please enter a valid email address.', + }, + }, + ], + }, + { + displayName: 'By ID', + name: 'userId', + type: 'string', + hint: 'Enter the user id', + placeholder: 'e.g. 123456789879230471055', + }, + ], + description: 'Select the user you want to update', }, { displayName: 'Update Fields', @@ -681,9 +1154,16 @@ export const userFields: INodeProperties[] = [ description: 'Whether user is archived', }, { - displayName: 'Change Password At Next Login', + displayName: 'Suspend', + name: 'suspendUi', + type: 'boolean', + default: false, + description: + 'Whether to set the user as suspended. If set to OFF, the user will be reactivated. If not added, the status will remain unchanged.', + }, + { + displayName: 'Change Password at Next Login', name: 'changePasswordAtNextLogin', - type: 'boolean', default: false, description: 'Whether the user is forced to change their password at next login', @@ -693,12 +1173,14 @@ export const userFields: INodeProperties[] = [ name: 'firstName', type: 'string', default: '', + placeholder: 'e.g. John', }, { displayName: 'Last Name', name: 'lastName', type: 'string', default: '', + placeholder: 'e.g. Doe', }, { displayName: 'Password', @@ -706,6 +1188,7 @@ export const userFields: INodeProperties[] = [ type: 'string', typeOptions: { password: true }, default: '', + placeholder: 'e.g. MyStrongP@ssword123', description: 'Stores the password for the user account. A minimum of 8 characters is required. The maximum length is 100 characters.', }, @@ -821,6 +1304,7 @@ export const userFields: INodeProperties[] = [ name: 'value', type: 'string', default: '', + placeholder: 'e.g. +1234567890', }, { displayName: 'Primary', @@ -839,6 +1323,7 @@ export const userFields: INodeProperties[] = [ name: 'primaryEmail', type: 'string', default: '', + placeholder: 'e.g. john.doe@example.com', description: "The user's primary email address. This property is required in a request to create a user account. The primaryEmail must be unique and cannot be an alias of another user.", }, @@ -882,6 +1367,122 @@ export const userFields: INodeProperties[] = [ name: 'address', type: 'string', default: '', + placeholder: 'e.g. john.doe.work@example.com', + }, + ], + }, + ], + }, + { + displayName: 'Roles', + name: 'roles', + type: 'multiOptions', + default: [], + description: 'Select the roles you want to assign to the user', + options: [ + { + name: 'Directory Sync Admin', + value: 'directorySyncAdmin', + description: 'Whether Assign Directory Sync Admin role', + }, + { + name: 'Groups Admin', + value: 'groupsAdmin', + description: 'Whether Assign Groups Admin role', + }, + { + name: 'Groups Editor', + value: 'groupsEditor', + description: 'Whether Assign Groups Editor role', + }, + { + name: 'Groups Reader', + value: 'groupsReader', + description: 'Whether Assign Groups Reader role', + }, + { + name: 'Help Desk Admin', + value: 'helpDeskAdmin', + description: 'Whether Assign Help Desk Admin role', + }, + { + name: 'Inventory Reporting Admin', + value: 'inventoryReportingAdmin', + description: 'Whether Assign Inventory Reporting Admin role', + }, + { + name: 'Mobile Admin', + value: 'mobileAdmin', + description: 'Whether Assign Mobile Admin role', + }, + { + name: 'Services Admin', + value: 'servicesAdmin', + description: 'Whether Assign Services Admin role', + }, + { + name: 'Storage Admin', + value: 'storageAdmin', + description: 'Whether Assign Storage Admin role', + }, + { + name: 'Super Admin', + value: 'superAdmin', + description: 'Whether Assign Super Admin role', + }, + { + name: 'User Management', + value: 'userManagement', + description: 'Whether Assign User Management role', + }, + ], + }, + { + displayName: 'Custom Fields', + name: 'customFields', + placeholder: 'Add or Edit Custom Fields', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + description: 'Allows editing and adding of custom fields', + options: [ + { + name: 'fieldValues', + displayName: 'Field', + values: [ + { + displayName: 'Schema Name or ID', + name: 'schemaName', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSchemas', + }, + default: '', + description: + 'Select the schema to use for custom fields. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Field Name or ID', + name: 'fieldName', + type: 'string', + // type: 'options', + // typeOptions: { + // loadOptionsMethod: 'getSchemaFields', + // loadOptionsDependsOn: ['customFields.fieldValues.schema.schemaName'], + // }, + default: '', + required: true, + description: 'Enter a field name from the selected schema', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + required: true, + description: 'Provide a value for the selected field', }, ], }, diff --git a/packages/nodes-base/nodes/Google/GSuiteAdmin/test/GoogleApiRequest.test.ts b/packages/nodes-base/nodes/Google/GSuiteAdmin/test/GoogleApiRequest.test.ts new file mode 100644 index 0000000000000..a23b2b4f0c091 --- /dev/null +++ b/packages/nodes-base/nodes/Google/GSuiteAdmin/test/GoogleApiRequest.test.ts @@ -0,0 +1,59 @@ +import type { IExecuteFunctions, ILoadOptionsFunctions } from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; + +import { googleApiRequest } from '../GenericFunctions'; + +describe('googleApiRequest', () => { + let mockContext: IExecuteFunctions | ILoadOptionsFunctions; + + beforeEach(() => { + mockContext = { + helpers: { + requestOAuth2: jest.fn(), + }, + getNode: jest.fn(), + } as unknown as IExecuteFunctions | ILoadOptionsFunctions; + + jest.clearAllMocks(); + }); + + it('should make a successful API request with default options', async () => { + (mockContext.helpers.requestOAuth2 as jest.Mock).mockResolvedValueOnce({ success: true }); + + const result = await googleApiRequest.call(mockContext, 'GET', '/example/resource'); + + expect(mockContext.helpers.requestOAuth2).toHaveBeenCalledWith( + 'gSuiteAdminOAuth2Api', + expect.objectContaining({ + method: 'GET', + uri: 'https://www.googleapis.com/admin/example/resource', + headers: { 'Content-Type': 'application/json' }, + json: true, + }), + ); + expect(result).toEqual({ success: true }); + }); + + it('should omit the body if it is empty', async () => { + (mockContext.helpers.requestOAuth2 as jest.Mock).mockResolvedValueOnce({ success: true }); + + await googleApiRequest.call(mockContext, 'GET', '/example/resource', {}); + + expect(mockContext.helpers.requestOAuth2).toHaveBeenCalledWith( + 'gSuiteAdminOAuth2Api', + expect.not.objectContaining({ body: expect.anything() }), + ); + }); + + it('should throw a NodeApiError if the request fails', async () => { + const errorResponse = { message: 'API Error' }; + (mockContext.helpers.requestOAuth2 as jest.Mock).mockRejectedValueOnce(errorResponse); + + await expect(googleApiRequest.call(mockContext, 'GET', '/example/resource')).rejects.toThrow( + NodeApiError, + ); + + expect(mockContext.getNode).toHaveBeenCalled(); + expect(mockContext.helpers.requestOAuth2).toHaveBeenCalled(); + }); +}); diff --git a/packages/nodes-base/nodes/Google/GSuiteAdmin/test/GoogleApiRequestAllItems.test.ts b/packages/nodes-base/nodes/Google/GSuiteAdmin/test/GoogleApiRequestAllItems.test.ts new file mode 100644 index 0000000000000..9b10b2624556d --- /dev/null +++ b/packages/nodes-base/nodes/Google/GSuiteAdmin/test/GoogleApiRequestAllItems.test.ts @@ -0,0 +1,122 @@ +import type { IExecuteFunctions, ILoadOptionsFunctions } from 'n8n-workflow'; + +import { googleApiRequestAllItems } from '../GenericFunctions'; + +describe('googleApiRequestAllItems', () => { + let mockContext: IExecuteFunctions | ILoadOptionsFunctions; + + beforeEach(() => { + mockContext = { + helpers: { + requestOAuth2: jest.fn(), + }, + getNode: jest.fn(), + } as unknown as IExecuteFunctions | ILoadOptionsFunctions; + + jest.clearAllMocks(); + }); + it('should return all items across multiple pages', async () => { + (mockContext.helpers.requestOAuth2 as jest.Mock) + .mockResolvedValueOnce({ + nextPageToken: 'pageToken1', + items: [{ id: '1' }, { id: '2' }], + }) + .mockResolvedValueOnce({ + nextPageToken: 'pageToken2', + items: [{ id: '3' }, { id: '4' }], + }) + .mockResolvedValueOnce({ + nextPageToken: '', + items: [{ id: '5' }], + }); + + const result = await googleApiRequestAllItems.call( + mockContext, + 'items', + 'GET', + '/example/resource', + ); + + expect(result).toEqual([{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }, { id: '5' }]); + expect(mockContext.helpers.requestOAuth2).toHaveBeenCalledTimes(3); + expect(mockContext.helpers.requestOAuth2).toHaveBeenNthCalledWith( + 1, + 'gSuiteAdminOAuth2Api', + expect.objectContaining({ + method: 'GET', + qs: { maxResults: 100, pageToken: '' }, + headers: { 'Content-Type': 'application/json' }, + uri: 'https://www.googleapis.com/admin/example/resource', + json: true, + }), + ); + expect(mockContext.helpers.requestOAuth2).toHaveBeenNthCalledWith( + 2, + 'gSuiteAdminOAuth2Api', + expect.objectContaining({ + method: 'GET', + qs: { maxResults: 100, pageToken: '' }, + headers: { 'Content-Type': 'application/json' }, + uri: 'https://www.googleapis.com/admin/example/resource', + json: true, + }), + ); + expect(mockContext.helpers.requestOAuth2).toHaveBeenNthCalledWith( + 3, + 'gSuiteAdminOAuth2Api', + expect.objectContaining({ + method: 'GET', + qs: { maxResults: 100, pageToken: '' }, + headers: { 'Content-Type': 'application/json' }, + uri: 'https://www.googleapis.com/admin/example/resource', + json: true, + }), + ); + }); + + it('should handle single-page responses', async () => { + (mockContext.helpers.requestOAuth2 as jest.Mock).mockResolvedValueOnce({ + nextPageToken: '', + items: [{ id: '1' }, { id: '2' }], + }); + + const result = await googleApiRequestAllItems.call( + mockContext, + 'items', + 'GET', + '/example/resource', + ); + + expect(result).toEqual([{ id: '1' }, { id: '2' }]); + expect(mockContext.helpers.requestOAuth2).toHaveBeenCalledTimes(1); + }); + + it('should handle empty responses', async () => { + (mockContext.helpers.requestOAuth2 as jest.Mock).mockResolvedValueOnce({ + nextPageToken: '', + items: [], + }); + + const result = await googleApiRequestAllItems.call( + mockContext, + 'items', + 'GET', + '/example/resource', + ); + + expect(result).toEqual([]); + expect(mockContext.helpers.requestOAuth2).toHaveBeenCalledTimes(1); + }); + + it('should throw a NodeApiError if a request fails', async () => { + const errorResponse = { message: 'API Error' }; + (mockContext.helpers.requestOAuth2 as jest.Mock).mockRejectedValueOnce(errorResponse); + + await expect( + googleApiRequestAllItems.call(mockContext, 'items', 'GET', '/example/resource'), + ).rejects.toThrow(); + + expect(mockContext.getNode).toHaveBeenCalled(); + expect(mockContext.helpers.requestOAuth2).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/nodes-base/nodes/Google/GSuiteAdmin/test/SearchDevices.test.ts b/packages/nodes-base/nodes/Google/GSuiteAdmin/test/SearchDevices.test.ts new file mode 100644 index 0000000000000..672b90346e504 --- /dev/null +++ b/packages/nodes-base/nodes/Google/GSuiteAdmin/test/SearchDevices.test.ts @@ -0,0 +1,48 @@ +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import { googleApiRequest } from '../GenericFunctions'; +import { searchDevices } from '../SearchFunctions'; + +jest.mock('../GenericFunctions'); + +describe('SearchFunctions - searchDevices', () => { + const mockContext = {} as unknown as ILoadOptionsFunctions; + + beforeEach(() => { + (googleApiRequest as jest.Mock).mockClear(); + }); + + it('should return a list of ChromeOS devices when googleApiRequest returns devices', async () => { + (googleApiRequest as jest.Mock).mockResolvedValueOnce({ + chromeosdevices: [ + { deviceId: 'device123', serialNumber: 'SN123' }, + { deviceId: 'device456', serialNumber: 'SN456' }, + ], + }); + + const result = await searchDevices.call(mockContext); + + expect(result).toEqual({ + results: [ + { name: 'SN123', value: 'device123' }, + { name: 'SN456', value: 'device456' }, + ], + }); + expect(googleApiRequest).toHaveBeenCalledTimes(1); + expect(googleApiRequest).toHaveBeenCalledWith( + 'GET', + '/directory/v1/customer/my_customer/devices/chromeos/', + {}, + { customerId: 'my_customer' }, + ); + }); + + it('should return an empty array when googleApiRequest response is undefined', async () => { + (googleApiRequest as jest.Mock).mockResolvedValueOnce(undefined); + + const result = await searchDevices.call(mockContext); + + expect(result).toEqual({ results: [] }); + expect(googleApiRequest).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/nodes-base/nodes/Google/GSuiteAdmin/test/SearchGroups.test.ts b/packages/nodes-base/nodes/Google/GSuiteAdmin/test/SearchGroups.test.ts new file mode 100644 index 0000000000000..5f4aaf4a6796c --- /dev/null +++ b/packages/nodes-base/nodes/Google/GSuiteAdmin/test/SearchGroups.test.ts @@ -0,0 +1,66 @@ +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import { googleApiRequestAllItems } from '../GenericFunctions'; +import { searchGroups } from '../SearchFunctions'; + +jest.mock('../GenericFunctions'); + +describe('GenericFunctions - searchGroups', () => { + const mockContext = {} as unknown as ILoadOptionsFunctions; + + beforeEach(() => { + (googleApiRequestAllItems as jest.Mock).mockClear(); + }); + + it('should return a list of groups when googleApiRequestAllItems returns groups', async () => { + (googleApiRequestAllItems as jest.Mock).mockResolvedValueOnce([ + { + kind: 'admin#directory#group', + id: '01302m922pmp3e4', + email: 'new3@digital-boss.de', + name: 'New2', + description: 'new1', + }, + { + kind: 'admin#directory#group', + id: '01x0gk373c9z46j', + email: 'newoness@digital-boss.de', + name: 'NewOness', + description: 'test', + }, + ]); + + const result = await searchGroups.call(mockContext); + + expect(result).toEqual({ + results: [ + { name: 'New2', value: '01302m922pmp3e4' }, + { name: 'NewOness', value: '01x0gk373c9z46j' }, + ], + }); + expect(googleApiRequestAllItems).toHaveBeenCalledTimes(1); + expect(googleApiRequestAllItems).toHaveBeenCalledWith( + 'groups', + 'GET', + '/directory/v1/groups', + {}, + { customer: 'my_customer' }, + ); + }); + + it('should return an empty array when googleApiRequestAllItems returns no groups', async () => { + (googleApiRequestAllItems as jest.Mock).mockResolvedValueOnce([]); + + const result = await searchGroups.call(mockContext); + + expect(result).toEqual({ results: [] }); + expect(googleApiRequestAllItems).toHaveBeenCalledTimes(1); + expect(googleApiRequestAllItems).toHaveBeenCalledWith( + 'groups', + 'GET', + '/directory/v1/groups', + {}, + { customer: 'my_customer' }, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Google/GSuiteAdmin/test/SearchUsers.test.ts b/packages/nodes-base/nodes/Google/GSuiteAdmin/test/SearchUsers.test.ts new file mode 100644 index 0000000000000..56153674c9b18 --- /dev/null +++ b/packages/nodes-base/nodes/Google/GSuiteAdmin/test/SearchUsers.test.ts @@ -0,0 +1,54 @@ +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import { googleApiRequestAllItems } from '../GenericFunctions'; +import { searchUsers } from '../SearchFunctions'; + +jest.mock('../GenericFunctions'); + +describe('GenericFunctions - searchUsers', () => { + const mockContext = {} as unknown as ILoadOptionsFunctions; + + beforeEach(() => { + (googleApiRequestAllItems as jest.Mock).mockClear(); + }); + + it('should return a list of users when googleApiRequestAllItems returns users', async () => { + (googleApiRequestAllItems as jest.Mock).mockResolvedValueOnce([ + { id: '1', name: { fullName: 'John Doe' } }, + { id: '2', name: { fullName: 'Jane Smith' } }, + ]); + + const result = await searchUsers.call(mockContext); + + expect(result).toEqual({ + results: [ + { name: 'John Doe', value: '1' }, + { name: 'Jane Smith', value: '2' }, + ], + }); + expect(googleApiRequestAllItems).toHaveBeenCalledTimes(1); + expect(googleApiRequestAllItems).toHaveBeenCalledWith( + 'users', + 'GET', + '/directory/v1/users', + {}, + { customer: 'my_customer' }, + ); + }); + + it('should return an empty array when googleApiRequestAllItems returns no users', async () => { + (googleApiRequestAllItems as jest.Mock).mockResolvedValueOnce([]); + + const result = await searchUsers.call(mockContext); + + expect(result).toEqual({ results: [] }); + expect(googleApiRequestAllItems).toHaveBeenCalledTimes(1); + expect(googleApiRequestAllItems).toHaveBeenCalledWith( + 'users', + 'GET', + '/directory/v1/users', + {}, + { customer: 'my_customer' }, + ); + }); +});