mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 12:57:29 -08:00
feat(n8n Form Trigger Node): Improvements (#10092)
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <netroy@users.noreply.github.com> Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com>
This commit is contained in:
parent
7a30d845e9
commit
711b667ebe
|
@ -59,6 +59,9 @@
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.n8n-link {
|
||||||
|
padding-bottom: 24px;
|
||||||
|
}
|
||||||
.n8n-link a {
|
.n8n-link a {
|
||||||
color: #7e8186;
|
color: #7e8186;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
@ -103,11 +106,12 @@
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #909399;
|
color: #71747A;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
form textarea:focus,
|
||||||
form input:focus {
|
form input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: rgb(90, 76, 194);
|
border-color: rgb(90, 76, 194);
|
||||||
|
@ -128,7 +132,7 @@
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #909399;
|
color: #71747A;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
@ -141,6 +145,10 @@
|
||||||
sans-serif;
|
sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::placeholder {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
#submit-btn {
|
#submit-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
|
@ -225,9 +233,77 @@
|
||||||
height: 18px;
|
height: 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
/* required field ----------------------------- */
|
||||||
.form-required {
|
.form-required {
|
||||||
}
|
}
|
||||||
|
label.form-required::after {
|
||||||
|
content: ' *';
|
||||||
|
color: #ff6d5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: 0;
|
||||||
|
height: 1px;
|
||||||
|
border-top: 1px solid #dbdfe7;
|
||||||
|
margin-top: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
input[type="file"] {
|
||||||
|
}
|
||||||
|
.clear-button {
|
||||||
|
position: absolute;
|
||||||
|
right: 5px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-65%);
|
||||||
|
background-color: #7e8186;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family:
|
||||||
|
Open Sans,
|
||||||
|
sans-serif;
|
||||||
|
color: white;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
input[type="file"]:not(:empty) + .clear-button {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 400px) {
|
||||||
|
hr {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
width: 95%;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px;
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #dbdfe7;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0px 4px 16px 0px #634dff0f;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
padding: 0px;
|
||||||
|
background-color: white;
|
||||||
|
border: 0px solid #dbdfe7;
|
||||||
|
border-radius: 0px;
|
||||||
|
box-shadow: 0px 0px 10px 0px #634dff0f;
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
@ -238,20 +314,23 @@
|
||||||
<div class='test-notice'>
|
<div class='test-notice'>
|
||||||
<p>This is test version of your form. Use it only for testing your Form Trigger.</p>
|
<p>This is test version of your form. Use it only for testing your Form Trigger.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<hr>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{{#if validForm}}
|
{{#if validForm}}
|
||||||
<form class='card' action='#' method='POST' name='n8n-form' id='n8n-form' novalidate>
|
<form class='card' action='#' method='POST' name='n8n-form' id='n8n-form' novalidate>
|
||||||
<div class='form-header'>
|
<div class='form-header'>
|
||||||
<h1>{{formTitle}}</h1>
|
<h1>{{formTitle}}</h1>
|
||||||
<p>{{formDescription}} </p>
|
<p style="white-space: pre-line">{{formDescription}} </p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class='inputs-wrapper'>
|
<div class='inputs-wrapper'>
|
||||||
{{#each formFields}}
|
{{#each formFields}}
|
||||||
{{#if isMultiSelect}}
|
{{#if isMultiSelect}}
|
||||||
<div>
|
<div>
|
||||||
<label class='form-label'>{{label}}</label>
|
<label class='form-label {{inputRequired}}'>{{label}}</label>
|
||||||
<div class='multiselect {{inputRequired}}' id='{{id}}'>
|
<div class='multiselect {{inputRequired}}' id='{{id}}'>
|
||||||
{{#each multiSelectOptions}}
|
{{#each multiSelectOptions}}
|
||||||
<div class='multiselect-option'>
|
<div class='multiselect-option'>
|
||||||
|
@ -268,7 +347,7 @@
|
||||||
|
|
||||||
{{#if isSelect}}
|
{{#if isSelect}}
|
||||||
<div class='form-group'>
|
<div class='form-group'>
|
||||||
<label class='form-label' for='{{id}}'>{{label}}</label>
|
<label class='form-label {{inputRequired}}' for='{{id}}'>{{label}}</label>
|
||||||
<div class='select-input'>
|
<div class='select-input'>
|
||||||
<select id='{{id}}' name='{{id}}' class='{{inputRequired}}'>
|
<select id='{{id}}' name='{{id}}' class='{{inputRequired}}'>
|
||||||
<option value='' disabled selected>Select an option ...</option>
|
<option value='' disabled selected>Select an option ...</option>
|
||||||
|
@ -285,12 +364,32 @@
|
||||||
|
|
||||||
{{#if isTextarea}}
|
{{#if isTextarea}}
|
||||||
<div class='form-group'>
|
<div class='form-group'>
|
||||||
<label class='form-label' for='{{id}}'>{{label}}</label>
|
<label class='form-label {{inputRequired}}' for='{{id}}'>{{label}}</label>
|
||||||
<textarea
|
<textarea
|
||||||
class='form-input {{inputRequired}}'
|
class='form-input {{inputRequired}}'
|
||||||
id='{{id}}'
|
id='{{id}}'
|
||||||
name='{{id}}'
|
name='{{id}}'
|
||||||
></textarea>
|
placeholder="{{placeholder}}"
|
||||||
|
>{{defaultValue}}</textarea>
|
||||||
|
<p class='{{errorId}} error-hidden'>
|
||||||
|
This field is required
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if isFileInput}}
|
||||||
|
<div class='form-group file-input-wrapper'>
|
||||||
|
<label class='form-label {{inputRequired}}' for='{{id}}'>{{label}}</label>
|
||||||
|
<input
|
||||||
|
class='form-input {{inputRequired}}'
|
||||||
|
type='file'
|
||||||
|
id='{{id}}'
|
||||||
|
name='{{id}}'
|
||||||
|
accept='{{acceptFileTypes}}'
|
||||||
|
{{multipleFiles}}
|
||||||
|
placeholder="{{placeholder}}"
|
||||||
|
/>
|
||||||
|
<button class="clear-button">×</button>
|
||||||
<p class='{{errorId}} error-hidden'>
|
<p class='{{errorId}} error-hidden'>
|
||||||
This field is required
|
This field is required
|
||||||
</p>
|
</p>
|
||||||
|
@ -299,12 +398,14 @@
|
||||||
|
|
||||||
{{#if isInput}}
|
{{#if isInput}}
|
||||||
<div class='form-group'>
|
<div class='form-group'>
|
||||||
<label class='form-label' for='{{id}}'>{{label}}</label>
|
<label class='form-label {{inputRequired}}' for='{{id}}'>{{label}}</label>
|
||||||
<input
|
<input
|
||||||
class='form-input {{inputRequired}}'
|
class='form-input {{inputRequired}}'
|
||||||
type='{{type}}'
|
type='{{type}}'
|
||||||
id='{{id}}'
|
id='{{id}}'
|
||||||
name='{{id}}'
|
name='{{id}}'
|
||||||
|
value="{{defaultValue}}"
|
||||||
|
placeholder="{{placeholder}}"
|
||||||
/>
|
/>
|
||||||
<p class='{{errorId}} error-hidden'>
|
<p class='{{errorId}} error-hidden'>
|
||||||
This field is required
|
This field is required
|
||||||
|
@ -355,9 +456,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#if appendAttribution}}
|
{{#if appendAttribution}}
|
||||||
|
<hr>
|
||||||
<div class='n8n-link'>
|
<div class='n8n-link'>
|
||||||
<a href={{n8nWebsiteLink}} target='_blank'>
|
<a href={{n8nWebsiteLink}} target='_blank'>
|
||||||
Form automated with
|
Form automated with
|
||||||
|
{{#if customAttribution}}
|
||||||
|
{{{customAttribution}}}
|
||||||
|
{{else}}
|
||||||
<svg
|
<svg
|
||||||
width='73'
|
width='73'
|
||||||
height='20'
|
height='20'
|
||||||
|
@ -384,10 +489,13 @@
|
||||||
fill='#101330'
|
fill='#101330'
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
{{/if}}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{{#if redirectUrl}}
|
{{#if redirectUrl}}
|
||||||
<a id='redirectUrl' href='{{redirectUrl}}' style='display: none;'></a>
|
<a id='redirectUrl' href='{{redirectUrl}}' style='display: none;'></a>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
@ -396,10 +504,13 @@
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
function validateInput(input, errorElement) {
|
function validateInput(input, errorElement) {
|
||||||
if (input.type === 'number' && input.value !== '') {
|
const value = input.value.trim();
|
||||||
const value = input.value.trim();
|
const type = input.type;
|
||||||
|
|
||||||
if (value === '' || isNaN(value)) {
|
if (type === 'email' && value !== '') {
|
||||||
|
return validateEmailInput(value, errorElement);
|
||||||
|
} else if (type === 'number' && value !== '') {
|
||||||
|
if (isNaN(value)) {
|
||||||
errorElement.textContent = 'Enter only numbers in this field';
|
errorElement.textContent = 'Enter only numbers in this field';
|
||||||
errorElement.classList.add('error-show');
|
errorElement.classList.add('error-show');
|
||||||
return false;
|
return false;
|
||||||
|
@ -407,7 +518,8 @@
|
||||||
errorElement.classList.remove('error-show');
|
errorElement.classList.remove('error-show');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} else if (input.value === '') {
|
} else if (value === '') {
|
||||||
|
errorElement.textContent = 'This field is required';
|
||||||
errorElement.classList.add('error-show');
|
errorElement.classList.add('error-show');
|
||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
|
@ -416,6 +528,21 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateEmailInput(value, errorElement) {
|
||||||
|
const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||||
|
const isValidEmail = regex.test(value);
|
||||||
|
|
||||||
|
if (!isValidEmail) {
|
||||||
|
errorElement.textContent = 'Enter a valid email address in this field';
|
||||||
|
errorElement.classList.add('error-show');
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
errorElement.textContent = 'This field is required';
|
||||||
|
errorElement.classList.remove('error-show');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getSelectedValues(input) {
|
function getSelectedValues(input) {
|
||||||
const selectedValues = [];
|
const selectedValues = [];
|
||||||
const checkboxes = input.querySelectorAll('.multiselect-checkbox');
|
const checkboxes = input.querySelectorAll('.multiselect-checkbox');
|
||||||
|
@ -444,7 +571,44 @@
|
||||||
|
|
||||||
const form = document.querySelector('#n8n-form');
|
const form = document.querySelector('#n8n-form');
|
||||||
|
|
||||||
const requiredInputs = document.querySelectorAll('.form-required');
|
document.querySelectorAll("input[type=number]").forEach(function (element) {
|
||||||
|
element.addEventListener("wheel", function(event) {
|
||||||
|
if (document.activeElement === event.target) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('input[type="file"]').forEach(fileInput => {
|
||||||
|
const clearButton = fileInput.nextElementSibling;
|
||||||
|
let previousFiles = [];
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', () => {
|
||||||
|
const files = fileInput.files;
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
previousFiles = Array.from(files);
|
||||||
|
clearButton.style.display = 'inline-block';
|
||||||
|
} else {
|
||||||
|
if (previousFiles.length > 0) {
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
previousFiles.forEach(file => dataTransfer.items.add(file));
|
||||||
|
fileInput.files = dataTransfer.files;
|
||||||
|
clearButton.style.display = 'inline-block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clearButton.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
fileInput.value = '';
|
||||||
|
previousFiles = [];
|
||||||
|
clearButton.style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const requiredInputs = document.querySelectorAll('.form-required:not(label)');
|
||||||
|
const emailInputs = document.querySelectorAll("input[type=email]");
|
||||||
|
|
||||||
requiredInputs.forEach((input) => {
|
requiredInputs.forEach((input) => {
|
||||||
const errorSelector = `.error-${input.id}`;
|
const errorSelector = `.error-${input.id}`;
|
||||||
|
@ -464,10 +628,34 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
emailInputs.forEach(function (input) {
|
||||||
|
const errorSelector = `.error-${input.id}`;
|
||||||
|
const error = document.querySelector(errorSelector);
|
||||||
|
|
||||||
|
input.addEventListener("input", function(event) {
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (value === "") {
|
||||||
|
error.classList.remove('error-show');
|
||||||
|
} else {
|
||||||
|
validateEmailInput(value, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
form.addEventListener('submit', (e) => {
|
form.addEventListener('submit', (e) => {
|
||||||
const valid = [];
|
const valid = [];
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
emailInputs.forEach(function (input) {
|
||||||
|
const value = input.value.trim();
|
||||||
|
if(value === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const errorSelector = `.error-${input.id}`;
|
||||||
|
const error = document.querySelector(errorSelector);
|
||||||
|
valid.push(validateEmailInput(value, error));
|
||||||
|
});
|
||||||
|
|
||||||
requiredInputs.forEach((input) => {
|
requiredInputs.forEach((input) => {
|
||||||
const errorSelector = `.error-${input.id}`;
|
const errorSelector = `.error-${input.id}`;
|
||||||
const error = document.querySelector(errorSelector);
|
const error = document.querySelector(errorSelector);
|
||||||
|
@ -480,7 +668,20 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
if (valid.every((v) => v)) {
|
if (valid.every((v) => v)) {
|
||||||
var formData = new FormData(form);
|
var formData = new FormData();
|
||||||
|
|
||||||
|
for (const filed of form.elements) {
|
||||||
|
if(filed.type !== 'file') {
|
||||||
|
formData.append(filed.name, filed.value);
|
||||||
|
} else {
|
||||||
|
for (const file of filed.files) {
|
||||||
|
if(file.size === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
formData.append(filed.name, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelectorAll('.multiselect').forEach((multiselect) => {
|
document.querySelectorAll('.multiselect').forEach((multiselect) => {
|
||||||
const selectedValues = getSelectedValues(multiselect);
|
const selectedValues = getSelectedValues(multiselect);
|
||||||
|
|
|
@ -310,7 +310,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
||||||
testUrl = `${rootStore.formWaitingUrl}/${runWorkflowApiResponse.executionId}${suffix}`;
|
testUrl = `${rootStore.formWaitingUrl}/${runWorkflowApiResponse.executionId}${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (testUrl) openPopUpWindow(testUrl);
|
if (testUrl && options.source !== 'RunData.ManualChatMessage') openPopUpWindow(testUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -648,6 +648,7 @@ export const KEEP_AUTH_IN_NDV_FOR_NODES = [
|
||||||
WAIT_NODE_TYPE,
|
WAIT_NODE_TYPE,
|
||||||
DISCORD_NODE_TYPE,
|
DISCORD_NODE_TYPE,
|
||||||
CHAT_TRIGGER_NODE_TYPE,
|
CHAT_TRIGGER_NODE_TYPE,
|
||||||
|
FORM_TRIGGER_NODE_TYPE,
|
||||||
];
|
];
|
||||||
export const MAIN_AUTH_FIELD_NAME = 'authentication';
|
export const MAIN_AUTH_FIELD_NAME = 'authentication';
|
||||||
export const NODE_RESOURCE_FIELD_NAME = 'resource';
|
export const NODE_RESOURCE_FIELD_NAME = 'resource';
|
||||||
|
|
|
@ -11,12 +11,13 @@ export class FormTrigger extends VersionedNodeType {
|
||||||
icon: 'file:form.svg',
|
icon: 'file:form.svg',
|
||||||
group: ['trigger'],
|
group: ['trigger'],
|
||||||
description: 'Runs the flow when an n8n generated webform is submitted',
|
description: 'Runs the flow when an n8n generated webform is submitted',
|
||||||
defaultVersion: 2,
|
defaultVersion: 2.1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
||||||
1: new FormTriggerV1(baseDescription),
|
1: new FormTriggerV1(baseDescription),
|
||||||
2: new FormTriggerV2(baseDescription),
|
2: new FormTriggerV2(baseDescription),
|
||||||
|
2.1: new FormTriggerV2(baseDescription),
|
||||||
};
|
};
|
||||||
|
|
||||||
super(nodeVersions, baseDescription);
|
super(nodeVersions, baseDescription);
|
||||||
|
|
|
@ -28,6 +28,9 @@ export const formDescription: INodeProperties = {
|
||||||
placeholder: "e.g. We'll get back to you soon",
|
placeholder: "e.g. We'll get back to you soon",
|
||||||
description:
|
description:
|
||||||
'Shown underneath the Form Title. Can be used to prompt the user on how to complete the form.',
|
'Shown underneath the Form Title. Can be used to prompt the user on how to complete the form.',
|
||||||
|
typeOptions: {
|
||||||
|
rows: 2,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formFields: INodeProperties = {
|
export const formFields: INodeProperties = {
|
||||||
|
@ -69,6 +72,14 @@ export const formFields: INodeProperties = {
|
||||||
name: 'Dropdown List',
|
name: 'Dropdown List',
|
||||||
value: 'dropdown',
|
value: 'dropdown',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Email',
|
||||||
|
value: 'email',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'File',
|
||||||
|
value: 'file',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Number',
|
name: 'Number',
|
||||||
value: 'number',
|
value: 'number',
|
||||||
|
@ -88,6 +99,17 @@ export const formFields: INodeProperties = {
|
||||||
],
|
],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Placeholder',
|
||||||
|
name: 'placeholder',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
displayOptions: {
|
||||||
|
hide: {
|
||||||
|
fieldType: ['dropdown', 'date', 'file'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Field Options',
|
displayName: 'Field Options',
|
||||||
name: 'fieldOptions',
|
name: 'fieldOptions',
|
||||||
|
@ -133,6 +155,48 @@ export const formFields: INodeProperties = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Multiple Files',
|
||||||
|
name: 'multipleFiles',
|
||||||
|
type: 'boolean',
|
||||||
|
default: true,
|
||||||
|
description:
|
||||||
|
'Whether to allow the user to select multiple files from the file input or just one',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
fieldType: ['file'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Accept File Types',
|
||||||
|
name: 'acceptFileTypes',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
description: 'List of file types that can be uploaded, separated by commas',
|
||||||
|
hint: 'Leave empty to allow all file types',
|
||||||
|
placeholder: 'e.g. .jpg, .png',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
fieldType: ['file'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Format Date As',
|
||||||
|
name: 'formatDate',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
description:
|
||||||
|
'Returns a string representation of this field formatted according to the specified format string. For a table of tokens and their interpretations, see <a href="https://moment.github.io/luxon/#/formatting?ID=table-of-tokens" target="_blank">here</a>.',
|
||||||
|
placeholder: 'e.g. dd/mm/yyyy',
|
||||||
|
hint: 'Leave empty to use the default format',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
fieldType: ['date'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Required Field',
|
displayName: 'Required Field',
|
||||||
name: 'requiredField',
|
name: 'requiredField',
|
||||||
|
|
|
@ -4,20 +4,29 @@ export type FormField = {
|
||||||
requiredField: boolean;
|
requiredField: boolean;
|
||||||
fieldOptions?: { values: Array<{ option: string }> };
|
fieldOptions?: { values: Array<{ option: string }> };
|
||||||
multiselect?: boolean;
|
multiselect?: boolean;
|
||||||
|
multipleFiles?: boolean;
|
||||||
|
acceptFileTypes?: string;
|
||||||
|
formatDate?: string;
|
||||||
|
placeholder?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FormTriggerInput = {
|
export type FormTriggerInput = {
|
||||||
isSelect?: boolean;
|
isSelect?: boolean;
|
||||||
isMultiSelect?: boolean;
|
isMultiSelect?: boolean;
|
||||||
isTextarea?: boolean;
|
isTextarea?: boolean;
|
||||||
|
isFileInput?: boolean;
|
||||||
isInput?: boolean;
|
isInput?: boolean;
|
||||||
labbel: string;
|
label: string;
|
||||||
|
defaultValue?: string;
|
||||||
id: string;
|
id: string;
|
||||||
errorId: string;
|
errorId: string;
|
||||||
type?: 'text' | 'number' | 'date';
|
type?: 'text' | 'number' | 'date';
|
||||||
inputRequired: 'form-required' | '';
|
inputRequired: 'form-required' | '';
|
||||||
selectOptions?: string[];
|
selectOptions?: string[];
|
||||||
multiSelectOptions?: Array<{ id: string; label: string }>;
|
multiSelectOptions?: Array<{ id: string; label: string }>;
|
||||||
|
acceptFileTypes?: string;
|
||||||
|
multipleFiles?: 'multiple' | '';
|
||||||
|
placeholder?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FormTriggerData = {
|
export type FormTriggerData = {
|
||||||
|
@ -31,4 +40,7 @@ export type FormTriggerData = {
|
||||||
formFields: FormTriggerInput[];
|
formFields: FormTriggerInput[];
|
||||||
useResponseData?: boolean;
|
useResponseData?: boolean;
|
||||||
appendAttribution?: boolean;
|
appendAttribution?: boolean;
|
||||||
|
customAttribution?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const FORM_TRIGGER_AUTHENTICATION_PROPERTY = 'authentication';
|
||||||
|
|
370
packages/nodes-base/nodes/Form/test/utils.test.ts
Normal file
370
packages/nodes-base/nodes/Form/test/utils.test.ts
Normal file
|
@ -0,0 +1,370 @@
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type { IWebhookFunctions } from 'n8n-workflow';
|
||||||
|
import type { FormField } from '../interfaces';
|
||||||
|
import { formWebhook, prepareFormData } from '../utils';
|
||||||
|
|
||||||
|
describe('FormTrigger, formWebhook', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call response render', async () => {
|
||||||
|
const executeFunctions = mock<IWebhookFunctions>();
|
||||||
|
const mockRender = jest.fn();
|
||||||
|
|
||||||
|
const formFields: FormField[] = [
|
||||||
|
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
|
||||||
|
{ fieldLabel: 'Age', fieldType: 'number', requiredField: false },
|
||||||
|
{
|
||||||
|
fieldLabel: 'Gender',
|
||||||
|
fieldType: 'select',
|
||||||
|
requiredField: true,
|
||||||
|
fieldOptions: { values: [{ option: 'Male' }, { option: 'Female' }] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldLabel: 'Resume',
|
||||||
|
fieldType: 'file',
|
||||||
|
requiredField: true,
|
||||||
|
acceptFileTypes: '.pdf,.doc',
|
||||||
|
multipleFiles: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any);
|
||||||
|
executeFunctions.getNodeParameter.calledWith('options').mockReturnValue({});
|
||||||
|
executeFunctions.getNodeParameter.calledWith('formTitle').mockReturnValue('Test Form');
|
||||||
|
executeFunctions.getNodeParameter
|
||||||
|
.calledWith('formDescription')
|
||||||
|
.mockReturnValue('Test Description');
|
||||||
|
executeFunctions.getNodeParameter.calledWith('responseMode').mockReturnValue('onReceived');
|
||||||
|
executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields);
|
||||||
|
executeFunctions.getResponseObject.mockReturnValue({ render: mockRender } as any);
|
||||||
|
executeFunctions.getRequestObject.mockReturnValue({ method: 'GET', query: {} } as any);
|
||||||
|
executeFunctions.getMode.mockReturnValue('manual');
|
||||||
|
executeFunctions.getInstanceId.mockReturnValue('instanceId');
|
||||||
|
executeFunctions.getBodyData.mockReturnValue({ data: {}, files: {} });
|
||||||
|
executeFunctions.getChildNodes.mockReturnValue([]);
|
||||||
|
|
||||||
|
await formWebhook(executeFunctions);
|
||||||
|
|
||||||
|
expect(mockRender).toHaveBeenCalledWith('form-trigger', {
|
||||||
|
appendAttribution: true,
|
||||||
|
customAttribution: undefined,
|
||||||
|
formDescription: 'Test Description',
|
||||||
|
formFields: [
|
||||||
|
{
|
||||||
|
defaultValue: '',
|
||||||
|
errorId: 'error-field-0',
|
||||||
|
id: 'field-0',
|
||||||
|
inputRequired: 'form-required',
|
||||||
|
isInput: true,
|
||||||
|
label: 'Name',
|
||||||
|
placeholder: undefined,
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
defaultValue: '',
|
||||||
|
errorId: 'error-field-1',
|
||||||
|
id: 'field-1',
|
||||||
|
inputRequired: '',
|
||||||
|
isInput: true,
|
||||||
|
label: 'Age',
|
||||||
|
placeholder: undefined,
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
defaultValue: '',
|
||||||
|
errorId: 'error-field-2',
|
||||||
|
id: 'field-2',
|
||||||
|
inputRequired: 'form-required',
|
||||||
|
isInput: true,
|
||||||
|
label: 'Gender',
|
||||||
|
placeholder: undefined,
|
||||||
|
type: 'select',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
acceptFileTypes: '.pdf,.doc',
|
||||||
|
defaultValue: '',
|
||||||
|
errorId: 'error-field-3',
|
||||||
|
id: 'field-3',
|
||||||
|
inputRequired: 'form-required',
|
||||||
|
isFileInput: true,
|
||||||
|
label: 'Resume',
|
||||||
|
multipleFiles: '',
|
||||||
|
placeholder: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
formSubmittedText: 'Your response has been recorded',
|
||||||
|
formTitle: 'Test Form',
|
||||||
|
n8nWebsiteLink:
|
||||||
|
'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger&utm_campaign=instanceId',
|
||||||
|
testRun: true,
|
||||||
|
useResponseData: false,
|
||||||
|
validForm: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return workflowData on POST request', async () => {
|
||||||
|
const executeFunctions = mock<IWebhookFunctions>();
|
||||||
|
const mockStatus = jest.fn();
|
||||||
|
const mockEnd = jest.fn();
|
||||||
|
|
||||||
|
const formFields: FormField[] = [
|
||||||
|
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
|
||||||
|
{ fieldLabel: 'Age', fieldType: 'number', requiredField: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const bodyData = {
|
||||||
|
'field-0': 'John Doe',
|
||||||
|
'field-1': '30',
|
||||||
|
};
|
||||||
|
|
||||||
|
executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any);
|
||||||
|
executeFunctions.getNodeParameter.calledWith('options').mockReturnValue({});
|
||||||
|
executeFunctions.getNodeParameter.calledWith('responseMode').mockReturnValue('onReceived');
|
||||||
|
executeFunctions.getChildNodes.mockReturnValue([]);
|
||||||
|
executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields);
|
||||||
|
executeFunctions.getResponseObject.mockReturnValue({ status: mockStatus, end: mockEnd } as any);
|
||||||
|
executeFunctions.getRequestObject.mockReturnValue({ method: 'POST' } as any);
|
||||||
|
executeFunctions.getMode.mockReturnValue('manual');
|
||||||
|
executeFunctions.getInstanceId.mockReturnValue('instanceId');
|
||||||
|
executeFunctions.getBodyData.mockReturnValue({ data: bodyData, files: {} });
|
||||||
|
|
||||||
|
const result = await formWebhook(executeFunctions);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
webhookResponse: { status: 200 },
|
||||||
|
workflowData: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
Name: 'John Doe',
|
||||||
|
Age: 30,
|
||||||
|
submittedAt: expect.any(String),
|
||||||
|
formMode: 'test',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FormTrigger, prepareFormData', () => {
|
||||||
|
it('should return valid form data with given parameters', () => {
|
||||||
|
const formFields: FormField[] = [
|
||||||
|
{
|
||||||
|
fieldLabel: 'Name',
|
||||||
|
fieldType: 'text',
|
||||||
|
requiredField: true,
|
||||||
|
placeholder: 'Enter your name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldLabel: 'Email',
|
||||||
|
fieldType: 'email',
|
||||||
|
requiredField: true,
|
||||||
|
placeholder: 'Enter your email',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldLabel: 'Gender',
|
||||||
|
fieldType: 'dropdown',
|
||||||
|
requiredField: false,
|
||||||
|
fieldOptions: { values: [{ option: 'Male' }, { option: 'Female' }] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldLabel: 'Files',
|
||||||
|
fieldType: 'file',
|
||||||
|
requiredField: false,
|
||||||
|
acceptFileTypes: '.jpg,.png',
|
||||||
|
multipleFiles: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const query = { Name: 'John Doe', Email: 'john@example.com' };
|
||||||
|
|
||||||
|
const result = prepareFormData({
|
||||||
|
formTitle: 'Test Form',
|
||||||
|
formDescription: 'This is a test form',
|
||||||
|
formSubmittedText: 'Thank you for your submission',
|
||||||
|
redirectUrl: 'https://example.com/thank-you',
|
||||||
|
formFields,
|
||||||
|
testRun: false,
|
||||||
|
query,
|
||||||
|
instanceId: 'test-instance',
|
||||||
|
useResponseData: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
testRun: false,
|
||||||
|
validForm: true,
|
||||||
|
formTitle: 'Test Form',
|
||||||
|
formDescription: 'This is a test form',
|
||||||
|
formSubmittedText: 'Thank you for your submission',
|
||||||
|
n8nWebsiteLink:
|
||||||
|
'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger&utm_campaign=test-instance',
|
||||||
|
formFields: [
|
||||||
|
{
|
||||||
|
id: 'field-0',
|
||||||
|
errorId: 'error-field-0',
|
||||||
|
label: 'Name',
|
||||||
|
inputRequired: 'form-required',
|
||||||
|
defaultValue: 'John Doe',
|
||||||
|
placeholder: 'Enter your name',
|
||||||
|
isInput: true,
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'field-1',
|
||||||
|
errorId: 'error-field-1',
|
||||||
|
label: 'Email',
|
||||||
|
inputRequired: 'form-required',
|
||||||
|
defaultValue: 'john@example.com',
|
||||||
|
placeholder: 'Enter your email',
|
||||||
|
isInput: true,
|
||||||
|
type: 'email',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'field-2',
|
||||||
|
errorId: 'error-field-2',
|
||||||
|
label: 'Gender',
|
||||||
|
inputRequired: '',
|
||||||
|
defaultValue: '',
|
||||||
|
placeholder: undefined,
|
||||||
|
isSelect: true,
|
||||||
|
selectOptions: ['Male', 'Female'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'field-3',
|
||||||
|
errorId: 'error-field-3',
|
||||||
|
label: 'Files',
|
||||||
|
inputRequired: '',
|
||||||
|
defaultValue: '',
|
||||||
|
placeholder: undefined,
|
||||||
|
isFileInput: true,
|
||||||
|
acceptFileTypes: '.jpg,.png',
|
||||||
|
multipleFiles: 'multiple',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
useResponseData: true,
|
||||||
|
appendAttribution: true,
|
||||||
|
customAttribution: undefined,
|
||||||
|
redirectUrl: 'https://example.com/thank-you',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing optional fields gracefully', () => {
|
||||||
|
const formFields: FormField[] = [
|
||||||
|
{
|
||||||
|
fieldLabel: 'Name',
|
||||||
|
fieldType: 'text',
|
||||||
|
requiredField: true,
|
||||||
|
placeholder: 'Enter your name',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = prepareFormData({
|
||||||
|
formTitle: 'Test Form',
|
||||||
|
formDescription: 'This is a test form',
|
||||||
|
formSubmittedText: undefined,
|
||||||
|
redirectUrl: undefined,
|
||||||
|
formFields,
|
||||||
|
testRun: true,
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
testRun: true,
|
||||||
|
validForm: true,
|
||||||
|
formTitle: 'Test Form',
|
||||||
|
formDescription: 'This is a test form',
|
||||||
|
formSubmittedText: 'Your response has been recorded',
|
||||||
|
n8nWebsiteLink: 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger',
|
||||||
|
formFields: [
|
||||||
|
{
|
||||||
|
id: 'field-0',
|
||||||
|
errorId: 'error-field-0',
|
||||||
|
label: 'Name',
|
||||||
|
inputRequired: 'form-required',
|
||||||
|
defaultValue: '',
|
||||||
|
placeholder: 'Enter your name',
|
||||||
|
isInput: true,
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
useResponseData: undefined,
|
||||||
|
appendAttribution: true,
|
||||||
|
customAttribution: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set redirectUrl with http if protocol is missing', () => {
|
||||||
|
const formFields: FormField[] = [
|
||||||
|
{
|
||||||
|
fieldLabel: 'Name',
|
||||||
|
fieldType: 'text',
|
||||||
|
requiredField: true,
|
||||||
|
placeholder: 'Enter your name',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const query = { Name: 'John Doe' };
|
||||||
|
|
||||||
|
const result = prepareFormData({
|
||||||
|
formTitle: 'Test Form',
|
||||||
|
formDescription: 'This is a test form',
|
||||||
|
formSubmittedText: undefined,
|
||||||
|
redirectUrl: 'example.com/thank-you',
|
||||||
|
formFields,
|
||||||
|
testRun: true,
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.redirectUrl).toBe('http://example.com/thank-you');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return invalid form data when formFields are empty', () => {
|
||||||
|
const result = prepareFormData({
|
||||||
|
formTitle: 'Test Form',
|
||||||
|
formDescription: 'This is a test form',
|
||||||
|
formSubmittedText: undefined,
|
||||||
|
redirectUrl: undefined,
|
||||||
|
formFields: [],
|
||||||
|
testRun: true,
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.validForm).toBe(false);
|
||||||
|
expect(result.formFields).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly handle multiselect fields', () => {
|
||||||
|
const formFields: FormField[] = [
|
||||||
|
{
|
||||||
|
fieldLabel: 'Favorite Colors',
|
||||||
|
fieldType: 'text',
|
||||||
|
requiredField: true,
|
||||||
|
multiselect: true,
|
||||||
|
fieldOptions: { values: [{ option: 'Red' }, { option: 'Blue' }, { option: 'Green' }] },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const query = { 'Favorite Colors': 'Red,Blue' };
|
||||||
|
|
||||||
|
const result = prepareFormData({
|
||||||
|
formTitle: 'Test Form',
|
||||||
|
formDescription: 'This is a test form',
|
||||||
|
formSubmittedText: 'Thank you',
|
||||||
|
redirectUrl: 'example.com',
|
||||||
|
formFields,
|
||||||
|
testRun: false,
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.formFields[0].isMultiSelect).toBe(true);
|
||||||
|
expect(result.formFields[0].multiSelectOptions).toEqual([
|
||||||
|
{ id: 'option0', label: 'Red' },
|
||||||
|
{ id: 'option1', label: 'Blue' },
|
||||||
|
{ id: 'option2', label: 'Green' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,22 +1,45 @@
|
||||||
import {
|
import type {
|
||||||
NodeOperationError,
|
INodeExecutionData,
|
||||||
jsonParse,
|
MultiPartFormData,
|
||||||
type IDataObject,
|
IDataObject,
|
||||||
type IWebhookFunctions,
|
IWebhookFunctions,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import type { FormField, FormTriggerData, FormTriggerInput } from './interfaces';
|
import { NodeOperationError, jsonParse } from 'n8n-workflow';
|
||||||
|
|
||||||
export const prepareFormData = (
|
import type { FormField, FormTriggerData, FormTriggerInput } from './interfaces';
|
||||||
formTitle: string,
|
import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from './interfaces';
|
||||||
formDescription: string,
|
|
||||||
formSubmittedText: string | undefined,
|
import { WebhookAuthorizationError } from '../Webhook/error';
|
||||||
redirectUrl: string | undefined,
|
import { validateWebhookAuthentication } from '../Webhook/utils';
|
||||||
formFields: FormField[],
|
|
||||||
testRun: boolean,
|
import { DateTime } from 'luxon';
|
||||||
instanceId?: string,
|
import isbot from 'isbot';
|
||||||
useResponseData?: boolean,
|
|
||||||
|
export function prepareFormData({
|
||||||
|
formTitle,
|
||||||
|
formDescription,
|
||||||
|
formSubmittedText,
|
||||||
|
redirectUrl,
|
||||||
|
formFields,
|
||||||
|
testRun,
|
||||||
|
query,
|
||||||
|
instanceId,
|
||||||
|
useResponseData,
|
||||||
appendAttribution = true,
|
appendAttribution = true,
|
||||||
) => {
|
customAttribution,
|
||||||
|
}: {
|
||||||
|
formTitle: string;
|
||||||
|
formDescription: string;
|
||||||
|
formSubmittedText: string | undefined;
|
||||||
|
redirectUrl: string | undefined;
|
||||||
|
formFields: FormField[];
|
||||||
|
testRun: boolean;
|
||||||
|
query: IDataObject;
|
||||||
|
instanceId?: string;
|
||||||
|
useResponseData?: boolean;
|
||||||
|
appendAttribution?: boolean;
|
||||||
|
customAttribution?: string;
|
||||||
|
}) {
|
||||||
const validForm = formFields.length > 0;
|
const validForm = formFields.length > 0;
|
||||||
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
|
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
|
||||||
const n8nWebsiteLink = `https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger${utm_campaign}`;
|
const n8nWebsiteLink = `https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger${utm_campaign}`;
|
||||||
|
@ -35,6 +58,7 @@ export const prepareFormData = (
|
||||||
formFields: [],
|
formFields: [],
|
||||||
useResponseData,
|
useResponseData,
|
||||||
appendAttribution,
|
appendAttribution,
|
||||||
|
customAttribution,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (redirectUrl) {
|
if (redirectUrl) {
|
||||||
|
@ -49,13 +73,15 @@ export const prepareFormData = (
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [index, field] of formFields.entries()) {
|
for (const [index, field] of formFields.entries()) {
|
||||||
const { fieldType, requiredField, multiselect } = field;
|
const { fieldType, requiredField, multiselect, placeholder } = field;
|
||||||
|
|
||||||
const input: IDataObject = {
|
const input: IDataObject = {
|
||||||
id: `field-${index}`,
|
id: `field-${index}`,
|
||||||
errorId: `error-field-${index}`,
|
errorId: `error-field-${index}`,
|
||||||
label: field.fieldLabel,
|
label: field.fieldLabel,
|
||||||
inputRequired: requiredField ? 'form-required' : '',
|
inputRequired: requiredField ? 'form-required' : '',
|
||||||
|
defaultValue: query[field.fieldLabel] ?? '',
|
||||||
|
placeholder,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (multiselect) {
|
if (multiselect) {
|
||||||
|
@ -65,6 +91,10 @@ export const prepareFormData = (
|
||||||
id: `option${i}`,
|
id: `option${i}`,
|
||||||
label: e.option,
|
label: e.option,
|
||||||
})) ?? [];
|
})) ?? [];
|
||||||
|
} else if (fieldType === 'file') {
|
||||||
|
input.isFileInput = true;
|
||||||
|
input.acceptFileTypes = field.acceptFileTypes;
|
||||||
|
input.multipleFiles = field.multipleFiles ? 'multiple' : '';
|
||||||
} else if (fieldType === 'dropdown') {
|
} else if (fieldType === 'dropdown') {
|
||||||
input.isSelect = true;
|
input.isSelect = true;
|
||||||
const fieldOptions = field.fieldOptions?.values ?? [];
|
const fieldOptions = field.fieldOptions?.values ?? [];
|
||||||
|
@ -73,14 +103,14 @@ export const prepareFormData = (
|
||||||
input.isTextarea = true;
|
input.isTextarea = true;
|
||||||
} else {
|
} else {
|
||||||
input.isInput = true;
|
input.isInput = true;
|
||||||
input.type = fieldType as 'text' | 'number' | 'date';
|
input.type = fieldType as 'text' | 'number' | 'date' | 'email';
|
||||||
}
|
}
|
||||||
|
|
||||||
formData.formFields.push(input as FormTriggerInput);
|
formData.formFields.push(input as FormTriggerInput);
|
||||||
}
|
}
|
||||||
|
|
||||||
return formData;
|
return formData;
|
||||||
};
|
}
|
||||||
|
|
||||||
const checkResponseModeConfiguration = (context: IWebhookFunctions) => {
|
const checkResponseModeConfiguration = (context: IWebhookFunctions) => {
|
||||||
const responseMode = context.getNodeParameter('responseMode', 'onReceived') as string;
|
const responseMode = context.getNodeParameter('responseMode', 'onReceived') as string;
|
||||||
|
@ -114,6 +144,37 @@ const checkResponseModeConfiguration = (context: IWebhookFunctions) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function formWebhook(context: IWebhookFunctions) {
|
export async function formWebhook(context: IWebhookFunctions) {
|
||||||
|
const nodeVersion = context.getNode().typeVersion;
|
||||||
|
const options = context.getNodeParameter('options', {}) as {
|
||||||
|
ignoreBots?: boolean;
|
||||||
|
respondWithOptions?: {
|
||||||
|
values: {
|
||||||
|
respondWith: 'text' | 'redirect';
|
||||||
|
formSubmittedText: string;
|
||||||
|
redirectUrl: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
formSubmittedText?: string;
|
||||||
|
useWorkflowTimezone?: boolean;
|
||||||
|
appendAttribution?: boolean;
|
||||||
|
customAttribution?: string;
|
||||||
|
};
|
||||||
|
const res = context.getResponseObject();
|
||||||
|
const req = context.getRequestObject();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (options.ignoreBots && isbot(req.headers['user-agent']))
|
||||||
|
throw new WebhookAuthorizationError(403);
|
||||||
|
await validateWebhookAuthentication(context, FORM_TRIGGER_AUTHENTICATION_PROPERTY);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof WebhookAuthorizationError) {
|
||||||
|
res.writeHead(error.responseCode, { 'WWW-Authenticate': 'Basic realm="Webhook"' });
|
||||||
|
res.end(error.message);
|
||||||
|
return { noWebhookResponse: true };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const mode = context.getMode() === 'manual' ? 'test' : 'production';
|
const mode = context.getMode() === 'manual' ? 'test' : 'production';
|
||||||
const formFields = context.getNodeParameter('formFields.values', []) as FormField[];
|
const formFields = context.getNodeParameter('formFields.values', []) as FormField[];
|
||||||
const method = context.getRequestObject().method;
|
const method = context.getRequestObject().method;
|
||||||
|
@ -123,10 +184,11 @@ export async function formWebhook(context: IWebhookFunctions) {
|
||||||
//Show the form on GET request
|
//Show the form on GET request
|
||||||
if (method === 'GET') {
|
if (method === 'GET') {
|
||||||
const formTitle = context.getNodeParameter('formTitle', '') as string;
|
const formTitle = context.getNodeParameter('formTitle', '') as string;
|
||||||
const formDescription = context.getNodeParameter('formDescription', '') as string;
|
const formDescription = (context.getNodeParameter('formDescription', '') as string)
|
||||||
|
.replace(/\\n/g, '\n')
|
||||||
|
.replace(/<br>/g, '\n');
|
||||||
const instanceId = context.getInstanceId();
|
const instanceId = context.getInstanceId();
|
||||||
const responseMode = context.getNodeParameter('responseMode', '') as string;
|
const responseMode = context.getNodeParameter('responseMode', '') as string;
|
||||||
const options = context.getNodeParameter('options', {}) as IDataObject;
|
|
||||||
|
|
||||||
let formSubmittedText;
|
let formSubmittedText;
|
||||||
let redirectUrl;
|
let redirectUrl;
|
||||||
|
@ -150,19 +212,22 @@ export async function formWebhook(context: IWebhookFunctions) {
|
||||||
|
|
||||||
const useResponseData = responseMode === 'responseNode';
|
const useResponseData = responseMode === 'responseNode';
|
||||||
|
|
||||||
const data = prepareFormData(
|
const query = context.getRequestObject().query as IDataObject;
|
||||||
|
|
||||||
|
const data = prepareFormData({
|
||||||
formTitle,
|
formTitle,
|
||||||
formDescription,
|
formDescription,
|
||||||
formSubmittedText,
|
formSubmittedText,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
formFields,
|
formFields,
|
||||||
mode === 'test',
|
testRun: mode === 'test',
|
||||||
|
query,
|
||||||
instanceId,
|
instanceId,
|
||||||
useResponseData,
|
useResponseData,
|
||||||
appendAttribution,
|
appendAttribution,
|
||||||
);
|
customAttribution: options.customAttribution as string,
|
||||||
|
});
|
||||||
|
|
||||||
const res = context.getResponseObject();
|
|
||||||
res.render('form-trigger', data);
|
res.render('form-trigger', data);
|
||||||
return {
|
return {
|
||||||
noWebhookResponse: true,
|
noWebhookResponse: true,
|
||||||
|
@ -170,13 +235,64 @@ export async function formWebhook(context: IWebhookFunctions) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const bodyData = (context.getBodyData().data as IDataObject) ?? {};
|
const bodyData = (context.getBodyData().data as IDataObject) ?? {};
|
||||||
|
const files = (context.getBodyData().files as IDataObject) ?? {};
|
||||||
|
|
||||||
|
const returnItem: INodeExecutionData = {
|
||||||
|
json: {},
|
||||||
|
};
|
||||||
|
if (files && Object.keys(files).length) {
|
||||||
|
returnItem.binary = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(files)) {
|
||||||
|
const processFiles: MultiPartFormData.File[] = [];
|
||||||
|
let multiFile = false;
|
||||||
|
const filesInput = files[key] as MultiPartFormData.File[] | MultiPartFormData.File;
|
||||||
|
|
||||||
|
if (Array.isArray(filesInput)) {
|
||||||
|
bodyData[key] = filesInput.map((file) => ({
|
||||||
|
filename: file.originalFilename,
|
||||||
|
mimetype: file.mimetype,
|
||||||
|
size: file.size,
|
||||||
|
}));
|
||||||
|
processFiles.push(...filesInput);
|
||||||
|
multiFile = true;
|
||||||
|
} else {
|
||||||
|
bodyData[key] = {
|
||||||
|
filename: filesInput.originalFilename,
|
||||||
|
mimetype: filesInput.mimetype,
|
||||||
|
size: filesInput.size,
|
||||||
|
};
|
||||||
|
processFiles.push(filesInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryIndex = Number(key.replace(/field-/g, ''));
|
||||||
|
const fieldLabel = isNaN(entryIndex) ? key : formFields[entryIndex].fieldLabel;
|
||||||
|
|
||||||
|
let fileCount = 0;
|
||||||
|
for (const file of processFiles) {
|
||||||
|
let binaryPropertyName = fieldLabel.replace(/\W/g, '_');
|
||||||
|
|
||||||
|
if (multiFile) {
|
||||||
|
binaryPropertyName += `_${fileCount++}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
returnItem.binary![binaryPropertyName] = await context.nodeHelpers.copyBinaryFile(
|
||||||
|
file.filepath,
|
||||||
|
file.originalFilename ?? file.newFilename,
|
||||||
|
file.mimetype,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const returnData: IDataObject = {};
|
|
||||||
for (const [index, field] of formFields.entries()) {
|
for (const [index, field] of formFields.entries()) {
|
||||||
const key = `field-${index}`;
|
const key = `field-${index}`;
|
||||||
let value = bodyData[key] ?? null;
|
let value = bodyData[key] ?? null;
|
||||||
|
|
||||||
if (value === null) returnData[field.fieldLabel] = null;
|
if (value === null) {
|
||||||
|
returnItem.json[field.fieldLabel] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (field.fieldType === 'number') {
|
if (field.fieldType === 'number') {
|
||||||
value = Number(value);
|
value = Number(value);
|
||||||
|
@ -187,16 +303,31 @@ export async function formWebhook(context: IWebhookFunctions) {
|
||||||
if (field.multiselect && typeof value === 'string') {
|
if (field.multiselect && typeof value === 'string') {
|
||||||
value = jsonParse(value);
|
value = jsonParse(value);
|
||||||
}
|
}
|
||||||
|
if (field.fieldType === 'date' && value && field.formatDate !== '') {
|
||||||
|
value = DateTime.fromFormat(String(value), 'yyyy-mm-dd').toFormat(field.formatDate as string);
|
||||||
|
}
|
||||||
|
if (field.fieldType === 'file' && field.multipleFiles && !Array.isArray(value)) {
|
||||||
|
value = [value];
|
||||||
|
}
|
||||||
|
|
||||||
returnData[field.fieldLabel] = value;
|
returnItem.json[field.fieldLabel] = value;
|
||||||
}
|
}
|
||||||
returnData.submittedAt = new Date().toISOString();
|
|
||||||
returnData.formMode = mode;
|
let { useWorkflowTimezone } = options;
|
||||||
|
|
||||||
|
if (useWorkflowTimezone === undefined && nodeVersion > 2) {
|
||||||
|
useWorkflowTimezone = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timezone = useWorkflowTimezone ? context.getTimezone() : 'UTC';
|
||||||
|
returnItem.json.submittedAt = DateTime.now().setZone(timezone).toISO();
|
||||||
|
|
||||||
|
returnItem.json.formMode = mode;
|
||||||
|
|
||||||
const webhookResponse: IDataObject = { status: 200 };
|
const webhookResponse: IDataObject = { status: 200 };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
webhookResponse,
|
webhookResponse,
|
||||||
workflowData: [context.helpers.returnJsonArray(returnData)],
|
workflowData: [[returnItem]],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||||
import {
|
import type {
|
||||||
type INodeType,
|
INodeProperties,
|
||||||
type INodeTypeBaseDescription,
|
INodeType,
|
||||||
type INodeTypeDescription,
|
INodeTypeBaseDescription,
|
||||||
type IWebhookFunctions,
|
INodeTypeDescription,
|
||||||
|
IWebhookFunctions,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { formWebhook } from '../utils';
|
import { formWebhook } from '../utils';
|
||||||
|
@ -16,13 +17,22 @@ import {
|
||||||
respondWithOptions,
|
respondWithOptions,
|
||||||
webhookPath,
|
webhookPath,
|
||||||
} from '../common.descriptions';
|
} from '../common.descriptions';
|
||||||
|
import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from '../interfaces';
|
||||||
|
|
||||||
|
const useWorkflowTimezone: INodeProperties = {
|
||||||
|
displayName: 'Use Workflow Timezone',
|
||||||
|
name: 'useWorkflowTimezone',
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
description: "Whether to use the workflow timezone set in node's settings rather than UTC",
|
||||||
|
};
|
||||||
|
|
||||||
const descriptionV2: INodeTypeDescription = {
|
const descriptionV2: INodeTypeDescription = {
|
||||||
displayName: 'n8n Form Trigger',
|
displayName: 'n8n Form Trigger',
|
||||||
name: 'formTrigger',
|
name: 'formTrigger',
|
||||||
icon: 'file:form.svg',
|
icon: 'file:form.svg',
|
||||||
group: ['trigger'],
|
group: ['trigger'],
|
||||||
version: 2,
|
version: [2, 2.1],
|
||||||
description: 'Runs the flow when an n8n generated webform is submitted',
|
description: 'Runs the flow when an n8n generated webform is submitted',
|
||||||
defaults: {
|
defaults: {
|
||||||
name: 'n8n Form Trigger',
|
name: 'n8n Form Trigger',
|
||||||
|
@ -54,7 +64,35 @@ const descriptionV2: INodeTypeDescription = {
|
||||||
eventTriggerDescription: 'Waiting for you to submit the form',
|
eventTriggerDescription: 'Waiting for you to submit the form',
|
||||||
activationMessage: 'You can now make calls to your production Form URL.',
|
activationMessage: 'You can now make calls to your production Form URL.',
|
||||||
triggerPanel: formTriggerPanel,
|
triggerPanel: formTriggerPanel,
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
// eslint-disable-next-line n8n-nodes-base/node-class-description-credentials-name-unsuffixed
|
||||||
|
name: 'httpBasicAuth',
|
||||||
|
required: true,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
[FORM_TRIGGER_AUTHENTICATION_PROPERTY]: ['basicAuth'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
properties: [
|
properties: [
|
||||||
|
{
|
||||||
|
displayName: 'Authentication',
|
||||||
|
name: FORM_TRIGGER_AUTHENTICATION_PROPERTY,
|
||||||
|
type: 'options',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'Basic Auth',
|
||||||
|
value: 'basicAuth',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'None',
|
||||||
|
value: 'none',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default: 'none',
|
||||||
|
},
|
||||||
webhookPath,
|
webhookPath,
|
||||||
formTitle,
|
formTitle,
|
||||||
formDescription,
|
formDescription,
|
||||||
|
@ -77,6 +115,17 @@ const descriptionV2: INodeTypeDescription = {
|
||||||
placeholder: 'Add option',
|
placeholder: 'Add option',
|
||||||
default: {},
|
default: {},
|
||||||
options: [
|
options: [
|
||||||
|
{
|
||||||
|
displayName: 'Custom Attribution',
|
||||||
|
name: 'customAttribution',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: 'e.g. <svg> ...</svg>',
|
||||||
|
description: "HTML code that will be shown at the bottom of the form instead n8n's logo",
|
||||||
|
default: '',
|
||||||
|
typeOptions: {
|
||||||
|
rows: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||||
displayName: 'Append n8n Attribution',
|
displayName: 'Append n8n Attribution',
|
||||||
|
@ -94,6 +143,31 @@ const descriptionV2: INodeTypeDescription = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Ignore Bots',
|
||||||
|
name: 'ignoreBots',
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
description: 'Whether to ignore requests from bots like link previewers and web crawlers',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...useWorkflowTimezone,
|
||||||
|
default: false,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
'@version': [2],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...useWorkflowTimezone,
|
||||||
|
default: true,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
'@version': [{ _cnd: { gt: 2 } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
@ -4,7 +4,6 @@ import { createWriteStream } from 'fs';
|
||||||
import { stat } from 'fs/promises';
|
import { stat } from 'fs/promises';
|
||||||
import type {
|
import type {
|
||||||
IWebhookFunctions,
|
IWebhookFunctions,
|
||||||
ICredentialDataDecryptedObject,
|
|
||||||
IDataObject,
|
IDataObject,
|
||||||
INodeExecutionData,
|
INodeExecutionData,
|
||||||
INodeTypeDescription,
|
INodeTypeDescription,
|
||||||
|
@ -15,12 +14,9 @@ import type {
|
||||||
import { BINARY_ENCODING, NodeOperationError, Node } from 'n8n-workflow';
|
import { BINARY_ENCODING, NodeOperationError, Node } from 'n8n-workflow';
|
||||||
|
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import basicAuth from 'basic-auth';
|
|
||||||
import isbot from 'isbot';
|
import isbot from 'isbot';
|
||||||
import { file as tmpFile } from 'tmp-promise';
|
import { file as tmpFile } from 'tmp-promise';
|
||||||
import jwt from 'jsonwebtoken';
|
|
||||||
|
|
||||||
import { formatPrivateKey } from '../../utils/utilities';
|
|
||||||
import {
|
import {
|
||||||
authenticationProperty,
|
authenticationProperty,
|
||||||
credentialsProperty,
|
credentialsProperty,
|
||||||
|
@ -39,6 +35,7 @@ import {
|
||||||
configuredOutputs,
|
configuredOutputs,
|
||||||
isIpWhitelisted,
|
isIpWhitelisted,
|
||||||
setupOutputConnection,
|
setupOutputConnection,
|
||||||
|
validateWebhookAuthentication,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
|
||||||
export class Webhook extends Node {
|
export class Webhook extends Node {
|
||||||
|
@ -265,93 +262,7 @@ export class Webhook extends Node {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validateAuth(context: IWebhookFunctions) {
|
private async validateAuth(context: IWebhookFunctions) {
|
||||||
const authentication = context.getNodeParameter(this.authPropertyName) as string;
|
return await validateWebhookAuthentication(context, this.authPropertyName);
|
||||||
if (authentication === 'none') return;
|
|
||||||
|
|
||||||
const req = context.getRequestObject();
|
|
||||||
const headers = context.getHeaderData();
|
|
||||||
|
|
||||||
if (authentication === 'basicAuth') {
|
|
||||||
// Basic authorization is needed to call webhook
|
|
||||||
let expectedAuth: ICredentialDataDecryptedObject | undefined;
|
|
||||||
try {
|
|
||||||
expectedAuth = await context.getCredentials('httpBasicAuth');
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (expectedAuth === undefined || !expectedAuth.user || !expectedAuth.password) {
|
|
||||||
// Data is not defined on node so can not authenticate
|
|
||||||
throw new WebhookAuthorizationError(500, 'No authentication data defined on node!');
|
|
||||||
}
|
|
||||||
|
|
||||||
const providedAuth = basicAuth(req);
|
|
||||||
// Authorization data is missing
|
|
||||||
if (!providedAuth) throw new WebhookAuthorizationError(401);
|
|
||||||
|
|
||||||
if (providedAuth.name !== expectedAuth.user || providedAuth.pass !== expectedAuth.password) {
|
|
||||||
// Provided authentication data is wrong
|
|
||||||
throw new WebhookAuthorizationError(403);
|
|
||||||
}
|
|
||||||
} else if (authentication === 'headerAuth') {
|
|
||||||
// Special header with value is needed to call webhook
|
|
||||||
let expectedAuth: ICredentialDataDecryptedObject | undefined;
|
|
||||||
try {
|
|
||||||
expectedAuth = await context.getCredentials('httpHeaderAuth');
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (expectedAuth === undefined || !expectedAuth.name || !expectedAuth.value) {
|
|
||||||
// Data is not defined on node so can not authenticate
|
|
||||||
throw new WebhookAuthorizationError(500, 'No authentication data defined on node!');
|
|
||||||
}
|
|
||||||
const headerName = (expectedAuth.name as string).toLowerCase();
|
|
||||||
const expectedValue = expectedAuth.value as string;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!headers.hasOwnProperty(headerName) ||
|
|
||||||
(headers as IDataObject)[headerName] !== expectedValue
|
|
||||||
) {
|
|
||||||
// Provided authentication data is wrong
|
|
||||||
throw new WebhookAuthorizationError(403);
|
|
||||||
}
|
|
||||||
} else if (authentication === 'jwtAuth') {
|
|
||||||
let expectedAuth;
|
|
||||||
|
|
||||||
try {
|
|
||||||
expectedAuth = (await context.getCredentials('jwtAuth')) as {
|
|
||||||
keyType: 'passphrase' | 'pemKey';
|
|
||||||
publicKey: string;
|
|
||||||
secret: string;
|
|
||||||
algorithm: jwt.Algorithm;
|
|
||||||
};
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (expectedAuth === undefined) {
|
|
||||||
// Data is not defined on node so can not authenticate
|
|
||||||
throw new WebhookAuthorizationError(500, 'No authentication data defined on node!');
|
|
||||||
}
|
|
||||||
|
|
||||||
const authHeader = req.headers.authorization;
|
|
||||||
const token = authHeader?.split(' ')[1];
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
throw new WebhookAuthorizationError(401, 'No token provided');
|
|
||||||
}
|
|
||||||
|
|
||||||
let secretOrPublicKey;
|
|
||||||
|
|
||||||
if (expectedAuth.keyType === 'passphrase') {
|
|
||||||
secretOrPublicKey = expectedAuth.secret;
|
|
||||||
} else {
|
|
||||||
secretOrPublicKey = formatPrivateKey(expectedAuth.publicKey, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return jwt.verify(token, secretOrPublicKey, {
|
|
||||||
algorithms: [expectedAuth.algorithm],
|
|
||||||
}) as IDataObject;
|
|
||||||
} catch (error) {
|
|
||||||
throw new WebhookAuthorizationError(403, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleFormData(
|
private async handleFormData(
|
||||||
|
|
|
@ -1,5 +1,14 @@
|
||||||
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
||||||
import type { IWebhookFunctions, INodeExecutionData, IDataObject } from 'n8n-workflow';
|
import type {
|
||||||
|
IWebhookFunctions,
|
||||||
|
INodeExecutionData,
|
||||||
|
IDataObject,
|
||||||
|
ICredentialDataDecryptedObject,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { WebhookAuthorizationError } from './error';
|
||||||
|
import basicAuth from 'basic-auth';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { formatPrivateKey } from '../../utils/utilities';
|
||||||
|
|
||||||
type WebhookParameters = {
|
type WebhookParameters = {
|
||||||
httpMethod: string;
|
httpMethod: string;
|
||||||
|
@ -167,3 +176,96 @@ export const checkResponseModeConfiguration = (context: IWebhookFunctions) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function validateWebhookAuthentication(
|
||||||
|
ctx: IWebhookFunctions,
|
||||||
|
authPropertyName: string,
|
||||||
|
) {
|
||||||
|
const authentication = ctx.getNodeParameter(authPropertyName) as string;
|
||||||
|
if (authentication === 'none') return;
|
||||||
|
|
||||||
|
const req = ctx.getRequestObject();
|
||||||
|
const headers = ctx.getHeaderData();
|
||||||
|
|
||||||
|
if (authentication === 'basicAuth') {
|
||||||
|
// Basic authorization is needed to call webhook
|
||||||
|
let expectedAuth: ICredentialDataDecryptedObject | undefined;
|
||||||
|
try {
|
||||||
|
expectedAuth = await ctx.getCredentials('httpBasicAuth');
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (expectedAuth === undefined || !expectedAuth.user || !expectedAuth.password) {
|
||||||
|
// Data is not defined on node so can not authenticate
|
||||||
|
throw new WebhookAuthorizationError(500, 'No authentication data defined on node!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const providedAuth = basicAuth(req);
|
||||||
|
// Authorization data is missing
|
||||||
|
if (!providedAuth) throw new WebhookAuthorizationError(401);
|
||||||
|
|
||||||
|
if (providedAuth.name !== expectedAuth.user || providedAuth.pass !== expectedAuth.password) {
|
||||||
|
// Provided authentication data is wrong
|
||||||
|
throw new WebhookAuthorizationError(403);
|
||||||
|
}
|
||||||
|
} else if (authentication === 'headerAuth') {
|
||||||
|
// Special header with value is needed to call webhook
|
||||||
|
let expectedAuth: ICredentialDataDecryptedObject | undefined;
|
||||||
|
try {
|
||||||
|
expectedAuth = await ctx.getCredentials('httpHeaderAuth');
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (expectedAuth === undefined || !expectedAuth.name || !expectedAuth.value) {
|
||||||
|
// Data is not defined on node so can not authenticate
|
||||||
|
throw new WebhookAuthorizationError(500, 'No authentication data defined on node!');
|
||||||
|
}
|
||||||
|
const headerName = (expectedAuth.name as string).toLowerCase();
|
||||||
|
const expectedValue = expectedAuth.value as string;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!headers.hasOwnProperty(headerName) ||
|
||||||
|
(headers as IDataObject)[headerName] !== expectedValue
|
||||||
|
) {
|
||||||
|
// Provided authentication data is wrong
|
||||||
|
throw new WebhookAuthorizationError(403);
|
||||||
|
}
|
||||||
|
} else if (authentication === 'jwtAuth') {
|
||||||
|
let expectedAuth;
|
||||||
|
|
||||||
|
try {
|
||||||
|
expectedAuth = (await ctx.getCredentials('jwtAuth')) as {
|
||||||
|
keyType: 'passphrase' | 'pemKey';
|
||||||
|
publicKey: string;
|
||||||
|
secret: string;
|
||||||
|
algorithm: jwt.Algorithm;
|
||||||
|
};
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (expectedAuth === undefined) {
|
||||||
|
// Data is not defined on node so can not authenticate
|
||||||
|
throw new WebhookAuthorizationError(500, 'No authentication data defined on node!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
const token = authHeader?.split(' ')[1];
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new WebhookAuthorizationError(401, 'No token provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
let secretOrPublicKey;
|
||||||
|
|
||||||
|
if (expectedAuth.keyType === 'passphrase') {
|
||||||
|
secretOrPublicKey = expectedAuth.secret;
|
||||||
|
} else {
|
||||||
|
secretOrPublicKey = formatPrivateKey(expectedAuth.publicKey, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, secretOrPublicKey, {
|
||||||
|
algorithms: [expectedAuth.algorithm],
|
||||||
|
}) as IDataObject;
|
||||||
|
} catch (error) {
|
||||||
|
throw new WebhookAuthorizationError(403, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1464,6 +1464,7 @@ export namespace MultiPartFormData {
|
||||||
mimetype?: string;
|
mimetype?: string;
|
||||||
originalFilename?: string;
|
originalFilename?: string;
|
||||||
newFilename: string;
|
newFilename: string;
|
||||||
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Request = express.Request<
|
export type Request = express.Request<
|
||||||
|
|
Loading…
Reference in a new issue