From 5a2dc43a06fe61f7a2efe2f4e0484c5ef63a79d7 Mon Sep 17 00:00:00 2001 From: rednakse Date: Wed, 1 Apr 2026 23:40:09 +0300 Subject: [PATCH] feat: add pause and delete actions for users --- docs/PLAN.md | 1 + docs/PROJECT_INDEX.md | 4 +- src/App.test.tsx | 36 ++++++++++++ src/App.tsx | 127 ++++++++++++++++++++++++++++++++++++++++-- src/app.css | 34 ++++++++++- src/lib/3proxy.ts | 2 +- 6 files changed, 194 insertions(+), 10 deletions(-) diff --git a/docs/PLAN.md b/docs/PLAN.md index c8fe014..ffc8b15 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -23,3 +23,4 @@ Updated: 2026-04-01 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. 9. Stabilized the Users table copy action so the column no longer shifts when the button label changes to `Copied`. +10. Added operator actions in the Users table for pause/resume and delete with confirmation modal coverage. diff --git a/docs/PROJECT_INDEX.md b/docs/PROJECT_INDEX.md index e04ecb7..98c84a8 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 rebuilt around a topbar/tab layout, plus modal user creation flow -- `src/App.test.tsx`: login-gate and modal interaction tests +- `src/App.tsx`: authenticated panel shell rebuilt around a topbar/tab layout, plus modal user creation, pause/resume, and delete-confirm flows +- `src/App.test.tsx`: login-gate, modal interaction, pause/resume, and delete-confirm tests - `src/app.css`: full panel styling - `src/data/mockDashboard.ts`: realistic mock state shaped for future API responses, including service-bound users - `src/lib/3proxy.ts`: formatting and status helpers diff --git a/src/App.test.tsx b/src/App.test.tsx index d868414..2772946 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -52,4 +52,40 @@ describe('App login gate', () => { expect(screen.queryByRole('dialog', { name: /add user/i })).not.toBeInTheDocument(); }); + + it('pauses and resumes a user profile from the table', async () => { + const user = userEvent.setup(); + render(); + + await loginIntoPanel(user); + await user.click(screen.getByRole('button', { name: /users/i })); + + await user.click(screen.getAllByRole('button', { name: /pause/i })[0]); + expect(screen.getByText(/^paused$/i)).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: /resume/i })).toHaveLength(1); + + await user.click(screen.getByRole('button', { name: /resume/i })); + expect(screen.queryByText(/^paused$/i)).not.toBeInTheDocument(); + }); + + it('confirms before deleting a user and removes the row after approval', async () => { + const user = userEvent.setup(); + render(); + + await loginIntoPanel(user); + await user.click(screen.getByRole('button', { name: /users/i })); + + expect(screen.getByText(/night-shift/i)).toBeInTheDocument(); + + await user.click(screen.getAllByRole('button', { name: /delete/i })[0]); + + const dialog = screen.getByRole('dialog', { name: /delete user/i }); + expect(dialog).toBeInTheDocument(); + expect(within(dialog).getByText(/remove profile/i)).toBeInTheDocument(); + + await user.click(within(dialog).getByRole('button', { name: /delete user/i })); + + expect(screen.queryByText(/night-shift/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog', { name: /delete user/i })).not.toBeInTheDocument(); + }); }); diff --git a/src/App.tsx b/src/App.tsx index 065d896..fa88e83 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,10 @@ const servicesById = new Map( dashboardSnapshot.system.services.map((service) => [service.id, service] as const), ); +type UserRow = (typeof dashboardSnapshot.userRecords)[number] & { + paused?: boolean; +}; + function LoginGate({ onUnlock }: { onUnlock: () => void }) { const [login, setLogin] = useState(''); const [password, setPassword] = useState(''); @@ -161,6 +165,60 @@ function AddUserModal({ onClose }: { onClose: () => void }) { ); } +function ConfirmDeleteModal({ + username, + onClose, + onConfirm, +}: { + username: string; + onClose: () => void; + onConfirm: () => 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 ( +
+
+
+

Delete user

+
+

+ Remove profile {username}? This action will delete the user entry from the + current panel state. +

+
+ + +
+
+
+ ); +} + function DashboardTab() { const serviceTone = getServiceTone(dashboardSnapshot.service.status); @@ -257,6 +315,8 @@ function DashboardTab() { function UsersTab() { const [copiedId, setCopiedId] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); + const [users, setUsers] = useState(() => dashboardSnapshot.userRecords); + const [deleteTargetId, setDeleteTargetId] = useState(null); const handleCopy = async (userId: string, proxyLink: string) => { await navigator.clipboard.writeText(proxyLink); @@ -264,6 +324,32 @@ function UsersTab() { window.setTimeout(() => setCopiedId((value) => (value === userId ? null : value)), 1200); }; + const handleTogglePause = (userId: string) => { + setUsers((current) => + current.map((user) => (user.id === userId ? { ...user, paused: !user.paused } : user)), + ); + }; + + const handleDelete = () => { + if (!deleteTargetId) { + return; + } + + setUsers((current) => current.filter((user) => user.id !== deleteTargetId)); + setDeleteTargetId(null); + }; + + const deleteTarget = users.find((user) => user.id === deleteTargetId) ?? null; + const liveUsers = users.filter((user) => !user.paused && user.status === 'live').length; + const nearQuotaUsers = users.filter((user) => { + if (user.paused || user.quotaBytes === null || isQuotaExceeded(user.usedBytes, user.quotaBytes)) { + return false; + } + + return user.usedBytes / user.quotaBytes >= 0.8; + }).length; + const exceededUsers = users.filter((user) => isQuotaExceeded(user.usedBytes, user.quotaBytes)).length; + return ( <>
@@ -271,13 +357,13 @@ function UsersTab() {

Users

-

{dashboardSnapshot.userRecords.length} accounts in current profile

+

{users.length} accounts in current profile

- {dashboardSnapshot.users.live} live - {dashboardSnapshot.users.nearQuota} near quota - {dashboardSnapshot.users.exceeded} exceeded + {liveUsers} live + {nearQuotaUsers} near quota + {exceededUsers} exceeded
- {user.status} + + {displayStatus} + {formatBytes(user.usedBytes)} {formatQuotaState(user.usedBytes, user.quotaBytes)} @@ -343,6 +433,24 @@ function UsersTab() { {copiedId === user.id ? 'Copied' : 'Copy'} + +
+ + +
+ ); })} @@ -353,6 +461,13 @@ function UsersTab() {
{isModalOpen ? setIsModalOpen(false)} /> : null} + {deleteTarget ? ( + setDeleteTargetId(null)} + onConfirm={handleDelete} + /> + ) : null} ); } diff --git a/src/app.css b/src/app.css index 2e80346..1db51ad 100644 --- a/src/app.css +++ b/src/app.css @@ -133,7 +133,8 @@ button { button, .button-secondary, -.copy-button { +.copy-button, +.button-danger { height: 36px; padding: 0 12px; border-radius: 8px; @@ -150,6 +151,19 @@ button, color: var(--text); } +.button-danger { + border-color: #ef4444; + background: #ef4444; + color: #ffffff; +} + +.button-small { + min-width: 72px; + height: 32px; + padding: 0 10px; + font-size: 13px; +} + .copy-button { display: inline-flex; align-items: center; @@ -499,6 +513,15 @@ tbody tr:last-child td { color: var(--muted); } +.status-pill.paused { + color: #475569; +} + +.row-actions { + display: inline-flex; + gap: 8px; +} + pre { margin: 0; padding: 14px; @@ -530,10 +553,19 @@ pre { gap: 16px; } +.confirm-card { + width: min(100%, 400px); +} + .modal-form { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.confirm-copy { + margin: 0; + color: var(--text); +} + .modal-preview { display: grid; gap: 4px; diff --git a/src/lib/3proxy.ts b/src/lib/3proxy.ts index 9608244..ebfc0d3 100644 --- a/src/lib/3proxy.ts +++ b/src/lib/3proxy.ts @@ -1,7 +1,7 @@ const MB = 1024 * 1024; const GB = MB * 1024; -export type ServiceState = 'live' | 'warn' | 'fail' | 'idle'; +export type ServiceState = 'live' | 'warn' | 'fail' | 'idle' | 'paused'; export function formatBytes(value: number): string { if (!Number.isFinite(value) || value < 0) {