From 4c352267198f94e69a3fab85228a5484c0f7363e Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 24 Apr 2026 22:26:12 -0400 Subject: [PATCH] fix(frontend): make copy buttons work over plain HTTP (#757) The Clipboard API requires a secure context, so navigator.clipboard is undefined when Sencho is accessed over HTTP on a LAN IP. Most copy buttons therefore failed silently and a few even fired success toasts without writing anything to the clipboard. Extract a shared copyToClipboard helper that prefers the modern API in secure contexts and falls back to a hidden-textarea execCommand path otherwise, then route every existing call site through it. --- frontend/src/components/ApiTokensSection.tsx | 7 +- frontend/src/components/EditorLayout.tsx | 5 +- frontend/src/components/HostConsole.tsx | 3 +- frontend/src/components/NodeManager.tsx | 27 +----- frontend/src/components/ResourcesView.tsx | 3 +- .../components/mfa/MfaBackupCodesDialog.tsx | 3 +- .../src/components/mfa/MfaEnrollDialog.tsx | 5 +- .../components/settings/WebhooksSection.tsx | 15 ++- frontend/src/lib/clipboard.test.ts | 97 +++++++++++++++++++ frontend/src/lib/clipboard.ts | 43 ++++++++ 10 files changed, 171 insertions(+), 37 deletions(-) create mode 100644 frontend/src/lib/clipboard.test.ts create mode 100644 frontend/src/lib/clipboard.ts diff --git a/frontend/src/components/ApiTokensSection.tsx b/frontend/src/components/ApiTokensSection.tsx index 9745d590..24998d39 100644 --- a/frontend/src/components/ApiTokensSection.tsx +++ b/frontend/src/components/ApiTokensSection.tsx @@ -8,6 +8,7 @@ import { Combobox } from '@/components/ui/combobox'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; +import { copyToClipboard } from '@/lib/clipboard'; import { AdmiralGate } from './AdmiralGate'; import { CapabilityGate } from './CapabilityGate'; import { Zap, Plus, Copy, Trash2, CheckCircle, RefreshCw, Clock } from 'lucide-react'; @@ -118,9 +119,9 @@ export function ApiTokensSection() { } catch { toast.error('Network error.'); } }; - const copyToClipboard = async (text: string, label: string) => { + const handleCopy = async (text: string, label: string) => { try { - await navigator.clipboard.writeText(text); + await copyToClipboard(text); toast.success(`${label} copied to clipboard.`); } catch { toast.error('Failed to copy to clipboard.'); @@ -195,7 +196,7 @@ export function ApiTokensSection() {

This token will not be shown again. Store it securely.

{newToken.token} -
diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 98fa7657..58d06ef0 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -24,6 +24,7 @@ import { type Label as StackLabel, type LabelColor } from './label-types'; import { UserProfileDropdown } from './UserProfileDropdown'; import { NotificationPanel } from './NotificationPanel'; import { apiFetch, fetchForNode } from '@/lib/api'; +import { copyToClipboard } from '@/lib/clipboard'; import { toast } from '@/components/ui/toast-store'; import { Label } from './ui/label'; import { ScrollArea } from './ui/scroll-area'; @@ -2273,7 +2274,7 @@ export default function EditorLayout() { aria-label={copiedDigest === first.ImageID ? 'Copied' : 'Copy digest'} onClick={() => { const id = first.ImageID as string; - void navigator.clipboard.writeText(id).then(() => { + void copyToClipboard(id).then(() => { setCopiedDigest(id); if (copiedDigestTimerRef.current !== null) { window.clearTimeout(copiedDigestTimerRef.current); @@ -2282,7 +2283,7 @@ export default function EditorLayout() { setCopiedDigest(prev => (prev === id ? null : prev)); copiedDigestTimerRef.current = null; }, 1500); - }); + }).catch(() => { /* clipboard unavailable */ }); }} className="inline-flex h-4 w-4 items-center justify-center rounded text-stat-subtitle hover:text-foreground hover:bg-muted/60 transition-colors" > diff --git a/frontend/src/components/HostConsole.tsx b/frontend/src/components/HostConsole.tsx index 6f8e93d7..578c5e36 100644 --- a/frontend/src/components/HostConsole.tsx +++ b/frontend/src/components/HostConsole.tsx @@ -7,6 +7,7 @@ import { Button } from './ui/button'; import { PageMasthead, type MastheadTone } from './ui/PageMasthead'; import '@xterm/xterm/css/xterm.css'; import { useNodes } from '@/context/NodeContext'; +import { copyToClipboard } from '@/lib/clipboard'; interface HostConsoleProps { stackName?: string | null; @@ -206,7 +207,7 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) { if (!term) return; const selection = term.getSelection(); if (!selection) return; - navigator.clipboard?.writeText(selection).catch(() => { /* ignore */ }); + void copyToClipboard(selection).catch(() => { /* ignore */ }); }, []); const handleClear = useCallback(() => { diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index 916ec602..49ef2512 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { useNodes } from '@/context/NodeContext'; import type { Node, NodeMode } from '@/context/NodeContext'; import { apiFetch } from '@/lib/api'; +import { copyToClipboard } from '@/lib/clipboard'; import { toast } from '@/components/ui/toast-store'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger } from './ui/dialog'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from './ui/alert-dialog'; @@ -161,14 +162,13 @@ export function NodeManager() { const copyEnrollment = async () => { if (!activeEnrollment) return; - const text = activeEnrollment.enrollment.dockerRun; try { - await navigator.clipboard.writeText(text); + await copyToClipboard(activeEnrollment.enrollment.dockerRun); setEnrollmentCopied(true); toast.success('Command copied to clipboard'); setTimeout(() => setEnrollmentCopied(false), 2000); } catch { - toast.error('Could not copy automatically - please select and copy the command manually.'); + toast.error('Could not copy automatically. Please select and copy the command manually.'); } }; @@ -263,29 +263,12 @@ export function NodeManager() { const copyToken = async () => { if (!generatedToken) return; try { - // Clipboard API requires a secure context (HTTPS or localhost) - await navigator.clipboard.writeText(generatedToken); + await copyToClipboard(generatedToken); setTokenCopied(true); toast.success('Token copied to clipboard'); setTimeout(() => setTokenCopied(false), 2000); } catch { - // Fallback for HTTP / non-localhost deployments where Clipboard API is unavailable - try { - const ta = document.createElement('textarea'); - ta.value = generatedToken; - ta.style.position = 'fixed'; - ta.style.opacity = '0'; - document.body.appendChild(ta); - ta.focus(); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - setTokenCopied(true); - toast.success('Token copied to clipboard'); - setTimeout(() => setTokenCopied(false), 2000); - } catch { - toast.error('Could not copy automatically - please select and copy the token manually.'); - } + toast.error('Could not copy automatically. Please select and copy the token manually.'); } }; diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 5395d50b..aa6864a8 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -16,6 +16,7 @@ import { Label } from "@/components/ui/label"; import { TogglePill } from "@/components/ui/toggle-pill"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { apiFetch } from '@/lib/api'; +import { copyToClipboard } from '@/lib/clipboard'; import { toast } from '@/components/ui/toast-store'; import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Plus, Eye, Copy, Container, Loader2, History } from 'lucide-react'; import { CursorProvider, CursorContainer, Cursor, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor'; @@ -1359,7 +1360,7 @@ export default function ResourcesView() { {inspectNetwork.Id.substring(0, 12)} diff --git a/frontend/src/components/mfa/MfaBackupCodesDialog.tsx b/frontend/src/components/mfa/MfaBackupCodesDialog.tsx index 48c92e38..04ca78d1 100644 --- a/frontend/src/components/mfa/MfaBackupCodesDialog.tsx +++ b/frontend/src/components/mfa/MfaBackupCodesDialog.tsx @@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button'; import { Check, Copy, Download } from 'lucide-react'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; +import { copyToClipboard } from '@/lib/clipboard'; import { cn } from '@/lib/utils'; import { TOTP_LENGTH, normalizeTotpInput } from '@/lib/mfa'; import { OtpDigitField } from '@/components/auth/OtpDigitField'; @@ -92,7 +93,7 @@ export function MfaBackupCodesDialog({ open, onOpenChange, onRegenerated }: MfaB const handleCopy = async () => { try { - await navigator.clipboard.writeText(backupCodes.join('\n')); + await copyToClipboard(backupCodes.join('\n')); toast.success('Backup codes copied'); } catch { toast.error('Could not copy to clipboard'); diff --git a/frontend/src/components/mfa/MfaEnrollDialog.tsx b/frontend/src/components/mfa/MfaEnrollDialog.tsx index faf686c4..91a5cc08 100644 --- a/frontend/src/components/mfa/MfaEnrollDialog.tsx +++ b/frontend/src/components/mfa/MfaEnrollDialog.tsx @@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button'; import { ArrowRight, Check, Copy, Download, Loader2 } from 'lucide-react'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; +import { copyToClipboard } from '@/lib/clipboard'; import { cn } from '@/lib/utils'; import { TOTP_LENGTH, normalizeTotpInput } from '@/lib/mfa'; import { OtpDigitField } from '@/components/auth/OtpDigitField'; @@ -121,7 +122,7 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia const handleCopySecret = async () => { try { - await navigator.clipboard.writeText(secret); + await copyToClipboard(secret); toast.success('Secret copied to clipboard'); } catch { toast.error('Could not copy to clipboard'); @@ -130,7 +131,7 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia const handleCopyBackupCodes = async () => { try { - await navigator.clipboard.writeText(backupCodes.join('\n')); + await copyToClipboard(backupCodes.join('\n')); toast.success('Backup codes copied'); } catch { toast.error('Could not copy to clipboard'); diff --git a/frontend/src/components/settings/WebhooksSection.tsx b/frontend/src/components/settings/WebhooksSection.tsx index e01ac51f..91c7987a 100644 --- a/frontend/src/components/settings/WebhooksSection.tsx +++ b/frontend/src/components/settings/WebhooksSection.tsx @@ -8,6 +8,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; +import { copyToClipboard } from '@/lib/clipboard'; import { PaidGate } from '@/components/PaidGate'; import { CapabilityGate } from '@/components/CapabilityGate'; import { @@ -128,9 +129,13 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) { } catch { /* ignore */ } finally { setLoadingHistory(null); } }; - const copyToClipboard = (text: string, label: string) => { - navigator.clipboard.writeText(text); - toast.success(`${label} copied to clipboard.`); + const handleCopy = async (text: string, label: string) => { + try { + await copyToClipboard(text); + toast.success(`${label} copied to clipboard.`); + } catch { + toast.error('Failed to copy to clipboard.'); + } }; if (!isPaid) { @@ -204,7 +209,7 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) {

This secret will not be shown again. Store it securely.

{newSecret.secret} -
@@ -256,7 +261,7 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
{triggerUrl} -
diff --git a/frontend/src/lib/clipboard.test.ts b/frontend/src/lib/clipboard.test.ts new file mode 100644 index 00000000..24894507 --- /dev/null +++ b/frontend/src/lib/clipboard.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { copyToClipboard } from './clipboard'; + +const originalIsSecureContext = Object.getOwnPropertyDescriptor(window, 'isSecureContext'); +const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); +const originalExecCommand = Object.getOwnPropertyDescriptor(document, 'execCommand'); + +function setSecureContext(value: boolean): void { + Object.defineProperty(window, 'isSecureContext', { + configurable: true, + value, + }); +} + +function setNavigatorClipboard(clipboard: Clipboard | undefined): void { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: clipboard, + }); +} + +function setExecCommand(impl: () => boolean): ReturnType { + const fn = vi.fn(impl); + Object.defineProperty(document, 'execCommand', { + configurable: true, + writable: true, + value: fn, + }); + return fn; +} + +describe('copyToClipboard', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + if (originalIsSecureContext) Object.defineProperty(window, 'isSecureContext', originalIsSecureContext); + if (originalClipboard) Object.defineProperty(navigator, 'clipboard', originalClipboard); + else delete (navigator as { clipboard?: unknown }).clipboard; + if (originalExecCommand) Object.defineProperty(document, 'execCommand', originalExecCommand); + else delete (document as { execCommand?: unknown }).execCommand; + }); + + it('uses navigator.clipboard in a secure context', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + setSecureContext(true); + setNavigatorClipboard({ writeText } as unknown as Clipboard); + + await copyToClipboard('hello'); + + expect(writeText).toHaveBeenCalledWith('hello'); + }); + + it('falls back to execCommand when not in a secure context', async () => { + setSecureContext(false); + const writeText = vi.fn().mockResolvedValue(undefined); + setNavigatorClipboard({ writeText } as unknown as Clipboard); + const execCommand = setExecCommand(() => true); + + await copyToClipboard('lan-host'); + + expect(writeText).not.toHaveBeenCalled(); + expect(execCommand).toHaveBeenCalledWith('copy'); + }); + + it('falls back to execCommand when clipboard API rejects', async () => { + setSecureContext(true); + const writeText = vi.fn().mockRejectedValue(new Error('blocked')); + setNavigatorClipboard({ writeText } as unknown as Clipboard); + const execCommand = setExecCommand(() => true); + + await copyToClipboard('rejected'); + + expect(writeText).toHaveBeenCalled(); + expect(execCommand).toHaveBeenCalledWith('copy'); + }); + + it('rejects when both modern API and execCommand fail and removes the textarea', async () => { + setSecureContext(false); + setNavigatorClipboard(undefined); + setExecCommand(() => false); + + await expect(copyToClipboard('nope')).rejects.toThrow(); + expect(document.querySelectorAll('textarea').length).toBe(0); + }); + + it('removes the textarea after using the fallback', async () => { + setSecureContext(false); + setNavigatorClipboard(undefined); + setExecCommand(() => true); + + await copyToClipboard('cleanup'); + + expect(document.querySelectorAll('textarea').length).toBe(0); + }); +}); diff --git a/frontend/src/lib/clipboard.ts b/frontend/src/lib/clipboard.ts new file mode 100644 index 00000000..79b56a0b --- /dev/null +++ b/frontend/src/lib/clipboard.ts @@ -0,0 +1,43 @@ +/** + * Copy text to the clipboard with a fallback for non-secure contexts. + * + * The Clipboard API (`navigator.clipboard`) is only available in secure + * contexts (HTTPS, localhost, or 127.0.0.1). Self-hosted Sencho is commonly + * accessed over plain HTTP on LAN IPs, so we fall back to the legacy + * `document.execCommand('copy')` path when the modern API is unavailable + * or rejects. + * + * Rejects only when both paths fail. + */ +export async function copyToClipboard(text: string): Promise { + if ( + typeof navigator !== 'undefined' && + navigator.clipboard && + typeof window !== 'undefined' && + window.isSecureContext + ) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // fall through to legacy fallback + } + } + + const ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.position = 'fixed'; + ta.style.top = '0'; + ta.style.left = '0'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + try { + const ok = document.execCommand('copy'); + if (!ok) throw new Error('execCommand copy returned false'); + } finally { + document.body.removeChild(ta); + } +}