feat: add pause and delete actions for users
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
127
src/App.tsx
127
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<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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
34
src/app.css
34
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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user