fix(ui): route every copy button through the clipboard door

Plan 76 milestone F3, drift/bug row 5. 33 call sites in 25 files reached for
navigator.clipboard.writeText directly, against 4 files using the shared
copyToClipboard/useClipboard helpers.

This is a fix rather than a tidy-up: navigator.clipboard is undefined in an
insecure context, and ServerKit's SSL is optional by policy, so on an
HTTP-served panel every one of those copy buttons did nothing. The failure was
silent at most sites — a bare fire-and-forget writeText, or a `.catch` that
swallowed. utils/clipboard.js already carries the execCommand fallback that
makes them work; it just had almost no callers.

Two of the bypasses were local functions *named* copyToClipboard
(ServerSettingsTab, Downloads) — the same shadowing shape as the backend's
private get_current_user() helpers, and the reason the call sites read as
though they were already correct. ServerSettingsTab's was a hand-rolled
useClipboard (copy + toast) and now uses the hook; Downloads' is renamed
copyCommand since it also owns per-command copied state.

Behaviour preserved per site, with two deliberate improvements the helper's
contract allows:

- state that was set unconditionally (`copied` flags, keyed copy markers) is
  now set only when the copy actually succeeded
- CreateTableModal's "this browser will not give the page clipboard access"
  notice is gone; the fallback is what it was warning about

`.then(onOk, onErr)` chains became `.then(ok => ...)` because copyToClipboard
resolves false instead of rejecting — the second callback would never have run.

The existing frontend boundary ratchet held an exact per-file legacy baseline;
it is now empty and documented as needing to stay that way, so a new direct
call fails `npm run lint`.

Gate: node scripts/check-frontend-boundaries.mjs (0 clipboard files ratcheted),
npm run lint (0 errors, 955 pre-existing warnings — unchanged), npm test
(61 passed), npm run build (clean).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Juan DenisandClaude Opus 5 committed 2026-08-18 19:42:14 -04:00
1 parent 9ff957edf7
commit e1a5a5e102
26 files changed
+89 -119

No files matched your search

+5 -27
View File
@@ -28,33 +28,11 @@ const EXTENSION_OWNED_STYLES = [
},
];
const LEGACY_CLIPBOARD = new Map(Object.entries({
'components/cloudflare/TunnelsPanel.jsx': 1,
'components/databases/CreateTableModal.jsx': 2,
'components/databases/DbUsersPanel.jsx': 2,
'components/databases/EngineInstallDrawer.jsx': 1,
'components/databases/ManagedDatabasesPanel.jsx': 2,
'components/deploy-console/ErrorCard.jsx': 1,
'components/docker/ContainersTab.jsx': 1,
'components/serverdetail/CloudflaredTab.jsx': 1,
'components/serverdetail/serverDetailShared.jsx': 1,
'components/serverdetail/ServerSettingsTab.jsx': 1,
'components/service-detail/LogsTab.jsx': 1,
'components/settings/ApiKeyModal.jsx': 1,
'components/settings/IconReferenceTab.jsx': 1,
'components/settings/InvitationsTab.jsx': 1,
'components/settings/InviteModal.jsx': 1,
'components/settings/LoginLinksSection.jsx': 1,
'components/settings/SecuritySettingsTab.jsx': 1,
'components/settings/WebhooksTab.jsx': 1,
'components/setup/SetupStepSecurity.jsx': 1,
'pages/Databases.jsx': 1,
'pages/DeployConsole.jsx': 1,
'pages/Downloads.jsx': 1,
'pages/FileManager.jsx': 1,
'pages/Git.jsx': 6,
'pages/StatusPages.jsx': 1,
}));
// Emptied 2026-08-18 (plan 76, F3): every call site now goes through
// copyToClipboard/useClipboard, whose execCommand fallback is what makes copy
// buttons work on an HTTP-served panel — navigator.clipboard is undefined in an
// insecure context, and SSL is optional by policy. This map must stay empty.
const LEGACY_CLIPBOARD = new Map();
// These are purpose-built experiences whose geometry/content is intentionally
// richer than the ordinary Modal contract. Any new exception needs review.
@@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge';
import EmptyState from '../EmptyState';
import { useToast } from '../../contexts/ToastContext';
import api from '../../services/api';
import { copyToClipboard } from '@/utils/clipboard';
function InstallBox({ command }) {
const toast = useToast();
@@ -15,12 +16,8 @@ function InstallBox({ command }) {
}
const handleCopy = async () => {
try {
await navigator.clipboard?.writeText(command);
toast.success('Copied');
} catch (err) {
toast.error(err.message);
}
if (await copyToClipboard(command)) toast.success('Copied');
else toast.error('Could not copy the install command');
};
return (
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
import { EngineIcon } from '../icons/DatabaseBrands';
import EngineGlyph from './EngineGlyph';
import { runQuery, connKey, ENGINE_META } from './dbAdapter';
import { copyToClipboard } from '@/utils/clipboard';
import {
engineBrandKey, engineInitialDatabase, engineInstanceKey, engineMeta,
engineTreeStatus, engineUnit, singular,
@@ -236,18 +237,15 @@ export default function CreateTableModal({ preset, engines = [], isAdmin = false
}
function copy() {
if (!navigator.clipboard) {
setResult({ ok: false, message: 'This browser will not give the page clipboard access. Select the statement and copy it manually.' });
return;
}
navigator.clipboard.writeText(statement).then(
() => {
setCopied(true);
clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), COPY_RESET_MS);
},
() => setResult({ ok: false, message: 'Could not copy to the clipboard.' }),
);
copyToClipboard(statement).then((ok) => {
if (!ok) {
setResult({ ok: false, message: 'Could not copy to the clipboard.' });
return;
}
setCopied(true);
clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), COPY_RESET_MS);
});
}
const footer = (
@@ -4,6 +4,7 @@ import api from '../../services/api';
import { useToast } from '../../contexts/ToastContext';
import { useConfirm } from '../../hooks/useConfirm';
import { Button } from '@/components/ui/button';
import { copyToClipboard } from '@/utils/clipboard';
// Users ServerKit created on a managed database (tracked rows merged with the
// live engine list). Create returns the password exactly once — it is shown
@@ -70,8 +71,7 @@ export default function DbUsersPanel({ databaseId }) {
}
async function copySecret() {
if (oneTimeSecret && navigator.clipboard) {
await navigator.clipboard.writeText(oneTimeSecret.password);
if (oneTimeSecret && await copyToClipboard(oneTimeSecret.password)) {
toast.success('Password copied');
}
}
@@ -11,6 +11,7 @@ import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { useToast } from '../../contexts/ToastContext';
import EngineGlyph from './EngineGlyph';
import { copyToClipboard } from '@/utils/clipboard';
import {
engineMeta, engineVersions, generatePassword, partitionVariables, seedVariables,
singular, slugifyIdent, slugifyService,
@@ -37,10 +38,9 @@ function SecretRow({ password, onRegenerate, disabled }) {
const [revealed, setRevealed] = useState(false);
const copy = () => {
navigator.clipboard?.writeText(password).then(
() => toast.success('Password copied'),
() => toast.error('Could not copy the password'),
);
copyToClipboard(password).then((ok) => (ok
? toast.success('Password copied')
: toast.error('Could not copy the password')));
};
return (
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
import { Pill } from '../ds';
import AdminerSsoButton from './AdminerSsoButton';
import DbUsersPanel from './DbUsersPanel';
import { copyToClipboard } from '@/utils/clipboard';
// Durable list of the databases ServerKit tracks (provisioned or adopted),
// beside the live explorer. Reveal/copy a real connection string (audited),
@@ -38,8 +39,7 @@ export default function ManagedDatabasesPanel() {
try {
const data = await api.revealManagedConnectionUri(row.id);
const uri = data?.connection_uri;
if (uri && navigator.clipboard) {
await navigator.clipboard.writeText(uri);
if (uri && await copyToClipboard(uri)) {
toast.success('Connection string copied (reveal was audited)');
} else if (uri) {
toast.info(uri);
@@ -1,6 +1,7 @@
import { useContext, useState } from 'react';
import { AlertTriangle, RefreshCw, Copy, Sparkles, ChevronDown, ChevronRight } from 'lucide-react';
import { AIContext } from '../../contexts/AIContext';
import { copyToClipboard } from '@/utils/clipboard';
// Pinned failure card (plan 51 §1.1): failed step name, the one-line error, a
// plain-language hint when a heuristic matched, actions (Retry / Copy / Ask AI)
@@ -27,7 +28,7 @@ export default function ErrorCard({ failedStepName, failureTail, hint, errorMess
errorMessage ? `Error: ${errorMessage}` : null,
tailText ? `\n${tailText}` : null,
].filter(Boolean).join('\n');
navigator.clipboard?.writeText(blob);
copyToClipboard(blob);
};
const askAI = () => {
@@ -39,6 +39,7 @@ import {
getContainerProjectName,
} from './dockerHelpers';
import { ContainerResourceBars } from './dockerShared';
import { copyToClipboard } from '@/utils/clipboard';
// Action Buttons
export const RunContainerButton = () => {
@@ -923,12 +924,8 @@ const ContainerInspector = ({ container, stats, onAction, onOpenLogs, onOpenExec
const projectName = getContainerProjectName(container, details);
async function copyContainerId() {
try {
await navigator.clipboard.writeText(containerId);
toast.success('Container ID copied');
} catch {
toast.error('Could not copy container ID');
}
if (await copyToClipboard(containerId)) toast.success('Container ID copied');
else toast.error('Could not copy container ID');
}
return (
@@ -15,6 +15,7 @@ import EmptyState from '../EmptyState';
import { Cloud } from 'lucide-react';
import Modal from '@/components/Modal';
import { Label } from '@/components/ui/label';
import { copyToClipboard } from '@/utils/clipboard';
import {
OfflineIcon,
TrashIcon,
@@ -600,7 +601,7 @@ const CloudflaredLoginCard = ({ login, onCancel }) => {
<button
type="button"
className="btn btn-outline"
onClick={() => navigator.clipboard?.writeText(login.authUrl)}
onClick={() => copyToClipboard(login.authUrl)}
>
Copy URL
</button>
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import api from '../../services/api';
import { useToast } from '../../contexts/ToastContext';
import { useConfirm } from '../../hooks/useConfirm';
import { useClipboard } from '@/hooks/useClipboard';
import { DangerZone } from '../DangerZone';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -372,6 +373,7 @@ const ServerSettingsTab = ({ server, onUpdate, onRegenerateToken, onDelete }) =>
export const TokenModal = ({ server, onClose, onGenerated }) => {
const toast = useToast();
const { copy } = useClipboard({ successMessage: 'Copied to clipboard' });
const [expiresIn, setExpiresIn] = useState(7 * 24 * 60 * 60);
const [generating, setGenerating] = useState(false);
// Result of the most recent generation in *this* modal session. We
@@ -395,11 +397,6 @@ export const TokenModal = ({ server, onClose, onGenerated }) => {
}
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
}
// Download-then-run, not `curl … | sudo bash`: a piped curl failure is
// invisible (the pipeline reports bash's exit status, so a 404 still exits
// 0 with nothing installed). See issue #101.
@@ -469,7 +466,7 @@ Install-ServerKitAgent -Server "${window.location.origin}" -Token "${result.regi
<div className="connection-string-field__header">
<KeyIcon />
<span>Connection string</span>
<Button variant="outline" size="sm" onClick={() => copyToClipboard(result.connection_string)}>
<Button variant="outline" size="sm" onClick={() => copy(result.connection_string)}>
<CopyIcon /> Copy
</Button>
</div>
@@ -486,7 +483,7 @@ Install-ServerKitAgent -Server "${window.location.origin}" -Token "${result.regi
<span>Linux</span>
<span className="install-tab-description">curl, tar, sudo, and systemd</span>
</div>
<Button variant="outline" size="sm" onClick={() => copyToClipboard(linuxScript)}>
<Button variant="outline" size="sm" onClick={() => copy(linuxScript)}>
<CopyIcon /> Copy
</Button>
</div>
@@ -499,7 +496,7 @@ Install-ServerKitAgent -Server "${window.location.origin}" -Token "${result.regi
<span>Windows (PowerShell)</span>
<span className="install-tab-description">Run as Administrator</span>
</div>
<Button variant="outline" size="sm" onClick={() => copyToClipboard(windowsScript)}>
<Button variant="outline" size="sm" onClick={() => copy(windowsScript)}>
<CopyIcon /> Copy
</Button>
</div>
@@ -1,6 +1,7 @@
import { useToast } from '../../contexts/ToastContext';
import { Button } from '@/components/ui/button';
import { Gauge } from '../ds';
import { copyToClipboard } from '@/utils/clipboard';
// Server status → ds Pill tone (shared by the header pill and the
// Overview "Status" row).
@@ -120,7 +121,7 @@ export const CopyChip = ({ label, value, title, mono }) => {
e.preventDefault();
e.stopPropagation();
if (!value) return;
navigator.clipboard.writeText(value);
copyToClipboard(value);
toast.success(`${label[0].toUpperCase()}${label.slice(1)} copied`);
};
return (
@@ -8,6 +8,7 @@ import DeploymentJobProgress from '../DeploymentJobProgress';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { copyToClipboard } from '@/utils/clipboard';
const LOG_LEVELS = ['all', 'error', 'warn', 'info', 'debug'];
@@ -148,7 +149,7 @@ const LogsTab = ({ app }) => {
}
function handleCopy() {
navigator.clipboard.writeText(rawLogs);
copyToClipboard(rawLogs);
}
const matchCount = searchTerm ? filteredLines.length : null;
@@ -5,6 +5,7 @@ import ApiKeyScopesModal from '../api/ApiKeyScopesModal';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { copyToClipboard } from '@/utils/clipboard';
const TIER_OPTIONS = [
{ value: 'standard', label: 'Standard', desc: '100 req/min' },
@@ -36,9 +37,8 @@ const ApiKeyModal = ({ onClose, onSubmit, createdKey }) => {
}
};
const copyKey = () => {
if (createdKey) {
navigator.clipboard.writeText(createdKey);
const copyKey = async () => {
if (createdKey && await copyToClipboard(createdKey)) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
@@ -12,6 +12,7 @@ import {
Minus, Unlock, ArrowDownLeft, ArrowUpRight
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import { copyToClipboard } from '@/utils/clipboard';
const ICON_CATALOG = {
'General': {
@@ -55,8 +56,8 @@ const IconReferenceTab = () => {
const [searchQuery, setSearchQuery] = useState('');
const [copiedIcon, setCopiedIcon] = useState(null);
function handleCopyImport(name) {
navigator.clipboard.writeText(name);
async function handleCopyImport(name) {
if (!await copyToClipboard(name)) return;
setCopiedIcon(name);
setTimeout(() => setCopiedIcon(null), 1500);
}
@@ -11,6 +11,7 @@ import {
import { useTableSort } from '@/hooks/useTableSort';
import { useColumnVisibility } from '@/hooks/useColumnVisibility';
import EmptyState from '../EmptyState';
import { copyToClipboard } from '@/utils/clipboard';
// A pending invite whose window has closed still carries status 'pending' in
// the database — the row only becomes 'expired' on screen. One accessor for
@@ -112,9 +113,9 @@ const InvitationsTab = () => {
}
}
function copyLink(token) {
async function copyLink(token) {
const url = `${window.location.origin}/register?invite=${token}`;
navigator.clipboard.writeText(url);
if (!await copyToClipboard(url)) return;
setCopied(token);
setTimeout(() => setCopied(null), 2000);
}
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
import { copyToClipboard } from '@/utils/clipboard';
const InviteModal = ({ onClose, onCreated }) => {
const [email, setEmail] = useState('');
@@ -60,9 +61,8 @@ const InviteModal = ({ onClose, onCreated }) => {
}
}
function copyLink() {
if (result?.invite_url) {
navigator.clipboard.writeText(result.invite_url);
async function copyLink() {
if (result?.invite_url && await copyToClipboard(result.invite_url)) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import api from '../../services/api';
import { Button } from '@/components/ui/button';
import { copyToClipboard } from '@/utils/clipboard';
const TTL_OPTIONS = [15, 30, 60];
@@ -63,12 +64,8 @@ const LoginLinksSection = ({ users, currentUserId }) => {
}
async function handleCopy() {
try {
await navigator.clipboard.writeText(minted.url);
setCopied(true);
} catch {
/* clipboard unavailable — the URL stays visible for manual copy */
}
// On failure the URL stays visible for manual copy.
if (await copyToClipboard(minted.url)) setCopied(true);
}
async function handleRevoke(id) {
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { copyToClipboard } from '@/utils/clipboard';
const LinkedAccounts = ({ register }) => {
const { ssoProviders } = useAuth();
@@ -281,7 +282,7 @@ Keep these codes in a safe place.`;
}
function copyBackupCodes() {
navigator.clipboard.writeText(backupCodes.join('\n'));
copyToClipboard(backupCodes.join('\n'));
}
return (
@@ -13,6 +13,7 @@ import { useToast } from '../../contexts/ToastContext';
import { useConfirm } from '../../hooks/useConfirm';
import { Plus, MoreVertical, Copy, RefreshCw, ArrowRightLeft } from 'lucide-react';
import EmptyState from '../EmptyState';
import { copyToClipboard } from '@/utils/clipboard';
const formatDate = (d) => (d ? new Date(d).toLocaleString() : '—');
@@ -274,7 +275,7 @@ export default function WebhooksTab() {
<Label>Secret</Label>
<div className="flex gap-2">
<Input readOnly type="text" value={regeneratedSecret?.secret || ''} />
<Button variant="outline" onClick={() => { navigator.clipboard.writeText(regeneratedSecret?.secret || ''); toast.success('Copied') }}>
<Button variant="outline" onClick={() => { copyToClipboard(regeneratedSecret?.secret || ''); toast.success('Copied') }}>
<Copy size={14} />
</Button>
</div>
@@ -3,6 +3,7 @@ import { ShieldCheck, Copy, Download, AlertTriangle, Check, Loader } from 'lucid
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import api from '../../services/api';
import { copyToClipboard } from '@/utils/clipboard';
// Enrolment is a three-beat flow. Offer is the default so someone who just
// wants a dashboard is one click from moving on — 2FA is offered here because
@@ -73,7 +74,7 @@ const SetupStepSecurity = ({ onComplete }) => {
}
function copyCodes() {
navigator.clipboard?.writeText(backupCodes.join('\n'));
copyToClipboard(backupCodes.join('\n'));
setSavedCodes(true);
}
+4 -4
View File
@@ -34,6 +34,7 @@ import {
engineBrandKey, engineInstanceKey, engineTreeStatus, engineUnit, singular,
} from '../components/databases/engineHelpers';
import { listTables, connKey, connLabel, quoteIdent, ENGINE_META } from '../components/databases/dbAdapter';
import { copyToClipboard } from '@/utils/clipboard';
const SIDEBAR_KEY = 'serverkit-dbx-sidebar';
@@ -562,10 +563,9 @@ export default function Databases() {
}
function copyName(node) {
navigator.clipboard?.writeText(node.label).then(
() => toast.success('Copied name'),
() => toast.error('Could not copy'),
);
copyToClipboard(node.label).then((ok) => (ok
? toast.success('Copied name')
: toast.error('Could not copy')));
}
function ctxActions(node) {
+2 -1
View File
@@ -10,6 +10,7 @@ import LogPane from '../components/deploy-console/LogPane';
import ErrorCard from '../components/deploy-console/ErrorCard';
import SuccessBanner from '../components/deploy-console/SuccessBanner';
import { sourceRef } from '../utils/deployActivity';
import { copyToClipboard } from '@/utils/clipboard';
const STATUS_META = {
pending: { label: 'Queued', icon: Clock, cls: 'pending' },
@@ -189,7 +190,7 @@ export default function DeployConsole() {
const copyLogs = useCallback(() => {
const text = lines.map((l) => l.message).join('\n');
navigator.clipboard?.writeText(text);
copyToClipboard(text);
}, [lines]);
const downloadLogs = useCallback(() => {
+7 -10
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import api from '../services/api';
import { Button } from '@/components/ui/button';
import { useTopbarActions } from '@/hooks/useTopbarActions';
import { copyToClipboard } from '@/utils/clipboard';
// Platform icons as SVG components
const LinuxIcon = () => (
@@ -83,14 +84,10 @@ function Downloads() {
}
};
const copyToClipboard = async (text, commandId) => {
try {
await navigator.clipboard.writeText(text);
setCopiedCommand(commandId);
setTimeout(() => setCopiedCommand(null), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
const copyCommand = async (text, commandId) => {
if (!await copyToClipboard(text)) return;
setCopiedCommand(commandId);
setTimeout(() => setCopiedCommand(null), 2000);
};
const getBaseUrl = () => {
@@ -255,7 +252,7 @@ function Downloads() {
</pre>
<button type="button"
className="copy-btn"
onClick={() => copyToClipboard(platforms[0].command, 'linux')}
onClick={() => copyCommand(platforms[0].command, 'linux')}
title="Copy to clipboard"
>
{copiedCommand === 'linux' ? <CheckIcon /> : <CopyIcon />}
@@ -274,7 +271,7 @@ function Downloads() {
</pre>
<button type="button"
className="copy-btn"
onClick={() => copyToClipboard(platforms[2].command, 'windows')}
onClick={() => copyCommand(platforms[2].command, 'windows')}
title="Copy to clipboard"
>
{copiedCommand === 'windows' ? <CheckIcon /> : <CopyIcon />}
+3 -2
View File
@@ -25,6 +25,7 @@ import PreviewDrawer from '../components/file-manager/PreviewDrawer';
import ContextMenu from '../components/file-manager/ContextMenu';
import TargetPicker from '../components/TargetPicker';
import { TREE_ROOTS, getFileType, formatBytes } from '../components/file-manager/fileTypes';
import { copyToClipboard } from '@/utils/clipboard';
// Demo rail shortcuts (Quick access) — one-click jumps to the paths people
// actually visit on a ServerKit host. "Stack" starts at the default install
@@ -850,8 +851,8 @@ function FileManager() {
};
const copyPathToClipboard = async (path) => {
try { await navigator.clipboard.writeText(path); toast.success('Path copied'); }
catch { toast.error('Could not copy path'); }
if (await copyToClipboard(path)) toast.success('Path copied');
else toast.error('Could not copy path');
};
const downloadSelected = () => {
+7 -6
View File
@@ -26,6 +26,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { copyToClipboard } from '@/utils/clipboard';
const VALID_TABS = ['overview', 'repositories', 'access', 'webhooks', 'deployments', 'settings'];
@@ -697,7 +698,7 @@ function Git({ basePath = '/git' }) {
const copyWebhookUrl = (webhook) => {
const baseUrl = window.location.origin;
const url = `${baseUrl}/api${webhook.webhook_url}`;
navigator.clipboard.writeText(url);
copyToClipboard(url);
toast.success('Webhook URL copied');
};
@@ -1070,7 +1071,7 @@ function Git({ basePath = '/git' }) {
<div className="sk-spec-card__label">Quick actions</div>
<div className="git-quick-actions" style={{ marginTop: 8 }}>
<Button variant="outline" size="sm" onClick={openGitea} disabled={!status?.running}>Open Gitea</Button>
<Button variant="outline" size="sm" onClick={() => { navigator.clipboard.writeText(`git clone ssh://git@${window.location.hostname}:${status?.ssh_port}/user/repo.git`); toast.success('SSH URL copied'); }}>
<Button variant="outline" size="sm" onClick={() => { copyToClipboard(`git clone ssh://git@${window.location.hostname}:${status?.ssh_port}/user/repo.git`); toast.success('SSH URL copied'); }}>
Copy SSH URL
</Button>
</div>
@@ -1165,7 +1166,7 @@ function Git({ basePath = '/git' }) {
<div className="sk-spec-card__value" style={{ fontSize: 13 }}>{getGiteaUrl()}</div>
<div className="sk-spec-card__sub">Web browser access</div>
<div className="git-quick-actions" style={{ marginTop: 12 }}>
<Button variant="outline" size="sm" onClick={() => { navigator.clipboard.writeText(getGiteaUrl()); toast.success('URL copied'); }}>Copy URL</Button>
<Button variant="outline" size="sm" onClick={() => { copyToClipboard(getGiteaUrl()); toast.success('URL copied'); }}>Copy URL</Button>
<Button variant="outline" size="sm" onClick={openGitea} disabled={!status?.running}>Open Gitea</Button>
</div>
</div>
@@ -1174,7 +1175,7 @@ function Git({ basePath = '/git' }) {
<div className="sk-spec-card__value" style={{ fontSize: 13 }}>ssh://git@{window.location.hostname}:{status?.ssh_port}/username/repo.git</div>
<div className="sk-spec-card__sub">Clone repositories via SSH</div>
<div className="git-quick-actions" style={{ marginTop: 12 }}>
<Button variant="outline" size="sm" onClick={() => { navigator.clipboard.writeText(`ssh://git@${window.location.hostname}:${status?.ssh_port}/username/repo.git`); toast.success('SSH URL copied'); }}>Copy SSH URL</Button>
<Button variant="outline" size="sm" onClick={() => { copyToClipboard(`ssh://git@${window.location.hostname}:${status?.ssh_port}/username/repo.git`); toast.success('SSH URL copied'); }}>Copy SSH URL</Button>
</div>
</div>
<div className="sk-spec-card">
@@ -1483,7 +1484,7 @@ function Git({ basePath = '/git' }) {
<ExternalLink size={14} /> Open in Gitea
</Button>
<Button variant="outline" size="sm" onClick={() => {
navigator.clipboard.writeText(`${getGiteaUrl()}/${selectedRepo.owner.login}/${selectedRepo.name}`);
copyToClipboard(`${getGiteaUrl()}/${selectedRepo.owner.login}/${selectedRepo.name}`);
toast.success('Repository URL copied');
}}>
Copy URL
@@ -1875,7 +1876,7 @@ function Git({ basePath = '/git' }) {
</div>
<div className="secret-display">
<code>{webhookSecret}</code>
<Button size="sm" variant="outline" onClick={() => { navigator.clipboard.writeText(webhookSecret); toast.success('Secret copied'); }}>Copy</Button>
<Button size="sm" variant="outline" onClick={() => { copyToClipboard(webhookSecret); toast.success('Secret copied'); }}>Copy</Button>
</div>
<p className="text-muted">Use this secret when configuring the webhook in your repository settings.</p>
<div className="modal-actions">
+3 -6
View File
@@ -12,6 +12,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Pill } from '@/components/ds';
import { useTopbarActions } from '@/hooks/useTopbarActions';
import { copyToClipboard } from '@/utils/clipboard';
import {
Activity,
CheckCircle2,
@@ -295,12 +296,8 @@ const StatusPages = () => {
const handleCopyUrl = async () => {
if (!selectedUrl) return;
try {
await navigator.clipboard.writeText(selectedUrl);
toast.success('Status page URL copied');
} catch {
toast.error('Could not copy URL');
}
if (await copyToClipboard(selectedUrl)) toast.success('Status page URL copied');
else toast.error('Could not copy URL');
};
const handleConfirmDelete = async () => {