feat(Ldap Node): Add LDAP node (#4783)

This commit is contained in:
Jon 2023-05-23 12:39:26 +01:00 committed by GitHub
parent 42c79cd6f1
commit ec393bc041
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 1010 additions and 0 deletions

View file

@ -0,0 +1,91 @@
/* eslint-disable n8n-nodes-base/cred-class-name-unsuffixed,n8n-nodes-base/cred-class-field-name-unsuffixed,n8n-nodes-base/cred-class-field-display-name-missing-api */
import { ICredentialType, INodeProperties } from 'n8n-workflow';
export class Ldap implements ICredentialType {
name = 'ldap';
displayName = 'LDAP';
properties: INodeProperties[] = [
{
displayName: 'LDAP Server Address',
name: 'hostname',
type: 'string',
default: '',
required: true,
description: 'IP or domain of the LDAP server',
},
{
displayName: 'LDAP Server Port',
name: 'port',
type: 'string',
default: '389',
description: 'Port used to connect to the LDAP server',
},
{
displayName: 'Binding DN',
name: 'bindDN',
type: 'string',
default: '',
description: 'Distinguished Name of the user to connect as',
required: true,
},
{
displayName: 'Binding Password',
name: 'bindPassword',
type: 'string',
typeOptions: {
password: true,
},
default: '',
description: 'Password of the user provided in the Binding DN field above',
required: true,
},
{
displayName: 'Connection Security',
name: 'connectionSecurity',
type: 'options',
default: 'none',
options: [
{
name: 'None',
value: 'none',
},
{
name: 'TLS',
value: 'tls',
},
{
name: 'STARTTLS',
value: 'startTls',
},
],
},
{
displayName: 'Ignore SSL/TLS Issues',
name: 'allowUnauthorizedCerts',
type: 'boolean',
description: 'Whether to connect even if SSL/TLS certificate validation is not possible',
default: false,
displayOptions: {
hide: {
connectionSecurity: ['none'],
},
},
},
{
displayName: 'CA Certificate',
name: 'caCertificate',
typeOptions: {
alwaysOpenEditWindow: true,
},
displayOptions: {
hide: {
connectionSecurity: ['none'],
},
},
type: 'string',
default: '',
},
];
}

View file

@ -0,0 +1,53 @@
import { Client } from 'ldapts';
import type { ClientOptions, Entry } from 'ldapts';
import type { ICredentialDataDecryptedObject, IDataObject } from 'n8n-workflow';
import { LoggerProxy as Logger } from 'n8n-workflow';
export const BINARY_AD_ATTRIBUTES = ['objectGUID', 'objectSid'];
const resolveEntryBinaryAttributes = (entry: Entry): Entry => {
Object.entries(entry)
.filter(([k]) => BINARY_AD_ATTRIBUTES.includes(k))
.forEach(([k]) => {
entry[k] = (entry[k] as Buffer).toString('hex');
});
return entry;
};
export const resolveBinaryAttributes = (entries: Entry[]): void => {
entries.forEach((entry) => resolveEntryBinaryAttributes(entry));
};
export async function createLdapClient(
credentials: ICredentialDataDecryptedObject,
nodeDebug?: boolean,
nodeType?: string,
nodeName?: string,
): Promise<Client> {
const protocol = credentials.connectionSecurity === 'tls' ? 'ldaps' : 'ldap';
const url = `${protocol}://${credentials.hostname}:${credentials.port}`;
const ldapOptions: ClientOptions = { url };
const tlsOptions: IDataObject = {};
if (credentials.connectionSecurity !== 'none') {
tlsOptions.rejectUnauthorized = credentials.allowUnauthorizedCerts === false;
if (credentials.caCertificate) {
tlsOptions.ca = [credentials.caCertificate as string];
}
if (credentials.connectionSecurity !== 'startTls') {
ldapOptions.tlsOptions = tlsOptions;
}
}
if (nodeDebug) {
Logger.info(
`[${nodeType} | ${nodeName}] - LDAP Options: ${JSON.stringify(ldapOptions, null, 2)}`,
);
}
const client = new Client(ldapOptions);
if (credentials.connectionSecurity === 'startTls') {
await client.startTLS(tlsOptions);
}
return client;
}

View file

@ -0,0 +1,19 @@
{
"node": "n8n-nodes-base.ldap",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Development", "Developer Tools"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/credentials/ldap"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.ldap/"
}
]
},
"alias": ["ad", "active directory"]
}

View file

@ -0,0 +1,405 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import type { IExecuteFunctions } from 'n8n-core';
import type {
ICredentialDataDecryptedObject,
ICredentialsDecrypted,
ICredentialTestFunctions,
IDataObject,
ILoadOptionsFunctions,
INodeCredentialTestResult,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { LoggerProxy as Logger, NodeOperationError } from 'n8n-workflow';
import { Attribute, Change } from 'ldapts';
import { ldapFields } from './LdapDescription';
import { BINARY_AD_ATTRIBUTES, createLdapClient, resolveBinaryAttributes } from './Helpers';
export class Ldap implements INodeType {
description: INodeTypeDescription = {
displayName: 'Ldap',
name: 'ldap',
icon: 'file:ldap.svg',
group: ['transform'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Interact with LDAP servers',
defaults: {
name: 'LDAP',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
// eslint-disable-next-line n8n-nodes-base/node-class-description-credentials-name-unsuffixed
name: 'ldap',
required: true,
testedBy: 'ldapConnectionTest',
},
],
properties: [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Compare',
value: 'compare',
description: 'Compare an attribute',
action: 'Compare an attribute',
},
{
name: 'Create',
value: 'create',
description: 'Create a new entry',
action: 'Create a new entry',
},
{
name: 'Delete',
value: 'delete',
description: 'Delete an entry',
action: 'Delete an entry',
},
{
name: 'Rename',
value: 'rename',
description: 'Rename the DN of an existing entry',
action: 'Rename the DN of an existing entry',
},
{
name: 'Search',
value: 'search',
description: 'Search LDAP',
action: 'Search LDAP',
},
{
name: 'Update',
value: 'update',
description: 'Update attributes',
action: 'Update attributes',
},
],
default: 'search',
},
{
displayName: 'Debug',
name: 'nodeDebug',
type: 'boolean',
isNodeSetting: true,
default: false,
noDataExpression: true,
},
...ldapFields,
],
};
methods = {
credentialTest: {
async ldapConnectionTest(
this: ICredentialTestFunctions,
credential: ICredentialsDecrypted,
): Promise<INodeCredentialTestResult> {
const credentials = credential.data as ICredentialDataDecryptedObject;
try {
const client = await createLdapClient(credentials);
await client.bind(credentials.bindDN as string, credentials.bindPassword as string);
} catch (error) {
return {
status: 'Error',
message: error.message,
};
}
return {
status: 'OK',
message: 'Connection successful!',
};
},
},
loadOptions: {
async getAttributes(this: ILoadOptionsFunctions) {
const credentials = await this.getCredentials('ldap');
const client = await createLdapClient(credentials);
try {
await client.bind(credentials.bindDN as string, credentials.bindPassword as string);
} catch (error) {
console.log(error);
}
const baseDN = this.getNodeParameter('baseDN', 0) as string;
const results = await client.search(baseDN, { sizeLimit: 200, paged: false }); // should this size limit be set in credentials?
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const unique = Object.keys(Object.assign({}, ...results.searchEntries));
return unique.map((x) => ({
name: x,
value: x,
}));
},
async getObjectClasses(this: ILoadOptionsFunctions) {
const credentials = await this.getCredentials('ldap');
const client = await createLdapClient(credentials);
try {
await client.bind(credentials.bindDN as string, credentials.bindPassword as string);
} catch (error) {
console.log(error);
}
const baseDN = this.getNodeParameter('baseDN', 0) as string;
const results = await client.search(baseDN, { sizeLimit: 10, paged: false }); // should this size limit be set in credentials?
const objects = [];
for (const entry of results.searchEntries) {
if (typeof entry.objectClass === 'string') {
objects.push(entry.objectClass);
} else {
objects.push(...entry.objectClass);
}
}
const unique = [...new Set(objects)];
unique.push('custom');
const result = [];
for (const value of unique) {
if (value === 'custom') {
result.push({ name: 'custom', value: 'custom' });
} else result.push({ name: value as string, value: `(objectclass=${value})` });
}
return result;
},
async getAttributesForDn(this: ILoadOptionsFunctions) {
const credentials = await this.getCredentials('ldap');
const client = await createLdapClient(credentials);
try {
await client.bind(credentials.bindDN as string, credentials.bindPassword as string);
} catch (error) {
console.log(error);
}
const baseDN = this.getNodeParameter('dn', 0) as string;
const results = await client.search(baseDN, { sizeLimit: 1, paged: false });
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const unique = Object.keys(Object.assign({}, ...results.searchEntries));
return unique.map((x) => ({
name: x,
value: x,
}));
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const nodeDebug = this.getNodeParameter('nodeDebug', 0) as boolean;
const items = this.getInputData();
const returnItems: INodeExecutionData[] = [];
if (nodeDebug) {
Logger.info(
`[${this.getNode().type} | ${this.getNode().name}] - Starting with ${
items.length
} input items`,
);
}
const credentials = await this.getCredentials('ldap');
const client = await createLdapClient(
credentials,
nodeDebug,
this.getNode().type,
this.getNode().name,
);
try {
await client.bind(credentials.bindDN as string, credentials.bindPassword as string);
} catch (error) {
delete error.cert;
if (this.continueOnFail()) {
return [
items.map((x) => {
x.json.error = error.reason || 'LDAP connection error occurred';
return x;
}),
];
} else {
throw new NodeOperationError(this.getNode(), error as Error, {});
}
}
const operation = this.getNodeParameter('operation', 0);
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
try {
if (operation === 'compare') {
const dn = this.getNodeParameter('dn', itemIndex) as string;
const attributeId = this.getNodeParameter('id', itemIndex) as string;
const value = this.getNodeParameter('value', itemIndex, '') as string;
const res = await client.compare(dn, attributeId, value);
returnItems.push({
json: { dn, attribute: attributeId, result: res },
pairedItem: { item: itemIndex },
});
} else if (operation === 'create') {
const dn = this.getNodeParameter('dn', itemIndex) as string;
const attributeFields = this.getNodeParameter('attributes', itemIndex) as IDataObject;
const attributes: IDataObject = {};
if (Object.keys(attributeFields).length) {
//@ts-ignore
attributeFields.attribute.map((attr) => {
attributes[attr.id as string] = attr.value;
});
}
await client.add(dn, attributes as unknown as Attribute[]);
returnItems.push({
json: { dn, result: 'success' },
pairedItem: { item: itemIndex },
});
} else if (operation === 'delete') {
const dn = this.getNodeParameter('dn', itemIndex) as string;
await client.del(dn);
returnItems.push({
json: { dn, result: 'success' },
pairedItem: { item: itemIndex },
});
} else if (operation === 'rename') {
const dn = this.getNodeParameter('dn', itemIndex) as string;
const targetDn = this.getNodeParameter('targetDn', itemIndex) as string;
await client.modifyDN(dn, targetDn);
returnItems.push({
json: { dn: targetDn, result: 'success' },
pairedItem: { item: itemIndex },
});
} else if (operation === 'update') {
const dn = this.getNodeParameter('dn', itemIndex) as string;
const attributes = this.getNodeParameter('attributes', itemIndex, {}) as IDataObject;
const changes: Change[] = [];
for (const [action, attrs] of Object.entries(attributes)) {
//@ts-ignore
attrs.map((attr) =>
changes.push(
new Change({
// @ts-ignore
operation: action,
modification: new Attribute({
type: attr.id as string,
values: [attr.value],
}),
}),
),
);
}
await client.modify(dn, changes);
returnItems.push({
json: { dn, result: 'success', changes },
pairedItem: { item: itemIndex },
});
} else if (operation === 'search') {
const baseDN = this.getNodeParameter('baseDN', itemIndex) as string;
let searchFor = this.getNodeParameter('searchFor', itemIndex) as string;
const returnAll = this.getNodeParameter('returnAll', itemIndex);
const limit = this.getNodeParameter('limit', itemIndex, 0);
const options = this.getNodeParameter('options', itemIndex);
const pageSize = this.getNodeParameter(
'options.pageSize',
itemIndex,
1000,
) as IDataObject;
// Set paging settings
delete options.pageSize;
options.sizeLimit = returnAll ? 0 : limit;
if (pageSize) {
options.paged = { pageSize };
}
// Set attributes to retrieve
if (typeof options.attributes === 'string') {
options.attributes = options.attributes.split(',').map((attribute) => attribute.trim());
}
options.explicitBufferAttributes = BINARY_AD_ATTRIBUTES;
if (searchFor === 'custom') {
searchFor = this.getNodeParameter('customFilter', itemIndex) as string;
} else {
const searchText = this.getNodeParameter('searchText', itemIndex) as string;
const attribute = this.getNodeParameter('attribute', itemIndex) as string;
searchFor = `(&${searchFor}(${attribute}=${searchText}))`;
}
// Replace escaped filter special chars for ease of use
// Character ASCII value
// ---------------------------
// * 0x2a
// ( 0x28
// ) 0x29
// \ 0x5c
searchFor = searchFor.replace(/\\\\/g, '\\5c');
searchFor = searchFor.replace(/\\\*/g, '\\2a');
searchFor = searchFor.replace(/\\\(/g, '\\28');
searchFor = searchFor.replace(/\\\)/g, '\\29');
options.filter = searchFor;
if (nodeDebug) {
Logger.info(
`[${this.getNode().type} | ${this.getNode().name}] - Search Options ${JSON.stringify(
options,
null,
2,
)}`,
);
}
const results = await client.search(baseDN, options);
// Not all LDAP servers respect the sizeLimit
if (!returnAll) {
results.searchEntries = results.searchEntries.slice(0, limit);
}
resolveBinaryAttributes(results.searchEntries);
returnItems.push.apply(
returnItems,
results.searchEntries.map((result) => ({
json: result,
pairedItem: { item: itemIndex },
})),
);
}
} catch (error) {
if (this.continueOnFail()) {
returnItems.push({ json: items[itemIndex].json, error, pairedItem: itemIndex });
} else {
if (error.context) {
error.context.itemIndex = itemIndex;
throw error;
}
throw new NodeOperationError(this.getNode(), error as Error, {
itemIndex,
});
}
}
}
if (nodeDebug) {
Logger.info(`[${this.getNode().type} | ${this.getNode().name}] - Finished`);
}
return this.prepareOutputData(returnItems);
}
}

View file

@ -0,0 +1,439 @@
import type { INodeProperties } from 'n8n-workflow';
export const ldapFields: INodeProperties[] = [
// ----------------------------------
// Common
// ----------------------------------
{
displayName: 'DN',
name: 'dn',
type: 'string',
default: '',
placeholder: 'e.g. ou=users,dc=n8n,dc=io',
required: true,
typeOptions: {
alwaysOpenEditWindow: false,
},
displayOptions: {
show: {
operation: ['compare'],
},
},
description: 'The distinguished name of the entry to compare',
},
{
displayName: 'DN',
name: 'dn',
type: 'string',
default: '',
placeholder: 'e.g. ou=users,dc=n8n,dc=io',
required: true,
typeOptions: {
alwaysOpenEditWindow: false,
},
displayOptions: {
show: {
operation: ['create'],
},
},
description: 'The distinguished name of the entry to create',
},
{
displayName: 'DN',
name: 'dn',
type: 'string',
default: '',
placeholder: 'e.g. ou=users,dc=n8n,dc=io',
required: true,
typeOptions: {
alwaysOpenEditWindow: false,
},
displayOptions: {
show: {
operation: ['delete'],
},
},
description: 'The distinguished name of the entry to delete',
},
{
displayName: 'DN',
name: 'dn',
type: 'string',
default: '',
placeholder: 'e.g. cn=john,ou=users,dc=n8n,dc=io',
required: true,
typeOptions: {
alwaysOpenEditWindow: false,
},
displayOptions: {
show: {
operation: ['rename'],
},
},
description: 'The distinguished name of the entry to rename',
},
{
displayName: 'DN',
name: 'dn',
type: 'string',
default: '',
placeholder: 'e.g. ou=users,dc=n8n,dc=io',
required: true,
typeOptions: {
alwaysOpenEditWindow: false,
},
displayOptions: {
show: {
operation: ['modify'],
},
},
description: 'The distinguished name of the entry to modify',
},
// ----------------------------------
// Compare
// ----------------------------------
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Attribute ID',
name: 'id',
type: 'options',
required: true,
default: [],
typeOptions: {
loadOptionsMethod: 'getAttributesForDn',
},
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
description: 'The ID of the attribute to compare',
displayOptions: {
show: {
operation: ['compare'],
},
},
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'The value to compare',
displayOptions: {
show: {
operation: ['compare'],
},
},
},
// ----------------------------------
// Rename
// ----------------------------------
{
displayName: 'New DN',
name: 'targetDn',
type: 'string',
default: '',
placeholder: 'e.g. cn=nathan,ou=users,dc=n8n,dc=io',
required: true,
displayOptions: {
show: {
operation: ['rename'],
},
},
description: 'The new distinguished name for the entry',
},
// ----------------------------------
// Create
// ----------------------------------
{
displayName: 'Attributes',
name: 'attributes',
placeholder: 'Add Attributes',
description: 'Attributes to add to the entry',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
displayOptions: {
show: {
operation: ['create'],
},
},
default: {},
options: [
{
name: 'attribute',
displayName: 'Attribute',
values: [
{
displayName: 'Attribute ID',
name: 'id',
type: 'string',
default: '',
description: 'The ID of the attribute to add',
required: true,
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'Value of the attribute to set',
},
],
},
],
},
// ----------------------------------
// Update
// ----------------------------------
{
displayName: 'Update Attributes',
name: 'attributes',
placeholder: 'Update Attributes',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
sortable: true,
},
displayOptions: {
show: {
operation: ['update'],
},
},
description: 'Update entry attributes',
default: {},
options: [
{
name: 'add',
displayName: 'Add',
values: [
{
displayName: 'Attribute ID',
name: 'id',
type: 'string',
default: '',
description: 'The ID of the attribute to add',
required: true,
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'Value of the attribute to set',
},
],
},
{
name: 'replace',
displayName: 'Replace',
values: [
{
displayName: 'Attribute ID',
name: 'id',
type: 'string',
default: '',
description: 'The ID of the attribute to replace',
required: true,
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'Value of the attribute to replace',
},
],
},
{
name: 'delete',
displayName: 'Remove',
values: [
{
displayName: 'Attribute ID',
name: 'id',
type: 'string',
default: '',
description: 'The ID of the attribute to remove',
required: true,
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'Value of the attribute to remove',
},
],
},
],
},
// ----------------------------------
// Search
// ----------------------------------
{
displayName: 'Base DN',
name: 'baseDN',
type: 'string',
default: '',
placeholder: 'e.g. ou=users, dc=n8n, dc=io',
required: true,
displayOptions: {
show: {
operation: ['search'],
},
},
description: 'The distinguished name of the subtree to search in',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Search For',
name: 'searchFor',
type: 'options',
default: [],
typeOptions: {
loadOptionsMethod: 'getObjectClasses',
},
displayOptions: {
show: {
operation: ['search'],
},
},
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
description: 'Directory object class to search for',
},
{
displayName: 'Custom Filter',
name: 'customFilter',
type: 'string',
default: '(objectclass=*)',
displayOptions: {
show: {
operation: ['search'],
searchFor: ['custom'],
},
},
description: 'Custom LDAP filter. Escape these chars * ( ) \\ with a backslash "\\".',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Attribute',
name: 'attribute',
type: 'options',
required: true,
default: [],
typeOptions: {
loadOptionsMethod: 'getAttributes',
},
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
description: 'Attribute to search for',
displayOptions: {
show: {
operation: ['search'],
},
hide: {
searchFor: ['custom'],
},
},
},
{
displayName: 'Search Text',
name: 'searchText',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
operation: ['search'],
},
hide: {
searchFor: ['custom'],
},
},
description: 'Text to search for, Use * for a wildcard',
},
{
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: ['search'],
},
},
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
default: 50,
description: 'Max number of results to return',
typeOptions: {
minValue: 1,
},
displayOptions: {
show: {
operation: ['search'],
returnAll: [false],
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
displayOptions: {
show: {
operation: ['search'],
},
},
options: [
{
displayName: 'Attribute Names or IDs',
name: 'attributes',
type: 'multiOptions',
typeOptions: {
loadOptionsMethod: 'getAttributes',
},
default: [],
description:
'Comma-separated list of attributes to return. Choose from the list, or specify IDs using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>.',
},
{
displayName: 'Page Size',
name: 'pageSize',
type: 'number',
default: 1000,
typeOptions: {
minValue: 0,
},
description:
'Maximum number of results to request at one time. Set to 0 to disable paging.',
},
{
displayName: 'Scope',
name: 'scope',
default: 'sub',
description:
'The set of entries at or below the BaseDN that may be considered potential matches',
type: 'options',
options: [
{
name: 'Base Object',
value: 'base',
},
{
name: 'Single Level',
value: 'one',
},
{
name: 'Whole Subtree',
value: 'sub',
},
],
},
],
},
];

View file

@ -0,0 +1 @@
<svg class="svg-icon" style="width: 1em; height: 1em;vertical-align: middle;fill: currentColor;overflow: hidden;" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M206.9 564.6c17.7 0 32-14.3 32-32V188.8c0-16.4 13.3-29.6 29.6-29.6h467.4c16.4 0 29.6 13.3 29.6 29.6v345.3c0 3.9 0.7 7.8 2.1 11.4 12.6 33.2 61.9 24.1 61.9-11.4V188.8c0-51.7-41.9-93.6-93.6-93.6H268.5c-51.7 0-93.6 41.9-93.6 93.6v343.8c0 17.7 14.3 32 32 32z" /><path d="M671.4 294c0-17.7-14.3-32-32-32H363.1c-17.7 0-32 14.3-32 32s14.3 32 32 32h276.3c17.6 0 32-14.3 32-32zM639.4 412.7H363.1c-17.7 0-32 14.3-32 32s14.3 32 32 32h276.3c17.7 0 32-14.3 32-32s-14.4-32-32-32zM40.9 645.5h-1.8c-16.2 0-29.4 13.2-29.4 29.4v261.4c0 16.2 13.2 29.4 29.4 29.4h160.2c16.2 0 29.4-13.2 29.4-29.4s-13.2-29.4-29.4-29.4h-129v-232c0-16.2-13.1-29.4-29.4-29.4zM502.2 805.6c0-51.1-12.7-90.1-37.5-117-26.2-28.7-64.1-43.1-114.7-43.1h-84.4c-16.2 0-29.4 13.2-29.4 29.4v261.4c0 16.2 13.2 29.4 29.4 29.4H350c50.6 0 88.6-14.4 114.7-43.1 24.9-27.3 37.5-66.3 37.5-117z m-81.1 77c-17.4 17.1-49.6 24.7-76.7 24.7h-47.3v-205h47.3c34.5 0 60.8 8 76.7 24.3 15.5 15.9 23.3 35.5 23.3 71.4 0 44.4-9.4 71-23.3 84.6zM585.5 664.7l-97.2 261.4c-7.2 19.2 7.1 39.7 27.6 39.7h2.4c12.5 0 23.6-7.9 27.7-19.6l21.4-60.7h109.4l21.4 60.7c4.2 11.8 15.3 19.6 27.7 19.6h1.4c20.5 0 34.7-20.5 27.6-39.7l-97.2-261.4c-4.3-11.5-15.3-19.2-27.6-19.2H613c-12.2 0-23.2 7.6-27.5 19.2z m0.1 169.5l35.3-100.6h1.3l35 100.6h-71.6zM896.3 645.5h-99.7c-16.2 0-29.4 13.2-29.4 29.4v261.4c0 16.2 13.2 29.4 29.4 29.4h2.2c16.2 0 29.4-13.2 29.4-29.4v-93.5h67.2c75.9 0 113.9-33.2 113.9-99.1 0-65.5-38-98.2-113-98.2z m45.8 131.9c-10.5 8.1-27 12.6-49.7 12.6h-64.2v-90.6h64.2c22.2 0 38.8 4 49.3 12.6 10.5 8.1 16.1 17.9 16.1 31.8 0 16.7-5.2 25.1-15.7 33.6z" /></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -170,6 +170,7 @@
"dist/credentials/KeapOAuth2Api.credentials.js",
"dist/credentials/KitemakerApi.credentials.js",
"dist/credentials/KoBoToolboxApi.credentials.js",
"dist/credentials/Ldap.credentials.js",
"dist/credentials/LemlistApi.credentials.js",
"dist/credentials/LinearApi.credentials.js",
"dist/credentials/LineNotifyOAuth2Api.credentials.js",
@ -532,6 +533,7 @@
"dist/nodes/Kitemaker/Kitemaker.node.js",
"dist/nodes/KoBoToolbox/KoBoToolbox.node.js",
"dist/nodes/KoBoToolbox/KoBoToolboxTrigger.node.js",
"dist/nodes/Ldap/Ldap.node.js",
"dist/nodes/Lemlist/Lemlist.node.js",
"dist/nodes/Lemlist/LemlistTrigger.node.js",
"dist/nodes/Line/Line.node.js",