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.
|
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.
|
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`.
|
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
|
## Frontend
|
||||||
|
|
||||||
- `src/main.tsx`: application bootstrap
|
- `src/main.tsx`: application bootstrap
|
||||||
- `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, pause/resume, and delete-confirm flows
|
||||||
- `src/App.test.tsx`: login-gate and modal interaction tests
|
- `src/App.test.tsx`: login-gate, modal interaction, pause/resume, and delete-confirm 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, including service-bound users
|
- `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
|
||||||
|
|||||||
@@ -52,4 +52,40 @@ describe('App login gate', () => {
|
|||||||
|
|
||||||
expect(screen.queryByRole('dialog', { name: /add user/i })).not.toBeInTheDocument();
|
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),
|
dashboardSnapshot.system.services.map((service) => [service.id, service] as const),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
type UserRow = (typeof dashboardSnapshot.userRecords)[number] & {
|
||||||
|
paused?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
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('');
|
||||||
@@ -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() {
|
function DashboardTab() {
|
||||||
const serviceTone = getServiceTone(dashboardSnapshot.service.status);
|
const serviceTone = getServiceTone(dashboardSnapshot.service.status);
|
||||||
|
|
||||||
@@ -257,6 +315,8 @@ function DashboardTab() {
|
|||||||
function UsersTab() {
|
function UsersTab() {
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
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) => {
|
const handleCopy = async (userId: string, proxyLink: string) => {
|
||||||
await navigator.clipboard.writeText(proxyLink);
|
await navigator.clipboard.writeText(proxyLink);
|
||||||
@@ -264,6 +324,32 @@ function UsersTab() {
|
|||||||
window.setTimeout(() => setCopiedId((value) => (value === userId ? null : value)), 1200);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="page-grid single-column">
|
<section className="page-grid single-column">
|
||||||
@@ -271,13 +357,13 @@ function UsersTab() {
|
|||||||
<div className="table-toolbar">
|
<div className="table-toolbar">
|
||||||
<div className="toolbar-title">
|
<div className="toolbar-title">
|
||||||
<h2>Users</h2>
|
<h2>Users</h2>
|
||||||
<p>{dashboardSnapshot.userRecords.length} accounts in current profile</p>
|
<p>{users.length} accounts in current profile</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="toolbar-actions">
|
<div className="toolbar-actions">
|
||||||
<div className="summary-pills">
|
<div className="summary-pills">
|
||||||
<span>{dashboardSnapshot.users.live} live</span>
|
<span>{liveUsers} live</span>
|
||||||
<span>{dashboardSnapshot.users.nearQuota} near quota</span>
|
<span>{nearQuotaUsers} near quota</span>
|
||||||
<span>{dashboardSnapshot.users.exceeded} exceeded</span>
|
<span>{exceededUsers} exceeded</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={() => setIsModalOpen(true)}>
|
<button type="button" onClick={() => setIsModalOpen(true)}>
|
||||||
New user
|
New user
|
||||||
@@ -295,10 +381,11 @@ function UsersTab() {
|
|||||||
<th>Remaining</th>
|
<th>Remaining</th>
|
||||||
<th>Share</th>
|
<th>Share</th>
|
||||||
<th>Proxy</th>
|
<th>Proxy</th>
|
||||||
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{dashboardSnapshot.userRecords.map((user) => {
|
{users.map((user) => {
|
||||||
const service = servicesById.get(user.serviceId);
|
const service = servicesById.get(user.serviceId);
|
||||||
const endpoint = service
|
const endpoint = service
|
||||||
? `${dashboardSnapshot.system.publicHost}:${service.port}`
|
? `${dashboardSnapshot.system.publicHost}:${service.port}`
|
||||||
@@ -313,6 +400,7 @@ function UsersTab() {
|
|||||||
)
|
)
|
||||||
: '';
|
: '';
|
||||||
const exhausted = isQuotaExceeded(user.usedBytes, user.quotaBytes);
|
const exhausted = isQuotaExceeded(user.usedBytes, user.quotaBytes);
|
||||||
|
const displayStatus = user.paused ? 'paused' : user.status;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={user.id}>
|
<tr key={user.id}>
|
||||||
@@ -328,7 +416,9 @@ function UsersTab() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`status-pill ${getServiceTone(user.status)}`}>{user.status}</span>
|
<span className={`status-pill ${getServiceTone(displayStatus)}`}>
|
||||||
|
{displayStatus}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{formatBytes(user.usedBytes)}</td>
|
<td>{formatBytes(user.usedBytes)}</td>
|
||||||
<td>{formatQuotaState(user.usedBytes, user.quotaBytes)}</td>
|
<td>{formatQuotaState(user.usedBytes, user.quotaBytes)}</td>
|
||||||
@@ -343,6 +433,24 @@ function UsersTab() {
|
|||||||
{copiedId === user.id ? 'Copied' : 'Copy'}
|
{copiedId === user.id ? 'Copied' : 'Copy'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -353,6 +461,13 @@ function UsersTab() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isModalOpen ? <AddUserModal onClose={() => setIsModalOpen(false)} /> : null}
|
{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,
|
||||||
.button-secondary,
|
.button-secondary,
|
||||||
.copy-button {
|
.copy-button,
|
||||||
|
.button-danger {
|
||||||
height: 36px;
|
height: 36px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -150,6 +151,19 @@ button,
|
|||||||
color: var(--text);
|
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 {
|
.copy-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -499,6 +513,15 @@ tbody tr:last-child td {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-pill.paused {
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
@@ -530,10 +553,19 @@ pre {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.confirm-card {
|
||||||
|
width: min(100%, 400px);
|
||||||
|
}
|
||||||
|
|
||||||
.modal-form {
|
.modal-form {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.confirm-copy {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.modal-preview {
|
.modal-preview {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const MB = 1024 * 1024;
|
const MB = 1024 * 1024;
|
||||||
const GB = MB * 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 {
|
export function formatBytes(value: number): string {
|
||||||
if (!Number.isFinite(value) || value < 0) {
|
if (!Number.isFinite(value) || value < 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user