fix: model user assignment by service instead of port

This commit is contained in:
2026-04-01 23:33:47 +03:00
parent 15f8748be1
commit f1f5caea06
6 changed files with 119 additions and 21 deletions

View File

@@ -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. 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. 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. 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.

View File

@@ -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.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.test.tsx`: login-gate and modal interaction tests
- `src/app.css`: full panel styling - `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.ts`: formatting and status helpers
- `src/lib/3proxy.test.ts`: paranoia-oriented tests for core domain rules - `src/lib/3proxy.test.ts`: paranoia-oriented tests for core domain rules
- `src/test/setup.ts`: Testing Library matchers - `src/test/setup.ts`: Testing Library matchers

View File

@@ -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 userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import App from './App'; 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: /users/i }));
await user.click(screen.getByRole('button', { name: /new user/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.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}'); await user.keyboard('{Escape}');

View File

@@ -18,6 +18,14 @@ const tabs: Array<{ id: TabId; label: string }> = [
{ id: 'system', label: 'System' }, { 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 }) { function LoginGate({ onUnlock }: { onUnlock: () => void }) {
const [login, setLogin] = useState(''); const [login, setLogin] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -71,6 +79,8 @@ function LoginGate({ onUnlock }: { onUnlock: () => void }) {
} }
function AddUserModal({ onClose }: { onClose: () => void }) { function AddUserModal({ onClose }: { onClose: () => void }) {
const [serviceId, setServiceId] = useState(assignableServices[0]?.id ?? '');
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: globalThis.KeyboardEvent) => { const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
@@ -87,6 +97,8 @@ function AddUserModal({ onClose }: { onClose: () => void }) {
event.stopPropagation(); event.stopPropagation();
}; };
const selectedService = servicesById.get(serviceId);
return ( return (
<div className="modal-backdrop" role="presentation" onClick={onClose}> <div className="modal-backdrop" role="presentation" onClick={onClose}>
<section <section
@@ -112,13 +124,31 @@ function AddUserModal({ onClose }: { onClose: () => void }) {
<input placeholder="generated secret" /> <input placeholder="generated secret" />
</label> </label>
<label> <label>
Port Service
<input placeholder="1080" /> <select value={serviceId} onChange={(event) => setServiceId(event.target.value)}>
{assignableServices.map((service) => (
<option key={service.id} value={service.id}>
{service.name}
</option>
))}
</select>
</label> </label>
<label> <label>
Quota (MB) Quota (MB)
<input placeholder="Optional" /> <input placeholder="Optional" />
</label> </label>
<div className="modal-preview">
<span>Endpoint</span>
<strong>
{selectedService
? `${dashboardSnapshot.system.publicHost}:${selectedService.port}`
: 'Unavailable'}
</strong>
</div>
<div className="modal-preview">
<span>Protocol</span>
<strong>{selectedService ? selectedService.protocol : 'Unavailable'}</strong>
</div>
<div className="modal-actions"> <div className="modal-actions">
<button type="button" className="button-secondary" onClick={onClose}> <button type="button" className="button-secondary" onClick={onClose}>
Cancel Cancel
@@ -269,7 +299,19 @@ function UsersTab() {
</thead> </thead>
<tbody> <tbody>
{dashboardSnapshot.userRecords.map((user) => { {dashboardSnapshot.userRecords.map((user) => {
const proxyLink = buildProxyLink(user.username, user.password, user.host, user.port); const service = servicesById.get(user.serviceId);
const endpoint = service
? `${dashboardSnapshot.system.publicHost}:${service.port}`
: 'service missing';
const proxyLink = service
? buildProxyLink(
user.username,
user.password,
dashboardSnapshot.system.publicHost,
service.port,
service.protocol,
)
: '';
const exhausted = isQuotaExceeded(user.usedBytes, user.quotaBytes); const exhausted = isQuotaExceeded(user.usedBytes, user.quotaBytes);
return ( return (
@@ -279,7 +321,12 @@ function UsersTab() {
<strong>{user.username}</strong> <strong>{user.username}</strong>
</div> </div>
</td> </td>
<td>{`${user.host}:${user.port}`}</td> <td>
<div className="endpoint-cell">
<strong>{service?.name ?? 'Unknown service'}</strong>
<span>{endpoint}</span>
</div>
</td>
<td> <td>
<span className={`status-pill ${getServiceTone(user.status)}`}>{user.status}</span> <span className={`status-pill ${getServiceTone(user.status)}`}>{user.status}</span>
</td> </td>
@@ -291,6 +338,7 @@ function UsersTab() {
type="button" type="button"
className={exhausted ? 'copy-button danger' : 'copy-button'} className={exhausted ? 'copy-button danger' : 'copy-button'}
onClick={() => handleCopy(user.id, proxyLink)} onClick={() => handleCopy(user.id, proxyLink)}
disabled={!service}
> >
{copiedId === user.id ? 'Copied' : 'Copy'} {copiedId === user.id ? 'Copied' : 'Copy'}
</button> </button>

View File

@@ -42,7 +42,8 @@ body {
} }
button, button,
input { input,
select {
font: inherit; font: inherit;
} }
@@ -112,7 +113,8 @@ button {
} }
.login-form input, .login-form input,
.modal-form input { .modal-form input,
.modal-form select {
width: 100%; width: 100%;
height: 40px; height: 40px;
padding: 0 12px; padding: 0 12px;
@@ -123,7 +125,8 @@ button {
} }
.login-form input:focus, .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); outline: 2px solid rgba(37, 99, 235, 0.12);
border-color: var(--accent); border-color: var(--accent);
} }
@@ -448,6 +451,15 @@ tbody tr:last-child td {
font-size: 14px; font-size: 14px;
} }
.endpoint-cell {
display: grid;
gap: 2px;
}
.endpoint-cell span {
color: var(--muted);
}
.status-pill { .status-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -515,6 +527,24 @@ pre {
grid-template-columns: repeat(2, minmax(0, 1fr)); 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 { .modal-actions {
grid-column: 1 / -1; grid-column: 1 / -1;
justify-content: flex-end; justify-content: flex-end;

View File

@@ -51,8 +51,7 @@ export const dashboardSnapshot = {
id: 'u-1', id: 'u-1',
username: 'night-shift', username: 'night-shift',
password: 'kettle!23', password: 'kettle!23',
host: 'edge.example.net', serviceId: 'socks-main',
port: 1080,
status: 'live' as const, status: 'live' as const,
usedBytes: 621_805_568, usedBytes: 621_805_568,
quotaBytes: 1_073_741_824, quotaBytes: 1_073_741_824,
@@ -61,8 +60,7 @@ export const dashboardSnapshot = {
id: 'u-2', id: 'u-2',
username: 'ops-east', username: 'ops-east',
password: 'east/line', password: 'east/line',
host: 'edge.example.net', serviceId: 'socks-main',
port: 1080,
status: 'warn' as const, status: 'warn' as const,
usedBytes: 949_010_432, usedBytes: 949_010_432,
quotaBytes: 1_073_741_824, quotaBytes: 1_073_741_824,
@@ -71,8 +69,7 @@ export const dashboardSnapshot = {
id: 'u-3', id: 'u-3',
username: 'lab-unlimited', username: 'lab-unlimited',
password: 'open lane', password: 'open lane',
host: 'edge.example.net', serviceId: 'socks-lab',
port: 2080,
status: 'idle' as const, status: 'idle' as const,
usedBytes: 42_844_160, usedBytes: 42_844_160,
quotaBytes: null, quotaBytes: null,
@@ -81,8 +78,7 @@ export const dashboardSnapshot = {
id: 'u-4', id: 'u-4',
username: 'burst-user', username: 'burst-user',
password: 'spent-all', password: 'spent-all',
host: 'edge.example.net', serviceId: 'socks-lab',
port: 2080,
status: 'fail' as const, status: 'fail' as const,
usedBytes: 1_228_800_000, usedBytes: 1_228_800_000,
quotaBytes: 1_073_741_824, quotaBytes: 1_073_741_824,
@@ -95,22 +91,40 @@ export const dashboardSnapshot = {
storageMode: 'flat files for config and counters', storageMode: 'flat files for config and counters',
services: [ services: [
{ {
name: 'socks', id: 'socks-main',
name: 'SOCKS5 main',
protocol: 'socks5',
description: 'Primary SOCKS5 entrypoint with user auth.', description: 'Primary SOCKS5 entrypoint with user auth.',
port: 1080, port: 1080,
enabled: true, 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.', description: 'Restricted admin visibility endpoint.',
port: 8081, port: 8081,
enabled: true, enabled: true,
assignable: false,
}, },
{ {
name: 'proxy', id: 'proxy',
name: 'HTTP proxy',
protocol: 'http',
description: 'Optional HTTP/HTTPS proxy profile.', description: 'Optional HTTP/HTTPS proxy profile.',
port: 3128, port: 3128,
enabled: false, enabled: false,
assignable: true,
}, },
], ],
previewConfig: `daemon previewConfig: `daemon