mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-18 05:59:42 +02:00
419 lines
19 KiB
Handlebars
419 lines
19 KiB
Handlebars
<!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'>▾</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'>×</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'>▾</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">▾</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>
|