n8n/packages/cli/templates/form-shell.handlebars

419 lines
19 KiB
Handlebars
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang='en'>
<!--
Form trigger — trusted hosting shell: n8n-controlled connect panel on the real
origin; the author's form renders unchanged in the sandboxed null-origin iframe
so author content can never touch the per-caller authorize links.
-->
<head>
<meta charset='UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<link rel='icon' type='image/png' href='https://n8n.io/favicon.ico' />
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap" rel="stylesheet">
<title>{{formTitle}}</title>
<style>
:root {
--font-family: 'Open Sans', sans-serif;
--color-background: #fbfcfe;
--color-card-bg: #ffffff;
--color-card-border: #dbdfe7;
--color-card-shadow: rgba(99, 77, 255, 0.06);
--color-header: #525356;
--color-label: #555555;
--color-muted: #7e8186;
--color-primary: #ff6d5a;
--color-primary-text: #ffffff;
--color-ok: #2e9e5b;
--color-icon-bg: #f2f0ff;
--color-icon-text: #6a5bdd;
--border-radius-card: 8px;
--border-radius-input: 6px;
--container-width: 480px;
--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; }
/* --- Required-credentials card (1–2 credentials, inline) --- */
.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;
}
.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 {
display: flex;
align-items: center;
gap: 14px;
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;
}
.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; }
/* --- Credential row (shared by card + 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;
}
.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; }
.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;
}
.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); }
.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; }
.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-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-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; }
</style>
</head>
<body>
<div class='shell' data-submitter-email='{{submitterEmail}}'>
{{#if useDialog}}
<div class='req-strip'>
<div class='stack'>
{{#each iconStack}}
<span class='cred-icon'>{{this}}</span>
{{/each}}
{{#if moreCount}}<span class='more'>+{{moreCount}}</span>{{/if}}
</div>
<div class='strip-meta'>
<div class='strip-title'>Required credentials</div>
<div class='strip-count'>{{connectedCount}} of {{total}} credentials connected</div>
</div>
<button class='btn btn-primary' id='open-dialog'>Connect</button>
</div>
{{else}}
<div class='req-card'>
<div class='req-title'>Required credentials</div>
<div class='req-sub'>Connect required credentials to run this form using your data.</div>
{{#each credentials}}
<div class='cred-row' data-cred-id='{{id}}' data-status='{{status}}' data-resolver-id='{{resolverId}}'{{#if connected}} data-connected='true'{{/if}}{{#if revokeUrl}} data-revoke-url='{{revokeUrl}}'{{/if}}>
<span class='cred-icon'>{{initial}}</span>
<span class='cred-meta'>
<span class='cred-name'>{{name}}</span>
<span class='cred-sub {{#if connected}}connected{{/if}}'>{{#if connected}}{{#if account}}Connected as {{account}}{{else}}Connected{{/if}}{{else}}Not connected{{/if}}{{#if usedBy}} · used by {{usedBy}}{{/if}}</span>
</span>
{{#if connected}}
<span class='cred-connected'><button type='button' class='btn-connected'><span class='dot'></span> Connected <span class='caret'>&#9662;</span></button><div class='cred-menu'><button type='button' class='btn-disconnect'>Disconnect</button></div></span>
{{else if authorizationUrl}}
<button class='btn btn-secondary connect' data-url='{{authorizationUrl}}'>{{#if (eq status 'expired')}}Reconnect{{else}}Connect{{/if}}</button>
{{else}}
<span class='cred-blocked'>Ask the form owner</span>
{{/if}}
</div>
{{/each}}
</div>
{{/if}}
<iframe
id='form-frame'
src='{{iframeSrc}}'
sandbox='allow-scripts allow-forms allow-modals allow-popups allow-downloads'
></iframe>
</div>
{{#if useDialog}}
<div class='overlay' id='overlay'>
<div class='dialog' role='dialog' aria-modal='true' aria-labelledby='dialog-title'>
<div class='dialog-head'>
<span class='dialog-title' id='dialog-title'>Required credentials</span>
<button class='dialog-close' id='close-dialog' aria-label='Close'>&times;</button>
</div>
<div class='dialog-sub'>Connect required credentials to run this form using your data.</div>
<div class='dialog-list'>
{{#each credentials}}
<div class='cred-row' data-cred-id='{{id}}' data-status='{{status}}' data-resolver-id='{{resolverId}}'{{#if connected}} data-connected='true'{{/if}}{{#if revokeUrl}} data-revoke-url='{{revokeUrl}}'{{/if}}>
<span class='cred-icon'>{{initial}}</span>
<span class='cred-meta'>
<span class='cred-name'>{{name}}</span>
<span class='cred-sub {{#if connected}}connected{{/if}}'>{{#if connected}}{{#if account}}Connected as {{account}}{{else}}Connected{{/if}}{{else}}Not connected{{/if}}{{#if usedBy}} · used by {{usedBy}}{{/if}}</span>
</span>
{{#if connected}}
<span class='cred-connected'><button type='button' class='btn-connected'><span class='dot'></span> Connected <span class='caret'>&#9662;</span></button><div class='cred-menu'><button type='button' class='btn-disconnect'>Disconnect</button></div></span>
{{else if authorizationUrl}}
<button class='btn btn-secondary connect' data-url='{{authorizationUrl}}'>{{#if (eq status 'expired')}}Reconnect{{else}}Connect{{/if}}</button>
{{else}}
<span class='cred-blocked'>Ask the form owner</span>
{{/if}}
</div>
{{/each}}
</div>
<div class='dialog-foot'>
<span class='dialog-count'>{{connectedCount}} of {{total}} credentials connected</span>
<button class='btn btn-primary' id='done-dialog'>Done</button>
</div>
</div>
</div>
{{/if}}
<script>
(function () {
// Rows update in place — a reload would bounce the submitter back through consent.
var frame = document.getElementById('form-frame');
var ids = {};
var connected = {};
document.querySelectorAll('.cred-row').forEach(function (row) {
var id = row.getAttribute('data-cred-id');
ids[id] = true;
if (row.getAttribute('data-connected') === 'true') connected[id] = true;
});
var total = Object.keys(ids).length;
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;
});
// 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' },
'*',
);
}
}
var shellEl = document.querySelector('.shell');
var SUBMITTER_EMAIL = shellEl ? shellEl.getAttribute('data-submitter-email') || '' : '';
var CONNECTED_MENU_HTML =
'<button type="button" class="btn-connected"><span class="dot"></span> Connected <span class="caret">&#9662;</span></button>' +
'<div class="cred-menu"><button type="button" class="btn-disconnect">Disconnect</button></div>';
function markConnected(id) {
if (!id || connected[id]) return;
connected[id] = true;
// Card and dialog share the same data-cred-id; update both.
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) {
// 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();
}
var pendingId = null;
// Connected only on the oauth-callback success signal — never on popup close,
// which also happens on provider-side failures.
function onSuccessSignal() { if (pendingId) markConnected(pendingId); }
try {
var channel = new BroadcastChannel('oauth-callback');
channel.addEventListener('message', function (event) {
if (event.data === 'success') onSuccessSignal();
});
} catch (e) {}
window.addEventListener('message', function (event) {
// Ignore the sandboxed form iframe so author content can't spoof a connect.
if (frame && event.source === frame.contentWindow) return;
if (event.data === 'success') onSuccessSignal();
});
function openConnect(id, url) {
if (!url) return;
pendingId = id;
// No popup-close handler: success is confirmed only by the signals above.
window.open(url, 'n8n-credential-connect', 'width=500,height=700,menubar=no,toolbar=no');
}
function markDisconnected(id) {
if (!id) return;
delete connected[id];
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'); }
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 (a reload would
// re-run the form's OAuth2 auth and re-show consent).
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();
}
// Session-cookie auth + the browser-id the session was issued with (stored in
// localStorage 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 disconnectCred(row) {
if (!row) return;
var id = row.getAttribute('data-cred-id');
var revokeUrl = row.getAttribute('data-revoke-url');
if (!id || !revokeUrl) return;
fetch(revokeUrl + '&authSource=cookie', {
method: 'DELETE',
credentials: 'include',
headers: authHeaders(),
})
.then(function (r) { if (r.ok) markDisconnected(id); })
.catch(function () {});
}
// Reconnect after a disconnect: mint a fresh authorize link and send the popup
// there. The popup opens synchronously so it isn't treated as a pop-under.
function reconnect(id, mintUrl) {
if (!id || !mintUrl) return;
var popup = window.open('', 'n8n-credential-connect', 'width=500,height=700,menubar=no,toolbar=no');
pendingId = id;
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();
})
.catch(function () { if (popup) popup.close(); });
}
// Delegated clicks so controls created after markConnected() also work.
document.addEventListener('click', function (e) {
var connectBtn = e.target.closest('.connect');
if (connectBtn) {
var row = connectBtn.closest('.cred-row');
var id = row ? row.getAttribute('data-cred-id') : null;
var mintUrl = connectBtn.getAttribute('data-authorize-url');
if (mintUrl) reconnect(id, mintUrl);
else openConnect(id, 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();
disconnectCred(disconnectBtn.closest('.cred-row'));
return;
}
// Click elsewhere closes any open menu.
document.querySelectorAll('.cred-menu.open').forEach(function (m) { m.classList.remove('open'); });
});
// Dialog (3+ credentials)
var overlay = document.getElementById('overlay');
var open = document.getElementById('open-dialog');
if (open && overlay) {
open.addEventListener('click', function () { overlay.classList.add('open'); });
document.getElementById('close-dialog').addEventListener('click', function () { overlay.classList.remove('open'); });
document.getElementById('done-dialog').addEventListener('click', function () { overlay.classList.remove('open'); });
overlay.addEventListener('click', function (e) { if (e.target === overlay) overlay.classList.remove('open'); });
}
})();
</script>
</body>
</html>