From f1f5caea0648787594dd0570817ffd4b5f091334 Mon Sep 17 00:00:00 2001 From: rednakse Date: Wed, 1 Apr 2026 23:33:47 +0300 Subject: [PATCH] fix: model user assignment by service instead of port --- docs/PLAN.md | 1 + docs/PROJECT_INDEX.md | 2 +- src/App.test.tsx | 9 +++++-- src/App.tsx | 56 ++++++++++++++++++++++++++++++++++++--- src/app.css | 36 ++++++++++++++++++++++--- src/data/mockDashboard.ts | 36 +++++++++++++++++-------- 6 files changed, 119 insertions(+), 21 deletions(-) diff --git a/docs/PLAN.md b/docs/PLAN.md index 04fb45b..396d1c2 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -21,3 +21,4 @@ Updated: 2026-04-01 5. Simplified the UI into a calmer minimalist layout with reduced visual noise and denser operational presentation. 6. Moved user creation into a modal flow and tightened the operator UX with quieter navigation and a denser users table. 7. Rebuilt the UI shell from scratch around a stable topbar/tab layout with fixed typography and lower visual noise across window sizes. +8. Corrected the user-creation flow to select a 3proxy service instead of assigning a per-user port, matching the documented 3proxy model. diff --git a/docs/PROJECT_INDEX.md b/docs/PROJECT_INDEX.md index 63d1b22..e04ecb7 100644 --- a/docs/PROJECT_INDEX.md +++ b/docs/PROJECT_INDEX.md @@ -21,7 +21,7 @@ Updated: 2026-04-01 - `src/App.tsx`: authenticated panel shell rebuilt around a topbar/tab layout, plus modal user creation flow - `src/App.test.tsx`: login-gate and modal interaction tests - `src/app.css`: full panel styling -- `src/data/mockDashboard.ts`: realistic mock state shaped for future API responses +- `src/data/mockDashboard.ts`: realistic mock state shaped for future API responses, including service-bound users - `src/lib/3proxy.ts`: formatting and status helpers - `src/lib/3proxy.test.ts`: paranoia-oriented tests for core domain rules - `src/test/setup.ts`: Testing Library matchers diff --git a/src/App.test.tsx b/src/App.test.tsx index 7102d2e..d868414 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it } from 'vitest'; import App from './App'; @@ -40,8 +40,13 @@ describe('App login gate', () => { await user.click(screen.getByRole('button', { name: /users/i })); await user.click(screen.getByRole('button', { name: /new user/i })); - expect(screen.getByRole('dialog', { name: /add user/i })).toBeInTheDocument(); + const dialog = screen.getByRole('dialog', { name: /add user/i }); + + expect(dialog).toBeInTheDocument(); expect(screen.getByPlaceholderText(/night-shift-01/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/service/i)).toBeInTheDocument(); + expect(screen.queryByLabelText(/port/i)).not.toBeInTheDocument(); + expect(within(dialog).getByText(/edge\.example\.net:1080/i)).toBeInTheDocument(); await user.keyboard('{Escape}'); diff --git a/src/App.tsx b/src/App.tsx index 7d53dbd..065d896 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,14 @@ const tabs: Array<{ id: TabId; label: string }> = [ { id: 'system', label: 'System' }, ]; +const assignableServices = dashboardSnapshot.system.services.filter( + (service) => service.enabled && service.assignable, +); + +const servicesById = new Map( + dashboardSnapshot.system.services.map((service) => [service.id, service] as const), +); + function LoginGate({ onUnlock }: { onUnlock: () => void }) { const [login, setLogin] = useState(''); const [password, setPassword] = useState(''); @@ -71,6 +79,8 @@ function LoginGate({ onUnlock }: { onUnlock: () => void }) { } function AddUserModal({ onClose }: { onClose: () => void }) { + const [serviceId, setServiceId] = useState(assignableServices[0]?.id ?? ''); + useEffect(() => { const handleKeyDown = (event: globalThis.KeyboardEvent) => { if (event.key === 'Escape') { @@ -87,6 +97,8 @@ function AddUserModal({ onClose }: { onClose: () => void }) { event.stopPropagation(); }; + const selectedService = servicesById.get(serviceId); + return (
void }) { +
+ Endpoint + + {selectedService + ? `${dashboardSnapshot.system.publicHost}:${selectedService.port}` + : 'Unavailable'} + +
+
+ Protocol + {selectedService ? selectedService.protocol : 'Unavailable'} +
- {`${user.host}:${user.port}`} + +
+ {service?.name ?? 'Unknown service'} + {endpoint} +
+ {user.status} @@ -291,6 +338,7 @@ function UsersTab() { type="button" className={exhausted ? 'copy-button danger' : 'copy-button'} onClick={() => handleCopy(user.id, proxyLink)} + disabled={!service} > {copiedId === user.id ? 'Copied' : 'Copy'} diff --git a/src/app.css b/src/app.css index 5f4609b..b6dad65 100644 --- a/src/app.css +++ b/src/app.css @@ -42,7 +42,8 @@ body { } button, -input { +input, +select { font: inherit; } @@ -112,7 +113,8 @@ button { } .login-form input, -.modal-form input { +.modal-form input, +.modal-form select { width: 100%; height: 40px; padding: 0 12px; @@ -123,7 +125,8 @@ button { } .login-form input:focus, -.modal-form input:focus { +.modal-form input:focus, +.modal-form select:focus { outline: 2px solid rgba(37, 99, 235, 0.12); border-color: var(--accent); } @@ -448,6 +451,15 @@ tbody tr:last-child td { font-size: 14px; } +.endpoint-cell { + display: grid; + gap: 2px; +} + +.endpoint-cell span { + color: var(--muted); +} + .status-pill { display: inline-flex; align-items: center; @@ -515,6 +527,24 @@ pre { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.modal-preview { + display: grid; + gap: 4px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-muted); +} + +.modal-preview span { + color: var(--muted); + font-size: 12px; +} + +.modal-preview strong { + font-size: 14px; +} + .modal-actions { grid-column: 1 / -1; justify-content: flex-end; diff --git a/src/data/mockDashboard.ts b/src/data/mockDashboard.ts index 5125d95..1a98adf 100644 --- a/src/data/mockDashboard.ts +++ b/src/data/mockDashboard.ts @@ -51,8 +51,7 @@ export const dashboardSnapshot = { id: 'u-1', username: 'night-shift', password: 'kettle!23', - host: 'edge.example.net', - port: 1080, + serviceId: 'socks-main', status: 'live' as const, usedBytes: 621_805_568, quotaBytes: 1_073_741_824, @@ -61,8 +60,7 @@ export const dashboardSnapshot = { id: 'u-2', username: 'ops-east', password: 'east/line', - host: 'edge.example.net', - port: 1080, + serviceId: 'socks-main', status: 'warn' as const, usedBytes: 949_010_432, quotaBytes: 1_073_741_824, @@ -71,8 +69,7 @@ export const dashboardSnapshot = { id: 'u-3', username: 'lab-unlimited', password: 'open lane', - host: 'edge.example.net', - port: 2080, + serviceId: 'socks-lab', status: 'idle' as const, usedBytes: 42_844_160, quotaBytes: null, @@ -81,8 +78,7 @@ export const dashboardSnapshot = { id: 'u-4', username: 'burst-user', password: 'spent-all', - host: 'edge.example.net', - port: 2080, + serviceId: 'socks-lab', status: 'fail' as const, usedBytes: 1_228_800_000, quotaBytes: 1_073_741_824, @@ -95,22 +91,40 @@ export const dashboardSnapshot = { storageMode: 'flat files for config and counters', services: [ { - name: 'socks', + id: 'socks-main', + name: 'SOCKS5 main', + protocol: 'socks5', description: 'Primary SOCKS5 entrypoint with user auth.', port: 1080, enabled: true, + assignable: true, }, { - name: 'admin', + id: 'socks-lab', + name: 'SOCKS5 lab', + protocol: 'socks5', + description: 'Secondary SOCKS5 service for lab and overflow users.', + port: 2080, + enabled: true, + assignable: true, + }, + { + id: 'admin', + name: 'Admin', + protocol: 'http', description: 'Restricted admin visibility endpoint.', port: 8081, enabled: true, + assignable: false, }, { - name: 'proxy', + id: 'proxy', + name: 'HTTP proxy', + protocol: 'http', description: 'Optional HTTP/HTTPS proxy profile.', port: 3128, enabled: false, + assignable: true, }, ], previewConfig: `daemon