fix: model user assignment by service instead of port
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}');
|
||||
|
||||
|
||||
56
src/App.tsx
56
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 (
|
||||
<div className="modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<section
|
||||
@@ -112,13 +124,31 @@ function AddUserModal({ onClose }: { onClose: () => void }) {
|
||||
<input placeholder="generated secret" />
|
||||
</label>
|
||||
<label>
|
||||
Port
|
||||
<input placeholder="1080" />
|
||||
Service
|
||||
<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>
|
||||
Quota (MB)
|
||||
<input placeholder="Optional" />
|
||||
</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">
|
||||
<button type="button" className="button-secondary" onClick={onClose}>
|
||||
Cancel
|
||||
@@ -269,7 +299,19 @@ function UsersTab() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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);
|
||||
|
||||
return (
|
||||
@@ -279,7 +321,12 @@ function UsersTab() {
|
||||
<strong>{user.username}</strong>
|
||||
</div>
|
||||
</td>
|
||||
<td>{`${user.host}:${user.port}`}</td>
|
||||
<td>
|
||||
<div className="endpoint-cell">
|
||||
<strong>{service?.name ?? 'Unknown service'}</strong>
|
||||
<span>{endpoint}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`status-pill ${getServiceTone(user.status)}`}>{user.status}</span>
|
||||
</td>
|
||||
@@ -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'}
|
||||
</button>
|
||||
|
||||
36
src/app.css
36
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user