From bf28af68e9f24fbda9c05b0a1f4c75b64a340276 Mon Sep 17 00:00:00 2001 From: Yuliia Pominchuk <31064937+yuliia-pominchuk@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:42:08 +0000 Subject: [PATCH] feat(core): Redesign the account connect panel on hosted forms (#36344) --- .../credential-check-proxy.service.test.ts | 120 ++++++++++ .../credential-check-proxy.service.ts | 67 ++++++ packages/cli/templates/form-shell.handlebars | 221 +++++++++++------- .../cli/templates/form-trigger.handlebars | 19 +- .../Form/test/formShellViewModel.test.ts | 170 ++++++++++++++ packages/nodes-base/nodes/Form/utils/utils.ts | 84 +++++-- .../form-trigger-submit-gate-client.spec.ts | 2 +- packages/workflow/src/interfaces.ts | 6 + 8 files changed, 584 insertions(+), 105 deletions(-) create mode 100644 packages/nodes-base/nodes/Form/test/formShellViewModel.test.ts diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/credential-check-proxy.service.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/credential-check-proxy.service.test.ts index 46d5435536f..9f6e72ba034 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/credential-check-proxy.service.test.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/credential-check-proxy.service.test.ts @@ -2,11 +2,16 @@ import type { Mocked } from 'vitest'; import type { GlobalConfig } from '@n8n/config'; import type { ICredentialContext, + ICredentialType, IExecutionContext, + INodeType, PlaintextExecutionContext, + Themed, } from 'n8n-workflow'; +import type { CredentialTypes } from '@/credential-types'; import type { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee'; +import type { NodeTypes } from '@/node-types'; import type { UrlService } from '@/services/url.service'; import type { ExecutionContextService } from 'n8n-core'; import { CredentialsEntity } from '@n8n/db'; @@ -44,6 +49,8 @@ describe('CredentialCheckProxyService', () => { let mockAuthorizeIntentService: Mocked; let mockDynamicCredentialService: Mocked; let mockUrlService: Mocked; + let mockCredentialTypes: Mocked; + let mockNodeTypes: Mocked; const executionContext: IExecutionContext = { version: 1, @@ -90,6 +97,19 @@ describe('CredentialCheckProxyService', () => { getInstanceBaseUrl: vi.fn().mockReturnValue('http://localhost:5678'), } as unknown as Mocked; + // Unknown types throw in the real registry, which is the no-icon path. + mockCredentialTypes = { + getByName: vi.fn().mockImplementation(() => { + throw new Error('Unrecognized credential type'); + }), + } as unknown as Mocked; + + mockNodeTypes = { + getByName: vi.fn().mockImplementation(() => { + throw new Error('Unrecognized node type'); + }), + } as unknown as Mocked; + const globalConfig = { endpoints: { rest: 'rest' } } as unknown as GlobalConfig; service = new CredentialCheckProxyService( @@ -100,6 +120,8 @@ describe('CredentialCheckProxyService', () => { mockDynamicCredentialService, mockUrlService, globalConfig, + mockCredentialTypes, + mockNodeTypes, ); }); @@ -374,4 +396,102 @@ describe('CredentialCheckProxyService', () => { expect(result.credentials).toHaveLength(0); }); }); + + describe('iconUrl', () => { + const credentialType = (overrides: Partial): ICredentialType => ({ + name: 'someApi', + displayName: 'Some API', + properties: [], + ...overrides, + }); + + // Only the description's icon is read, so that's all the stand-in carries. + const nodeType = (iconUrl: Themed) => ({ description: { iconUrl } }) as INodeType; + + const iconUrlFor = async (type: string) => { + mockCredentialResolverWorkflowService.getWorkflowStatus.mockResolvedValue([ + { + credentialId: 'cred-1', + credentialName: 'Google Sheets account', + credentialType: type, + resolverId: 'resolver-1', + status: 'configured', + }, + ]); + const result = await service.checkCredentialStatus('workflow-1', executionContext); + return result.credentials[0].iconUrl; + }; + + it("should use the credential type's own iconUrl, made absolute", async () => { + mockCredentialTypes.getByName.mockReturnValue( + credentialType({ iconUrl: 'icons/n8n-nodes-base/dist/nodes/Slack/slack.svg' }), + ); + + await expect(iconUrlFor('slackOAuth2Api')).resolves.toBe( + 'http://localhost:5678/icons/n8n-nodes-base/dist/nodes/Slack/slack.svg', + ); + }); + + it('should use the light variant of a themed iconUrl', async () => { + mockCredentialTypes.getByName.mockReturnValue( + credentialType({ iconUrl: { light: 'icons/pkg/light.svg', dark: 'icons/pkg/dark.svg' } }), + ); + + await expect(iconUrlFor('themedApi')).resolves.toBe( + 'http://localhost:5678/icons/pkg/light.svg', + ); + }); + + it("should resolve a node: icon reference to that node type's icon", async () => { + mockCredentialTypes.getByName.mockReturnValue( + credentialType({ icon: 'node:n8n-nodes-base.googleSheets' }), + ); + mockNodeTypes.getByName.mockReturnValue( + nodeType('icons/n8n-nodes-base/dist/nodes/Google/Sheet/googleSheets.svg'), + ); + + await expect(iconUrlFor('googleSheetsOAuth2Api')).resolves.toBe( + 'http://localhost:5678/icons/n8n-nodes-base/dist/nodes/Google/Sheet/googleSheets.svg', + ); + expect(mockNodeTypes.getByName).toHaveBeenCalledWith('n8n-nodes-base.googleSheets'); + }); + + it('should fall back to the extends chain when the type has no icon of its own', async () => { + mockCredentialTypes.getByName.mockImplementation((name) => + name === 'childApi' + ? credentialType({ name, extends: ['parentApi'] }) + : credentialType({ name, iconUrl: 'icons/pkg/parent.svg' }), + ); + + await expect(iconUrlFor('childApi')).resolves.toBe( + 'http://localhost:5678/icons/pkg/parent.svg', + ); + }); + + it('should not loop on a circular extends chain', async () => { + mockCredentialTypes.getByName.mockImplementation((name) => + credentialType({ name, extends: [name === 'aApi' ? 'bApi' : 'aApi'] }), + ); + + await expect(iconUrlFor('aApi')).resolves.toBeUndefined(); + }); + + it('should leave iconUrl undefined when nothing resolves', async () => { + await expect(iconUrlFor('unknownApi')).resolves.toBeUndefined(); + }); + + it('should leave an already-absolute iconUrl untouched', async () => { + mockCredentialTypes.getByName.mockReturnValue( + credentialType({ iconUrl: 'https://cdn.example.com/icon.svg' }), + ); + + await expect(iconUrlFor('remoteApi')).resolves.toBe('https://cdn.example.com/icon.svg'); + }); + + it('should ignore fa: icons, which the shell cannot render', async () => { + mockCredentialTypes.getByName.mockReturnValue(credentialType({ icon: 'fa:key' })); + + await expect(iconUrlFor('faApi')).resolves.toBeUndefined(); + }); + }); }); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/credential-check-proxy.service.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/credential-check-proxy.service.ts index c6915332f50..e92865202ff 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/services/credential-check-proxy.service.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/credential-check-proxy.service.ts @@ -5,9 +5,13 @@ import type { CredentialCheckStatus, DynamicCredentialCheckProxyProvider, ICredentialContext, + ICredentialType, + Themed, } from 'n8n-workflow'; +import { CredentialTypes } from '@/credential-types'; import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee'; +import { NodeTypes } from '@/node-types'; import { UrlService } from '@/services/url.service'; import { ExecutionContextService } from 'n8n-core'; @@ -15,6 +19,10 @@ import { AuthorizeIntentService } from './authorize-intent.service'; import { CredentialResolverWorkflowService } from './credential-resolver-workflow.service'; import { DynamicCredentialService } from './dynamic-credential.service'; +/** The shell is light-theme only, so themed icons collapse to their light variant. */ +const lightVariant = (value: Themed | undefined): string | undefined => + typeof value === 'string' ? value : value?.light; + @Service() export class CredentialCheckProxyService implements DynamicCredentialCheckProxyProvider { constructor( @@ -25,6 +33,8 @@ export class CredentialCheckProxyService implements DynamicCredentialCheckProxyP private readonly dynamicCredentialService: DynamicCredentialService, private readonly urlService: UrlService, private readonly globalConfig: GlobalConfig, + private readonly credentialTypes: CredentialTypes, + private readonly nodeTypes: NodeTypes, ) {} async checkCredentialStatus( @@ -61,6 +71,7 @@ export class CredentialCheckProxyService implements DynamicCredentialCheckProxyP credentialType: status.credentialType, resolverId: status.resolverId, status: status.status, + iconUrl: this.resolveCredentialIconUrl(status.credentialType), }; if (status.status === 'missing' && status.resolverId) { @@ -91,6 +102,62 @@ export class CredentialCheckProxyService implements DynamicCredentialCheckProxyP * fast and small. The caller identity is captured in a server-side intent so the * connection binds to the right subject regardless of who opens the link. */ + /** + * Absolute URL of the credential type's provider icon, so consumers that render + * outside the editor — the form hosting shell, rendered from nodes-base — can + * show it without reaching into the credential registry. Mirrors the editor's + * `CredentialIcon.vue`: the type's own `iconUrl` first, then an + * `icon: 'node:'` reference resolved to that node's icon, then the + * `extends` chain. Types with neither already inherit an icon from a supported + * node at load time. + */ + private resolveCredentialIconUrl( + credentialType: string, + seen = new Set(), + ): string | undefined { + if (!credentialType || seen.has(credentialType)) return undefined; + seen.add(credentialType); + + let type: ICredentialType; + try { + type = this.credentialTypes.getByName(credentialType); + } catch { + return undefined; + } + + const ownIconUrl = lightVariant(type.iconUrl); + if (ownIconUrl) return this.toAbsoluteIconUrl(ownIconUrl); + + const icon = lightVariant(type.icon); + if (icon?.startsWith('node:')) { + const nodeIconUrl = this.resolveNodeIconUrl(icon.slice('node:'.length)); + if (nodeIconUrl) return this.toAbsoluteIconUrl(nodeIconUrl); + } + + for (const parentType of type.extends ?? []) { + const inherited = this.resolveCredentialIconUrl(parentType, seen); + if (inherited) return inherited; + } + + return undefined; + } + + private resolveNodeIconUrl(nodeTypeName: string): string | undefined { + try { + const { description } = this.nodeTypes.getByName(nodeTypeName); + return lightVariant(description.iconUrl); + } catch { + return undefined; + } + } + + /** Loader-generated icon paths are instance-relative (`icons//…`), and the + * shell renders on a webhook path where that wouldn't resolve. */ + private toAbsoluteIconUrl(iconUrl: string): string { + if (/^https?:\/\//i.test(iconUrl)) return iconUrl; + return `${this.urlService.getInstanceBaseUrl()}/${iconUrl.replace(/^\/+/, '')}`; + } + /** Deletes the caller's own connection; mirrors `workflow-status.controller.ts`. */ private generateRevokeUrl(credentialId: string, resolverId: string): string { const basePath = this.urlService.getInstanceBaseUrl(); diff --git a/packages/cli/templates/form-shell.handlebars b/packages/cli/templates/form-shell.handlebars index a835f4e7676..2e817e1dea8 100644 --- a/packages/cli/templates/form-shell.handlebars +++ b/packages/cli/templates/form-shell.handlebars @@ -29,136 +29,158 @@ --color-icon-text: #6a5bdd; --border-radius-card: 8px; --border-radius-input: 6px; - --container-width: 480px; + /* Matches the form card's own --container-width so the panel and the card + line up edge to edge; the narrow-viewport rule below mirrors the form's. */ + --container-width: 448px; --box-shadow-card: 0px 4px 16px 0px var(--color-card-shadow); --box-shadow-dialog: 0px 12px 48px rgba(0, 0, 0, 0.18); } *, ::before, ::after { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: var(--font-family); background: var(--color-background); min-height: 100vh; } .shell { width: 100%; padding-top: 24px; } - /* Aligned to the form card's width, which centers itself in the iframe below. */ - .req-card, .req-strip { width: var(--container-width); max-width: calc(100vw - 32px); margin: 0 auto 16px; } + .req-card, .req-summary { width: var(--container-width); margin: 0 auto 16px; } - /* --- Required-credentials card (1–2 credentials, inline) --- */ + /* --- Single-account panel: the credential row connects directly --- */ .req-card { background: var(--color-card-bg); border: 1px solid var(--color-card-border); border-radius: var(--border-radius-card); box-shadow: var(--box-shadow-card); - padding: 20px; + padding: 8px 20px; } - .req-title { font-size: 15px; font-weight: 600; color: var(--color-header); } - .req-sub { font-size: 13px; color: var(--color-muted); margin-top: 4px; margin-bottom: 14px; } - /* --- Compact strip (3+ credentials) --- */ - .req-strip { + /* --- Summary line (two or more accounts, details behind the dialog) --- */ + .req-summary { display: flex; align-items: center; - gap: 14px; + gap: 12px; background: var(--color-card-bg); border: 1px solid var(--color-card-border); border-radius: var(--border-radius-card); box-shadow: var(--box-shadow-card); - padding: 16px 18px; + padding: 14px 18px; } - .stack { display: flex; align-items: center; } - .stack .cred-icon { margin-right: -8px; border: 2px solid var(--color-card-bg); } - .stack .more { - width: 28px; height: 28px; border-radius: 6px; border: 2px solid var(--color-card-bg); - background: #eceafd; color: var(--color-icon-text); - font-size: 11px; font-weight: 600; display: flex; align-items: center; justify-content: center; - } - .strip-meta { flex: 1; min-width: 0; } - .strip-title { font-size: 14px; font-weight: 600; color: var(--color-header); } - .strip-count { font-size: 13px; color: var(--color-muted); margin-top: 2px; } + .summary-icon { display: flex; flex-shrink: 0; color: var(--color-muted); } + .summary-icon svg { display: block; } + .summary-icon .icon-ok, .req-summary.all-connected .summary-icon .icon-pending { display: none; } + .req-summary.all-connected .summary-icon { color: var(--color-ok); } + .req-summary.all-connected .summary-icon .icon-ok { display: block; } + .summary-text { flex: 1; min-width: 0; font-size: 13px; color: var(--color-header); } - /* --- Credential row (shared by card + dialog) --- */ + /* --- Credential row (shared by the single-account panel + dialog) --- */ .cred-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; } - .req-card .cred-row + .cred-row, - .dialog .cred-row + .cred-row { border-top: 1px solid #f0f1f4; } .cred-icon { width: 28px; height: 28px; border-radius: 6px; flex-shrink: 0; background: var(--color-icon-bg); color: var(--color-icon-text); font-size: 13px; font-weight: 600; display: flex; align-items: center; justify-content: center; } + /* Provider icon, served unauthenticated from /icons/** on this same origin. */ + 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(--color-label); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + /* Stays muted when connected — the green lives on the row's control, not the copy. */ .cred-sub { display: block; font-size: 12px; color: var(--color-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .cred-sub.connected { color: var(--color-ok); } /* --- Buttons --- */ .btn { font-family: var(--font-family); font-size: 13px; font-weight: 600; padding: 8px 16px; border-radius: var(--border-radius-input); border: 1px solid transparent; cursor: pointer; + flex-shrink: 0; } .btn-primary { background: var(--color-primary); color: var(--color-primary-text); } .btn-primary:hover { opacity: 0.88; } .btn-secondary { background: var(--color-card-bg); color: var(--color-label); border-color: var(--color-card-border); } .btn-secondary:hover { border-color: var(--color-muted); } + /* Connect (primary) while accounts are outstanding; Manage (quiet) once they're all in. */ + #open-dialog { display: inline-flex; align-items: center; gap: 6px; } + #open-dialog .dot { display: none; width: 7px; height: 7px; border-radius: 50%; background: var(--color-ok); } + .req-summary.all-connected #open-dialog { background: var(--color-card-bg); color: var(--color-label); border-color: var(--color-card-border); } + .req-summary.all-connected #open-dialog:hover { opacity: 1; border-color: var(--color-muted); } + .req-summary.all-connected #open-dialog .dot { display: block; } .cred-connected { position: relative; } - .btn-connected { display: inline-flex; align-items: center; gap: 6px; background: none; border: 0; color: var(--color-ok); font-family: var(--font-family); font-size: 13px; font-weight: 600; cursor: pointer; padding: 6px 4px; } + /* Bordered like the Connect button it replaces, so the row's control keeps its shape. */ + .btn-connected { + display: inline-flex; align-items: center; gap: 6px; cursor: pointer; + background: var(--color-card-bg); border: 1px solid var(--color-card-border); border-radius: var(--border-radius-input); + color: var(--color-label); font-family: var(--font-family); font-size: 13px; font-weight: 600; padding: 8px 12px; + } + .btn-connected:hover { border-color: var(--color-muted); } + .btn-connected .caret { color: var(--color-muted); } .btn-connected .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--color-ok); } /* Fixed so the menu escapes the dialog's overflow clipping; positioned by JS. */ .cred-menu { display: none; position: fixed; min-width: 150px; background: var(--color-card-bg); border: 1px solid var(--color-card-border); border-radius: 6px; box-shadow: var(--box-shadow-dialog); z-index: 30; } .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-family: var(--font-family); font-size: 13px; color: #a02f28; cursor: pointer; } .btn-disconnect:hover { background: #fdeceb; } - .cred-status-ok { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; color: var(--color-ok); white-space: nowrap; } - .cred-status-ok .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--color-ok); } .cred-blocked { font-size: 13px; color: var(--color-muted); } /* --- Dialog --- */ .overlay { display: none; position: fixed; inset: 0; background: rgba(20, 20, 30, 0.28); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); align-items: center; justify-content: center; z-index: 10; padding: 16px; } .overlay.open { display: flex; } - .dialog { width: 480px; max-width: 100%; max-height: calc(100vh - 48px); display: flex; flex-direction: column; background: var(--color-card-bg); border-radius: 12px; box-shadow: var(--box-shadow-dialog); overflow: hidden; } + .dialog { width: 448px; max-width: 100%; max-height: calc(100vh - 48px); display: flex; flex-direction: column; background: var(--color-card-bg); border-radius: 12px; box-shadow: var(--box-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(--color-header); } .dialog-close { background: none; border: 0; font-size: 20px; line-height: 1; color: var(--color-muted); cursor: pointer; } - .dialog-sub { padding: 0 20px 8px; font-size: 13px; color: var(--color-muted); } + .dialog-sub { padding: 0 20px 16px; font-size: 13px; color: var(--color-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(--color-muted); } /* --- The form itself, unchanged, in a null-origin sandboxed iframe --- */ #form-frame { display: block; width: 100%; height: calc(100vh - 40px); border: 0; background: transparent; } + + /* Mirrors the form card's own narrow-viewport width so the edges stay aligned. */ + @media only screen and (max-width: 500px) { + .req-card, .req-summary { width: 95%; } + } + {{#*inline "credRow"}} +
+ {{!-- The provider icon when it resolved server-side, else the letter tile. --}} + {{#if iconUrl}} + + {{else}} + {{initial}} + {{/if}} + + {{name}} + {{#if connected}}{{#if account}}Connected as {{account}}{{else}}Connected{{/if}}{{else}}{{notConnectedText}}{{/if}}{{#if usedBy}} · used by {{usedBy}}{{/if}} + + {{#if connected}} +
+ {{else if authorizationUrl}} + + {{else}} + Ask the form owner + {{/if}} +
+ {{/inline}} +
{{#if useDialog}} -
-
- {{#each iconStack}} - {{this}} - {{/each}} - {{#if moreCount}}+{{moreCount}}{{/if}} -
-
-
Required credentials
-
{{connectedCount}} of {{total}} credentials connected
-
- +
+ + + + + {{summaryText}} +
{{else}}
-
Required credentials
-
Connect required credentials to run this form using your data.
{{#each credentials}} -
- {{initial}} - - {{name}} - {{#if connected}}{{#if account}}Connected as {{account}}{{else}}Connected{{/if}}{{else}}Not connected{{/if}}{{#if usedBy}} · used by {{usedBy}}{{/if}} - - {{#if connected}} -
- {{else if authorizationUrl}} - - {{else}} - Ask the form owner - {{/if}} -
+ {{> credRow notConnectedText='Not connected · needed to submit this form'}} {{/each}}
{{/if}} @@ -174,30 +196,17 @@
@@ -217,16 +226,33 @@ }); var total = Object.keys(ids).length; + function accountsLabel(count) { return count === 1 ? 'account' : 'accounts'; } + + // Mirrors `formShellSummaryText` in nodes-base/nodes/Form/utils/utils.ts, + // which renders the same line server-side and is what the unit tests cover. + function summaryText(count) { + var remaining = total - count; + if (remaining <= 0) return 'All ' + total + ' accounts connected · ready to submit'; + if (count === 0) return total + ' accounts needed to submit this form'; + return remaining + ' more ' + accountsLabel(remaining) + ' needed to submit this form'; + } + + var summaryEl = document.getElementById('summary-text'); + var summaryRow = document.getElementById('req-summary'); + var countEl = document.getElementById('dialog-count'); + var openBtnLabel = document.querySelector('#open-dialog .label'); + function refresh() { var count = Object.keys(connected).length; - var txt = count + ' of ' + total + ' credentials connected'; - document.querySelectorAll('.strip-count, .dialog-count').forEach(function (el) { - el.textContent = txt; - }); + var allConnected = count >= total; + if (summaryEl) summaryEl.textContent = summaryText(count); + if (summaryRow) summaryRow.classList.toggle('all-connected', allConnected); + if (openBtnLabel) openBtnLabel.textContent = allConnected ? 'Manage' : 'Connect'; + if (countEl) countEl.textContent = count + ' of ' + total + ' ' + accountsLabel(total) + ' connected'; // UX-only signal to the form iframe; the server-side POST gate is the guarantee. if (frame && frame.contentWindow) { frame.contentWindow.postMessage( - { type: count >= total ? 'n8n-shell-credentials-ready' : 'n8n-shell-credentials-not-ready' }, + { type: allConnected ? 'n8n-shell-credentials-ready' : 'n8n-shell-credentials-not-ready' }, '*', ); } @@ -238,16 +264,32 @@ '' + '
'; + // A provider icon that 404s (renamed node 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); + } + // Capture phase because `error` doesn't bubble. + document.addEventListener('error', function (e) { fallBackToLetterTile(e.target); }, true); + // Images load in parallel with this script, so some may have already failed + // by the time the listener above attaches — sweep those up. + document.querySelectorAll('.cred-icon-img').forEach(function (img) { + if (img.complete && img.naturalWidth === 0) fallBackToLetterTile(img); + }); + function markConnected(id) { if (!id || connected[id]) return; connected[id] = true; - // Card and dialog share the same data-cred-id; update both. + // The single-account panel and the dialog share the same data-cred-id. document.querySelectorAll('.cred-row[data-cred-id="' + id + '"]').forEach(function (row) { row.setAttribute('data-connected', 'true'); var sub = row.querySelector('.cred-sub'); if (sub) { sub.textContent = SUBMITTER_EMAIL ? 'Connected as ' + SUBMITTER_EMAIL : 'Connected'; - sub.classList.add('connected'); } var btn = row.querySelector('.connect'); if (btn) { @@ -307,7 +349,11 @@ document.querySelectorAll('.cred-row[data-cred-id="' + id + '"]').forEach(function (row) { row.removeAttribute('data-connected'); var sub = row.querySelector('.cred-sub'); - if (sub) { sub.textContent = 'Not connected'; sub.classList.remove('connected'); } + if (sub) { + // The single-account row spells out why the account is needed; the + // dialog's rows sit under copy that already says it. + sub.textContent = row.getAttribute('data-not-connected-text') || 'Not connected'; + } var wrap = row.querySelector('.cred-connected'); if (wrap) { var btn = document.createElement('button'); @@ -407,7 +453,7 @@ document.querySelectorAll('.cred-menu.open').forEach(function (m) { m.classList.remove('open'); }); }); - // Dialog (3+ credentials) + // Dialog (two or more accounts) var overlay = document.getElementById('overlay'); var open = document.getElementById('open-dialog'); if (open && overlay) { @@ -419,9 +465,10 @@ // The form's POST was refused by the server-side gate. Rows rendered at page load // can be stale (revoked since, or disconnected in another tab), so flip the ones - // the server no longer considers connected and surface the panel — the 3+ layout - // otherwise keeps every row behind the strip. Declared after the dialog wiring so - // `overlay` is assigned; hoisting keeps it callable from the listener above. + // the server no longer considers connected and surface the panel — the 2+ layout + // otherwise keeps every row behind the summary line. Declared after the dialog + // wiring so `overlay` is assigned; hoisting keeps it callable from the listener + // above. function handleGateRejection(data) { if (!data || data.type !== 'n8n-form-credentials-rejected' || !Array.isArray(data.ids)) return; data.ids.forEach(function (id) { diff --git a/packages/cli/templates/form-trigger.handlebars b/packages/cli/templates/form-trigger.handlebars index 74e8c334f40..4562b774631 100644 --- a/packages/cli/templates/form-trigger.handlebars +++ b/packages/cli/templates/form-trigger.handlebars @@ -261,6 +261,12 @@ cursor: not-allowed; } + #submit-hint { + margin-top: 8px; + font-size: var(--font-size-link); + color: var(--color-link); + } + #submit-btn:disabled:hover { opacity: 1; } @@ -650,7 +656,7 @@ {{#if hasAuthenticatedSubmitter}}

{{/if}} - + + {{#if shellInner}} + {{!-- Only inside the hosting shell, where the connect panel above supplies the accounts. --}} +

This form will run using your connected accounts.

+ {{/if}}