feat(Chat Trigger Node): Add end-user credential connect bar to the hosted chat shell (no-changelog) (#37550)

This commit is contained in:
MHMD 2026-09-02 12:37:31 +00:00 committed by GitHub
parent 91dff038f9
commit 4608fd236c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1605 additions and 614 deletions

View File

@ -28,6 +28,7 @@ import * as a from 'node:assert';
import { ChatTriggerConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import { buildChatShellViewModel, connectBarText } from './connect-panel';
import { cssVariables } from './constants';
import {
establishChatSessionIdentity,
@ -43,7 +44,7 @@ import {
isChatRefreshRequest,
isShellInnerRequest,
} from './shell';
import { createPage, createShellPage } from './templates';
import { createPage } from './templates';
import { assertValidLoadPreviousSessionOption, type ChatFrameIdentity } from './types';
const isPublicChatTriggerDisabled = () => Container.get(ChatTriggerConfig).disablePublicChat;
@ -960,24 +961,42 @@ export class ChatTrigger extends Node {
// real cookies, unlike the sandboxed, opaque-origin frame this shell is about
// to create. It is the only gate: a visitor without an editor session is
// authenticated by the flow rather than bounced to sign-in ahead of it.
const session = await establishChatSessionIdentity(ctx, resourceUrl);
if (!session) {
const outerIdentity = await establishChatSessionIdentity(ctx, resourceUrl);
if (!outerIdentity) {
return { noWebhookResponse: true };
}
let credentialStatus: CredentialCheckResult | undefined;
try {
credentialStatus = await ctx.checkTriggerCredentialStatus();
} catch {
// No error object: may carry decrypted credential context.
ctx.logger.error('Chat trigger credential readiness check failed');
// `send` ends the response itself.
res.status(503).send('Chat is unavailable right now. Please try again later.');
return { noWebhookResponse: true };
}
const connect = credentialStatus?.credentials.length
? buildChatShellViewModel(credentialStatus.credentials, outerIdentity.visitor.email)
: undefined;
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
res
.status(200)
.send(
createShellPage({
iframeSrc: buildInnerFrameSrc(req),
refresh: {
url: buildChatRefreshUrl(req),
expiresIn: session.expiresIn,
},
}),
)
.end();
// Express defaults to 200 for `render`; stated so the success status is
// not implicit next to the 503 branch above.
res.status(200).render('chat-shell', {
iframeSrc: buildInnerFrameSrc(req),
sandbox: CHAT_FRAME_SANDBOX,
refreshUrl: buildChatRefreshUrl(req),
refreshExpiresIn: Math.max(0, Math.round(outerIdentity.expiresIn)),
testMode: mode === 'test',
visitorEmail: outerIdentity.visitor.email,
hasCredentials: !!connect,
// Not forced in test mode: the send gate refuses builders too.
ready: connect ? connect.connectedCount >= connect.total : false,
barText: connect ? connectBarText(connect, mode === 'test') : '',
...connect,
});
return { noWebhookResponse: true };
}
@ -1044,8 +1063,9 @@ export class ChatTrigger extends Node {
let readiness: CredentialCheckResult | undefined;
try {
readiness = await ctx.checkTriggerCredentialStatus();
} catch (error) {
ctx.logger.error('Chat trigger credential readiness check failed', { error });
} catch {
// No error object: may carry decrypted credential context.
ctx.logger.error('Chat trigger credential readiness check failed');
res.status(503).json({ status: 'credential_readiness_check_failed' });
return { noWebhookResponse: true };
}

View File

@ -118,20 +118,22 @@ export async function validateAuth(context: IWebhookFunctions) {
* receive the AS's session-cookie check, and any consent/sign-in page the AS
* falls back to would then render editor-ui inside the opaque frame.
*
* On success, stashes the AS token in the one-hop `n8n-chat-oauth` cookie, the
* grant's refresh token in the long-lived httpOnly `n8n-chat-oauth-refresh`
* cookie, and returns the session — the caller renders the shell around it,
* whose frame's own GET picks the one-hop cookie up via
* `resolveInnerFrameIdentity`. Returns `null` after already sending a
* redirect/error response — the caller must abort with `noWebhookResponse`.
* On success, stashes the AS token in the one-hop `n8n-chat-oauth` cookie and the
* grant's refresh token in the long-lived httpOnly `n8n-chat-oauth-refresh` cookie,
* establishes the run's identity from the access token (so the outer GET can check
* end-user-credential readiness for the connect panel), and returns the resolved
* identity plus the access token's remaining life — the caller renders the shell
* around it, whose frame's own GET picks the one-hop cookie up via
* `resolveInnerFrameIdentity`. Returns `null` after already sending a redirect/error
* response — the caller must abort with `noWebhookResponse`.
*
* The returned session carries the expiry only. The refresh token stays in its
* cookie and never reaches the caller, so it can't reach a document either.
* The refresh token stays in its cookie and never reaches the caller, so it can't
* reach a document either.
*/
export async function establishChatSessionIdentity(
context: IWebhookFunctions,
resourceUrl: string,
): Promise<ChatShellSession | null> {
): Promise<(ChatFrameIdentity & ChatShellSession) | null> {
const req = context.getRequestObject();
const res = context.getResponseObject();
const { code, state } = req.query;
@ -178,7 +180,12 @@ export async function establishChatSessionIdentity(
if (session) {
const validation = await context.validateN8nOAuth2Token(session.token, resourceUrl);
if (validation.valid) {
return { expiresIn: secondsUntil(session.expiresAt) };
await context.establishTriggerIdentity(session.token, resourceUrl, validation.user.id);
return {
visitor: validation.user,
authToken: session.token,
expiresIn: secondsUntil(session.expiresAt),
};
}
// Stale/invalid cookie — fall through to restart the OAuth2 flow.
} else {
@ -186,7 +193,20 @@ export async function establishChatSessionIdentity(
// is still live in the refresh cookie. Rotating is cheaper than a full
// redirect round trip through the AS, and keeps the visitor on the page.
const refreshed = await refreshChatSession(context, resourceUrl);
if (refreshed) return { expiresIn: refreshed.expiresIn };
if (refreshed) {
// A refresh result names no user — the grant already fixes the subject — so
// the fresh token is validated to recover the visitor the connect panel is
// rendered for.
const validation = await context.validateN8nOAuth2Token(refreshed.token, resourceUrl);
if (validation.valid) {
await context.establishTriggerIdentity(refreshed.token, resourceUrl, validation.user.id);
return {
visitor: validation.user,
authToken: refreshed.token,
expiresIn: refreshed.expiresIn,
};
}
}
// Refresh failed — fall through to restart the OAuth2 flow, which handles it.
}
}

View File

@ -507,7 +507,10 @@ describe('ChatTrigger Node', () => {
});
expect(mockContext.logger.error).toHaveBeenCalledWith(
'Chat trigger credential readiness check failed',
{ error },
);
expect(mockContext.logger.error).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ error: expect.anything() }),
);
expect(result).toEqual({ noWebhookResponse: true });
});
@ -552,6 +555,10 @@ describe('ChatTrigger Node', () => {
const renderedPage = () => vi.mocked(mockResponse.send).mock.calls.at(-1)?.[0] as string;
const renderedView = () =>
(vi.mocked(mockResponse.render).mock.calls.at(-1)?.[1] ?? {}) as Record<string, unknown>;
const renderedTemplate = () => vi.mocked(mockResponse.render).mock.calls.at(-1)?.[0];
// Every body and header this request produced, so a `/signin` anywhere in the
// response — a redirect Location as much as a rendered page — shows up.
const everySentResponse = () =>
@ -573,7 +580,11 @@ describe('ChatTrigger Node', () => {
typeVersion: 1.4,
webhookId: 'webhook-1',
} as never);
vi.mocked(establishChatSessionIdentity).mockResolvedValue({ expiresIn: 3600 });
vi.mocked(establishChatSessionIdentity).mockResolvedValue({
visitor,
authToken: 'outer-token',
expiresIn: 3600,
});
vi.mocked(resolveInnerFrameIdentity).mockResolvedValue({
visitor,
authToken: 'as-token',
@ -601,12 +612,14 @@ describe('ChatTrigger Node', () => {
'Content-Security-Policy',
"frame-ancestors 'none'",
);
expect(renderedPage()).toContain('data-src="/webhook/abc/chat?n8nShellInner=1"');
expect(renderedPage()).toContain(
'sandbox="allow-scripts allow-forms allow-modals allow-popups"',
);
// No author-supplied CSS or markup on the trusted document.
expect(renderedPage()).not.toContain('createChat');
expect(renderedTemplate()).toBe('chat-shell');
expect(renderedView()).toMatchObject({
iframeSrc: '/webhook/abc/chat?n8nShellInner=1',
sandbox: 'allow-scripts allow-forms allow-modals allow-popups',
});
// The frame must have no origin of its own.
expect(renderedView().sandbox).not.toContain('allow-same-origin');
expect(renderedView().sandbox).not.toContain('allow-popups-to-escape-sandbox');
});
it('renders the author chat for the frame own request', async () => {
@ -670,8 +683,8 @@ describe('ChatTrigger Node', () => {
await renderSetupPage();
expect(renderedPage()).toContain('data-src=');
expect(renderedPage()).not.toContain('createChat');
expect(renderedTemplate()).toBe('chat-shell');
expect(renderedView().iframeSrc).toBeTruthy();
});
// The page used to bounce a visitor with no editor session to `/signin` before the
@ -700,6 +713,8 @@ describe('ChatTrigger Node', () => {
expect(establishChatSessionIdentity).not.toHaveBeenCalled();
expect(renderedPage()).toContain('createChat');
expect(renderedPage()).not.toContain('n8nShellInner');
// The unsplit render is the page itself, not a shell around a frame.
expect(mockResponse.render).not.toHaveBeenCalled();
expect(renderedPage()).not.toContain('n8nChatRefresh');
});
@ -712,6 +727,7 @@ describe('ChatTrigger Node', () => {
expect(establishChatSessionIdentity).not.toHaveBeenCalled();
expect(renderedPage()).toContain('createChat');
expect(renderedPage()).not.toContain('n8nShellInner');
expect(mockResponse.render).not.toHaveBeenCalled();
expect(renderedPage()).not.toContain('n8nChatRefresh');
},
);
@ -719,11 +735,13 @@ describe('ChatTrigger Node', () => {
it('carries the refresh endpoint and schedule into the shell', async () => {
await renderSetupPage();
expect(renderedPage()).toContain('/webhook/abc/chat?n8nChatRefresh=1');
expect(renderedPage()).toContain("'x-n8n-chat-refresh': '1'");
// The session's duration, passed straight through — no timestamp of ours
// for the page's clock to disagree with.
expect(renderedPage()).toContain('planFor(3600)');
expect(renderedTemplate()).toBe('chat-shell');
expect(renderedView()).toMatchObject({
refreshUrl: '/webhook/abc/chat?n8nChatRefresh=1',
// The session's duration, passed straight through — no timestamp of ours
// for the page's clock to disagree with.
refreshExpiresIn: 3600,
});
});
// The leg answers with JSON, not a page, and authenticates from its own httpOnly
@ -747,6 +765,7 @@ describe('ChatTrigger Node', () => {
expect(establishChatSessionIdentity).not.toHaveBeenCalled();
expect(resolveInnerFrameIdentity).not.toHaveBeenCalled();
expect(mockResponse.send).not.toHaveBeenCalled();
expect(mockResponse.render).not.toHaveBeenCalled();
});
// Without the custom header the request is forgeable by shape alone, so it must
@ -768,7 +787,136 @@ describe('ChatTrigger Node', () => {
await renderSetupPage();
expect(renderedPage()).toContain('data-src="/webhook-test/abc/chat?n8nShellInner=1"');
expect(renderedView()).toMatchObject({
iframeSrc: '/webhook-test/abc/chat?n8nShellInner=1',
});
});
describe('connect status', () => {
it('embeds no connect script or dialog when the workflow needs no end-user credentials', async () => {
mockContext.checkTriggerCredentialStatus.mockResolvedValue({
readyToExecute: true,
credentials: [],
});
await renderSetupPage();
expect(renderedView()).toMatchObject({ hasCredentials: false });
expect(renderedView().credentials).toBeUndefined();
});
it('embeds no connect script or dialog when the credential check reports nothing (dynamic credentials off)', async () => {
mockContext.checkTriggerCredentialStatus.mockResolvedValue(undefined);
await renderSetupPage();
expect(renderedView()).toMatchObject({ hasCredentials: false });
expect(renderedView().credentials).toBeUndefined();
});
it('embeds the credential and visitor data for a single required credential, with no dialog', async () => {
mockContext.checkTriggerCredentialStatus.mockResolvedValue({
readyToExecute: false,
credentials: [
{
credentialId: 'cred-1',
credentialName: 'Slack account',
credentialType: 'slackOAuth2Api',
status: 'missing',
authorizationUrl: 'https://n8n.example.com/credentials/cred-1/authorize',
},
],
});
await renderSetupPage();
expect(renderedView()).toMatchObject({
hasCredentials: true,
ready: false,
visitorEmail: 'visitor@example.com',
barText: 'Connect Slack account to start this chat',
// One required account connects straight from the bar, so the template
// keeps the no-dialog shortcut — but still renders the dialog, since it
// is the only place carrying Disconnect once that account is connected.
useDialog: false,
total: 1,
connectedCount: 0,
});
expect(renderedView().credentials).toMatchObject([
{
id: 'cred-1',
name: 'Slack account',
connected: false,
authorizationUrl: 'https://n8n.example.com/credentials/cred-1/authorize',
},
]);
});
// Matches the message-send path: a readiness check we cannot answer must not
// render a chat that looks usable and then fails on the first send.
it('fails closed with 503 when the readiness check throws on the shell GET', async () => {
const error = new Error('could not decrypt credential context');
mockContext.checkTriggerCredentialStatus.mockRejectedValue(error);
const result = await renderSetupPage();
expect(mockResponse.status).toHaveBeenCalledWith(503);
expect(mockContext.logger.error).toHaveBeenCalledWith(
'Chat trigger credential readiness check failed',
);
expect(mockContext.logger.error).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ error: expect.anything() }),
);
expect(result).toEqual({ noWebhookResponse: true });
expect(mockResponse.render).not.toHaveBeenCalled();
});
it('renders the "Connect your accounts" dialog when two or more end-user credentials are required', async () => {
mockContext.checkTriggerCredentialStatus.mockResolvedValue({
readyToExecute: false,
credentials: [
{
credentialId: 'cred-1',
credentialName: 'Slack account',
credentialType: 'slackOAuth2Api',
status: 'missing',
authorizationUrl: 'https://n8n.example.com/credentials/cred-1/authorize',
},
{
credentialId: 'cred-2',
credentialName: 'Google account',
credentialType: 'googleOAuth2Api',
status: 'missing',
authorizationUrl: 'https://n8n.example.com/credentials/cred-2/authorize',
},
],
});
await renderSetupPage();
expect(renderedView()).toMatchObject({
hasCredentials: true,
useDialog: true,
total: 2,
connectedCount: 0,
footerText: '0 of 2 accounts connected',
barText: '2 accounts needed to start this chat',
});
});
it("never checks credential status for the frame's own request", async () => {
mockRequest.query = { n8nShellInner: '1' };
mockRequest.headers = {
'x-forwarded-proto': 'http',
host: 'localhost:5678',
'sec-fetch-dest': 'iframe',
};
await renderSetupPage();
expect(mockContext.checkTriggerCredentialStatus).not.toHaveBeenCalled();
});
});
});
});

View File

@ -396,11 +396,17 @@ describe('establishChatSessionIdentity', () => {
const result = await establishChatSessionIdentity(mockContext, resourceUrl);
expect(result).toMatchObject({ visitor: user, authToken: 'as-token' });
// Converted from the cookie's absolute expiry here, on the clock that wrote it,
// so no server timestamp reaches the shell.
expect(result?.expiresIn).toBeGreaterThan(3590);
expect(result?.expiresIn).toBeLessThanOrEqual(3600);
expect(mockContext.validateN8nOAuth2Token).toHaveBeenCalledWith('as-token', resourceUrl);
expect(mockContext.establishTriggerIdentity).toHaveBeenCalledWith(
'as-token',
resourceUrl,
user.id,
);
expect(mockContext.getResponseObject().clearCookie).not.toHaveBeenCalled();
expect(mockContext.refreshN8nOAuth2Flow).not.toHaveBeenCalled();
});
@ -435,13 +441,21 @@ describe('establishChatSessionIdentity', () => {
refreshToken: 'rotated-token',
expiresIn: 3600,
});
mockContext.validateN8nOAuth2Token.mockResolvedValue({ valid: true, user });
const result = await establishChatSessionIdentity(mockContext, resourceUrl);
expect(mockContext.refreshN8nOAuth2Flow).toHaveBeenCalledWith('refresh-token', resourceUrl);
// The AS's own duration, handed on untouched — the shell schedules off its own clock.
expect(result).toEqual({ expiresIn: 3600 });
expect(result).toEqual({ visitor: user, authToken: 'fresh-token', expiresIn: 3600 });
expect(mockContext.beginN8nOAuth2Flow).not.toHaveBeenCalled();
// A refresh result names no user, so the fresh token is validated to recover the
// visitor the connect panel is rendered for.
expect(mockContext.establishTriggerIdentity).toHaveBeenCalledWith(
'fresh-token',
resourceUrl,
user.id,
);
// Both cookies are rewritten: the frame's next GET needs the new access token,
// and the rotated refresh token replaces the one just consumed.
expect(mockContext.getResponseObject().cookie).toHaveBeenCalledWith(

View File

@ -0,0 +1,140 @@
import type { CredentialCheckStatus } from 'n8n-workflow';
import { buildChatShellViewModel, connectBarText } from '../connect-panel';
const missingCred = (overrides: Partial<CredentialCheckStatus> = {}): CredentialCheckStatus => ({
credentialId: 'cred-1',
credentialName: 'Slack account',
credentialType: 'slackOAuth2Api',
status: 'missing',
authorizationUrl: 'https://n8n.example.com/credentials/cred-1/authorize',
revokeUrl: undefined,
...overrides,
});
const configuredCred = (overrides: Partial<CredentialCheckStatus> = {}): CredentialCheckStatus => ({
credentialId: 'cred-2',
credentialName: 'Google account',
credentialType: 'googleOAuth2Api',
status: 'configured',
revokeUrl: 'https://n8n.example.com/credentials/cred-2/revoke?resolverId=n8n',
...overrides,
});
describe('buildChatShellViewModel', () => {
it('skips the dialog for exactly one required credential', () => {
const vm = buildChatShellViewModel([missingCred()]);
expect(vm.useDialog).toBe(false);
expect(vm.total).toBe(1);
});
it('uses the dialog for two or more required credentials', () => {
const vm = buildChatShellViewModel([missingCred(), configuredCred()]);
expect(vm.useDialog).toBe(true);
expect(vm.total).toBe(2);
expect(vm.connectedCount).toBe(1);
expect(vm.footerText).toBe('1 of 2 accounts connected');
});
it('marks a row connected only for a configured credential, and carries its links', () => {
const vm = buildChatShellViewModel([missingCred(), configuredCred()]);
expect(vm.credentials).toMatchObject([
{
id: 'cred-1',
name: 'Slack account',
connected: false,
authorizationUrl: 'https://n8n.example.com/credentials/cred-1/authorize',
},
{
id: 'cred-2',
name: 'Google account',
connected: true,
revokeUrl: 'https://n8n.example.com/credentials/cred-2/revoke?resolverId=n8n',
},
]);
});
it('shows the visitor email only on a connected row', () => {
const vm = buildChatShellViewModel([missingCred(), configuredCred()], 'visitor@example.com');
expect(vm.credentials.find((c) => c.id === 'cred-1')?.account).toBeUndefined();
expect(vm.credentials.find((c) => c.id === 'cred-2')?.account).toBe('visitor@example.com');
});
// One credential resolves once per distinct fallback resolver, so the same
// credentialId legitimately arrives twice. Keying rows on it alone collapsed them,
// and connecting one context then marked the other ready.
it('gives each credential-and-resolver pair its own row key', () => {
const vm = buildChatShellViewModel([
missingCred({ resolverId: 'resolver-a' }),
missingCred({ resolverId: 'resolver-b' }),
]);
expect(vm.total).toBe(2);
expect(vm.credentials.map((c) => c.key)).toEqual(['cred-1::resolver-a', 'cred-1::resolver-b']);
expect(new Set(vm.credentials.map((c) => c.key)).size).toBe(2);
// The credential id itself is unchanged - only the row identity is composite.
expect(vm.credentials.every((c) => c.id === 'cred-1')).toBe(true);
});
it('derives the letter tile from the credential name', () => {
const vm = buildChatShellViewModel([missingCred({ credentialName: 'slack account' })]);
expect(vm.credentials[0].initial).toBe('S');
});
it('falls back to a placeholder tile for a nameless credential', () => {
const vm = buildChatShellViewModel([missingCred({ credentialName: ' ' })]);
expect(vm.credentials[0].initial).toBe('?');
});
});
describe('connectBarText', () => {
// A single account is named so the visitor knows what they are about to connect
// before the consent screen opens.
it('names the credential when exactly one is required', () => {
expect(connectBarText(buildChatShellViewModel([missingCred()]))).toBe(
'Connect Slack account to start this chat',
);
});
it('names the full count for two or more, while nothing is connected', () => {
expect(
connectBarText(buildChatShellViewModel([missingCred(), missingCred({ credentialId: 'x' })])),
).toBe('2 accounts needed to start this chat');
});
// Part-way through, naming the full count again would read as if no progress had
// been made, so only the remainder is named.
it('names only what is left once some are connected', () => {
const vm = buildChatShellViewModel([missingCred(), missingCred(), configuredCred()]);
expect(connectBarText(vm)).toBe('2 more accounts needed to start this chat');
});
it('confirms readiness once every account is connected', () => {
expect(connectBarText(buildChatShellViewModel([configuredCred()]))).toBe(
'All 1 account connected · ready to chat',
);
});
// Test mode resolves identity from the builder's own credentials, so once they are
// all connected the bar explains that a visitor's experience differs.
it('explains test mode once every account is connected', () => {
expect(connectBarText(buildChatShellViewModel([configuredCred()]), true)).toBe(
"You're testing with your own connected accounts. Visitors will need to connect their own.",
);
});
// The send gate refuses a builder with outstanding accounts just as it refuses a
// visitor, so test mode is not exempt from naming what is missing.
it('still names what is missing in test mode', () => {
expect(connectBarText(buildChatShellViewModel([missingCred()]), true)).toBe(
'Connect Slack account to start this chat',
);
});
});

View File

@ -1,7 +1,5 @@
import {
createPage,
createShellPage,
escapeForHtmlAttribute,
escapeForScriptContext,
getSanitizedCustomCss,
getSanitizedInitialMessages,
@ -612,205 +610,6 @@ describe('ChatTrigger Templates Security', () => {
});
});
describe('escapeForHtmlAttribute', () => {
it('escapes what would break out of a double-quoted attribute', () => {
expect(escapeForHtmlAttribute('/chat?a="><script>&\'')).toBe(
'/chat?a=&quot;&gt;&lt;script&gt;&amp;&#39;',
);
});
});
describe('createShellPage', () => {
const shell = createShellPage({ iframeSrc: '/webhook/abc/chat?n8nShellInner=1' });
it('renders nothing but the frame the chat lives in', () => {
expect(shell).toContain('<iframe');
expect(shell).toContain('data-src="/webhook/abc/chat?n8nShellInner=1"');
// The widget, its stylesheet and the author's CSS all belong to the frame.
expect(shell).not.toContain('cdn.jsdelivr.net');
expect(shell).not.toContain('createChat');
});
it('gives the frame no origin of its own', () => {
expect(shell).toContain('sandbox="allow-scripts allow-forms allow-modals allow-popups"');
expect(shell).not.toContain('allow-same-origin');
});
// Links in bot replies are `target="_blank"`, so the frame needs `allow-popups`
// to open them at all — but not `allow-popups-to-escape-sandbox`, which would let
// author script put a real-origin document in front of the visitor.
it('lets the frame open popups without letting them escape the sandbox', () => {
expect(shell).toContain('allow-popups');
expect(shell).not.toContain('allow-popups-to-escape-sandbox');
});
// The src comes from the request URL, so it must not be able to close the
// attribute and add markup of its own.
it('escapes the frame src', () => {
const escaped = createShellPage({ iframeSrc: '/chat?x="><img src=x onerror=alert(1)>' });
expect(escaped).not.toContain('<img');
expect(escaped).toContain('&quot;&gt;&lt;img');
});
it('owns the session id so a frame reload continues the conversation', () => {
expect(shell).toContain("'n8n-chat-shell/sessionId' + window.location.pathname");
expect(shell).toContain("'#sessionId=' + encodeURIComponent(sessionId)");
});
// With the OAuth path off there is no token to keep alive, so the shell must be
// exactly the document it was before refresh existed.
it('carries no refresh machinery when no refresh is passed', () => {
expect(shell).not.toContain('n8nChatRefresh');
expect(shell).not.toContain('n8n-chat-auth-token');
expect(shell).not.toContain('n8n-chat-frame-ready');
expect(shell).not.toContain('MessageChannel');
expect(shell).not.toContain('fetch(');
});
// The refresh script had to move ahead of this one so its listener is installed
// while the frame is still about:blank. With refresh absent the document must be
// byte-for-byte the one it was before refresh existed.
it('places the frame script exactly where it was before refresh existed', () => {
expect(shell).toContain('></iframe>\n\t\t<script>');
expect(shell).toContain('\t\t</script>\n\t</body>');
});
});
describe('createShellPage with token refresh', () => {
const shell = createShellPage({
iframeSrc: '/webhook/abc/chat?n8nShellInner=1',
refresh: { url: '/webhook/abc/chat?n8nChatRefresh=1', expiresIn: 3600 },
});
it('schedules ahead of the lifetime it was given', () => {
expect(shell).toContain('planFor(3600)');
expect(shell).toContain('setTimeout');
// The lead is the margin BEFORE expiry, not the refresh time: a fifth of the
// lifetime clamped to [60s, 600s], so a one-hour token refreshes at t+50min.
expect(shell).toContain('Math.min(600, Math.max(60, lifetimeSeconds * 0.2))');
});
// The timer and its one retry are the only things that start a refresh, so two can
// never be in flight and the script needs no concurrency guard. A second trigger —
// a visibility or focus listener — would race the timer over the single refresh
// cookie on this path, so it cannot be added without a latch.
it('starts a refresh from the timer alone', () => {
expect(shell).toContain('timer = setTimeout(function () { refresh(false); }, delay);');
expect(shell).not.toContain('visibilitychange');
expect(shell).not.toContain('inFlight');
});
// An absolute expiry the server computed, compared against the page's own
// `Date.now()`, is wrong by however far the two clocks disagree — and the page has
// no way to detect that. Every reading the schedule makes must come from one clock,
// so the server converts to a duration before it reaches the document.
it('never interpolates a server timestamp into the document', () => {
const rendered = createShellPage({
iframeSrc: '/webhook/abc/chat?n8nShellInner=1',
refresh: { url: '/webhook/abc/chat?n8nChatRefresh=1', expiresIn: 3600 },
});
// A 13-digit epoch-ms literal is what a leaked `expiresAt` would look like.
expect(rendered).not.toMatch(/\b1[0-9]{12}\b/);
});
// Anchoring the new lifetime to when the response *arrived* always overstates what
// is left, by however long the round trip took — a slow leg, or a paused server —
// and overstating is the direction that ends in 401s.
it('subtracts the round trip it measured before rescheduling', () => {
expect(shell).toContain('var startedAt = Date.now();');
expect(shell).toContain('planFor(lifetime - (Date.now() - startedAt) / 1000);');
});
it('rounds a fractional lifetime and never emits a negative one', () => {
const soon = createShellPage({
iframeSrc: '/x',
refresh: { url: '/x?n8nChatRefresh=1', expiresIn: 0 },
});
expect(soon).toContain('planFor(0)');
expect(soon).not.toContain('planFor(-');
});
it('fetches the refresh leg with the custom header that guards it', () => {
expect(shell).toContain('"/webhook/abc/chat?n8nChatRefresh=1"');
expect(shell).toContain("'x-n8n-chat-refresh': '1'");
expect(shell).toContain("credentials: 'same-origin'");
});
// `frame.contentWindow` names the browsing context, not the document, so it keeps
// resolving after author script navigates the frame away — and would hand the next
// rotated token to whatever loaded there. A port dies with the document that made it.
it('hands the token down a channel the frame opened, never at its window', () => {
expect(shell).toContain("port.postMessage({ type: 'n8n-chat-auth-token', token: token })");
expect(shell).toContain('deliver(data.token);');
expect(shell).not.toContain('contentWindow.postMessage');
});
// Two parser-inserted scripts do not run in one uninterrupted turn, so a listener
// installed after the frame is navigated could miss the announcement. Installed
// first, it is in place while the frame is still about:blank — which, sandboxed
// without allow-same-origin, has no script and cannot post.
it('installs the ready listener before it navigates the frame', () => {
expect(shell.indexOf("addEventListener('message'")).toBeGreaterThan(-1);
expect(shell.indexOf("addEventListener('message'")).toBeLessThan(
shell.indexOf("frame.src = frame.getAttribute('data-src')"),
);
});
it('accepts a ready message only from the frame itself', () => {
expect(shell).toContain('if (!frame || event.source !== frame.contentWindow) return;');
expect(shell).toContain("data.type !== 'n8n-chat-frame-ready'");
});
it('latches the first ready message and never a later one', () => {
expect(shell).toContain('if (latched) return;');
expect(shell).toContain('var latched = false;');
expect(shell).toContain('latched = true;');
// Nothing ever sets it back: the only assignment to false is the declaration.
expect(shell.match(/latched = false/g)).toHaveLength(1);
});
// A browser with no MessageChannel announces itself without a port. That still has
// to close the latch, or a document that later replaces the frame could claim it.
it('closes the latch on a ready message that carries no port', () => {
expect(shell.indexOf('latched = true;')).toBeLessThan(
shell.indexOf('if (event.ports && event.ports.length) port = event.ports[0];'),
);
});
// With no port there is nowhere to put a fresh token, and the frame's own baked-in
// token lasts its full lifetime — exactly the pre-refresh behaviour.
it('stops refreshing when the frame announces readiness without a port', () => {
expect(shell).toContain('if (latched && !port) return;');
expect(shell).toContain('if (timer) { clearTimeout(timer); timer = null; }');
});
// A refresh can beat the frame's bootstrap, and the post is one-shot.
it('holds the newest token until the port arrives', () => {
expect(shell).toContain('pendingToken = token;');
expect(shell).toContain(
"port.postMessage({ type: 'n8n-chat-auth-token', token: pendingToken })",
);
expect(shell).toContain("pendingToken = '';");
// If no port ever arrives, reload rather than fall back to the frame's window.
expect(shell).toContain('portTimer = setTimeout(portMissing, 10000);');
});
it('retries once and then reloads exactly once', () => {
expect(shell).toContain('refresh(true)');
expect(shell).toContain('window.location.reload()');
expect(shell).toContain('if (reloaded) return;');
});
// The whole point of the httpOnly cookie: the refresh token exists in no document.
it('carries no refresh token', () => {
expect(shell).not.toContain('refreshToken');
expect(shell).not.toContain('n8n-chat-oauth-refresh');
});
});
describe('createPage inside the shell frame', () => {
const params = {
instanceId: 'test-instance',
@ -838,6 +637,11 @@ describe('createPage inside the shell frame', () => {
frameIdentity: { visitor, authToken: 'signed.jwt.token' },
});
it('loads the widget from the published CDN bundle', () => {
expect(inner).toContain('cdn.jsdelivr.net/npm/@n8n/chat/dist/chat.bundle.es.js');
expect(inner).not.toContain('/chat-widget/');
});
it('stands in for localStorage before the widget loads', () => {
expect(inner).toContain("Object.defineProperty(window, 'localStorage'");
// A classic inline script runs before the deferred module script, so the shim

View File

@ -0,0 +1,64 @@
import type { CredentialCheckStatus } from 'n8n-workflow';
import type { ChatShellCredentialRow, ChatShellViewModel } from './types';
/** Visitor-facing copy talks about accounts, never credentials — and never `account(s)`. */
const accountsLabel = (count: number) => (count === 1 ? 'account' : 'accounts');
/**
* View model for `chat-shell.handlebars`, which renders the bar and the "Connect
* your accounts" dialog. Mirrors Form's shipped `buildFormShellViewModel`: the
* node supplies data, the template owns all markup and script.
*/
export function buildChatShellViewModel(
credentials: CredentialCheckStatus[],
visitorEmail?: string,
): ChatShellViewModel {
const initialOf = (name: string) => (name.trim().charAt(0) || '?').toUpperCase();
const rows: ChatShellCredentialRow[] = credentials.map((c) => ({
key: `${c.credentialId}::${c.resolverId ?? ''}`,
id: c.credentialId,
name: c.credentialName,
connected: c.status === 'configured',
initial: initialOf(c.credentialName),
iconUrl: c.iconUrl,
authorizationUrl: c.authorizationUrl,
revokeUrl: c.revokeUrl,
resolverId: c.resolverId,
account: c.status === 'configured' ? visitorEmail : undefined,
}));
const total = rows.length;
const connectedCount = rows.filter((r) => r.connected).length;
return {
credentials: rows,
total,
connectedCount,
// Design: one account connects directly from the bar; two or more collapse.
useDialog: total >= 2,
footerText: `${connectedCount} of ${total} ${accountsLabel(total)} connected`,
};
}
/**
* The bar's initial line. The template's own script recomputes it whenever an
* account connects or disconnects, and adds a failure state that only arises
* after a click - so this covers the three states the server can know about.
*/
export function connectBarText(vm: ChatShellViewModel, testMode?: boolean): string {
const remaining = Math.max(vm.total - vm.connectedCount, 0);
if (remaining === 0) {
return testMode
? "You're testing with your own connected accounts. Visitors will need to connect their own."
: `All ${vm.total} ${accountsLabel(vm.total)} connected \u00b7 ready to chat`;
}
if (vm.total === 1) {
return `Connect ${vm.credentials[0].name} to start this chat`;
}
// Nothing connected yet names the full count; part-way through, only what is left.
if (vm.connectedCount === 0) {
return `${vm.total} ${accountsLabel(vm.total)} needed to start this chat`;
}
return `${remaining} more ${accountsLabel(remaining)} needed to start this chat`;
}

View File

@ -1,12 +1,29 @@
import sanitizeHtml from 'sanitize-html';
import { CHAT_FRAME_SANDBOX } from './shell';
import type {
AuthenticationChatOption,
ChatFrameIdentity,
LoadPreviousSessionChatOption,
} from './types';
// Escapes what would let a value break out of an inline <script> block: `<`/`>`
// to prevent `</script>` breakout, and U+2028/U+2029, which are valid in a JS
// string but were statement terminators to older engines.
const SCRIPT_CONTEXT_ESCAPES: Record<string, string> = {
'<': '\\u003c',
'>': '\\u003e',
'&': '\\u0026',
'\u2028': '\\u2028',
'\u2029': '\\u2029',
};
// Returns a JSON literal safe to embed inside an inline <script> block. For string
// inputs the returned literal includes surrounding double quotes — do not add
// quotes at the call site.
export function escapeForScriptContext(value: string | object): string {
return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (c) => SCRIPT_CONTEXT_ESCAPES[c]);
}
function sanitizeUserInput(input: unknown): string {
// Only strings and numbers are meaningful display values; sanitize-html
// requires a string input, so coerce numbers and drop everything else.
@ -32,35 +49,6 @@ export function getSanitizedInitialMessages(initialMessages: string): string[] {
.filter((line) => line !== '');
}
const SCRIPT_CONTEXT_ESCAPES: Record<string, string> = {
'<': '\\u003c',
'>': '\\u003e',
'&': '\\u0026',
'\u2028': '\\u2028',
'\u2029': '\\u2029',
};
// Returns a JSON literal safe to embed inside an inline <script> block. Escapes
// `<`/`>` to prevent </script> breakout and U+2028/U+2029 for legacy JS engines.
// For string inputs the returned literal includes surrounding double quotes \u2014
// do not add quotes at the call site.
export function escapeForScriptContext(value: string | object): string {
return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (c) => SCRIPT_CONTEXT_ESCAPES[c]);
}
const HTML_ATTRIBUTE_ESCAPES: Record<string, string> = {
'&': '&amp;',
'"': '&quot;',
"'": '&#39;',
'<': '&lt;',
'>': '&gt;',
};
// For use inside a double-quoted HTML attribute.
export function escapeForHtmlAttribute(value: string): string {
return value.replace(/[&"'<>]/g, (c) => HTML_ATTRIBUTE_ESCAPES[c]);
}
export function getSanitizedI18nConfig(config: Record<string, string>): Record<string, string> {
const sanitized: Record<string, string> = {};
@ -81,6 +69,7 @@ export function getSanitizedCustomCss(customCss: string): string {
* `localStorageSessionIdKey` in `@n8n/chat/src/constants/localStorage.ts`. Seeding the
* shim under it keeps continuity working against a widget build predating the
* `sessionId` option — the bundle comes from an unpinned CDN URL, not the instance.
* The shell that passes the id in the fragment is `chat-shell.handlebars`.
*/
const WIDGET_SESSION_ID_KEY = 'n8n-chat/sessionId';
@ -145,203 +134,6 @@ const innerBootstrapScript = `
})();
</script>`;
/**
* The trusted shell: an n8n-controlled document on the real origin holding nothing but
* the frame. Everything the author can shape lives in that frame, which has no origin
* and so can't reach this document's cookies or the OAuth `BroadcastChannel`.
*/
export function createShellPage({
iframeSrc,
refresh,
}: {
iframeSrc: string;
/**
* Set only on the OAuth2 path: where to ask for a fresh access token, and how many
* seconds the one the frame was just handed has left. A duration, never an absolute
* timestamp — see `ChatShellSession`. Absent leaves the shell exactly what it was
* before refresh existed.
*/
refresh?: { url: string; expiresIn: number };
}) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chat</title>
<style>
html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
#n8n-chat-frame { display: block; width: 100%; height: 100%; border: 0; }
</style>
</head>
<body>
<iframe
id="n8n-chat-frame"
title="Chat"
sandbox="${CHAT_FRAME_SANDBOX}"
data-src="${escapeForHtmlAttribute(iframeSrc)}"
></iframe>${refresh ? refreshScript(refresh) : ''}
<script>
(function () {
// Held here, not in the frame, whose storage dies with its opaque origin on
// every reload. Keyed by path so two chats don't share a conversation.
var key = 'n8n-chat-shell/sessionId' + window.location.pathname;
var sessionId = '';
try { sessionId = window.localStorage.getItem(key) || ''; } catch (error) {}
if (!sessionId) {
sessionId =
window.crypto && window.crypto.randomUUID
? window.crypto.randomUUID()
: String(Date.now()) + Math.random().toString(16).slice(2);
try { window.localStorage.setItem(key, sessionId); } catch (error) {}
}
var frame = document.getElementById('n8n-chat-frame');
frame.src = frame.getAttribute('data-src') + '#sessionId=' + encodeURIComponent(sessionId);
})();
</script>
</body>
</html>`;
}
/**
* Keeps the frame's access token alive. The token is interpolated into the frame's HTML
* once and frozen for the life of that document, so without this a conversation older
* than the token fails every message with a 401.
*
* Lives on the shell, not in the frame: the refresh token is in an httpOnly cookie
* scoped to this path, and only a same-origin request carries it. The shell never reads
* that cookie either — it only asks the server to trade it.
*/
function refreshScript({ url, expiresIn }: { url: string; expiresIn: number }): string {
return `
<script>
(function () {
var endpoint = ${escapeForScriptContext(url)};
var timer = null;
var reloaded = false;
// Every token goes down a port the frame handed us, never at its contentWindow:
// that names the browsing context, which survives a navigation, so author script
// that navigates the frame away would be handed the next token. A port is an
// object in the frame document's realm — it dies with that document, and no
// replacement can obtain it.
var frame = document.getElementById('n8n-chat-frame');
var port = null;
var latched = false;
var pendingToken = '';
var portTimer = null;
window.addEventListener('message', function (event) {
// First announcement wins, and the latch never re-arms: the inner document is
// already unreloadable (its one-hop cookie is consumed on the first GET), so a
// second announcement can only come from a document we must not hand a token.
if (latched) return;
// allow-popups means a popup the frame opened can reach us as opener.parent,
// so the sender has to be the frame itself.
if (!frame || event.source !== frame.contentWindow) return;
var data = event.data;
if (!data || data.type !== 'n8n-chat-frame-ready') return;
latched = true;
if (event.ports && event.ports.length) port = event.ports[0];
if (portTimer) { clearTimeout(portTimer); portTimer = null; }
if (!port) {
// This browser can't carry a token to the frame. Stop: the frame keeps the
// token baked into its HTML for its full hour, as it did before refresh existed.
if (timer) { clearTimeout(timer); timer = null; }
return;
}
if (pendingToken) {
port.postMessage({ type: 'n8n-chat-auth-token', token: pendingToken });
pendingToken = '';
}
});
function deliver(token) {
if (port) { port.postMessage({ type: 'n8n-chat-auth-token', token: token }); return; }
// Hold the newest token rather than lose a one-shot post: a refresh can beat
// the frame's own bootstrap.
pendingToken = token;
if (!portTimer) portTimer = setTimeout(portMissing, 10000);
}
function portMissing() {
portTimer = null;
if (port) return;
// No fallback that posts at the frame's own window: a document that navigated
// the frame simply never announces itself, so the fallback would be the exact
// path that hands it the token. Reload instead — guarded, same-origin.
giveUp();
}
// How long BEFORE expiry to refresh, not when to refresh: a fifth of the
// lifetime, clamped to [60s, 600s]. A one-hour token is therefore replaced at
// t+50min, leaving ten minutes of margin — enough for a throttled background
// tab, a slept laptop, and the one retry before the reload fallback.
function leadSeconds(lifetimeSeconds) {
return Math.min(600, Math.max(60, lifetimeSeconds * 0.2));
}
// Takes a duration, never an absolute expiry the server computed: a clock that
// disagrees with the server's would otherwise skew every schedule. This timer is
// the only thing that starts a refresh, so two can never be in flight at once.
function planFor(lifetimeSeconds) {
var remaining = Math.max(0, lifetimeSeconds);
var delay = Math.max(0, (remaining - leadSeconds(remaining)) * 1000);
if (timer) clearTimeout(timer);
timer = setTimeout(function () { refresh(false); }, delay);
}
function giveUp() {
// One reload, guarded: it re-runs the handshake, which auto-approves against
// the visitor's existing consent. Without the guard a broken AS would put the
// page in a reload loop.
if (reloaded) return;
reloaded = true;
window.location.reload();
}
function refresh(isRetry) {
// The frame announced itself with no port, so there is nowhere to put a fresh
// token. Reachable through the 5s retry: a request already in flight when that
// announcement arrives still schedules one.
if (latched && !port) return;
// Taken before the request leaves, so the elapsed time subtracted below covers
// the whole window — both network legs, our handler, and the AS round trip.
// Without it the page anchors the lifetime to when the response *arrived* and
// so always believes it has more left than it does, which is the direction
// that ends in 401s.
var startedAt = Date.now();
fetch(endpoint, {
method: 'GET',
credentials: 'same-origin',
cache: 'no-store',
// Custom header, so the request needs a preflight no other origin gets
// past. This is the CSRF guard on the leg.
headers: { 'x-n8n-chat-refresh': '1' },
})
.then(function (response) {
if (!response.ok) throw new Error('refresh failed: ' + response.status);
return response.json();
})
.then(function (data) {
if (!data || typeof data.token !== 'string' || !data.token) {
throw new Error('refresh returned no token');
}
deliver(data.token);
var lifetime = typeof data.expiresIn === 'number' ? data.expiresIn : 3600;
planFor(lifetime - (Date.now() - startedAt) / 1000);
})
.catch(function () {
if (isRetry) giveUp();
else setTimeout(function () { refresh(true); }, 5000);
});
}
planFor(${String(Math.max(0, Math.round(expiresIn)))});
})();
</script>`;
}
export function createPage({
instanceId,
webhookUrl,

View File

@ -29,6 +29,36 @@ export type ChatFrameIdentity = { visitor: IUser; authToken: string };
*/
export type ChatShellSession = { expiresIn: number };
/** One credential row in the outer shell's "Connect your accounts" panel. */
export type ChatShellCredentialRow = {
key: string;
id: string;
name: string;
connected: boolean;
/** Letter tile, used whenever the provider icon doesn't resolve. */
initial: string;
iconUrl?: string;
authorizationUrl?: string;
revokeUrl?: string;
resolverId?: string;
/** The connected identity shown as "Connected as …" — the visitor themselves. */
account?: string;
};
/**
* View model for the outer shell's connect panel. One required credential gets
* its own row with a Connect button that opens the OAuth popup directly; two or
* more collapse behind a summary line plus the "Connect your accounts" dialog —
* mirrors Form's shipped `FormShellViewModel`.
*/
export type ChatShellViewModel = {
credentials: ChatShellCredentialRow[];
total: number;
connectedCount: number;
useDialog: boolean;
footerText: string;
};
function isValidLoadPreviousSessionOption(value: unknown): value is LoadPreviousSessionChatOption {
return typeof value === 'string' && (validOptions as readonly string[]).includes(value);
}

View File

@ -0,0 +1,327 @@
import express from 'express';
import request from 'supertest';
import { TEMPLATES_DIR } from '@/constants';
import { createHandlebarsEngine } from '@/utils/handlebars.util';
const baseView = {
iframeSrc: '/webhook/abc/chat?n8nShellInner=1',
sandbox: 'allow-scripts allow-forms allow-modals allow-popups',
};
const withOneMissingAccount = {
...baseView,
hasCredentials: true,
ready: false,
visitorEmail: 'visitor@example.com',
barText: '1 account needed to start this chat',
useDialog: false,
total: 1,
connectedCount: 0,
footerText: '0 of 1 account connected',
credentials: [
{
key: 'cred-1::system-n8n',
id: 'cred-1',
name: 'Slack account',
connected: false,
initial: 'S',
resolverId: 'system-n8n',
authorizationUrl: 'https://n8n.example.com/credentials/cred-1/authorize',
},
],
};
const withRefresh = {
...baseView,
refreshUrl: '/webhook/abc/chat?n8nChatRefresh=1',
refreshExpiresIn: 3600,
};
const renderView = async (view: Record<string, unknown>) => {
const app = express();
app.engine('handlebars', createHandlebarsEngine());
app.set('view engine', 'handlebars');
app.set('views', TEMPLATES_DIR);
app.get('/', (_req, res) => res.render('chat-shell', view));
const response = await request(app).get('/');
return response.text;
};
describe('chat-shell.handlebars', () => {
it('renders without leaving any placeholder unresolved', async () => {
const html = await renderView(withOneMissingAccount);
expect(html).not.toContain('{{');
expect(html).toContain("data-src='/webhook/abc/chat?n8nShellInner&#x3D;1'");
expect(html).toContain('1 account needed to start this chat');
});
it('owns the session id so a frame reload continues the conversation', async () => {
const html = await renderView(withOneMissingAccount);
expect(html).toContain("'n8n-chat-shell/sessionId' + window.location.pathname");
expect(html).toContain("'#sessionId=' + encodeURIComponent(sessionId)");
// Keyed by path, so two chats on one instance never share a conversation.
expect(html).toContain('window.location.pathname');
});
it('carries no widget or author code of its own', async () => {
const html = await renderView(withOneMissingAccount);
expect(html).not.toContain('cdn.jsdelivr.net');
expect(html).not.toContain('createChat');
});
describe('the sandboxed frame', () => {
it('gives the frame no origin of its own', async () => {
const html = await renderView(withOneMissingAccount);
expect(html).toContain("sandbox='allow-scripts allow-forms allow-modals allow-popups'");
expect(html).not.toContain('allow-same-origin');
expect(html).not.toContain('allow-popups-to-escape-sandbox');
});
it('renders the frame even when no accounts are needed', async () => {
const html = await renderView({ ...baseView, hasCredentials: false });
expect(html).toContain("id='n8n-chat-frame'");
expect(html).not.toMatch(/<div[^>]*class='connect-bar/);
expect(html).not.toContain("id='n8n-connect-overlay'");
// No status signal is posted into the frame either.
expect(html).not.toContain('n8n-chat:credential-status');
});
});
describe('escaping', () => {
it.each([
['the frame src', { iframeSrc: '/chat?x="><img src=x onerror=alert(1)>' }, '<img src=x'],
[
'a credential name',
{ credentials: [{ id: 'c1', initial: 'X', name: '"><script>alert(1)</script>' }] },
'<script>alert(1)',
],
[
'an authorize URL',
{
credentials: [
{ id: 'c1', initial: 'X', name: 'n', authorizationUrl: '"><script>alert(2)</script>' },
],
},
'<script>alert(2)',
],
['the visitor email', { visitorEmail: '"><script>alert(3)</script>' }, '<script>alert(3)'],
['the bar text', { barText: '"><script>alert(4)</script>' }, '<script>alert(4)'],
[
'the refresh URL',
{ refreshUrl: '/chat?x="><script>alert(5)</script>' },
'<script>alert(5)',
],
])('escapes %s', async (_label, override, breakout) => {
const html = await renderView({ ...withOneMissingAccount, ...override });
expect(html).not.toContain(breakout);
expect(html).toContain('&quot;&gt;');
});
});
describe('the status bar', () => {
it('offers Connect while an account is outstanding', async () => {
const html = await renderView(withOneMissingAccount);
expect(html).toContain('1 account needed to start this chat');
expect(html).toMatch(/<button[^>]*bar-action/);
expect(html).toContain('>Connect<');
expect(html).not.toMatch(/class='connect-bar all-connected/);
});
it('switches to a quiet Manage control once every account is connected', async () => {
const html = await renderView({
...withOneMissingAccount,
ready: true,
connectedCount: 1,
barText: 'All 1 account connected · ready to chat',
credentials: [
{
id: 'cred-1',
name: 'Slack account',
connected: true,
initial: 'S',
account: 'visitor@example.com',
revokeUrl: 'https://n8n.example.com/credentials/cred-1/revoke?resolverId=system-n8n',
},
],
});
expect(html).toMatch(/class='connect-bar all-connected/);
expect(html).toContain('>Manage<');
// The dialog is what carries Disconnect, so Manage has somewhere to land.
expect(html).toContain('btn-disconnect');
expect(html).toContain('Connected as visitor@example.com');
});
it('keeps the control in test mode while accounts are outstanding', async () => {
const html = await renderView({
...withOneMissingAccount,
testMode: true,
barText: 'Connect Slack account to start this chat',
});
expect(html).toMatch(/<button[^>]*bar-action/);
expect(html).toContain("data-test-mode='true'");
expect(html).toContain('Connect Slack account to start this chat');
});
});
describe('the connect dialog', () => {
it('lists every account with its own authorize link', async () => {
const html = await renderView({
...withOneMissingAccount,
useDialog: true,
total: 2,
footerText: '0 of 2 accounts connected',
credentials: [
...withOneMissingAccount.credentials,
{
id: 'cred-2',
name: 'Google account',
connected: false,
initial: 'G',
authorizationUrl: 'https://n8n.example.com/credentials/cred-2/authorize',
},
],
});
expect(html).toContain('Connect your accounts');
expect(html).toContain('0 of 2 accounts connected');
expect(html).toContain("data-url='https://n8n.example.com/credentials/cred-1/authorize'");
expect(html).toContain("data-url='https://n8n.example.com/credentials/cred-2/authorize'");
});
it('says to ask the owner when no authorize link could be built', async () => {
const html = await renderView({
...withOneMissingAccount,
credentials: [{ id: 'cred-1', name: 'Slack account', connected: false, initial: 'S' }],
});
expect(html).toContain('Ask the workflow owner');
expect(html).not.toContain('data-url=');
});
});
describe('the token refresh leg', () => {
// The script is static and reads the endpoint and schedule off the frame's data
// attributes, so nothing server-supplied enters script context.
const refreshScript = async (view: Record<string, unknown> = withRefresh) => {
const html = await renderView(view);
return html.slice(html.indexOf('<script>'), html.indexOf('</script>'));
};
it('passes the endpoint and the schedule as data attributes', async () => {
const html = await renderView(withRefresh);
expect(html).toContain("data-refresh-url='/webhook/abc/chat?n8nChatRefresh&#x3D;1'");
expect(html).toContain("data-refresh-expires-in='3600'");
expect(await refreshScript()).toContain("getAttribute('data-refresh-url')");
});
it('omits the leg entirely when the shell is rendered without one', async () => {
const html = await renderView(withOneMissingAccount);
expect(html).not.toContain('data-refresh-url');
expect(html).not.toContain('x-n8n-chat-refresh');
});
// The CSRF guard on the leg: a custom header needs a preflight no other origin
// gets past.
it('asks for a token with the custom header and no cache', async () => {
const src = await refreshScript();
expect(src).toContain("'x-n8n-chat-refresh': '1'");
expect(src).toContain("credentials: 'same-origin'");
expect(src).toContain("cache: 'no-store'");
});
// A port is an object in the frame document's realm, so it dies with that
// document. Posting at `contentWindow` would hand the next token to whatever
// author script navigated the frame to.
it("delivers every token down the frame's port, never at its window", async () => {
const src = await refreshScript();
expect(src).toContain("port.postMessage({ type: 'n8n-chat-auth-token'");
expect(src).not.toContain('frame.contentWindow.postMessage');
});
it('accepts the port only from the frame, and only once', async () => {
const src = await refreshScript();
expect(src).toContain('event.source !== frame.contentWindow');
expect(src).toContain('if (latched) return;');
});
// A refresh can beat the frame's bootstrap, and the post is one-shot.
it('holds the newest token until the port arrives', async () => {
const src = await refreshScript();
expect(src).toContain('pendingToken = token;');
expect(src).toContain(
"port.postMessage({ type: 'n8n-chat-auth-token', token: pendingToken })",
);
// If no port ever arrives, reload rather than fall back to the frame's window.
expect(src).toContain('portTimer = setTimeout(portMissing, 10000);');
});
it('retries once and then reloads exactly once', async () => {
const src = await refreshScript();
expect(src).toContain('refresh(true)');
expect(src).toContain('window.location.reload()');
expect(src).toContain('if (reloaded) return;');
});
// The whole point of the httpOnly cookie: the refresh token exists in no document.
it('carries no refresh token', async () => {
const html = await renderView(withRefresh);
expect(html).not.toContain('refreshToken');
expect(html).not.toContain('n8n-chat-oauth-refresh');
});
});
it('uses no inline event handlers', async () => {
const html = await renderView(withOneMissingAccount);
expect(html).not.toMatch(/\son\w+=['"]/);
});
describe('the inline script keeps its security gates', () => {
const script = async () => {
const html = await renderView(withOneMissingAccount);
return html.slice(html.lastIndexOf('<script>'));
};
it('trusts a connect signal only from the popup it opened', async () => {
expect(await script()).toContain('event.source !== pendingPopup');
});
it('refuses to open a popup at a non-http scheme', async () => {
expect(await script()).toContain('/^https?:\\/\\//i.test(url)');
});
it('never forces readiness in test mode', async () => {
const src = await script();
expect(src).toContain('ready: total <= count');
expect(src).not.toContain('TEST_MODE || total <= count');
});
it('surfaces a failed or cancelled attempt', async () => {
const src = await script();
expect(src).toContain("'Connection failed \u00b7 try again'");
expect(src).toContain("sub.classList.add('error')");
expect(src).toContain('if (!pendingPopup || pendingPopup.closed) onErrorSignal()');
});
});
});

View File

@ -0,0 +1,772 @@
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<title>Chat</title>
<style>
:root {
--n8n-connect-bg-card: #ffffff;
--n8n-connect-bar-bg: #f7f7f8;
--n8n-connect-border: #dbdfe7;
--n8n-connect-header: #525356;
--n8n-connect-label: #555555;
--n8n-connect-muted: #7e8186;
--n8n-connect-primary: #ff6d5a;
--n8n-connect-primary-text: #ffffff;
--n8n-connect-ok: #2e9e5b;
--n8n-connect-error: #c0392b;
--n8n-connect-error-bg: #fdeceb;
--n8n-connect-icon-bg: #f2f0ff;
--n8n-connect-icon-text: #6a5bdd;
--n8n-connect-radius-card: 8px;
--n8n-connect-radius-input: 6px;
--n8n-connect-shadow-dialog: 0px 12px 48px rgba(0, 0, 0, 0.18);
--n8n-connect-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
/* Column so the bar sits below the frame: the visitor reads it directly under
the chat input it gates, and it stays outside the frame, where the author's
content cannot reach it. */
body { display: flex; flex-direction: column; }
#n8n-chat-frame { display: block; flex: 1; width: 100%; border: 0; min-height: 0; }
/* --- Status bar --- */
.connect-bar {
display: flex; align-items: center; gap: 12px; flex-shrink: 0;
padding: 14px 18px;
background: var(--n8n-connect-bar-bg);
border-top: 1px solid var(--n8n-connect-border);
font-family: var(--n8n-connect-font-family);
font-size: 13px;
color: var(--n8n-connect-header);
}
.bar-text { flex: 1; min-width: 0; }
.bar-info { position: relative; display: flex; flex-shrink: 0; }
.bar-icon { display: flex; padding: 0; background: none; border: 0; cursor: pointer; color: var(--n8n-connect-muted); }
.bar-icon svg { display: block; }
.bar-icon .icon-ok, .connect-bar.all-connected .bar-icon .icon-pending { display: none; }
.connect-bar.all-connected .bar-icon { color: var(--n8n-connect-ok); }
.connect-bar.all-connected .bar-icon .icon-ok { display: block; }
/* Opens upward: the bar is the last thing on the page, so downward is
off-screen. `.open` is the tap toggle, since iOS Safari does not focus a
tapped button and :focus-within alone never fires there. */
.bar-tooltip { display: none; position: absolute; bottom: 100%; left: -12px; padding-bottom: 8px; width: min(300px, calc(100vw - 32px)); z-index: 20; }
.bar-info:hover .bar-tooltip, .bar-info:focus-within .bar-tooltip, .bar-info.open .bar-tooltip { display: block; }
.bar-tooltip-body { display: block; background: var(--n8n-connect-bg-card); border: 1px solid var(--n8n-connect-border); border-radius: var(--n8n-connect-radius-card); box-shadow: var(--n8n-connect-shadow-dialog); padding: 10px 12px; font-size: 12px; line-height: 1.5; color: var(--n8n-connect-label); }
.bar-tooltip-body a { color: var(--n8n-connect-primary); font-weight: 600; text-decoration: none; white-space: nowrap; }
.bar-tooltip-body a:hover { text-decoration: underline; }
.bar-action { display: inline-flex; align-items: center; gap: 6px; }
.bar-action .dot { display: none; width: 7px; height: 7px; border-radius: 50%; background: var(--n8n-connect-ok); }
/* Connected: the control drops to quiet, since nothing needs doing. */
.connect-bar.all-connected .bar-action { background: var(--n8n-connect-bg-card); color: var(--n8n-connect-label); border-color: var(--n8n-connect-border); }
.connect-bar.all-connected .bar-action:hover { opacity: 1; border-color: var(--n8n-connect-muted); }
.connect-bar.all-connected .bar-action .dot { display: block; }
/* --- "Connect your accounts" dialog --- */
.overlay, .overlay * { box-sizing: border-box; }
.overlay {
display: none; position: fixed; inset: 0; background: rgba(20, 20, 30, 0.28);
align-items: center; justify-content: center; z-index: 30; padding: 16px;
font-family: var(--n8n-connect-font-family);
}
.overlay.open { display: flex; }
.dialog { width: 448px; max-width: 100%; max-height: calc(100vh - 48px); display: flex; flex-direction: column; background: var(--n8n-connect-bg-card); border-radius: 12px; box-shadow: var(--n8n-connect-shadow-dialog); overflow: hidden; }
.dialog-head { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px 8px; }
.dialog-title { font-size: 16px; font-weight: 600; color: var(--n8n-connect-header); }
.dialog-close { background: none; border: 0; font-size: 20px; line-height: 1; color: var(--n8n-connect-muted); cursor: pointer; }
.dialog-sub { padding: 0 20px 16px; font-size: 13px; color: var(--n8n-connect-muted); }
.dialog-list { padding: 0 20px; overflow-y: auto; }
.dialog-foot { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-top: 1px solid #f0f1f4; }
.dialog-count { font-size: 13px; color: var(--n8n-connect-muted); }
/* --- Credential row --- */
.cred-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; }
.cred-icon {
width: 28px; height: 28px; border-radius: 6px; flex-shrink: 0;
background: var(--n8n-connect-icon-bg); color: var(--n8n-connect-icon-text);
font-size: 13px; font-weight: 600; display: flex; align-items: center; justify-content: center;
}
img.cred-icon { background: none; object-fit: contain; }
.cred-meta { flex: 1; min-width: 0; }
.cred-name { display: block; font-size: 14px; font-weight: 600; color: var(--n8n-connect-label); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cred-sub { display: block; font-size: 12px; color: var(--n8n-connect-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cred-sub.error { color: var(--n8n-connect-error); }
/* --- Buttons --- */
.btn {
font-family: var(--n8n-connect-font-family); font-size: 13px; font-weight: 600;
padding: 8px 16px; border-radius: var(--n8n-connect-radius-input); border: 1px solid transparent; cursor: pointer;
flex-shrink: 0;
}
.btn-primary { background: var(--n8n-connect-primary); color: var(--n8n-connect-primary-text); }
.btn-primary:hover { opacity: 0.88; }
.btn-secondary { background: var(--n8n-connect-bg-card); color: var(--n8n-connect-label); border-color: var(--n8n-connect-border); }
.btn-secondary:hover { border-color: var(--n8n-connect-muted); }
.cred-connected { position: relative; }
.btn-connected {
display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
background: var(--n8n-connect-bg-card); border: 1px solid var(--n8n-connect-border); border-radius: var(--n8n-connect-radius-input);
color: var(--n8n-connect-label); font-size: 13px; font-weight: 600; padding: 8px 12px;
}
.btn-connected:hover { border-color: var(--n8n-connect-muted); }
.btn-connected .caret { color: var(--n8n-connect-muted); }
.btn-connected .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--n8n-connect-ok); }
.cred-menu { display: none; position: fixed; min-width: 150px; background: var(--n8n-connect-bg-card); border: 1px solid var(--n8n-connect-border); border-radius: 6px; box-shadow: var(--n8n-connect-shadow-dialog); z-index: 40; }
.cred-menu.open { display: block; }
.btn-disconnect { display: block; width: 100%; text-align: left; white-space: nowrap; background: none; border: 0; padding: 8px 14px; font-size: 13px; color: var(--n8n-connect-error); cursor: pointer; }
.btn-disconnect:hover { background: var(--n8n-connect-error-bg); }
.cred-blocked { font-size: 13px; color: var(--n8n-connect-muted); }
</style>
</head>
<body>
{{!-- Every dynamic value below goes through {{ }}, so Handlebars escapes it.
The script that follows is static and reads its state from these
attributes - no view model is interpolated into script context. --}}
{{#*inline "credRow"}}
<div
class='cred-row'
data-row-key='{{key}}'
data-resolver-id='{{resolverId}}'
data-not-connected-text='{{notConnectedText}}'
{{#if connected}} data-connected='true'{{/if}}
{{#if revokeUrl}} data-revoke-url='{{revokeUrl}}'{{/if}}
>
{{#if iconUrl}}
<img class='cred-icon cred-icon-img' src='{{iconUrl}}' alt='' data-initial='{{initial}}' />
{{else}}
<span class='cred-icon'>{{initial}}</span>
{{/if}}
<span class='cred-meta'>
<span class='cred-name'>{{name}}</span>
<span class='cred-sub'>{{#if connected}}{{#if account}}Connected as {{account}}{{else}}Connected{{/if}}{{else}}{{notConnectedText}}{{/if}}</span>
</span>
{{#if connected}}
<span class='cred-connected'><button type='button' class='btn-connected'><span class='dot'></span> Connected <span class='caret'>&#9662;</span></button><div class='cred-menu'><button type='button' class='btn-disconnect'>Disconnect</button></div></span>
{{else if authorizationUrl}}
<button class='btn btn-secondary connect' data-url='{{authorizationUrl}}'>Connect</button>
{{else}}
<span class='cred-blocked'>Ask the workflow owner</span>
{{/if}}
</div>
{{/inline}}
<iframe
id='n8n-chat-frame'
title='Chat'
sandbox='{{sandbox}}'
data-src='{{iframeSrc}}'
{{#if refreshUrl}} data-refresh-url='{{refreshUrl}}' data-refresh-expires-in='{{refreshExpiresIn}}'{{/if}}
></iframe>
{{#if refreshUrl}}
{{!-- Keeps the frame's access token alive. The token is interpolated into the
frame's HTML once and frozen for the life of that document, so without
this a conversation older than the token fails every message with a 401.
Lives on this page, not in the frame: the refresh token is in an httpOnly
cookie scoped to this path, and only a same-origin request carries it.
This page never reads that cookie either - it only asks the server to
trade it. Runs before the script that assigns `frame.src`, so the frame's
announcement can't arrive before the listener exists. --}}
<script>
(function () {
var frame = document.getElementById('n8n-chat-frame');
var endpoint = frame.getAttribute('data-refresh-url') || '';
if (!endpoint) return;
var lifetime = parseFloat(frame.getAttribute('data-refresh-expires-in')) || 0;
var timer = null;
var reloaded = false;
// Every token goes down a port the frame handed us, never at its contentWindow:
// that names the browsing context, which survives a navigation, so author script
// that navigates the frame away would be handed the next token. A port is an
// object in the frame document's realm - it dies with that document, and no
// replacement can obtain it.
var port = null;
var latched = false;
var pendingToken = '';
var portTimer = null;
window.addEventListener('message', function (event) {
// First announcement wins, and the latch never re-arms: the inner document is
// already unreloadable (its one-hop cookie is consumed on the first GET), so a
// second announcement can only come from a document we must not hand a token.
if (latched) return;
// allow-popups means a popup the frame opened can reach us as opener.parent,
// so the sender has to be the frame itself.
if (!frame || event.source !== frame.contentWindow) return;
var data = event.data;
if (!data || data.type !== 'n8n-chat-frame-ready') return;
latched = true;
if (event.ports && event.ports.length) port = event.ports[0];
if (portTimer) { clearTimeout(portTimer); portTimer = null; }
if (!port) {
// This browser can't carry a token to the frame. Stop: the frame keeps the
// token baked into its HTML for its full hour, as it did before refresh existed.
if (timer) { clearTimeout(timer); timer = null; }
return;
}
if (pendingToken) {
port.postMessage({ type: 'n8n-chat-auth-token', token: pendingToken });
pendingToken = '';
}
});
function deliver(token) {
if (port) { port.postMessage({ type: 'n8n-chat-auth-token', token: token }); return; }
// Hold the newest token rather than lose a one-shot post: a refresh can beat
// the frame's own bootstrap.
pendingToken = token;
if (!portTimer) portTimer = setTimeout(portMissing, 10000);
}
function portMissing() {
portTimer = null;
if (port) return;
// No fallback that posts at the frame's own window: a document that navigated
// the frame simply never announces itself, so the fallback would be the exact
// path that hands it the token. Reload instead - guarded, same-origin.
giveUp();
}
// How long BEFORE expiry to refresh, not when to refresh: a fifth of the
// lifetime, clamped to [60s, 600s]. A one-hour token is therefore replaced at
// t+50min, leaving ten minutes of margin - enough for a throttled background
// tab, a slept laptop, and the one retry before the reload fallback.
function leadSeconds(lifetimeSeconds) {
return Math.min(600, Math.max(60, lifetimeSeconds * 0.2));
}
// Takes a duration, never an absolute expiry the server computed: a clock that
// disagrees with the server's would otherwise skew every schedule. This timer is
// the only thing that starts a refresh, so two can never be in flight at once.
function planFor(lifetimeSeconds) {
var remaining = Math.max(0, lifetimeSeconds);
var delay = Math.max(0, (remaining - leadSeconds(remaining)) * 1000);
if (timer) clearTimeout(timer);
timer = setTimeout(function () { refresh(false); }, delay);
}
function giveUp() {
// One reload, guarded: it re-runs the handshake, which auto-approves against
// the visitor's existing consent. Without the guard a broken AS would put the
// page in a reload loop.
if (reloaded) return;
reloaded = true;
window.location.reload();
}
function refresh(isRetry) {
// The frame announced itself with no port, so there is nowhere to put a fresh
// token. Reachable through the 5s retry: a request already in flight when that
// announcement arrives still schedules one.
if (latched && !port) return;
// Taken before the request leaves, so the elapsed time subtracted below covers
// the whole window - both network legs, our handler, and the AS round trip.
// Without it the page anchors the lifetime to when the response *arrived* and
// so always believes it has more left than it does, which is the direction
// that ends in 401s.
var startedAt = Date.now();
fetch(endpoint, {
method: 'GET',
credentials: 'same-origin',
cache: 'no-store',
// Custom header, so the request needs a preflight no other origin gets
// past. This is the CSRF guard on the leg.
headers: { 'x-n8n-chat-refresh': '1' },
})
.then(function (response) {
if (!response.ok) throw new Error('refresh failed: ' + response.status);
return response.json();
})
.then(function (data) {
if (!data || typeof data.token !== 'string' || !data.token) {
throw new Error('refresh returned no token');
}
deliver(data.token);
var next = typeof data.expiresIn === 'number' ? data.expiresIn : 3600;
planFor(next - (Date.now() - startedAt) / 1000);
})
.catch(function () {
if (isRetry) giveUp();
else setTimeout(function () { refresh(true); }, 5000);
});
}
planFor(lifetime);
})();
</script>
{{/if}}
{{#if hasCredentials}}
<div
class='connect-bar{{#if ready}} all-connected{{/if}}'
id='n8n-connect-bar'
data-test-mode='{{#if testMode}}true{{else}}false{{/if}}'
data-use-dialog='{{#if useDialog}}true{{else}}false{{/if}}'
data-visitor-email='{{visitorEmail}}'
>
<span class='bar-info' id='n8n-connect-bar-info'>
<button type='button' class='bar-icon' aria-describedby='n8n-connect-bar-tooltip' aria-label='About the accounts this chat uses'>
<svg class='icon-pending' width='17' height='17' viewBox='0 0 16 16' fill='none' stroke='currentColor' stroke-width='1.3' aria-hidden='true'><circle cx='8' cy='8' r='6.7' /><path d='M8 7.3v3.5' stroke-linecap='round' /><circle cx='8' cy='5.1' r='.8' fill='currentColor' stroke='none' /></svg>
<svg class='icon-ok' width='17' height='17' viewBox='0 0 16 16' fill='none' stroke='currentColor' stroke-width='1.3' aria-hidden='true'><circle cx='8' cy='8' r='6.7' /><path d='m5.2 8.2 1.9 1.9 3.7-4' stroke-linecap='round' stroke-linejoin='round' /></svg>
</button>
<span class='bar-tooltip' role='tooltip' id='n8n-connect-bar-tooltip'>
<span class='bar-tooltip-body'>Chatting runs actions on your behalf, using accounts you connect. You can disconnect them at any time. <a href='https://docs.n8n.io/administer/manage-credentials/end-user-credentials' target='_blank' rel='noopener noreferrer'>Learn more</a></span>
</span>
</span>
<span class='bar-text' id='n8n-connect-bar-text'>{{barText}}</span>
<button class='btn btn-primary bar-action' id='n8n-connect-bar-action'><span class='dot'></span><span class='label'>{{#if ready}}Manage{{else}}Connect{{/if}}</span></button>
</div>
{{!-- Holds the real per-credential authorize/revoke links, which is why it
lives on this page and not in the author-scriptable frame. Rendered for
a single account too: once that one is connected, this is the only place
carrying a Disconnect control, so omitting it would leave the bar's
Manage with nothing to open.
--}}
<div class='overlay' id='n8n-connect-overlay'>
<div class='dialog' role='dialog' aria-modal='true' aria-labelledby='n8n-connect-dialog-title'>
<div class='dialog-head'>
<span class='dialog-title' id='n8n-connect-dialog-title'>Connect your accounts</span>
<button class='dialog-close' id='n8n-connect-close' aria-label='Close'>&times;</button>
</div>
<div class='dialog-sub'>Chatting uses these accounts on your behalf.</div>
<div class='dialog-list'>
{{#each credentials}}
{{> credRow notConnectedText='Not connected'}}
{{/each}}
</div>
<div class='dialog-foot'>
<span class='dialog-count' id='n8n-connect-count'>{{footerText}}</span>
<button class='btn btn-primary' id='n8n-connect-done'>Done</button>
</div>
</div>
</div>
{{/if}}
<script>
(function () {
// Held here, not in the frame, whose storage dies with its opaque origin on
// every reload. Keyed by path so two chats don't share a conversation.
var key = 'n8n-chat-shell/sessionId' + window.location.pathname;
var sessionId = '';
try { sessionId = window.localStorage.getItem(key) || ''; } catch (error) {}
if (!sessionId) {
sessionId =
window.crypto && window.crypto.randomUUID
? window.crypto.randomUUID()
: String(Date.now()) + Math.random().toString(16).slice(2);
try { window.localStorage.setItem(key, sessionId); } catch (error) {}
}
var frame = document.getElementById('n8n-chat-frame');
frame.src = frame.getAttribute('data-src') + '#sessionId=' + encodeURIComponent(sessionId);
})();
</script>
{{#if hasCredentials}}
<script>
(function () {
var frame = document.getElementById('n8n-chat-frame');
var overlay = document.getElementById('n8n-connect-overlay');
var barEl = document.getElementById('n8n-connect-bar');
var barTextEl = document.getElementById('n8n-connect-bar-text');
var barLabelEl = document.querySelector('#n8n-connect-bar-action .label');
var countEl = document.getElementById('n8n-connect-count');
// State comes from the rendered rows, never from an interpolated view
// model - that keeps script context free of server-supplied values.
var TEST_MODE = barEl.getAttribute('data-test-mode') === 'true';
var VISITOR_EMAIL = barEl.getAttribute('data-visitor-email') || '';
var ids = {};
var connected = {};
var failed = {};
document.querySelectorAll('.cred-row').forEach(function (row) {
var key = row.getAttribute('data-row-key');
ids[key] = true;
if (row.getAttribute('data-connected') === 'true') connected[key] = true;
});
var total = Object.keys(ids).length;
// Design: one account connects straight from the bar; two or more collapse
// behind the dialog. Decided server-side so the rule lives in one place.
var useDialog = barEl.getAttribute('data-use-dialog') === 'true';
function accountsLabel(count) { return count === 1 ? 'account' : 'accounts'; }
function connectedCount() {
var n = 0;
for (var k in connected) if (connected[k]) n++;
return n;
}
function hasFailed() {
for (var k in failed) if (failed[k]) return true;
return false;
}
function onlyRow() {
return useDialog ? null : document.querySelector('.cred-row');
}
// Mirrors `connectBarText`, which renders the same line server-side, plus
// the failure state - which only arises after a click, so it has no
// server-rendered counterpart.
function barText(count) {
var remaining = Math.max(total - count, 0);
if (remaining === 0) {
return TEST_MODE
? "You're testing with your own connected accounts. Visitors will need to connect their own."
: 'All ' + total + ' ' + accountsLabel(total) + ' connected · ready to chat';
}
if (hasFailed()) return 'Connection failed · try again';
if (count === 0) return total + ' ' + accountsLabel(total) + ' needed to start this chat';
return remaining + ' more ' + accountsLabel(remaining) + ' needed to start this chat';
}
// The frame only ever learns ready/count, never a name, icon or authorize
// URL - it uses this solely to disable its own input while accounts are
// outstanding. Must match `@n8n/chat/src/utils/credentialStatus.ts`.
function notifyFrame() {
if (!frame || !frame.contentWindow) return;
var count = connectedCount();
frame.contentWindow.postMessage(
{
type: 'n8n-chat:credential-status',
ready: total <= count,
missingCount: Math.max(total - count, 0),
testMode: TEST_MODE,
},
'*',
);
}
function refresh() {
var count = connectedCount();
if (countEl) countEl.textContent = count + ' of ' + total + ' ' + accountsLabel(total) + ' connected';
if (barTextEl) barTextEl.textContent = barText(count);
if (barLabelEl) barLabelEl.textContent = count >= total ? 'Manage' : 'Connect';
if (barEl) {
if (count >= total) barEl.classList.add('all-connected');
else barEl.classList.remove('all-connected');
}
notifyFrame();
}
// The widget mounts asynchronously, so its message listener may not be
// attached when the frame's load event fires. A short burst of retries
// covers that race; every later change notifies immediately.
frame.addEventListener('load', function () {
notifyFrame();
[250, 750, 1500, 3000].forEach(function (delay) { setTimeout(notifyFrame, delay); });
});
// A provider icon that 404s (renamed package, stale path) falls back to the
// letter tile the row also carries.
function fallBackToLetterTile(img) {
if (!img || !img.classList || !img.classList.contains('cred-icon-img')) return;
var tile = document.createElement('span');
tile.className = 'cred-icon';
tile.textContent = img.getAttribute('data-initial') || '?';
img.replaceWith(tile);
}
document.addEventListener('error', function (e) { fallBackToLetterTile(e.target); }, true);
document.querySelectorAll('.cred-icon-img').forEach(function (img) {
if (img.complete && img.naturalWidth === 0) fallBackToLetterTile(img);
});
function clearError(key) {
delete failed[key];
document.querySelectorAll('.cred-row[data-row-key="' + key + '"] .cred-sub').forEach(function (sub) {
sub.classList.remove('error');
});
}
var CONNECTED_MENU_HTML =
'<button type="button" class="btn-connected"><span class="dot"></span> Connected <span class="caret">&#9662;</span></button>' +
'<div class="cred-menu"><button type="button" class="btn-disconnect">Disconnect</button></div>';
function markConnected(key) {
if (!key || connected[key]) return;
connected[key] = true;
clearError(key);
document.querySelectorAll('.cred-row[data-row-key="' + key + '"]').forEach(function (row) {
row.setAttribute('data-connected', 'true');
var sub = row.querySelector('.cred-sub');
if (sub) sub.textContent = VISITOR_EMAIL ? 'Connected as ' + VISITOR_EMAIL : 'Connected';
var btn = row.querySelector('.connect');
if (btn) {
// Derive the revoke URL from the authorize link (same server-built
// base), so a just-connected row can disconnect without a reload.
var authUrl = btn.getAttribute('data-url');
var resolverId = row.getAttribute('data-resolver-id');
if (authUrl && resolverId && !row.getAttribute('data-revoke-url')) {
try {
var u = new URL(authUrl);
u.pathname = u.pathname.replace(/\/authorize$/, '/revoke');
u.search = '?resolverId=' + encodeURIComponent(resolverId);
row.setAttribute('data-revoke-url', u.toString());
} catch (e) {}
}
var wrap = document.createElement('span');
wrap.className = 'cred-connected';
wrap.innerHTML = CONNECTED_MENU_HTML;
btn.replaceWith(wrap);
}
});
refresh();
}
function markDisconnected(key) {
if (!key) return;
delete connected[key];
clearError(key);
document.querySelectorAll('.cred-row[data-row-key="' + key + '"]').forEach(function (row) {
row.removeAttribute('data-connected');
var sub = row.querySelector('.cred-sub');
if (sub) sub.textContent = row.getAttribute('data-not-connected-text') || 'Not connected';
var wrap = row.querySelector('.cred-connected');
if (wrap) {
var btn = document.createElement('button');
btn.className = 'btn btn-secondary connect';
// The one-time authorize link was consumed by the previous connect,
// so this Connect mints a fresh one instead of reloading.
var revokeUrl = row.getAttribute('data-revoke-url');
if (revokeUrl) btn.setAttribute('data-authorize-url', revokeUrl.replace('/revoke?', '/authorize?'));
btn.textContent = 'Connect';
wrap.replaceWith(btn);
}
});
refresh();
}
// A visible error, never a silent revert to "Connect": the row and the bar
// both say plainly that the last attempt didn't work.
function markFailed(key) {
if (!key || connected[key]) return;
failed[key] = true;
document.querySelectorAll('.cred-row[data-row-key="' + key + '"] .cred-sub').forEach(function (sub) {
sub.textContent = 'Connection failed · try again';
sub.classList.add('error');
});
refresh();
}
var pendingId = null;
var pendingPopup = null;
var pendingSettled = true;
var pendingPollHandle = null;
function settlePending() {
pendingSettled = true;
if (pendingPollHandle) { clearInterval(pendingPollHandle); pendingPollHandle = null; }
}
function onSuccessSignal() {
if (!pendingId || pendingSettled) return;
var key = pendingId;
settlePending();
markConnected(key);
}
function onErrorSignal() {
if (!pendingId || pendingSettled) return;
var key = pendingId;
settlePending();
markFailed(key);
}
// The popup can close without either broadcast firing - the visitor closes
// the tab, or a blocker eats the redirect. Treated as cancelled, so the row
// never sits in limbo looking connectable.
function watchPopup(popup) {
pendingPopup = popup;
pendingSettled = false;
if (pendingPollHandle) clearInterval(pendingPollHandle);
pendingPollHandle = setInterval(function () {
if (!pendingPopup || pendingPopup.closed) onErrorSignal();
}, 500);
}
try {
var channel = new BroadcastChannel('oauth-callback');
channel.addEventListener('message', function (event) {
if (event.data === 'success') onSuccessSignal();
else if (event.data === 'error') onErrorSignal();
});
} catch (e) {}
// The AS callback page and this shell can live on different origins
// (BroadcastChannel is same-origin only), so also accept the fallback
// message it posts to its opener. Only the popup this page opened is
// trusted: excluding the frame is not enough, since a sandboxed frame can
// nest another frame whose `source` is neither the frame nor the popup.
window.addEventListener('message', function (event) {
if (!pendingPopup || event.source !== pendingPopup) return;
if (event.data === 'success') onSuccessSignal();
else if (event.data === 'error') onErrorSignal();
});
// Centred on the window the visitor is looking at, not wherever the browser
// would drop it - the provider's consent screen has to read as this page's
// own step. Clamped so it still fits a smaller window.
function popupFeatures() {
var width = Math.min(520, window.outerWidth || 520);
var height = Math.min(780, window.outerHeight || 780);
var left = Math.round((window.screenX || 0) + Math.max(((window.outerWidth || width) - width) / 2, 0));
var top = Math.round((window.screenY || 0) + Math.max(((window.outerHeight || height) - height) / 2, 0));
return 'width=' + width + ',height=' + height + ',left=' + left + ',top=' + top + ',menubar=no,toolbar=no';
}
function openConnect(key, url) {
// Both links are built server-side from the instance base URL, so this is
// belt and braces - but nothing else stands between a data attribute and
// window.open.
if (!url || !/^https?:\/\//i.test(url)) return;
pendingId = key;
clearError(key);
refresh();
watchPopup(window.open(url, 'n8n-credential-connect', popupFeatures()));
}
// Session-cookie auth plus the browser-id the session was issued with
// (stored on this same origin by the editor/consent pages).
function authHeaders() {
try {
var browserId = localStorage.getItem('n8n-browserId');
if (browserId) return { 'browser-id': browserId };
} catch (e) {}
return {};
}
function disconnectById(key) {
var row = document.querySelector('.cred-row[data-row-key="' + key + '"]');
var revokeUrl = row ? row.getAttribute('data-revoke-url') : null;
if (!key || !revokeUrl) return;
fetch(revokeUrl + '&authSource=cookie', {
method: 'DELETE',
credentials: 'include',
headers: authHeaders(),
})
.then(function (r) { if (r.ok) markDisconnected(key); })
.catch(function () {});
}
// Reconnect after a disconnect: mint a fresh authorize link and send the
// popup there. The popup opens synchronously so it isn't a pop-under.
function reconnect(key, mintUrl) {
if (!key || !mintUrl) return;
var popup = window.open('', 'n8n-credential-connect', popupFeatures());
pendingId = key;
clearError(key);
refresh();
watchPopup(popup);
fetch(mintUrl + '&authSource=cookie', {
method: 'POST',
credentials: 'include',
headers: authHeaders(),
})
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (body) {
var url = body && (body.data || body);
if (typeof url === 'string' && popup) {
popup.location = url;
} else {
if (popup) popup.close();
onErrorSignal();
}
})
.catch(function () { if (popup) popup.close(); onErrorSignal(); });
}
// Delegated, so controls created by markConnected()/markDisconnected() work
// too. Reachable only from the dialog's own rows.
document.addEventListener('click', function (e) {
var connectBtn = e.target.closest('.connect');
if (connectBtn) {
var row = connectBtn.closest('.cred-row');
var key = row ? row.getAttribute('data-row-key') : null;
var mintUrl = connectBtn.getAttribute('data-authorize-url');
if (mintUrl) reconnect(key, mintUrl);
else openConnect(key, connectBtn.getAttribute('data-url'));
return;
}
var menuBtn = e.target.closest('.btn-connected');
if (menuBtn) {
e.stopPropagation();
var menu = menuBtn.parentElement.querySelector('.cred-menu');
var wasOpen = menu && menu.classList.contains('open');
document.querySelectorAll('.cred-menu.open').forEach(function (m) { m.classList.remove('open'); });
if (menu && !wasOpen) {
menu.classList.add('open');
// Anchor to the button; flip above when out of room below.
var r = menuBtn.getBoundingClientRect();
var flipUp = window.innerHeight - r.bottom < menu.offsetHeight + 12;
menu.style.top = (flipUp ? r.top - menu.offsetHeight - 4 : r.bottom + 4) + 'px';
menu.style.left = (r.right - menu.offsetWidth) + 'px';
}
return;
}
var disconnectBtn = e.target.closest('.btn-disconnect');
if (disconnectBtn) {
e.stopPropagation();
var disconnectRow = disconnectBtn.closest('.cred-row');
if (disconnectRow) disconnectById(disconnectRow.getAttribute('data-row-key'));
return;
}
document.querySelectorAll('.cred-menu.open').forEach(function (m) { m.classList.remove('open'); });
});
// No dialog is rendered in test mode, so every use of it tolerates null.
var closeBtn = document.getElementById('n8n-connect-close');
var doneBtn = document.getElementById('n8n-connect-done');
if (closeBtn) closeBtn.addEventListener('click', function () { overlay.classList.remove('open'); });
if (doneBtn) doneBtn.addEventListener('click', function () { overlay.classList.remove('open'); });
if (overlay) {
overlay.addEventListener('click', function (e) { if (e.target === overlay) overlay.classList.remove('open'); });
}
// The bar's own control, so the popup is opened by a real click on this
// document: one opened from a message posted up by the sandboxed frame is
// downgraded to a tab by some browsers.
var barAction = document.getElementById('n8n-connect-bar-action');
if (barAction) {
barAction.addEventListener('click', function () {
// The shortcut needs a live authorize link, which an account that
// arrived already connected never got. A previously failed attempt is
// fine to repeat: the intent behind the link is read without being
// consumed, so it stays valid for a retry within its TTL.
var row = onlyRow();
var key = row ? row.getAttribute('data-row-key') : null;
var btn = row ? row.querySelector('.connect') : null;
var url = btn ? btn.getAttribute('data-url') : null;
if (key && !connected[key] && url) {
openConnect(key, url);
return;
}
// Everything else, including a fully connected single account, lands
// in the dialog - that is what holds Disconnect.
if (overlay) overlay.classList.add('open');
});
}
// Tap toggle for the tooltip, for browsers that don't focus a tapped button.
var barInfo = document.getElementById('n8n-connect-bar-info');
var barIcon = barInfo && barInfo.querySelector('.bar-icon');
if (barIcon) {
barIcon.addEventListener('click', function (e) {
e.stopPropagation();
barInfo.classList.toggle('open');
});
// Without this a tapped tooltip stays over the chat until the icon is
// tapped again - the platforms `.open` exists for are exactly the ones
// with no hover to end.
document.addEventListener('click', function (e) {
if (!barInfo.contains(e.target)) barInfo.classList.remove('open');
});
}
notifyFrame();
})();
</script>
{{/if}}
</body>
</html>

View File

@ -28,7 +28,7 @@ vi.mock('../components/Input.vue', () => ({
vi.mock('../components/Layout.vue', () => ({
default: {
name: 'Layout',
template: '<div><slot /><slot name="statusStrip" /><slot name="footer" /></div>',
template: '<div><slot /><slot name="footer" /></div>',
},
}));
@ -300,33 +300,4 @@ describe('Chat', () => {
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('focusInput');
});
});
describe('credential status strip', () => {
it('does not render the strip when no host has ever signaled credential status', () => {
wrapper = mount(Chat);
expect(wrapper.find('[data-test-id="chat-credential-status-strip"]').exists()).toBe(false);
});
it('renders the strip while required accounts are still missing', () => {
mockChatStore.credentialStatus.value = { ready: false, missingCount: 2, testMode: false };
wrapper = mount(Chat);
expect(wrapper.find('[data-test-id="chat-credential-status-strip"]').exists()).toBe(true);
});
it('hides the strip once the host reports readiness outside test mode', () => {
mockChatStore.credentialStatus.value = { ready: true, missingCount: 0, testMode: false };
wrapper = mount(Chat);
expect(wrapper.find('[data-test-id="chat-credential-status-strip"]').exists()).toBe(false);
});
it('keeps the strip visible in test mode even when credentials are ready', () => {
mockChatStore.credentialStatus.value = { ready: true, missingCount: 0, testMode: true };
wrapper = mount(Chat);
expect(wrapper.find('[data-test-id="chat-credential-status-strip"]').exists()).toBe(true);
});
});
});

View File

@ -1,40 +0,0 @@
import { mount } from '@vue/test-utils';
import CredentialStatusStrip from '../components/CredentialStatusStrip.vue';
import type { CredentialStatus } from '../types';
const i18n: Record<string, string> = {
credentialStatusMissingAccount: 'Connect 1 account to start chatting',
credentialStatusMissingAccounts: 'Connect {count} accounts to start chatting',
credentialStatusTestMode: "You're testing with your own connected accounts.",
};
vi.mock('@n8n/chat/composables', () => ({
useI18n: () => ({
t: (key: string) => i18n[key] ?? key,
}),
}));
function mountStrip(status: CredentialStatus) {
return mount(CredentialStatusStrip, { props: { status } });
}
describe('CredentialStatusStrip', () => {
it('renders the singular message for exactly one missing account', () => {
const wrapper = mountStrip({ ready: false, missingCount: 1, testMode: false });
expect(wrapper.text()).toBe('Connect 1 account to start chatting');
});
it('renders the plural message with the interpolated count for multiple missing accounts', () => {
const wrapper = mountStrip({ ready: false, missingCount: 3, testMode: false });
expect(wrapper.text()).toBe('Connect 3 accounts to start chatting');
});
it('renders the test-mode message regardless of the missing count', () => {
const wrapper = mountStrip({ ready: true, missingCount: 0, testMode: true });
expect(wrapper.text()).toBe("You're testing with your own connected accounts.");
});
});

View File

@ -2,7 +2,6 @@
import Close from 'virtual:icons/mdi/close';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import CredentialStatusStrip from '@n8n/chat/components/CredentialStatusStrip.vue';
import GetStarted from '@n8n/chat/components/GetStarted.vue';
import GetStartedFooter from '@n8n/chat/components/GetStartedFooter.vue';
import Input from '@n8n/chat/components/Input.vue';
@ -11,7 +10,6 @@ import Layout from '@n8n/chat/components/Layout.vue';
import MessagesList from '@n8n/chat/components/MessagesList.vue';
import { useI18n, useChat, useOptions } from '@n8n/chat/composables';
import { chatEventBus } from '@n8n/chat/event-buses';
import type { CredentialStatus } from '@n8n/chat/types';
import { listenForCredentialStatus } from '@n8n/chat/utils/credentialStatus';
const { t } = useI18n();
@ -22,12 +20,6 @@ const { options } = useOptions();
const showCloseButton = computed(() => options.mode === 'window' && options.showWindowCloseButton);
const activeCredentialStatus = computed<CredentialStatus | null>(() => {
const status = credentialStatus.value;
if (!status) return null;
return status.testMode || !status.ready ? status : null;
});
// Message history navigation
const messageHistoryIndex = ref(-1);
const currentInputBuffer = ref('');
@ -114,8 +106,6 @@ let clearOnMessageSent: () => void;
let stopListeningForCredentialStatus: () => void;
onMounted(async () => {
// Register before the initialize() await so a credential-status message
// arriving while loadPreviousSession() is pending isn't dropped.
stopListeningForCredentialStatus = listenForCredentialStatus((status) => {
credentialStatus.value = status;
});
@ -165,9 +155,6 @@ onUnmounted(() => {
</template>
<GetStarted v-if="!currentSessionId && options.showWelcomeScreen" @click:button="getStarted" />
<MessagesList v-else :messages="messages" />
<template v-if="activeCredentialStatus" #statusStrip>
<CredentialStatusStrip :status="activeCredentialStatus" />
</template>
<template #footer>
<Input v-if="currentSessionId" @arrow-key-down="onArrowKeyDown" />
<GetStartedFooter v-else />

View File

@ -1,44 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from '@n8n/chat/composables';
import type { CredentialStatus } from '@n8n/chat/types';
const props = defineProps<{
status: CredentialStatus;
}>();
const { t } = useI18n();
const message = computed(() => {
if (props.status.testMode) {
return t('credentialStatusTestMode');
}
const template =
props.status.missingCount === 1
? t('credentialStatusMissingAccount')
: t('credentialStatusMissingAccounts');
return template.replace('{count}', String(props.status.missingCount));
});
</script>
<template>
<div class="chat-credential-status" data-test-id="chat-credential-status-strip">
{{ message }}
</div>
</template>
<style lang="scss">
.chat-credential-status {
padding: var(--chat--credential-status--padding, 0.75em 1em);
background: var(--chat--credential-status--background, var(--chat--color-light-shade-50));
color: var(--chat--credential-status--color, var(--chat--color-dark));
font-size: var(--chat--credential-status--font-size, 0.85em);
border-top: var(
--chat--credential-status--border-top,
1px solid var(--chat--color-light-shade-100)
);
}
</style>

View File

@ -30,9 +30,6 @@ onBeforeUnmount(() => {
<div v-if="$slots.default" ref="chatBodyRef" class="chat-body">
<slot />
</div>
<div v-if="$slots.statusStrip" class="chat-status-strip">
<slot name="statusStrip" />
</div>
<div v-if="$slots.footer" class="chat-footer">
<slot name="footer" />
</div>

View File

@ -26,10 +26,6 @@ export const defaultOptions: ChatOptions = {
closeButtonTooltip: 'Close chat',
repostButton: 'Repost message',
reuseButton: 'Reuse message',
credentialStatusMissingAccount: 'Connect 1 account to start chatting',
credentialStatusMissingAccounts: 'Connect {count} accounts to start chatting',
credentialStatusTestMode:
"You're testing with your own connected accounts. Visitors will need to connect their own.",
},
},
theme: {},

View File

@ -137,13 +137,6 @@
--chat--footer--background: var(--chat--color-light);
--chat--footer--color: var(--chat--color-dark);
--chat--footer--border-top: 1px solid var(--chat--color-light-shade-100);
/* Credential Status Strip */
--chat--credential-status--padding: 0.75em 1em;
--chat--credential-status--background: var(--chat--color-light-shade-50);
--chat--credential-status--color: var(--chat--color-dark);
--chat--credential-status--font-size: 0.85em;
--chat--credential-status--border-top: 1px solid var(--chat--color-light-shade-100);
}
.n8n-chat {