From 24a60d2fbccdcbc5ff6af66c8adae8bd42e966bd Mon Sep 17 00:00:00 2001 From: Mitchell Currie Date: Wed, 22 Jul 2026 08:44:25 +1000 Subject: [PATCH 1/5] Fix API key copy interaction --- smolrouter/static/vendor/api-key-copy.js | 137 +++++++++++++++ smolrouter/templates/project_detail.html | 49 ++++-- tests/js/test_api_key_copy.js | 213 +++++++++++++++++++++++ tests/unit/test_api_key_copy_js.py | 19 ++ tests/unit/test_project_key_manager.py | 10 +- 5 files changed, 407 insertions(+), 21 deletions(-) create mode 100644 smolrouter/static/vendor/api-key-copy.js create mode 100644 tests/js/test_api_key_copy.js create mode 100644 tests/unit/test_api_key_copy_js.py diff --git a/smolrouter/static/vendor/api-key-copy.js b/smolrouter/static/vendor/api-key-copy.js new file mode 100644 index 0000000..4143053 --- /dev/null +++ b/smolrouter/static/vendor/api-key-copy.js @@ -0,0 +1,137 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + if (root) root.smolrouterApiKeyCopy = api; +})(typeof window !== 'undefined' ? window : globalThis, function () { + 'use strict'; + + const COPY_LABEL = 'Copy API key'; + const COPIED_LABEL = 'API key copied'; + + function legacyCopy(text, doc) { + const previousFocus = doc.activeElement; + const temporary = doc.createElement('textarea'); + temporary.setAttribute('readonly', ''); + temporary.setAttribute('aria-label', 'Temporary API key copy field'); + temporary.setAttribute('tabindex', '-1'); + temporary.style.position = 'fixed'; + temporary.style.left = '-9999px'; + temporary.style.opacity = '0'; + temporary.value = text; + doc.body.appendChild(temporary); + try { + temporary.focus(); + temporary.select(); + temporary.setSelectionRange(0, temporary.value.length); + if (!doc.execCommand('copy')) throw new Error('Copy command was rejected'); + } finally { + temporary.value = ''; + temporary.remove(); + if (previousFocus && previousFocus.isConnected !== false && typeof previousFocus.focus === 'function') { + previousFocus.focus(); + } + } + } + + async function copyText(text, options) { + const win = options && options.window ? options.window : window; + const doc = options && options.document ? options.document : document; + const clipboard = win.navigator && win.navigator.clipboard; + if (win.isSecureContext && clipboard && typeof clipboard.writeText === 'function') { + try { + await clipboard.writeText(text); + return; + } catch (_) { + // Firefox and hardened browsers can reject Clipboard API calls even when exposed. + } + } + legacyCopy(text, doc); + } + + function createController(options) { + const win = options.window; + const input = options.input; + const panel = options.panel; + const copyButton = options.copyButton; + const copyIcon = options.copyIcon; + const dismissButton = options.dismissButton; + const status = options.status; + const performCopy = options.copyText || ((text) => copyText(text, {window: win, document: options.document})); + let secretCopied = false; + let copyResetTimer = null; + + function clearResetTimer() { + if (copyResetTimer !== null) { + win.clearTimeout(copyResetTimer); + copyResetTimer = null; + } + } + + function resetCopyFeedback() { + clearResetTimer(); + copyIcon.textContent = 'content_copy'; + copyButton.setAttribute('aria-label', COPY_LABEL); + copyButton.setAttribute('title', COPY_LABEL); + status.textContent = ''; + status.classList.remove('success', 'warning'); + } + + function markCopied() { + secretCopied = true; + clearResetTimer(); + copyIcon.textContent = 'check'; + copyButton.setAttribute('aria-label', COPIED_LABEL); + copyButton.setAttribute('title', COPIED_LABEL); + status.textContent = 'API key copied.'; + status.classList.remove('warning'); + status.classList.add('success'); + copyResetTimer = win.setTimeout(resetCopyFeedback, 2000); + } + + function clearSecret() { + clearResetTimer(); + input.value = ''; + secretCopied = false; + resetCopyFeedback(); + } + + function showSecret(secret) { + clearSecret(); + input.value = secret; + panel.hidden = false; + input.focus(); + input.select(); + } + + async function handleCopy() { + try { + await performCopy(input.value); + markCopied(); + } catch (_) { + resetCopyFeedback(); + status.textContent = 'Automatic copy is unavailable. The key is selected; press Ctrl+C or Command+C to copy it.'; + status.classList.add('warning'); + input.focus(); + input.select(); + } + } + + function handleDismiss() { + if (input.value && !secretCopied && !win.confirm('This key will not be shown again. Dismiss without copying?')) return; + clearSecret(); + panel.hidden = true; + win.location.reload(); + } + + copyButton.addEventListener('click', handleCopy); + input.addEventListener('copy', () => { + if (input.value && input.selectionStart === 0 && input.selectionEnd === input.value.length) markCopied(); + }); + dismissButton.addEventListener('click', handleDismiss); + win.addEventListener('pagehide', clearSecret); + + return {showSecret: showSecret, clearSecret: clearSecret}; + } + + return {copyText: copyText, createController: createController}; +}); diff --git a/smolrouter/templates/project_detail.html b/smolrouter/templates/project_detail.html index 42f6cf3..355f0de 100644 --- a/smolrouter/templates/project_detail.html +++ b/smolrouter/templates/project_detail.html @@ -105,8 +105,19 @@ .management-panel { background:#fff; border:1px solid #e5e7eb; border-radius:10px; padding:1rem 1.5rem; margin-bottom:1rem; } .management-row { display:flex; gap:.6rem; align-items:center; flex-wrap:wrap; } .management-message { color:#b91c1c; min-height:1.2rem; margin-top:.5rem; } - .secret-panel { display:none; background:#fffbeb; border:1px solid #f59e0b; border-radius:8px; padding:1rem; margin-top:.75rem; } + .secret-panel { position:relative; background:#fffbeb; border:1px solid #f59e0b; border-radius:8px; padding:1rem; margin-top:.75rem; } + .secret-panel[hidden] { display:none; } + .secret-title { display:block; padding-right:2.5rem; margin-bottom:.5rem; } + .secret-row { display:flex; gap:.4rem; align-items:stretch; } + .secret-value { flex:1; min-width:0; border:1px solid #d1d5db; border-radius:6px; background:#fff; padding:.55rem .65rem; } .secret-value, .fingerprint { font-family:"Monaco","Menlo",monospace; overflow-wrap:anywhere; } + .icon-button { display:inline-flex; align-items:center; justify-content:center; min-width:2.6rem; padding:.45rem; } + .dismiss-secret { position:absolute; right:.55rem; top:.55rem; border:0; background:transparent; color:#475569; border-radius:5px; cursor:pointer; } + .dismiss-secret:hover { background:#fef3c7; color:#0f172a; } + .icon-button:focus-visible, .dismiss-secret:focus-visible, .secret-value:focus-visible { outline:3px solid #2563eb; outline-offset:2px; } + .copy-status { min-height:1.2rem; margin-top:.4rem; color:#475569; font-size:.9rem; } + .copy-status.success { color:#166534; } + .copy-status.warning { color:#b45309; } .danger { background:#b91c1c !important; color:#fff !important; } {% include 'responsive_shell.html' %} @@ -141,11 +152,14 @@

{{ project.display_name or project.id }}

API keys

-
- Copy this key now. It will not be shown again. -
- - + @@ -243,27 +257,28 @@

Recent Facade-Key Requests

{% include 'project_mutation.html' %} {% if project.configured %} +