diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts
index 27fb1bcd35..fab05be8ec 100644
--- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts
+++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts
@@ -12,6 +12,7 @@ import type {
INodeProperties,
} from 'n8n-workflow';
+import { cssVariables } from './constants';
import { validateAuth } from './GenericFunctions';
import { createPage } from './templates';
import type { LoadPreviousSessionChatOption } from './types';
@@ -378,6 +379,29 @@ export class ChatTrigger extends Node {
placeholder: 'e.g. Welcome',
description: 'Shown at the top of the chat',
},
+ {
+ displayName: 'Custom Chat Styling',
+ name: 'customCss',
+ type: 'string',
+ typeOptions: {
+ rows: 10,
+ editor: 'cssEditor',
+ },
+ displayOptions: {
+ show: {
+ '/mode': ['hostedChat'],
+ },
+ },
+ default: `
+${cssVariables}
+
+/* You can override any class styles, too. Right-click inspect in Chat UI to find class to override. */
+.chat-message {
+ max-width: 50%;
+}
+`.trim(),
+ description: 'Override default styling of the public chat interface with CSS',
+ },
],
},
],
@@ -466,6 +490,7 @@ export class ChatTrigger extends Node {
title?: string;
allowFileUploads?: boolean;
allowedFilesMimeTypes?: string;
+ customCss?: string;
};
const req = ctx.getRequestObject();
@@ -517,6 +542,7 @@ export class ChatTrigger extends Node {
authentication,
allowFileUploads: options.allowFileUploads,
allowedFilesMimeTypes: options.allowedFilesMimeTypes,
+ customCss: options.customCss,
});
res.status(200).send(page).end();
diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/constants.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/constants.ts
new file mode 100644
index 0000000000..379629a896
--- /dev/null
+++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/constants.ts
@@ -0,0 +1,122 @@
+// CSS Variables are defined in `@n8n/chat/src/css/_tokens.scss`
+export const cssVariables = `
+:root {
+ /* Colors */
+ --chat--color-primary: #e74266;
+ --chat--color-primary-shade-50: #db4061;
+ --chat--color-primary-shade-100: #cf3c5c;
+ --chat--color-secondary: #20b69e;
+ --chat--color-secondary-shade-50: #1ca08a;
+ --chat--color-white: #ffffff;
+ --chat--color-light: #f2f4f8;
+ --chat--color-light-shade-50: #e6e9f1;
+ --chat--color-light-shade-100: #c2c5cc;
+ --chat--color-medium: #d2d4d9;
+ --chat--color-dark: #101330;
+ --chat--color-disabled: #777980;
+ --chat--color-typing: #404040;
+
+ /* Base Layout */
+ --chat--spacing: 1rem;
+ --chat--border-radius: 0.25rem;
+ --chat--transition-duration: 0.15s;
+ --chat--font-family: (
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ Roboto,
+ Oxygen-Sans,
+ Ubuntu,
+ Cantarell,
+ 'Helvetica Neue',
+ sans-serif
+ );
+
+ /* Window Dimensions */
+ --chat--window--width: 400px;
+ --chat--window--height: 600px;
+ --chat--window--bottom: var(--chat--spacing);
+ --chat--window--right: var(--chat--spacing);
+ --chat--window--z-index: 9999;
+ --chat--window--border: 1px solid var(--chat--color-light-shade-50);
+ --chat--window--border-radius: var(--chat--border-radius);
+ --chat--window--margin-bottom: var(--chat--spacing);
+
+ /* Header Styles */
+ --chat--header-height: auto;
+ --chat--header--padding: var(--chat--spacing);
+ --chat--header--background: var(--chat--color-dark);
+ --chat--header--color: var(--chat--color-light);
+ --chat--header--border-top: none;
+ --chat--header--border-bottom: none;
+ --chat--header--border-left: none;
+ --chat--header--border-right: none;
+ --chat--heading--font-size: 2em;
+ --chat--subtitle--font-size: inherit;
+ --chat--subtitle--line-height: 1.8;
+
+ /* Message Styles */
+ --chat--message--font-size: 1rem;
+ --chat--message--padding: var(--chat--spacing);
+ --chat--message--border-radius: var(--chat--border-radius);
+ --chat--message-line-height: 1.5;
+ --chat--message--margin-bottom: calc(var(--chat--spacing) * 1);
+ --chat--message--bot--background: var(--chat--color-white);
+ --chat--message--bot--color: var(--chat--color-dark);
+ --chat--message--bot--border: none;
+ --chat--message--user--background: var(--chat--color-secondary);
+ --chat--message--user--color: var(--chat--color-white);
+ --chat--message--user--border: none;
+ --chat--message--pre--background: rgba(0, 0, 0, 0.05);
+ --chat--messages-list--padding: var(--chat--spacing);
+
+ /* Toggle Button */
+ --chat--toggle--size: 64px;
+ --chat--toggle--width: var(--chat--toggle--size);
+ --chat--toggle--height: var(--chat--toggle--size);
+ --chat--toggle--border-radius: 50%;
+ --chat--toggle--background: var(--chat--color-primary);
+ --chat--toggle--hover--background: var(--chat--color-primary-shade-50);
+ --chat--toggle--active--background: var(--chat--color-primary-shade-100);
+ --chat--toggle--color: var(--chat--color-white);
+
+ /* Input Area */
+ --chat--textarea--height: 50px;
+ --chat--textarea--max-height: 30rem;
+ --chat--input--font-size: inherit;
+ --chat--input--border: 0;
+ --chat--input--border-radius: 0;
+ --chat--input--padding: 0.8rem;
+ --chat--input--background: var(--chat--color-white);
+ --chat--input--text-color: initial;
+ --chat--input--line-height: 1.5;
+ --chat--input--placeholder--font-size: var(--chat--input--font-size);
+ --chat--input--border-active: 0;
+ --chat--input--left--panel--width: 2rem;
+
+ /* Button Styles */
+ --chat--button--color: var(--chat--color-light);
+ --chat--button--background: var(--chat--color-primary);
+ --chat--button--padding: calc(var(--chat--spacing) * 1 / 2) var(--chat--spacing);
+ --chat--button--border-radius: var(--chat--border-radius);
+ --chat--button--hover--color: var(--chat--color-light);
+ --chat--button--hover--background: var(--chat--color-primary-shade-50);
+ --chat--close--button--color-hover: var(--chat--color-primary);
+
+ /* Send and File Buttons */
+ --chat--input--send--button--background: var(--chat--color-white);
+ --chat--input--send--button--color: var(--chat--color-light);
+ --chat--input--send--button--background-hover: var(--chat--color-primary-shade-50);
+ --chat--input--send--button--color-hover: var(--chat--color-secondary-shade-50);
+ --chat--input--file--button--background: var(--chat--color-white);
+ --chat--input--file--button--color: var(--chat--color-secondary);
+ --chat--input--file--button--background-hover: var(--chat--input--file--button--background);
+ --chat--input--file--button--color-hover: var(--chat--color-secondary-shade-50);
+ --chat--files-spacing: 0.25rem;
+
+ /* Body and Footer */
+ --chat--body--background: var(--chat--color-light);
+ --chat--footer--background: var(--chat--color-light);
+ --chat--footer--color: var(--chat--color-dark);
+}
+`;
diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts
index 0435df59e8..d224a89361 100644
--- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts
+++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts
@@ -1,5 +1,6 @@
-import type { AuthenticationChatOption, LoadPreviousSessionChatOption } from './types';
+import sanitizeHtml from 'sanitize-html';
+import type { AuthenticationChatOption, LoadPreviousSessionChatOption } from './types';
export function createPage({
instanceId,
webhookUrl,
@@ -10,6 +11,7 @@ export function createPage({
authentication,
allowFileUploads,
allowedFilesMimeTypes,
+ customCss,
}: {
instanceId: string;
webhookUrl?: string;
@@ -23,6 +25,7 @@ export function createPage({
authentication: AuthenticationChatOption;
allowFileUploads?: boolean;
allowedFilesMimeTypes?: string;
+ customCss?: string;
}) {
const validAuthenticationOptions: AuthenticationChatOption[] = [
'none',
@@ -41,6 +44,11 @@ export function createPage({
const sanitizedShowWelcomeScreen = !!showWelcomeScreen;
const sanitizedAllowFileUploads = !!allowFileUploads;
const sanitizedAllowedFilesMimeTypes = allowedFilesMimeTypes?.toString() ?? '';
+ const sanitizedCustomCss = sanitizeHtml(``, {
+ allowedTags: ['style'],
+ allowedAttributes: false,
+ });
+
const sanitizedLoadPreviousSession = validLoadPreviousSessionOptions.includes(
loadPreviousSession as LoadPreviousSessionChatOption,
)
@@ -63,6 +71,7 @@ export function createPage({
height: 100%;
}
+ ${sanitizedCustomCss}
+
+
+
+
+
+
diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue
index c610185903..6c672ffe1d 100644
--- a/packages/editor-ui/src/components/ParameterInput.vue
+++ b/packages/editor-ui/src/components/ParameterInput.vue
@@ -67,6 +67,7 @@ import { createEventBus } from '@n8n/utils/event-bus';
import { useRouter } from 'vue-router';
import { useElementSize } from '@vueuse/core';
import { completeExpressionSyntax, isStringWithExpressionSyntax } from '@/utils/expressions';
+import CssEditor from './CssEditor/CssEditor.vue';
type Picker = { $emit: (arg0: string, arg1: Date) => void };
@@ -408,7 +409,7 @@ const displayIssues = computed(
getIssues.value.length > 0,
);
-const editorType = computed(() => {
+const editorType = computed(() => {
return getArgument('editor');
});
const editorIsReadOnly = computed(() => {
@@ -1225,6 +1226,14 @@ onUpdated(async () => {
fullscreen
@update:model-value="valueChangedDebounced"
/>
+
{
+
+
+
+
+
+
diff --git a/packages/frontend/@n8n/chat/src/components/Layout.vue b/packages/frontend/@n8n/chat/src/components/Layout.vue
index 3c1546e8d4..d1226146c2 100644
--- a/packages/frontend/@n8n/chat/src/components/Layout.vue
+++ b/packages/frontend/@n8n/chat/src/components/Layout.vue
@@ -43,46 +43,33 @@ onBeforeUnmount(() => {
display: flex;
overflow-y: auto;
flex-direction: column;
- font-family: var(
- --chat--font-family,
- (
- -apple-system,
- BlinkMacSystemFont,
- 'Segoe UI',
- Roboto,
- Oxygen-Sans,
- Ubuntu,
- Cantarell,
- 'Helvetica Neue',
- sans-serif
- )
- );
+ font-family: var(--chat--font-family);
.chat-header {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1em;
- height: var(--chat--header-height, auto);
- padding: var(--chat--header--padding, var(--chat--spacing));
- background: var(--chat--header--background, var(--chat--color-dark));
- color: var(--chat--header--color, var(--chat--color-light));
- border-top: var(--chat--header--border-top, none);
- border-bottom: var(--chat--header--border-bottom, none);
- border-left: var(--chat--header--border-left, none);
- border-right: var(--chat--header--border-right, none);
+ height: var(--chat--header-height);
+ padding: var(--chat--header--padding);
+ background: var(--chat--header--background);
+ color: var(--chat--header--color);
+ border-top: var(--chat--header--border-top);
+ border-bottom: var(--chat--header--border-bottom);
+ border-left: var(--chat--header--border-left);
+ border-right: var(--chat--header--border-right);
h1 {
font-size: var(--chat--heading--font-size);
- color: var(--chat--header--color, var(--chat--color-light));
+ color: var(--chat--header--color);
}
p {
- font-size: var(--chat--subtitle--font-size, inherit);
- line-height: var(--chat--subtitle--line-height, 1.8);
+ font-size: var(--chat--subtitle--font-size);
+ line-height: var(--chat--subtitle--line-height);
}
}
.chat-body {
- background: var(--chat--body--background, var(--chat--color-light));
+ background: var(--chat--body--background);
flex: 1;
display: flex;
flex-direction: column;
@@ -93,8 +80,8 @@ onBeforeUnmount(() => {
.chat-footer {
border-top: 1px solid var(--chat--color-light-shade-100);
- background: var(--chat--footer--background, var(--chat--color-light));
- color: var(--chat--footer--color, var(--chat--color-dark));
+ background: var(--chat--footer--background);
+ color: var(--chat--footer--color);
}
}
diff --git a/packages/frontend/@n8n/chat/src/components/Message.vue b/packages/frontend/@n8n/chat/src/components/Message.vue
index 4ee4514632..0cecd78cfc 100644
--- a/packages/frontend/@n8n/chat/src/components/Message.vue
+++ b/packages/frontend/@n8n/chat/src/components/Message.vue
@@ -133,9 +133,9 @@ onMounted(async () => {
display: block;
position: relative;
max-width: fit-content;
- font-size: var(--chat--message--font-size, 1rem);
- padding: var(--chat--message--padding, var(--chat--spacing));
- border-radius: var(--chat--message--border-radius, var(--chat--border-radius));
+ font-size: var(--chat--message--font-size);
+ padding: var(--chat--message--padding);
+ border-radius: var(--chat--message--border-radius);
scroll-margin: 3rem;
.chat-message-actions {
@@ -160,13 +160,13 @@ onMounted(async () => {
}
p {
- line-height: var(--chat--message-line-height, 1.5);
+ line-height: var(--chat--message-line-height);
word-wrap: break-word;
}
// Default message gap is half of the spacing
+ .chat-message {
- margin-top: var(--chat--message--margin-bottom, calc(var(--chat--spacing) * 1));
+ margin-top: var(--chat--message--margin-bottom);
}
// Spacing between messages from different senders is double the individual message gap
@@ -178,7 +178,7 @@ onMounted(async () => {
&.chat-message-from-bot {
&:not(.chat-message-transparent) {
background-color: var(--chat--message--bot--background);
- border: var(--chat--message--bot--border, none);
+ border: var(--chat--message--bot--border);
}
color: var(--chat--message--bot--color);
border-bottom-left-radius: 0;
@@ -187,7 +187,7 @@ onMounted(async () => {
&.chat-message-from-user {
&:not(.chat-message-transparent) {
background-color: var(--chat--message--user--background);
- border: var(--chat--message--user--border, none);
+ border: var(--chat--message--user--border);
}
color: var(--chat--message--user--color);
margin-left: auto;
diff --git a/packages/frontend/@n8n/chat/src/components/MessagesList.vue b/packages/frontend/@n8n/chat/src/components/MessagesList.vue
index 1a0cc31874..215e1baffe 100644
--- a/packages/frontend/@n8n/chat/src/components/MessagesList.vue
+++ b/packages/frontend/@n8n/chat/src/components/MessagesList.vue
@@ -51,6 +51,6 @@ watch(
.chat-messages-list {
margin-top: auto;
display: block;
- padding: var(--chat--messages-list--padding, var(--chat--spacing));
+ padding: var(--chat--messages-list--padding);
}
diff --git a/packages/frontend/@n8n/chat/src/css/_tokens.scss b/packages/frontend/@n8n/chat/src/css/_tokens.scss
index 872dfb99b2..d5357ee863 100644
--- a/packages/frontend/@n8n/chat/src/css/_tokens.scss
+++ b/packages/frontend/@n8n/chat/src/css/_tokens.scss
@@ -1,4 +1,5 @@
:root {
+ /* Colors */
--chat--color-primary: #e74266;
--chat--color-primary-shade-50: #db4061;
--chat--color-primary-shade-100: #cf3c5c;
@@ -13,26 +14,106 @@
--chat--color-disabled: #777980;
--chat--color-typing: #404040;
+ /* Base Layout */
--chat--spacing: 1rem;
--chat--border-radius: 0.25rem;
--chat--transition-duration: 0.15s;
+ --chat--font-family: (
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ Roboto,
+ Oxygen-Sans,
+ Ubuntu,
+ Cantarell,
+ 'Helvetica Neue',
+ sans-serif
+ );
+ /* Window Dimensions */
--chat--window--width: 400px;
--chat--window--height: 600px;
+ --chat--window--bottom: var(--chat--spacing);
+ --chat--window--right: var(--chat--spacing);
+ --chat--window--z-index: 9999;
+ --chat--window--border: 1px solid var(--chat--color-light-shade-50);
+ --chat--window--border-radius: var(--chat--border-radius);
+ --chat--window--margin-bottom: var(--chat--spacing);
- --chat--textarea--height: 50px;
+ /* Header Styles */
+ --chat--header-height: auto;
+ --chat--header--padding: var(--chat--spacing);
+ --chat--header--background: var(--chat--color-dark);
+ --chat--header--color: var(--chat--color-light);
+ --chat--header--border-top: none;
+ --chat--header--border-bottom: none;
+ --chat--header--border-left: none;
+ --chat--header--border-right: none;
+ --chat--heading--font-size: 2em;
+ --chat--subtitle--font-size: inherit;
+ --chat--subtitle--line-height: 1.8;
+ /* Message Styles */
+ --chat--message--font-size: 1rem;
+ --chat--message--padding: var(--chat--spacing);
+ --chat--message--border-radius: var(--chat--border-radius);
+ --chat--message-line-height: 1.5;
+ --chat--message--margin-bottom: calc(var(--chat--spacing) * 1);
--chat--message--bot--background: var(--chat--color-white);
--chat--message--bot--color: var(--chat--color-dark);
+ --chat--message--bot--border: none;
--chat--message--user--background: var(--chat--color-secondary);
--chat--message--user--color: var(--chat--color-white);
+ --chat--message--user--border: none;
--chat--message--pre--background: rgba(0, 0, 0, 0.05);
+ --chat--messages-list--padding: var(--chat--spacing);
+ /* Toggle Button */
+ --chat--toggle--size: 64px;
+ --chat--toggle--width: var(--chat--toggle--size);
+ --chat--toggle--height: var(--chat--toggle--size);
+ --chat--toggle--border-radius: 50%;
--chat--toggle--background: var(--chat--color-primary);
--chat--toggle--hover--background: var(--chat--color-primary-shade-50);
--chat--toggle--active--background: var(--chat--color-primary-shade-100);
--chat--toggle--color: var(--chat--color-white);
- --chat--toggle--size: 64px;
- --chat--heading--font-size: 2em;
+ /* Input Area */
+ --chat--textarea--height: 50px;
+ --chat--textarea--max-height: 30rem;
+ --chat--input--font-size: inherit;
+ --chat--input--border: 0;
+ --chat--input--border-radius: 0;
+ --chat--input--padding: 0.8rem;
+ --chat--input--background: var(--chat--color-white);
+ --chat--input--text-color: initial;
+ --chat--input--line-height: 1.5;
+ --chat--input--placeholder--font-size: var(--chat--input--font-size);
+ --chat--input--border-active: 0;
+ --chat--input--left--panel--width: 2rem;
+
+ /* Button Styles */
+ --chat--button--color: var(--chat--color-light);
+ --chat--button--background: var(--chat--color-primary);
+ --chat--button--padding: calc(var(--chat--spacing) * 1 / 2) var(--chat--spacing);
+ --chat--button--border-radius: var(--chat--border-radius);
+ --chat--button--hover--color: var(--chat--color-light);
+ --chat--button--hover--background: var(--chat--color-primary-shade-50);
+ --chat--close--button--color-hover: var(--chat--color-primary);
+
+ /* Send and File Buttons */
+ --chat--input--send--button--background: var(--chat--color-white);
+ --chat--input--send--button--color: var(--chat--color-light);
+ --chat--input--send--button--background-hover: var(--chat--color-primary-shade-50);
+ --chat--input--send--button--color-hover: var(--chat--color-secondary-shade-50);
+ --chat--input--file--button--background: var(--chat--color-white);
+ --chat--input--file--button--color: var(--chat--color-secondary);
+ --chat--input--file--button--background-hover: var(--chat--input--file--button--background);
+ --chat--input--file--button--color-hover: var(--chat--color-secondary-shade-50);
+ --chat--files-spacing: 0.25rem;
+
+ /* Body and Footer */
+ --chat--body--background: var(--chat--color-light);
+ --chat--footer--background: var(--chat--color-light);
+ --chat--footer--color: var(--chat--color-dark);
}
diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts
index 7d7578a971..f060e3f815 100644
--- a/packages/nodes-base/nodes/Form/Form.node.ts
+++ b/packages/nodes-base/nodes/Form/Form.node.ts
@@ -18,6 +18,7 @@ import {
NodeConnectionType,
} from 'n8n-workflow';
+import { cssVariables } from './cssVariables';
import { renderFormCompletion } from './formCompletionUtils';
import { renderFormNode } from './formNodeUtils';
import { configureWaitTillDate } from '../../utils/sendAndWait/configureWaitTillDate.util';
@@ -107,6 +108,17 @@ const pageProperties = updateDisplayOptions(
type: 'string',
default: 'Submit',
},
+ {
+ displayName: 'Custom Form Styling',
+ name: 'customCss',
+ type: 'string',
+ typeOptions: {
+ rows: 10,
+ editor: 'cssEditor',
+ },
+ default: cssVariables.trim(),
+ description: 'Override default styling of the public form interface with CSS',
+ },
],
},
],
@@ -205,7 +217,20 @@ const completionProperties = updateDisplayOptions(
type: 'collection',
placeholder: 'Add option',
default: {},
- options: [{ ...formTitle, required: false, displayName: 'Completion Page Title' }],
+ options: [
+ { ...formTitle, required: false, displayName: 'Completion Page Title' },
+ {
+ displayName: 'Custom Form Styling',
+ name: 'customCss',
+ type: 'string',
+ typeOptions: {
+ rows: 10,
+ editor: 'cssEditor',
+ },
+ default: cssVariables.trim(),
+ description: 'Override default styling of the public form interface with CSS',
+ },
+ ],
displayOptions: {
show: {
respondWith: ['text'],
diff --git a/packages/nodes-base/nodes/Form/cssVariables.ts b/packages/nodes-base/nodes/Form/cssVariables.ts
new file mode 100644
index 0000000000..2cd06dc01a
--- /dev/null
+++ b/packages/nodes-base/nodes/Form/cssVariables.ts
@@ -0,0 +1,70 @@
+export const cssVariables = `
+:root {
+ --font-family: 'Open Sans', sans-serif;
+ --font-weight-normal: 400;
+ --font-weight-bold: 600;
+ --font-size-body: 12px;
+ --font-size-label: 14px;
+ --font-size-test-notice: 12px;
+ --font-size-input: 14px;
+ --font-size-header: 20px;
+ --font-size-paragraph: 14px;
+ --font-size-link: 12px;
+ --font-size-error: 12px;
+ --font-size-html-h1: 28px;
+ --font-size-html-h2: 20px;
+ --font-size-html-h3: 16px;
+ --font-size-html-h4: 14px;
+ --font-size-html-h5: 12px;
+ --font-size-html-h6: 10px;
+ --font-size-subheader: 14px;
+
+ /* Colors */
+ --color-background: #fbfcfe;
+ --color-test-notice-text: #e6a23d;
+ --color-test-notice-bg: #fefaf6;
+ --color-test-notice-border: #f6dcb7;
+ --color-card-bg: #ffffff;
+ --color-card-border: #dbdfe7;
+ --color-card-shadow: rgba(99, 77, 255, 0.06);
+ --color-link: #7e8186;
+ --color-header: #525356;
+ --color-label: #555555;
+ --color-input-border: #dbdfe7;
+ --color-input-text: #71747A;
+ --color-focus-border: rgb(90, 76, 194);
+ --color-submit-btn-bg: #ff6d5a;
+ --color-submit-btn-text: #ffffff;
+ --color-error: #ea1f30;
+ --color-required: #ff6d5a;
+ --color-clear-button-bg: #7e8186;
+ --color-html-text: #555;
+ --color-html-link: #ff6d5a;
+ --color-header-subtext: #7e8186;
+
+ /* Border Radii */
+ --border-radius-card: 8px;
+ --border-radius-input: 6px;
+ --border-radius-clear-btn: 50%;
+ --card-border-radius: 8px;
+
+ /* Spacing */
+ --padding-container-top: 24px;
+ --padding-card: 24px;
+ --padding-test-notice-vertical: 12px;
+ --padding-test-notice-horizontal: 24px;
+ --margin-bottom-card: 16px;
+ --padding-form-input: 12px;
+ --card-padding: 24px;
+ --card-margin-bottom: 16px;
+
+ /* Dimensions */
+ --container-width: 448px;
+ --submit-btn-height: 48px;
+ --checkbox-size: 18px;
+
+ /* Others */
+ --box-shadow-card: 0px 4px 16px 0px var(--color-card-shadow);
+ --opacity-placeholder: 0.5;
+}
+`;
diff --git a/packages/nodes-base/nodes/Form/formCompletionUtils.ts b/packages/nodes-base/nodes/Form/formCompletionUtils.ts
index f2b19fabae..9f1e6e3f0d 100644
--- a/packages/nodes-base/nodes/Form/formCompletionUtils.ts
+++ b/packages/nodes-base/nodes/Form/formCompletionUtils.ts
@@ -5,7 +5,7 @@ import {
type IWebhookResponseData,
} from 'n8n-workflow';
-import { sanitizeHtml } from './utils';
+import { sanitizeCustomCss, sanitizeHtml } from './utils';
export const renderFormCompletion = async (
context: IWebhookFunctions,
@@ -15,7 +15,10 @@ export const renderFormCompletion = async (
const completionTitle = context.getNodeParameter('completionTitle', '') as string;
const completionMessage = context.getNodeParameter('completionMessage', '') as string;
const redirectUrl = context.getNodeParameter('redirectUrl', '') as string;
- const options = context.getNodeParameter('options', {}) as { formTitle: string };
+ const options = context.getNodeParameter('options', {}) as {
+ formTitle: string;
+ customCss?: string;
+ };
const responseText = context.getNodeParameter('responseText', '') as string;
let title = options.formTitle;
@@ -32,6 +35,7 @@ export const renderFormCompletion = async (
formTitle: title,
appendAttribution,
responseText: sanitizeHtml(responseText),
+ dangerousCustomCss: sanitizeCustomCss(options.customCss),
redirectUrl,
});
diff --git a/packages/nodes-base/nodes/Form/formNodeUtils.ts b/packages/nodes-base/nodes/Form/formNodeUtils.ts
index 1d8aeebd40..4d33b1365d 100644
--- a/packages/nodes-base/nodes/Form/formNodeUtils.ts
+++ b/packages/nodes-base/nodes/Form/formNodeUtils.ts
@@ -19,6 +19,7 @@ export const renderFormNode = async (
formTitle: string;
formDescription: string;
buttonLabel: string;
+ customCss?: string;
};
let title = options.formTitle;
@@ -56,6 +57,7 @@ export const renderFormNode = async (
redirectUrl: undefined,
appendAttribution,
buttonLabel,
+ customCss: options.customCss,
});
return {
diff --git a/packages/nodes-base/nodes/Form/interfaces.ts b/packages/nodes-base/nodes/Form/interfaces.ts
index b04d30d1d3..23b0b31f3f 100644
--- a/packages/nodes-base/nodes/Form/interfaces.ts
+++ b/packages/nodes-base/nodes/Form/interfaces.ts
@@ -31,6 +31,7 @@ export type FormTriggerData = {
useResponseData?: boolean;
appendAttribution?: boolean;
buttonLabel?: string;
+ dangerousCustomCss?: string;
};
export const FORM_TRIGGER_AUTHENTICATION_PROPERTY = 'authentication';
diff --git a/packages/nodes-base/nodes/Form/test/Form.node.test.ts b/packages/nodes-base/nodes/Form/test/Form.node.test.ts
index ec5b1c93da..5998f2d20a 100644
--- a/packages/nodes-base/nodes/Form/test/Form.node.test.ts
+++ b/packages/nodes-base/nodes/Form/test/Form.node.test.ts
@@ -306,6 +306,90 @@ describe('Form Node', () => {
}
});
+ it('should pass customCss to form template', async () => {
+ const mockResponseObject = {
+ render: jest.fn(),
+ };
+ mockWebhookFunctions.getResponseObject.mockReturnValue(
+ mockResponseObject as unknown as Response,
+ );
+ mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request);
+ mockWebhookFunctions.getParentNodes.mockReturnValue([
+ {
+ type: 'n8n-nodes-base.formTrigger',
+ name: 'Form Trigger',
+ typeVersion: 2.1,
+ disabled: false,
+ },
+ ]);
+ mockWebhookFunctions.evaluateExpression.mockReturnValue('test');
+ mockWebhookFunctions.getNode.mockReturnValue(mock());
+ mockWebhookFunctions.getNodeParameter.mockImplementation((paramName: string) => {
+ if (paramName === 'operation') return 'page';
+ if (paramName === 'formFields.values') return [];
+ if (paramName === 'options') {
+ return {
+ customCss: '.form-container { background-color: #f5f5f5; }',
+ };
+ }
+ return undefined;
+ });
+
+ mockWebhookFunctions.getChildNodes.mockReturnValue([]);
+
+ await form.webhook(mockWebhookFunctions);
+
+ expect(mockResponseObject.render).toHaveBeenCalledWith(
+ 'form-trigger',
+ expect.objectContaining({
+ dangerousCustomCss: '.form-container { background-color: #f5f5f5; }',
+ }),
+ );
+ });
+
+ it('should pass customCss to form completion template', async () => {
+ mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request);
+ mockWebhookFunctions.getNodeParameter.mockImplementation((paramName) => {
+ if (paramName === 'operation') return 'completion';
+ if (paramName === 'respondWith') return 'text';
+ if (paramName === 'completionTitle') return 'Completion Title';
+ if (paramName === 'completionMessage') return 'Completion Message';
+ if (paramName === 'options')
+ return {
+ customCss: '.completion-container { color: blue; }',
+ };
+ if (paramName === 'formFields.values') return [];
+ return {};
+ });
+ mockWebhookFunctions.getParentNodes.mockReturnValue([
+ {
+ type: 'n8n-nodes-base.formTrigger',
+ name: 'Form Trigger',
+ typeVersion: 2.1,
+ disabled: false,
+ },
+ ]);
+ mockWebhookFunctions.evaluateExpression.mockReturnValue('test');
+
+ const mockResponseObject = {
+ render: jest.fn(),
+ };
+ mockWebhookFunctions.getResponseObject.mockReturnValue(
+ mockResponseObject as unknown as Response,
+ );
+ mockWebhookFunctions.getNode.mockReturnValue(mock());
+
+ const result = await form.webhook(mockWebhookFunctions);
+
+ expect(result).toEqual({ noWebhookResponse: true });
+ expect(mockResponseObject.render).toHaveBeenCalledWith(
+ 'form-trigger-completion',
+ expect.objectContaining({
+ dangerousCustomCss: '.completion-container { color: blue; }',
+ }),
+ );
+ });
+
it('should handle completion operation and redirect', async () => {
mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request);
mockWebhookFunctions.getNodeParameter.mockImplementation((paramName) => {
diff --git a/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts b/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts
index fe2b9a5a41..f97c071976 100644
--- a/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts
+++ b/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts
@@ -241,6 +241,33 @@ describe('FormTrigger', () => {
);
});
+ it('should apply customCss property to form render', async () => {
+ const formFields = [{ fieldLabel: 'Name', fieldType: 'text', requiredField: true }];
+
+ const { response } = await testVersionedWebhookTriggerNode(FormTrigger, 2.2, {
+ mode: 'manual',
+ node: {
+ typeVersion: 2.2,
+ parameters: {
+ formTitle: 'Custom CSS Test',
+ formDescription: 'Testing custom CSS',
+ responseMode: 'onReceived',
+ formFields: { values: formFields },
+ options: {
+ customCss: '.form-input { border-color: red; }',
+ },
+ },
+ },
+ });
+
+ expect(response.render).toHaveBeenCalledWith(
+ 'form-trigger',
+ expect.objectContaining({
+ dangerousCustomCss: '.form-input { border-color: red; }',
+ }),
+ );
+ });
+
it('should handle files', async () => {
const formFields = [
{
diff --git a/packages/nodes-base/nodes/Form/utils.ts b/packages/nodes-base/nodes/Form/utils.ts
index 40a2c64a0e..d23ebb73a0 100644
--- a/packages/nodes-base/nodes/Form/utils.ts
+++ b/packages/nodes-base/nodes/Form/utils.ts
@@ -72,6 +72,18 @@ export function sanitizeHtml(text: string) {
});
}
+export function sanitizeCustomCss(css: string | undefined): string | undefined {
+ if (!css) return undefined;
+
+ // Use sanitize-html with custom settings for CSS
+ return sanitize(css, {
+ allowedTags: [], // No HTML tags allowed
+ allowedAttributes: {}, // No attributes allowed
+ // This ensures we're only keeping the text content
+ // which should be the CSS, while removing any HTML/script tags
+ });
+}
+
export function createDescriptionMetadata(description: string) {
return description === ''
? 'n8n form'
@@ -91,6 +103,7 @@ export function prepareFormData({
useResponseData,
appendAttribution = true,
buttonLabel,
+ customCss,
}: {
formTitle: string;
formDescription: string;
@@ -104,6 +117,7 @@ export function prepareFormData({
appendAttribution?: boolean;
buttonLabel?: string;
formSubmittedHeader?: string;
+ customCss?: string;
}) {
const validForm = formFields.length > 0;
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
@@ -126,6 +140,7 @@ export function prepareFormData({
useResponseData,
appendAttribution,
buttonLabel,
+ dangerousCustomCss: sanitizeCustomCss(customCss),
};
if (redirectUrl) {
@@ -352,6 +367,7 @@ export function renderForm({
redirectUrl,
appendAttribution,
buttonLabel,
+ customCss,
}: {
context: IWebhookFunctions;
res: Response;
@@ -364,6 +380,7 @@ export function renderForm({
redirectUrl?: string;
appendAttribution?: boolean;
buttonLabel?: string;
+ customCss?: string;
}) {
formDescription = (formDescription || '').replace(/\\n/g, '\n').replace(/
/g, '\n');
const instanceId = context.getInstanceId();
@@ -406,6 +423,7 @@ export function renderForm({
useResponseData,
appendAttribution,
buttonLabel,
+ customCss,
});
res.render('form-trigger', data);
@@ -436,6 +454,7 @@ export async function formWebhook(
useWorkflowTimezone?: boolean;
appendAttribution?: boolean;
buttonLabel?: string;
+ customCss?: string;
};
const res = context.getResponseObject();
const req = context.getRequestObject();
@@ -526,6 +545,7 @@ export async function formWebhook(
redirectUrl,
appendAttribution,
buttonLabel,
+ customCss: options.customCss,
});
return {
diff --git a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts
index da813c758d..46b45d2ef5 100644
--- a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts
+++ b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts
@@ -19,6 +19,7 @@ import {
respondWithOptions,
webhookPath,
} from '../common.descriptions';
+import { cssVariables } from '../cssVariables';
import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from '../interfaces';
import { formWebhook } from '../utils';
@@ -180,6 +181,22 @@ const descriptionV2: INodeTypeDescription = {
},
},
},
+ {
+ displayName: 'Custom Form Styling',
+ name: 'customCss',
+ type: 'string',
+ typeOptions: {
+ rows: 10,
+ editor: 'cssEditor',
+ },
+ displayOptions: {
+ show: {
+ '@version': [{ _cnd: { gt: 2 } }],
+ },
+ },
+ default: cssVariables.trim(),
+ description: 'Override default styling of the public form interface with CSS',
+ },
],
},
],
diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts
index fba34bc0a0..9ef38c4532 100644
--- a/packages/workflow/src/Interfaces.ts
+++ b/packages/workflow/src/Interfaces.ts
@@ -1242,7 +1242,7 @@ export type NodePropertyTypes =
export type CodeAutocompleteTypes = 'function' | 'functionItem';
-export type EditorType = 'codeNodeEditor' | 'jsEditor' | 'htmlEditor' | 'sqlEditor';
+export type EditorType = 'codeNodeEditor' | 'jsEditor' | 'htmlEditor' | 'sqlEditor' | 'cssEditor';
export type CodeNodeEditorLanguage = (typeof CODE_LANGUAGES)[number];
export type CodeExecutionMode = (typeof CODE_EXECUTION_MODES)[number];
export type SQLDialect =
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b247ee1f0f..3b7f1f4d95 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -638,6 +638,9 @@ importers:
redis:
specifier: 4.6.12
version: 4.6.12
+ sanitize-html:
+ specifier: 2.12.1
+ version: 2.12.1
sqlite3:
specifier: 5.1.7
version: 5.1.7
@@ -672,6 +675,9 @@ importers:
'@types/pg':
specifier: ^8.11.6
version: 8.11.6
+ '@types/sanitize-html':
+ specifier: ^2.11.0
+ version: 2.11.0
'@types/temp':
specifier: ^0.9.1
version: 0.9.4
@@ -1434,6 +1440,9 @@ importers:
'@codemirror/commands':
specifier: ^6.5.0
version: 6.5.0
+ '@codemirror/lang-css':
+ specifier: ^6.0.1
+ version: 6.0.1(@codemirror/view@6.26.3)(@lezer/common@1.1.0)
'@codemirror/lang-javascript':
specifier: ^6.2.2
version: 6.2.2
@@ -8008,9 +8017,6 @@ packages:
domutils@2.8.0:
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
- domutils@3.0.1:
- resolution: {integrity: sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==}
-
domutils@3.1.0:
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
@@ -21254,12 +21260,6 @@ snapshots:
domelementtype: 2.3.0
domhandler: 4.3.1
- domutils@3.0.1:
- dependencies:
- dom-serializer: 2.0.0
- domelementtype: 2.3.0
- domhandler: 5.0.3
-
domutils@3.1.0:
dependencies:
dom-serializer: 2.0.0
@@ -22732,7 +22732,7 @@ snapshots:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
- domutils: 3.0.1
+ domutils: 3.1.0
entities: 4.5.0
htmlparser2@9.1.0:
@@ -26350,7 +26350,7 @@ snapshots:
htmlparser2: 8.0.2
is-plain-object: 5.0.0
parse-srcset: 1.0.2
- postcss: 8.4.38
+ postcss: 8.4.49
sass@1.64.1:
dependencies: