Fix proxy copy on plain HTTP
This commit is contained in:
@@ -46,3 +46,4 @@ Updated: 2026-04-02
|
||||
29. Added backend user-update support plus runtime stop control, then verified both in Docker by updating `u-1` and stopping the real bundled 3proxy process through the API.
|
||||
30. Added a websocket heartbeat so time-based status transitions such as `live -> idle/warn` are recalculated predictably even when no new proxy events arrive.
|
||||
31. Moved proxy-copy into the Users actions column, added a last-seen/online column from parsed 3proxy logs, and introduced bounded websocket/API reconnect attempts with a visible connection banner and forced logout after full recovery failure.
|
||||
32. Restored proxy-link copying for plain-`http` deployments by falling back from the Clipboard API to `execCommand('copy')`, and added regression coverage for both clipboard paths.
|
||||
|
||||
@@ -23,9 +23,9 @@ Updated: 2026-04-02
|
||||
## Frontend
|
||||
|
||||
- `src/main.tsx`: application bootstrap
|
||||
- `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, hash-based tab history, websocket snapshot patch sync, bounded reconnect/API fallback policy with connection notices, log-derived last-seen user labels, icon-based user actions, create/edit user modal flows, runtime stop control, localized labels, early theme application, and protected panel mutations
|
||||
- `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, hash-based tab history, websocket snapshot patch sync, bounded reconnect/API fallback policy with connection notices, log-derived last-seen user labels, icon-based user actions, HTTP-safe proxy-link copying fallback, create/edit user modal flows, runtime stop control, localized labels, early theme application, and protected panel mutations
|
||||
- `src/SystemTab.tsx`: Settings tab with separate panel-settings and services cards, editable proxy endpoint, dirty-draft protection against incoming live sync, unified service type editing, remove confirmation, and generated config preview
|
||||
- `src/App.test.tsx`: login-gate, preferences persistence, hash-tab restoration, websocket-sync safety, reconnect/logout fallback handling, generated-credential/create-edit modal flows, runtime stop, pause/resume, delete-confirm, and settings-save UI tests
|
||||
- `src/App.test.tsx`: login-gate, preferences persistence, hash-tab restoration, websocket-sync safety, reconnect/logout fallback handling, clipboard fallback coverage, generated-credential/create-edit modal flows, runtime stop, pause/resume, delete-confirm, and settings-save UI tests
|
||||
- `src/app.css`: full panel styling including fixed-width icon action buttons, busy-state treatment, and connection banner styling
|
||||
- `src/data/mockDashboard.ts`: default panel state and frontend fallback snapshot
|
||||
- `src/lib/3proxy.ts`: formatting and status helpers
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
44
src/App.tsx
44
src/App.tsx
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user