fix(frontend): copy works over HTTP (non-secure context) + snippet copy button

This commit is contained in:
Catubba
2026-07-09 01:23:36 +02:00
parent 26a3e875ef
commit de2c27b402
5 changed files with 252 additions and 8 deletions
+3
View File
@@ -335,6 +335,9 @@
"keyShownOnce": "Copy this key now — it won't be shown again.",
"copy": "Copy",
"copied": "Copied",
"copyFailed": "Couldn't copy automatically — select the text and copy it manually.",
"copySnippet": "Copy snippet",
"snippetHint": "Paste this under a group in your dashboard's config and keep the indentation intact.",
"keyHiddenNote": "A key is configured. Regenerate to see a new one; the current key stays valid.",
"dashboardLabel": "Dashboard",
"snippetLabel": "Configuration snippet",
+3
View File
@@ -335,6 +335,9 @@
"keyShownOnce": "Copia subito questa chiave — non verrà più mostrata.",
"copy": "Copia",
"copied": "Copiata",
"copyFailed": "Copia automatica non riuscita — seleziona il testo e copialo manualmente.",
"copySnippet": "Copia snippet",
"snippetHint": "Incolla questo sotto un gruppo nella configurazione della tua dashboard e mantieni l'indentazione intatta.",
"keyHiddenNote": "Una chiave è configurata. Rigenerala per vederne una nuova; quella attuale resta valida.",
"dashboardLabel": "Dashboard",
"snippetLabel": "Snippet di configurazione",
+37 -8
View File
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { api, ApiError } from '../../api/client'
import { ConfirmModal, type ConfirmState } from '../../components/ConfirmModal'
import { c, ghostBtn, labelStyle, panelStyle, primaryBtn } from '../../theme'
import { copyToClipboard } from '../../utils/clipboard'
type Dashboard = 'homepage' | 'glance' | 'homarr' | 'dashy'
const DASHBOARDS: Dashboard[] = ['homepage', 'glance', 'homarr', 'dashy']
@@ -87,7 +88,8 @@ export function Integrations() {
const [freshKey, setFreshKey] = useState<string | null>(null)
const [dash, setDash] = useState<Dashboard>('homepage')
const [busy, setBusy] = useState(false)
const [copied, setCopied] = useState(false)
const [keyCopyState, setKeyCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
const [snippetCopyState, setSnippetCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
const [err, setErr] = useState<string | null>(null)
const [confirm, setConfirm] = useState<ConfirmState | null>(null)
@@ -129,11 +131,17 @@ export function Integrations() {
const keyForSnippet = freshKey ?? t('settings.integrations.keyPlaceholder')
const code = snippet(dash, endpointUrl(), keyForSnippet)
function copyKey() {
async function copyKey() {
if (!freshKey) return
void navigator.clipboard.writeText(freshKey)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
const ok = await copyToClipboard(freshKey)
setKeyCopyState(ok ? 'copied' : 'failed')
setTimeout(() => setKeyCopyState('idle'), ok ? 1500 : 3000)
}
async function copySnippet() {
const ok = await copyToClipboard(code)
setSnippetCopyState(ok ? 'copied' : 'failed')
setTimeout(() => setSnippetCopyState('idle'), ok ? 1500 : 3000)
}
return (
@@ -204,10 +212,15 @@ export function Integrations() {
<code style={{ fontFamily: "'IBM Plex Mono', monospace", fontSize: 13, wordBreak: 'break-all' }}>
{freshKey}
</code>
<button onClick={copyKey} style={{ ...ghostBtn, padding: '6px 12px', flex: '0 0 auto' }}>
{copied ? t('settings.integrations.copied') : t('settings.integrations.copy')}
<button onClick={() => void copyKey()} style={{ ...ghostBtn, padding: '6px 12px', flex: '0 0 auto' }}>
{keyCopyState === 'copied' ? t('settings.integrations.copied') : t('settings.integrations.copy')}
</button>
</div>
{keyCopyState === 'failed' && (
<div style={{ fontSize: 12, color: c.red, marginTop: 8 }}>
{t('settings.integrations.copyFailed')}
</div>
)}
</div>
)}
@@ -238,7 +251,15 @@ export function Integrations() {
</div>
</div>
<span style={labelStyle}>{t('settings.integrations.snippetLabel')}</span>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ ...labelStyle, marginBottom: 0 }}>{t('settings.integrations.snippetLabel')}</span>
<button
onClick={() => void copySnippet()}
style={{ ...ghostBtn, padding: '4px 10px', fontSize: 12, flex: '0 0 auto' }}
>
{snippetCopyState === 'copied' ? t('settings.integrations.copied') : t('settings.integrations.copySnippet')}
</button>
</div>
<pre
style={{
background: c.inputBg,
@@ -254,6 +275,14 @@ export function Integrations() {
>
{code}
</pre>
<div style={{ fontSize: 12, color: c.textDim, marginTop: 8 }}>
{t('settings.integrations.snippetHint')}
</div>
{snippetCopyState === 'failed' && (
<div style={{ fontSize: 12, color: c.red, marginTop: 4 }}>
{t('settings.integrations.copyFailed')}
</div>
)}
{err && <div style={{ fontSize: 12, color: c.red, marginTop: 12 }}>{err}</div>}
+164
View File
@@ -0,0 +1,164 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { copyToClipboard } from './clipboard.ts'
// Stubs globalThis[name], returning a restore function. Uses defineProperty (not a
// plain assignment) because Node 24's built-in `navigator` is an accessor with no
// setter — a bare assignment silently no-ops on it.
function setGlobal(name: string, value: unknown): () => void {
const original = Object.getOwnPropertyDescriptor(globalThis, name)
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true })
return () => {
if (original) Object.defineProperty(globalThis, name, original)
else delete (globalThis as Record<string, unknown>)[name]
}
}
test('secure context: uses navigator.clipboard.writeText and resolves true', async () => {
let calledWith: string | undefined
const restoreWindow = setGlobal('window', { isSecureContext: true })
const restoreNavigator = setGlobal('navigator', {
clipboard: {
writeText: async (t: string) => {
calledWith = t
},
},
})
try {
const result = await copyToClipboard('x')
assert.equal(result, true)
assert.equal(calledWith, 'x')
} finally {
restoreWindow()
restoreNavigator()
}
})
test('secure context but writeText throws: falls back to execCommand', async () => {
const restoreWindow = setGlobal('window', { isSecureContext: true })
const restoreNavigator = setGlobal('navigator', {
clipboard: {
writeText: async () => {
throw new Error('denied')
},
},
})
let execCommandArg: string | undefined
const fakeTextarea = { value: '', style: {} as Record<string, string>, focus: () => {}, select: () => {} }
const restoreDocument = setGlobal('document', {
createElement: () => fakeTextarea,
body: {
appendChild: () => {},
removeChild: () => {},
},
execCommand: (cmd: string) => {
execCommandArg = cmd
return true
},
})
try {
const result = await copyToClipboard('x')
assert.equal(result, true)
assert.equal(execCommandArg, 'copy')
assert.equal(fakeTextarea.value, 'x')
} finally {
restoreWindow()
restoreNavigator()
restoreDocument()
}
})
test('HTTP (non-secure context): falls back to a hidden textarea + execCommand', async () => {
const restoreWindow = setGlobal('window', { isSecureContext: false })
const restoreNavigator = setGlobal('navigator', {})
let execCommandArg: string | undefined
const appended: unknown[] = []
const removed: unknown[] = []
const fakeTextarea = { value: '', style: {} as Record<string, string>, focus: () => {}, select: () => {} }
const restoreDocument = setGlobal('document', {
createElement: () => fakeTextarea,
body: {
appendChild: (el: unknown) => appended.push(el),
removeChild: (el: unknown) => removed.push(el),
},
execCommand: (cmd: string) => {
execCommandArg = cmd
return true
},
})
try {
const result = await copyToClipboard('x')
assert.equal(result, true)
assert.equal(execCommandArg, 'copy')
assert.equal(fakeTextarea.value, 'x')
assert.equal(fakeTextarea.style.position, 'fixed')
assert.equal(fakeTextarea.style.opacity, '0')
assert.equal(fakeTextarea.style.pointerEvents, 'none')
assert.equal(appended.length, 1)
assert.equal(removed.length, 1)
} finally {
restoreWindow()
restoreNavigator()
restoreDocument()
}
})
test('fallback failure (execCommand returns false): resolves false', async () => {
const restoreWindow = setGlobal('window', { isSecureContext: false })
const restoreNavigator = setGlobal('navigator', {})
const fakeTextarea = { value: '', style: {} as Record<string, string>, focus: () => {}, select: () => {} }
const restoreDocument = setGlobal('document', {
createElement: () => fakeTextarea,
body: {
appendChild: () => {},
removeChild: () => {},
},
execCommand: () => false,
})
try {
const result = await copyToClipboard('x')
assert.equal(result, false)
} finally {
restoreWindow()
restoreNavigator()
restoreDocument()
}
})
test('fallback failure (execCommand throws): resolves false', async () => {
const restoreWindow = setGlobal('window', { isSecureContext: false })
const restoreNavigator = setGlobal('navigator', {})
const fakeTextarea = { value: '', style: {} as Record<string, string>, focus: () => {}, select: () => {} }
const restoreDocument = setGlobal('document', {
createElement: () => fakeTextarea,
body: {
appendChild: () => {},
removeChild: () => {},
},
execCommand: () => {
throw new Error('boom')
},
})
try {
const result = await copyToClipboard('x')
assert.equal(result, false)
} finally {
restoreWindow()
restoreNavigator()
restoreDocument()
}
})
test('no document available: resolves false without throwing', async () => {
const restoreWindow = setGlobal('window', { isSecureContext: false })
const restoreNavigator = setGlobal('navigator', {})
const restoreDocument = setGlobal('document', undefined)
try {
const result = await copyToClipboard('x')
assert.equal(result, false)
} finally {
restoreWindow()
restoreNavigator()
restoreDocument()
}
})
+45
View File
@@ -0,0 +1,45 @@
// Copies text to the clipboard. Prefers the async Clipboard API, but that only
// exists in a secure context (HTTPS or localhost) — on a plain-HTTP LAN origin
// (e.g. http://192.168.x.x:8080, which is how this app is commonly reached)
// navigator.clipboard is undefined and writeText throws/no-ops. In that case we
// fall back to a hidden <textarea> + document.execCommand('copy'), which works
// regardless of secure-context.
//
// Globals are read via bare identifiers (typeof-guarded) rather than destructured
// at module scope, so tests can stub window/navigator/document per-case.
export async function copyToClipboard(text: string): Promise<boolean> {
const hasSecureClipboard =
typeof window !== 'undefined' &&
Boolean(window.isSecureContext) &&
typeof navigator !== 'undefined' &&
typeof navigator.clipboard?.writeText === 'function'
if (hasSecureClipboard) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Fall through to the textarea fallback below.
}
}
if (typeof document === 'undefined' || typeof document.createElement !== 'function' || !document.body) {
return false
}
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
textarea.style.pointerEvents = 'none'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
const ok = document.execCommand('copy')
document.body.removeChild(textarea)
return Boolean(ok)
} catch {
return false
}
}