mirror of
https://github.com/Joulenap/joulenap.git
synced 2026-08-11 13:21:43 +02:00
fix(frontend): copy works over HTTP (non-secure context) + snippet copy button
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user