mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat(Chat Trigger Node): Add end-user credential connect bar to the hosted chat shell (no-changelog) (#37550)
This commit is contained in:
parent
91dff038f9
commit
4608fd236c
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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="><script>&'',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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('"><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
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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> = {
|
||||
'&': '&',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
};
|
||||
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
327
packages/cli/src/__tests__/chat-shell.template.test.ts
Normal file
327
packages/cli/src/__tests__/chat-shell.template.test.ts
Normal 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=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('">');
|
||||
});
|
||||
});
|
||||
|
||||
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=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()');
|
||||
});
|
||||
});
|
||||
});
|
||||
772
packages/cli/templates/chat-shell.handlebars
Normal file
772
packages/cli/templates/chat-shell.handlebars
Normal 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'>▾</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'>×</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">▾</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>
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user