From 23d36ddf3d3ecc3b401925187d7c384e04e639b4 Mon Sep 17 00:00:00 2001 From: rednakse Date: Wed, 1 Apr 2026 23:16:46 +0300 Subject: [PATCH] feat: move user creation into modal flow --- docs/PLAN.md | 1 + docs/PROJECT_INDEX.md | 4 +- src/App.test.tsx | 26 +++++++- src/App.tsx | 135 +++++++++++++++++++++++++++++------------- src/app.css | 114 ++++++++++++++++++++++++++++------- 5 files changed, 213 insertions(+), 67 deletions(-) diff --git a/docs/PLAN.md b/docs/PLAN.md index 3ecc726..a89f422 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -19,3 +19,4 @@ Updated: 2026-04-01 3. Implemented the first UI slice with hardcoded panel auth, operator-focused dashboard, users table, and system config preview. 4. Added paranoia-oriented tests for login gating, proxy link encoding, quota edge cases, and traffic share formatting. 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. diff --git a/docs/PROJECT_INDEX.md b/docs/PROJECT_INDEX.md index 3a86039..d1dd40c 100644 --- a/docs/PROJECT_INDEX.md +++ b/docs/PROJECT_INDEX.md @@ -18,8 +18,8 @@ Updated: 2026-04-01 ## Frontend - `src/main.tsx`: application bootstrap -- `src/App.tsx`: authenticated panel shell and tab composition -- `src/App.test.tsx`: login-gate component tests +- `src/App.tsx`: authenticated panel shell, modal user creation flow, and tab composition +- `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/lib/3proxy.ts`: formatting and status helpers diff --git a/src/App.test.tsx b/src/App.test.tsx index 1db8154..7b59374 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -3,6 +3,12 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it } from 'vitest'; import App from './App'; +async function loginIntoPanel(user: ReturnType) { + await user.type(screen.getByLabelText(/login/i), 'admin'); + await user.type(screen.getByLabelText(/password/i), 'proxy-ui-demo'); + await user.click(screen.getByRole('button', { name: /open panel/i })); +} + describe('App login gate', () => { it('rejects wrong hardcoded credentials and keeps the panel locked', async () => { const user = userEvent.setup(); @@ -20,11 +26,25 @@ describe('App login gate', () => { const user = userEvent.setup(); render(); - await user.type(screen.getByLabelText(/login/i), 'admin'); - await user.type(screen.getByLabelText(/password/i), 'proxy-ui-demo'); - await user.click(screen.getByRole('button', { name: /open panel/i })); + await loginIntoPanel(user); expect(screen.getByRole('navigation', { name: /primary/i })).toBeInTheDocument(); expect(screen.getByText(/control panel/i)).toBeInTheDocument(); }); + + it('opens add-user flow in a modal and closes it on escape', async () => { + const user = userEvent.setup(); + render(); + + await loginIntoPanel(user); + 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(); + expect(screen.getByPlaceholderText(/night-shift-01/i)).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + + expect(screen.queryByRole('dialog', { name: /add user/i })).not.toBeInTheDocument(); + }); }); diff --git a/src/App.tsx b/src/App.tsx index 103e61d..ca8213f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { FormEvent, useMemo, useState } from 'react'; +import { FormEvent, KeyboardEvent, useEffect, useMemo, useState } from 'react'; import './app.css'; import { dashboardSnapshot, panelAuth } from './data/mockDashboard'; import { @@ -12,10 +12,10 @@ import { type TabId = 'dashboard' | 'users' | 'system'; -const tabs: Array<{ id: TabId; label: string; description: string }> = [ - { id: 'dashboard', label: 'Dashboard', description: 'Health, traffic, quick actions' }, - { id: 'users', label: 'Users', description: 'Accounts, quotas, quick-copy links' }, - { id: 'system', label: 'System', description: 'Config profile, ports, runtime controls' }, +const tabs: Array<{ id: TabId; label: string }> = [ + { id: 'dashboard', label: 'Dashboard' }, + { id: 'users', label: 'Users' }, + { id: 'system', label: 'System' }, ]; function LoginGate({ onUnlock }: { onUnlock: () => void }) { @@ -155,8 +155,77 @@ function DashboardTab() { ); } +function AddUserModal({ + onClose, +}: { + onClose: () => void; +}) { + useEffect(() => { + const handleKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const stopPropagation = (event: KeyboardEvent) => { + event.stopPropagation(); + }; + + return ( +
+
+
+
+

Users

+

Add user

+
+ +
+
+ + + + +
+ + +
+
+
+
+ ); +} + function UsersTab() { const [copiedId, setCopiedId] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); const handleCopy = async (userId: string, proxyLink: string) => { await navigator.clipboard.writeText(proxyLink); @@ -166,50 +235,28 @@ function UsersTab() { return (
-
-

Add user

-

New account

-
- - - - - -
-

- Final flow will write `users`, `allow`, and quota counters into generated runtime files. -

-
-

Users

Accounts and usage

- {dashboardSnapshot.userRecords.length} rows +
+ {dashboardSnapshot.userRecords.length} rows + +
+ - - - + + @@ -223,15 +270,15 @@ function UsersTab() { + -
UserEndpoint StatusTrafficQuotaShareUsedRemaining Proxy
{user.username} - {user.host} + {user.port}
{`${user.host}:${user.port}`} {user.status} {formatBytes(user.usedBytes)} {formatQuotaState(user.usedBytes, user.quotaBytes)}{formatTrafficShare(user.usedBytes, dashboardSnapshot.traffic.totalBytes)}
+
+ Total traffic share is derived from current snapshot: {formatTrafficShare( + dashboardSnapshot.userRecords.reduce((sum, user) => sum + user.usedBytes, 0), + dashboardSnapshot.traffic.totalBytes, + )} +
+ + {isModalOpen ? setIsModalOpen(false)} /> : null}
); } @@ -326,7 +381,6 @@ export default function App() {

3proxy UI

Control panel

-

Dashboard, users, and system settings.

@@ -358,8 +411,8 @@ export default function App() {

{dashboardSnapshot.system.publicHost}

+ {dashboardSnapshot.service.status} {dashboardSnapshot.service.versionLabel} - {dashboardSnapshot.service.uptimeLabel}
diff --git a/src/app.css b/src/app.css index 1cac8dc..dcfc563 100644 --- a/src/app.css +++ b/src/app.css @@ -102,19 +102,19 @@ button { .sidebar-foot p, .service-row span, .user-cell span, -.nav-item small { +.table-foot { margin: 0; color: var(--muted); } .login-form, -.user-form { +.modal-form { display: grid; gap: 14px; } .login-form label, -.user-form label { +.modal-form label { display: grid; gap: 6px; color: var(--text); @@ -122,7 +122,7 @@ button { } .login-form input, -.user-form input { +.modal-form input { width: 100%; padding: 10px 12px; border-radius: 10px; @@ -132,17 +132,18 @@ button { } .login-form input:focus, -.user-form input:focus { +.modal-form input:focus { outline: 2px solid rgba(31, 111, 235, 0.18); border-color: var(--accent); } .login-form button, .action-row button, -.user-form button, +.modal-form button, .copy-link, .danger-link, -.nav-item { +.nav-item, +.ghost-button { transition: background-color 0.15s ease, border-color 0.15s ease, @@ -151,7 +152,8 @@ button { .login-form button, .action-row button, -.user-form button { +.modal-form button, +.header-actions button { padding: 10px 14px; border-radius: 10px; border: 1px solid var(--accent); @@ -168,7 +170,11 @@ button { .login-form button:hover, .action-row button:hover, -.user-form button:hover { +.modal-form button:hover, +.header-actions button:hover, +.ghost-button:hover, +.copy-link:hover, +.danger-link:hover { filter: brightness(0.98); } @@ -189,7 +195,7 @@ button { .app-shell { display: grid; - grid-template-columns: 260px minmax(0, 1fr); + grid-template-columns: 220px minmax(0, 1fr); gap: 16px; padding: 16px; } @@ -206,11 +212,11 @@ button { .brand-block { display: grid; - gap: 8px; + gap: 4px; } .brand-block h1 { - font-size: 1.25rem; + font-size: 1.12rem; } .nav-list { @@ -219,14 +225,14 @@ button { } .nav-item { - padding: 12px; - border-radius: 10px; + padding: 10px 12px; + border-radius: 8px; text-align: left; border: 1px solid transparent; background: transparent; color: var(--text); - display: grid; - gap: 2px; + display: block; + font-weight: 500; } .nav-item.active { @@ -247,7 +253,7 @@ button { } .topbar { - padding: 18px 20px; + padding: 14px 18px; border-radius: 14px; display: flex; justify-content: space-between; @@ -260,7 +266,7 @@ button { gap: 10px; flex-wrap: wrap; color: var(--muted); - font-size: 0.92rem; + font-size: 0.88rem; } .tab-grid { @@ -269,16 +275,19 @@ button { grid-template-columns: repeat(2, minmax(0, 1fr)); } -.users-grid, +.users-grid { + grid-template-columns: 1fr; +} + .system-grid { grid-template-columns: minmax(280px, 1fr) minmax(0, 2fr); } .panel-card { border-radius: 14px; - padding: 20px; + padding: 18px; display: grid; - gap: 16px; + gap: 14px; min-width: 0; } @@ -388,6 +397,12 @@ button { align-items: start; } +.header-actions { + display: flex; + gap: 10px; + align-items: center; +} + .table-wrap { overflow: auto; border: 1px solid var(--border); @@ -480,6 +495,10 @@ tbody tr:last-child td { grid-column: 1 / -1; } +.table-foot { + font-size: 0.88rem; +} + pre { margin: 0; padding: 14px; @@ -493,6 +512,51 @@ pre { line-height: 1.6; } +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.18); + display: grid; + place-items: center; + padding: 16px; +} + +.modal-card { + width: min(100%, 480px); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.12); + padding: 18px; + display: grid; + gap: 16px; +} + +.modal-header, +.modal-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; +} + +.modal-form { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.modal-actions { + grid-column: 1 / -1; + justify-content: end; +} + +.ghost-button { + padding: 8px 12px; + border-radius: 10px; + border: 1px solid var(--border-strong); + background: var(--surface); + color: var(--text); +} + @media (max-width: 1120px) { .app-shell { grid-template-columns: 1fr; @@ -527,6 +591,14 @@ pre { align-items: start; } + .header-actions, + .modal-form, + .modal-actions { + grid-template-columns: 1fr; + flex-direction: column; + align-items: stretch; + } + .sparkline-row { grid-template-columns: 52px minmax(0, 1fr); }