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.
|
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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}');
|
||||||
|
|
||||||
|
|||||||
56
src/App.tsx
56
src/App.tsx
@@ -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>
|
||||||
|
|||||||
36
src/app.css
36
src/app.css
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user