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: