From 4608fd236ccfbaeba2bb09708f2c5916f0f2e910 Mon Sep 17 00:00:00 2001 From: MHMD <45968454+dou-mhmd@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:37:31 +0000 Subject: [PATCH] feat(Chat Trigger Node): Add end-user credential connect bar to the hosted chat shell (no-changelog) (#37550) --- .../trigger/ChatTrigger/ChatTrigger.node.ts | 54 +- .../trigger/ChatTrigger/GenericFunctions.ts | 42 +- .../__test__/ChatTrigger.node.test.ts | 180 +++- .../__test__/GenericFunctions.test.ts | 16 +- .../__test__/connect-panel.test.ts | 140 ++++ .../ChatTrigger/__test__/templates.test.ts | 206 +---- .../trigger/ChatTrigger/connect-panel.ts | 64 ++ .../nodes/trigger/ChatTrigger/templates.ts | 246 +----- .../nodes/trigger/ChatTrigger/types.ts | 30 + .../src/__tests__/chat-shell.template.test.ts | 327 ++++++++ packages/cli/templates/chat-shell.handlebars | 772 ++++++++++++++++++ .../@n8n/chat/src/__tests__/Chat.spec.ts | 31 +- .../__tests__/CredentialStatusStrip.spec.ts | 40 - .../@n8n/chat/src/components/Chat.vue | 13 - .../src/components/CredentialStatusStrip.vue | 44 - .../@n8n/chat/src/components/Layout.vue | 3 - .../@n8n/chat/src/constants/defaults.ts | 4 - .../frontend/@n8n/chat/src/css/_tokens.scss | 7 - 18 files changed, 1605 insertions(+), 614 deletions(-) create mode 100644 packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/connect-panel.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/connect-panel.ts create mode 100644 packages/cli/src/__tests__/chat-shell.template.test.ts create mode 100644 packages/cli/templates/chat-shell.handlebars delete mode 100644 packages/frontend/@n8n/chat/src/__tests__/CredentialStatusStrip.spec.ts delete mode 100644 packages/frontend/@n8n/chat/src/components/CredentialStatusStrip.vue diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts index 47e76ee618b..7ccafe6f825 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts @@ -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 }; } diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/GenericFunctions.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/GenericFunctions.ts index 3b2aff0b813..894debe3493 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/GenericFunctions.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/GenericFunctions.ts @@ -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 { +): 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. } } diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/ChatTrigger.node.test.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/ChatTrigger.node.test.ts index 3508a1f5899..b39c329bef5 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/ChatTrigger.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/ChatTrigger.node.test.ts @@ -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; + 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(); + }); }); }); }); diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/GenericFunctions.test.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/GenericFunctions.test.ts index fc42f67057a..c5a8e26d658 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/GenericFunctions.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/GenericFunctions.test.ts @@ -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( diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/connect-panel.test.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/connect-panel.test.ts new file mode 100644 index 00000000000..30a1fdaf71a --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/connect-panel.test.ts @@ -0,0 +1,140 @@ +import type { CredentialCheckStatus } from 'n8n-workflow'; + +import { buildChatShellViewModel, connectBarText } from '../connect-panel'; + +const missingCred = (overrides: Partial = {}): 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 => ({ + 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', + ); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/templates.test.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/templates.test.ts index e0ebe6173f5..7faf89fceed 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/templates.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/templates.test.ts @@ -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=">\n\t'); - }); -}); - -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 diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/connect-panel.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/connect-panel.ts new file mode 100644 index 00000000000..73f792473b3 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/connect-panel.ts @@ -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`; +} diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts index bb7d1456feb..7e9559df4a3 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts @@ -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 ` 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 = { + '<': '\\u003c', + '>': '\\u003e', + '&': '\\u0026', + '\u2028': '\\u2028', + '\u2029': '\\u2029', +}; + +// Returns a JSON literal safe to embed inside an inline 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 = { - '&': '&', - '"': '"', - "'": ''', - '<': '<', - '>': '>', -}; - -// 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): Record { const sanitized: Record = {}; @@ -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 = ` })(); `; -/** - * 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 ` - - - - - Chat - - - - ${refresh ? refreshScript(refresh) : ''} - - -`; -} - -/** - * 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 ` - `; -} - export function createPage({ instanceId, webhookUrl, diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/types.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/types.ts index d1442bd7a92..b38f75aa207 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/types.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/types.ts @@ -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); } diff --git a/packages/cli/src/__tests__/chat-shell.template.test.ts b/packages/cli/src/__tests__/chat-shell.template.test.ts new file mode 100644 index 00000000000..0dfb248b019 --- /dev/null +++ b/packages/cli/src/__tests__/chat-shell.template.test.ts @@ -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) => { + 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=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(/]*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=">' }, '' }] }, + '' }, + ], + }, + '' }, '' }, '' }, + '')); + }; + + 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=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(' + {{/if}} + + {{#if hasCredentials}} +
+ + + + Chatting runs actions on your behalf, using accounts you connect. You can disconnect them at any time. Learn more + + + {{barText}} + +
+ + {{!-- 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. + --}} +
+ +
+ {{/if}} + + + + {{#if hasCredentials}} + + {{/if}} + + diff --git a/packages/frontend/@n8n/chat/src/__tests__/Chat.spec.ts b/packages/frontend/@n8n/chat/src/__tests__/Chat.spec.ts index 2c3f10b7bec..9ddceab7957 100644 --- a/packages/frontend/@n8n/chat/src/__tests__/Chat.spec.ts +++ b/packages/frontend/@n8n/chat/src/__tests__/Chat.spec.ts @@ -28,7 +28,7 @@ vi.mock('../components/Input.vue', () => ({ vi.mock('../components/Layout.vue', () => ({ default: { name: 'Layout', - template: '
', + template: '
', }, })); @@ -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); - }); - }); }); diff --git a/packages/frontend/@n8n/chat/src/__tests__/CredentialStatusStrip.spec.ts b/packages/frontend/@n8n/chat/src/__tests__/CredentialStatusStrip.spec.ts deleted file mode 100644 index b6e0a8b91c3..00000000000 --- a/packages/frontend/@n8n/chat/src/__tests__/CredentialStatusStrip.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { mount } from '@vue/test-utils'; - -import CredentialStatusStrip from '../components/CredentialStatusStrip.vue'; -import type { CredentialStatus } from '../types'; - -const i18n: Record = { - 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."); - }); -}); diff --git a/packages/frontend/@n8n/chat/src/components/Chat.vue b/packages/frontend/@n8n/chat/src/components/Chat.vue index 95d98a5c7c2..efa0a72cbb7 100644 --- a/packages/frontend/@n8n/chat/src/components/Chat.vue +++ b/packages/frontend/@n8n/chat/src/components/Chat.vue @@ -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(() => { - 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(() => { -