feat: add pause and delete actions for users

This commit is contained in:
2026-04-01 23:40:09 +03:00
parent 10f20c8aec
commit 5a2dc43a06
6 changed files with 194 additions and 10 deletions

View File

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

View File

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

View File

@@ -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(<App />);
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(<App />);
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();
});
});

View File

@@ -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<HTMLDivElement>) => {
event.stopPropagation();
};
return (
<div className="modal-backdrop" role="presentation" onClick={onClose}>
<section
aria-labelledby="delete-user-title"
aria-modal="true"
className="modal-card confirm-card"
role="dialog"
onClick={stopPropagation}
>
<div className="modal-header">
<h2 id="delete-user-title">Delete user</h2>
</div>
<p className="confirm-copy">
Remove profile <strong>{username}</strong>? This action will delete the user entry from the
current panel state.
</p>
<div className="modal-actions">
<button type="button" className="button-secondary" onClick={onClose}>
Cancel
</button>
<button type="button" className="button-danger" onClick={onConfirm}>
Delete user
</button>
</div>
</section>
</div>
);
}
function DashboardTab() {
const serviceTone = getServiceTone(dashboardSnapshot.service.status);
@@ -257,6 +315,8 @@ function DashboardTab() {
function UsersTab() {
const [copiedId, setCopiedId] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [users, setUsers] = useState<UserRow[]>(() => dashboardSnapshot.userRecords);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(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 (
<>
<section className="page-grid single-column">
@@ -271,13 +357,13 @@ function UsersTab() {
<div className="table-toolbar">
<div className="toolbar-title">
<h2>Users</h2>
<p>{dashboardSnapshot.userRecords.length} accounts in current profile</p>
<p>{users.length} accounts in current profile</p>
</div>
<div className="toolbar-actions">
<div className="summary-pills">
<span>{dashboardSnapshot.users.live} live</span>
<span>{dashboardSnapshot.users.nearQuota} near quota</span>
<span>{dashboardSnapshot.users.exceeded} exceeded</span>
<span>{liveUsers} live</span>
<span>{nearQuotaUsers} near quota</span>
<span>{exceededUsers} exceeded</span>
</div>
<button type="button" onClick={() => setIsModalOpen(true)}>
New user
@@ -295,10 +381,11 @@ function UsersTab() {
<th>Remaining</th>
<th>Share</th>
<th>Proxy</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{dashboardSnapshot.userRecords.map((user) => {
{users.map((user) => {
const service = servicesById.get(user.serviceId);
const endpoint = service
? `${dashboardSnapshot.system.publicHost}:${service.port}`
@@ -313,6 +400,7 @@ function UsersTab() {
)
: '';
const exhausted = isQuotaExceeded(user.usedBytes, user.quotaBytes);
const displayStatus = user.paused ? 'paused' : user.status;
return (
<tr key={user.id}>
@@ -328,7 +416,9 @@ function UsersTab() {
</div>
</td>
<td>
<span className={`status-pill ${getServiceTone(user.status)}`}>{user.status}</span>
<span className={`status-pill ${getServiceTone(displayStatus)}`}>
{displayStatus}
</span>
</td>
<td>{formatBytes(user.usedBytes)}</td>
<td>{formatQuotaState(user.usedBytes, user.quotaBytes)}</td>
@@ -343,6 +433,24 @@ function UsersTab() {
{copiedId === user.id ? 'Copied' : 'Copy'}
</button>
</td>
<td>
<div className="row-actions">
<button
type="button"
className="button-secondary button-small"
onClick={() => handleTogglePause(user.id)}
>
{user.paused ? 'Resume' : 'Pause'}
</button>
<button
type="button"
className="button-danger button-small"
onClick={() => setDeleteTargetId(user.id)}
>
Delete
</button>
</div>
</td>
</tr>
);
})}
@@ -353,6 +461,13 @@ function UsersTab() {
</section>
{isModalOpen ? <AddUserModal onClose={() => setIsModalOpen(false)} /> : null}
{deleteTarget ? (
<ConfirmDeleteModal
username={deleteTarget.username}
onClose={() => setDeleteTargetId(null)}
onConfirm={handleDelete}
/>
) : null}
</>
);
}

View File

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

View File

@@ -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) {