Fix proxy copy on plain HTTP

This commit is contained in:
2026-04-02 04:07:54 +03:00
parent 49b41edcb0
commit eb64f70269
4 changed files with 79 additions and 6 deletions

View File

@@ -1,7 +1,7 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import App from './App';
import App, { copyTextToClipboard } from './App';
import { fallbackDashboardSnapshot } from './data/mockDashboard';
import { MockWebSocket } from './test/setup';
@@ -11,6 +11,15 @@ async function loginIntoPanel(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: /open panel/i }));
}
function mockClipboardWriteText(writeText: ReturnType<typeof vi.fn>) {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText,
},
});
}
beforeEach(() => {
document.documentElement.dataset.theme = '';
window.sessionStorage.clear();
@@ -128,6 +137,31 @@ describe('App login gate', () => {
expect(screen.getAllByText(/^now$/i).length).toBeGreaterThan(0);
});
it('copies the proxy link via the Clipboard API when available', async () => {
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
mockClipboardWriteText(clipboardWriteText);
await copyTextToClipboard('socks5://night-shift:kettle!23@edge.example.net:1080');
expect(clipboardWriteText).toHaveBeenCalledWith('socks5://night-shift:kettle!23@edge.example.net:1080');
});
it('falls back to execCommand copy when the Clipboard API is unavailable', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
});
const execCommand = vi.fn(() => true);
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: execCommand,
});
await copyTextToClipboard('socks5://night-shift:kettle!23@edge.example.net:1080');
expect(execCommand).toHaveBeenCalledWith('copy');
});
it('opens add-user flow in a modal and closes it on escape', async () => {
const user = userEvent.setup();
render(<App />);

View File

@@ -49,6 +49,40 @@ interface StoredSession {
expiresAt: string;
}
export async function copyTextToClipboard(value: string): Promise<void> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
return;
} catch {
// Fall back for plain-http deployments where Clipboard API may reject the request.
}
}
if (typeof document.execCommand !== 'function') {
throw new Error('Clipboard copy is unavailable.');
}
const input = document.createElement('textarea');
input.value = value;
input.setAttribute('readonly', '');
input.style.position = 'fixed';
input.style.opacity = '0';
input.style.pointerEvents = 'none';
document.body.appendChild(input);
input.focus();
input.select();
input.setSelectionRange(0, input.value.length);
try {
if (!document.execCommand('copy')) {
throw new Error('Clipboard copy was rejected.');
}
} finally {
document.body.removeChild(input);
}
}
function LoginGate({
onUnlock,
preferences,
@@ -484,9 +518,13 @@ function UsersTab({
const editTarget = snapshot.userRecords.find((user) => user.id === editTargetId) ?? null;
const handleCopy = async (userId: string, proxyLink: string) => {
await navigator.clipboard.writeText(proxyLink);
setCopiedId(userId);
window.setTimeout(() => setCopiedId((value) => (value === userId ? null : value)), 1200);
try {
await copyTextToClipboard(proxyLink);
setCopiedId(userId);
window.setTimeout(() => setCopiedId((value) => (value === userId ? null : value)), 1200);
} catch {
// Keep the current UI stable when clipboard access is blocked entirely.
}
};
const handlePauseToggle = async (userId: string) => {