mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-23 03:27:43 +09:00
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:
1 parent
9ff957edf7
commit
e1a5a5e102
26 files changed
+89
-119
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user