diff --git a/docs/PLAN.md b/docs/PLAN.md index b0a9063..0bcbfbf 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -42,3 +42,5 @@ Updated: 2026-04-02 25. Replaced frontend polling with websocket live sync over `/ws`, sending only changed top-level snapshot sections while keeping the current `http/ws` path compatible with future `https/wss` deployment. 26. Stopped incoming runtime sync from overwriting dirty Settings drafts and added hash-based tab navigation so refresh/back/forward stay on the current panel tab. 27. Verified websocket delivery in Docker over plain `ws://127.0.0.1:3000/ws` by authenticating, receiving `snapshot.init`, mutating panel state, and observing a follow-up `snapshot.patch`. +28. Reworked Users actions into fixed-width icon buttons, added edit-in-modal flow, generated credentials only for new users, and blocked action buttons while commands are in flight. +29. Added backend user-update support plus runtime stop control, then verified both in Docker by updating `u-1` and stopping the real bundled 3proxy process through the API. diff --git a/docs/PROJECT_INDEX.md b/docs/PROJECT_INDEX.md index b961f29..ac22085 100644 --- a/docs/PROJECT_INDEX.md +++ b/docs/PROJECT_INDEX.md @@ -23,15 +23,15 @@ Updated: 2026-04-02 ## Frontend - `src/main.tsx`: application bootstrap -- `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, hash-based tab history, websocket snapshot patch sync, localized labels, early theme application, and protected panel mutations +- `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, hash-based tab history, websocket snapshot patch sync, icon-based user actions, create/edit user modal flows, runtime stop control, localized labels, early theme application, and protected panel mutations - `src/SystemTab.tsx`: Settings tab with separate panel-settings and services cards, editable proxy endpoint, dirty-draft protection against incoming live sync, unified service type editing, remove confirmation, and generated config preview -- `src/App.test.tsx`: login-gate, preferences persistence, hash-tab restoration, websocket-sync safety, modal interaction, pause/resume, delete-confirm, and settings-save UI tests -- `src/app.css`: full panel styling +- `src/App.test.tsx`: login-gate, preferences persistence, hash-tab restoration, websocket-sync safety, generated-credential/create-edit modal flows, runtime stop, pause/resume, delete-confirm, and settings-save UI tests +- `src/app.css`: full panel styling including fixed-width icon action buttons and busy-state treatment - `src/data/mockDashboard.ts`: default panel state and frontend fallback snapshot - `src/lib/3proxy.ts`: formatting and status helpers - `src/lib/3proxy.test.ts`: paranoia-oriented tests for core domain rules - `src/lib/panelPreferences.ts`: `localStorage`-backed panel language/theme preferences plus theme application helpers with `system` as the default theme -- `src/lib/panelText.ts`: English/Russian UI text catalog for the panel shell and settings flows +- `src/lib/panelText.ts`: English/Russian UI text catalog for the panel shell, user-edit actions, and runtime controls - `src/shared/contracts.ts`: shared panel, service, user, and API data contracts - `src/shared/validation.ts`: shared validation for user creation, system edits, service type mapping, and quota conversion - `src/test/setup.ts`: Testing Library matchers plus browser WebSocket test double @@ -39,8 +39,8 @@ Updated: 2026-04-02 ## Server - `server/index.ts`: backend entrypoint, runtime bootstrap, and HTTP server wiring for websocket upgrades -- `server/app.ts`: Express app with login, protected panel state/runtime routes, live-sync change notifications, and writable system configuration API with linked-user cleanup on removed services -- `server/app.test.ts`: API tests for user management plus system-update safety, cascade delete, and config edge cases +- `server/app.ts`: Express app with login, protected panel state/runtime routes, live-sync change notifications, writable system configuration API with linked-user cleanup on removed services, and editable user records +- `server/app.test.ts`: API tests for user management plus user editing, runtime stop, system-update safety, cascade delete, and config edge cases - `server/lib/auth.ts`: expiring token issuance and bearer-token verification for the panel - `server/lib/config.ts`: 3proxy config renderer, validation, and dashboard derivation for SOCKS/HTTP managed services - `server/lib/config.test.ts`: config-generation regression tests @@ -49,7 +49,7 @@ Updated: 2026-04-02 - `server/lib/snapshot.ts`: runtime-backed dashboard snapshot assembly that combines stored panel state with parsed 3proxy traffic observations - `server/lib/traffic.ts`: 3proxy access-log reader that derives current user usage, recent activity, daily totals, and lightweight live-connection estimates - `server/lib/traffic.test.ts`: parser and empty-runtime regression tests for log-derived traffic metrics -- `server/lib/runtime.ts`: managed 3proxy process controller +- `server/lib/runtime.ts`: managed 3proxy process controller with start/stop/restart/reload operations - `server/lib/store.ts`: JSON-backed persistent state store with legacy admin-service migration ## Static diff --git a/server/app.test.ts b/server/app.test.ts index 7d790de..13d30dd 100644 --- a/server/app.test.ts +++ b/server/app.test.ts @@ -36,6 +36,16 @@ class FakeRuntime implements RuntimeController { return this.start(); } + async stop() { + this.status = { + status: 'idle', + pid: null, + startedAt: null, + lastError: null, + }; + return this.getSnapshot(); + } + async reload() { return this.getSnapshot(); } @@ -101,6 +111,41 @@ describe('panel api', () => { ); }); + it('updates a user through the api', async () => { + const app = await createTestApp(); + const token = await authorize(app); + const initial = await request(app).get('/api/state').set('Authorization', `Bearer ${token}`); + const userId = initial.body.userRecords[0].id; + + const updated = await request(app).put(`/api/users/${userId}`).set('Authorization', `Bearer ${token}`).send({ + username: 'night-shift-updated', + password: 'fresh-secret', + serviceId: 'socks-lab', + quotaMb: 512, + }); + + expect(updated.status).toBe(200); + expect(updated.body.userRecords.find((entry: { id: string }) => entry.id === userId)).toMatchObject({ + username: 'night-shift-updated', + password: 'fresh-secret', + serviceId: 'socks-lab', + quotaBytes: 512 * 1024 * 1024, + }); + }); + + it('stops the runtime through the api', async () => { + const app = await createTestApp(); + const token = await authorize(app); + + await request(app).post('/api/runtime/start').set('Authorization', `Bearer ${token}`); + const stopped = await request(app).post('/api/runtime/stop').set('Authorization', `Bearer ${token}`); + + expect(stopped.status).toBe(200); + expect(stopped.body.service.status).toBe('idle'); + expect(stopped.body.service.pidLabel).toBe('pid -'); + expect(stopped.body.service.lastEvent).toMatch(/stop requested/i); + }); + it('rejects system updates when two services reuse the same port', async () => { const app = await createTestApp(); const token = await authorize(app); diff --git a/server/app.ts b/server/app.ts index 9432c5c..ebd3e9c 100644 --- a/server/app.ts +++ b/server/app.ts @@ -94,18 +94,26 @@ export function createApp({ store, runtime, runtimeRootDir, auth, liveSync }: Ap try { const state = await store.read(); const action = request.params.action; - const controller = action === 'restart' ? runtime.restart.bind(runtime) : runtime.start.bind(runtime); + const controller = + action === 'restart' + ? runtime.restart.bind(runtime) + : action === 'stop' + ? runtime.stop.bind(runtime) + : runtime.start.bind(runtime); - if (!['start', 'restart'].includes(action)) { + if (!['start', 'restart', 'stop'].includes(action)) { response.status(404).json({ error: 'Unknown runtime action.' }); return; } const runtimeSnapshot = await controller(); - state.service.lastEvent = action === 'restart' ? 'Runtime restarted from panel' : 'Runtime start requested from panel'; - if (runtimeSnapshot.startedAt) { - state.service.startedAt = runtimeSnapshot.startedAt; - } + state.service.lastEvent = + action === 'restart' + ? 'Runtime restarted from panel' + : action === 'stop' + ? 'Runtime stop requested from panel' + : 'Runtime start requested from panel'; + state.service.startedAt = runtimeSnapshot.startedAt ?? null; await writeConfigAndState(store, state, runtimePaths); liveSync?.notifyPotentialChange(); @@ -130,6 +138,40 @@ export function createApp({ store, runtime, runtimeRootDir, auth, liveSync }: Ap } }); + app.put('/api/users/:id', async (request, response, next) => { + try { + const state = await store.read(); + const user = state.userRecords.find((entry) => entry.id === request.params.id); + + if (!user) { + response.status(404).json({ error: 'User not found.' }); + return; + } + + const input = validateCreateUserInput(request.body as Partial, state.system.services); + const duplicateUser = state.userRecords.find( + (entry) => entry.id !== user.id && entry.username.toLowerCase() === input.username.toLowerCase(), + ); + + if (duplicateUser) { + response.status(400).json({ error: 'Username already exists.' }); + return; + } + + user.username = input.username; + user.password = input.password; + user.serviceId = input.serviceId; + user.quotaBytes = input.quotaMb === null ? null : input.quotaMb * 1024 * 1024; + state.service.lastEvent = `User ${user.username} updated from panel`; + + await persistRuntimeMutation(store, runtime, state, runtimePaths); + liveSync?.notifyPotentialChange(); + response.json(await getDashboardSnapshot(store, runtime, runtimePaths)); + } catch (error) { + next(error); + } + }); + app.put('/api/system', async (request, response, next) => { try { const state = await store.read(); diff --git a/server/lib/runtime.ts b/server/lib/runtime.ts index 3461811..c203fcb 100644 --- a/server/lib/runtime.ts +++ b/server/lib/runtime.ts @@ -6,6 +6,7 @@ import type { RuntimePaths, RuntimeSnapshot } from './config'; export interface RuntimeController { getSnapshot(): RuntimeSnapshot; start(): Promise; + stop(): Promise; restart(): Promise; reload(): Promise; } @@ -83,19 +84,17 @@ export class ThreeProxyManager implements RuntimeController { return this.start(); } - async reload(): Promise { - if (!this.child || this.child.exitCode !== null || !this.child.pid) { - return this.start(); - } - - process.kill(this.child.pid, 'SIGUSR1'); - return this.getSnapshot(); - } - - private async stop(): Promise { + async stop(): Promise { if (!this.child || this.child.exitCode !== null || !this.child.pid) { this.child = null; - return; + this.snapshot = { + ...this.snapshot, + status: 'idle', + pid: null, + startedAt: null, + lastError: null, + }; + return this.getSnapshot(); } const current = this.child; @@ -105,6 +104,16 @@ export class ThreeProxyManager implements RuntimeController { current.kill('SIGTERM'); await waitForExit; + return this.getSnapshot(); + } + + async reload(): Promise { + if (!this.child || this.child.exitCode !== null || !this.child.pid) { + return this.start(); + } + + process.kill(this.child.pid, 'SIGUSR1'); + return this.getSnapshot(); } } diff --git a/src/App.test.tsx b/src/App.test.tsx index 0afd7fb..dc624af 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -105,6 +105,16 @@ describe('App login gate', () => { expect(screen.getByRole('button', { name: /new user/i })).toBeInTheDocument(); }); + it('can stop the runtime from the dashboard', async () => { + const user = userEvent.setup(); + render(); + + await loginIntoPanel(user); + await user.click(screen.getByRole('button', { name: /stop/i })); + + expect(screen.getAllByText(/^idle$/i).length).toBeGreaterThan(0); + }); + it('opens add-user flow in a modal and closes it on escape', async () => { const user = userEvent.setup(); render(); @@ -116,7 +126,8 @@ describe('App login gate', () => { const dialog = screen.getByRole('dialog', { name: /add user/i }); expect(dialog).toBeInTheDocument(); - expect(screen.getByPlaceholderText(/night-shift-01/i)).toBeInTheDocument(); + expect(String((screen.getByLabelText(/username/i) as HTMLInputElement).value)).toMatch(/^user-/i); + expect(String((screen.getByLabelText(/password/i) as HTMLInputElement).value)).toMatch(/^pw-/i); expect(screen.getByLabelText(/service/i)).toBeInTheDocument(); expect(screen.queryByLabelText(/port/i)).not.toBeInTheDocument(); expect(within(dialog).getByText(/edge\.example\.net:1080/i)).toBeInTheDocument(); @@ -133,11 +144,11 @@ describe('App login gate', () => { await loginIntoPanel(user); await user.click(screen.getByRole('button', { name: /users/i })); - await user.click(screen.getAllByRole('button', { name: /pause/i })[0]); + await user.click(screen.getAllByRole('button', { name: /pause user/i })[0]); expect(screen.getByText(/^paused$/i)).toBeInTheDocument(); - expect(screen.getAllByRole('button', { name: /resume/i })).toHaveLength(1); + expect(screen.getAllByRole('button', { name: /resume user/i })).toHaveLength(1); - await user.click(screen.getByRole('button', { name: /resume/i })); + await user.click(screen.getByRole('button', { name: /resume user/i })); expect(screen.queryByText(/^paused$/i)).not.toBeInTheDocument(); }); @@ -150,7 +161,7 @@ describe('App login gate', () => { expect(screen.getByText(/night-shift/i)).toBeInTheDocument(); - await user.click(screen.getAllByRole('button', { name: /delete/i })[0]); + await user.click(screen.getAllByRole('button', { name: /^delete user$/i })[0]); const dialog = screen.getByRole('dialog', { name: /delete user/i }); expect(dialog).toBeInTheDocument(); @@ -190,6 +201,27 @@ describe('App login gate', () => { expect(screen.getAllByText(/gw\.example\.net:1180/i).length).toBeGreaterThan(0); }); + it('opens edit-user flow with existing credentials instead of generating new ones', 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: /edit user/i })[0]); + + const dialog = screen.getByRole('dialog', { name: /edit user/i }); + + expect(within(dialog).getByLabelText(/username/i)).toHaveValue('night-shift'); + expect(within(dialog).getByLabelText(/password/i)).toHaveValue('kettle!23'); + + await user.clear(within(dialog).getByLabelText(/username/i)); + await user.type(within(dialog).getByLabelText(/username/i), 'night-shift-updated'); + await user.click(within(dialog).getByRole('button', { name: /save user/i })); + + expect(screen.getByText(/night-shift-updated/i)).toBeInTheDocument(); + expect(screen.queryByRole('dialog', { name: /edit user/i })).not.toBeInTheDocument(); + }); + it('does not overwrite dirty system settings when a websocket patch arrives', async () => { const user = userEvent.setup(); render(); diff --git a/src/App.tsx b/src/App.tsx index 176d982..3be7854 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { FormEvent, KeyboardEvent, useEffect, useMemo, useState } from 'react'; +import { type ReactNode, FormEvent, KeyboardEvent, useEffect, useMemo, useState } from 'react'; import './app.css'; import { fallbackDashboardSnapshot, panelAuth } from './data/mockDashboard'; import { @@ -112,25 +112,32 @@ function LoginGate({ ); } -function AddUserModal({ +function UserEditorModal({ + mode, host, services, + initialValues, onClose, - onCreate, + onSubmit, preferences, }: { + mode: 'create' | 'edit'; host: string; services: ProxyServiceRecord[]; + initialValues?: CreateUserInput; onClose: () => void; - onCreate: (input: CreateUserInput) => Promise; + onSubmit: (input: CreateUserInput) => Promise; preferences: PanelPreferences; }) { const text = getPanelText(preferences.language); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [serviceId, setServiceId] = useState(services[0]?.id ?? ''); - const [quotaMb, setQuotaMb] = useState(''); + const [username, setUsername] = useState(() => initialValues?.username ?? generateUsername()); + const [password, setPassword] = useState(() => initialValues?.password ?? generatePassword()); + const [serviceId, setServiceId] = useState(() => initialValues?.serviceId ?? services[0]?.id ?? ''); + const [quotaMb, setQuotaMb] = useState(() => + initialValues?.quotaMb === null || initialValues?.quotaMb === undefined ? '' : String(initialValues.quotaMb), + ); const [error, setError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => { const handleKeyDown = (event: globalThis.KeyboardEvent) => { @@ -152,9 +159,10 @@ function AddUserModal({ const handleSubmit = async (event: FormEvent) => { event.preventDefault(); + setIsSubmitting(true); try { - await onCreate({ + await onSubmit({ username, password, serviceId, @@ -162,22 +170,24 @@ function AddUserModal({ }); onClose(); } catch (submitError) { - setError(submitError instanceof Error ? submitError.message : 'Unable to create user.'); + setError(submitError instanceof Error ? submitError.message : 'Unable to save user.'); + } finally { + setIsSubmitting(false); } }; return (
-

{text.users.addUser}

-
@@ -187,6 +197,7 @@ function AddUserModal({ setUsername(event.target.value)} /> @@ -195,13 +206,14 @@ function AddUserModal({ {text.users.password} setPassword(event.target.value)} />
{error ?

{error}

: null}
- - +
@@ -240,11 +255,13 @@ function AddUserModal({ function ConfirmDeleteModal({ username, + isSubmitting, onClose, onConfirm, preferences, }: { username: string; + isSubmitting: boolean; onClose: () => void; onConfirm: () => Promise; preferences: PanelPreferences; @@ -284,10 +301,10 @@ function ConfirmDeleteModal({ {text.users.deletePromptSuffix}

- -
@@ -302,11 +319,22 @@ function DashboardTab({ preferences, }: { snapshot: DashboardSnapshot; - onRuntimeAction: (action: 'start' | 'restart') => Promise; + onRuntimeAction: (action: 'start' | 'stop' | 'restart') => Promise; preferences: PanelPreferences; }) { const text = getPanelText(preferences.language); const serviceTone = getServiceTone(snapshot.service.status); + const [pendingAction, setPendingAction] = useState<'start' | 'stop' | 'restart' | null>(null); + + const handleRuntimeAction = async (action: 'start' | 'stop' | 'restart') => { + setPendingAction(action); + + try { + await onRuntimeAction(action); + } finally { + setPendingAction((current) => (current === action ? null : current)); + } + }; return (
@@ -334,10 +362,31 @@ function DashboardTab({
- - +
@@ -403,20 +452,24 @@ function DashboardTab({ function UsersTab({ snapshot, onCreateUser, + onUpdateUser, onTogglePause, onDeleteUser, preferences, }: { snapshot: DashboardSnapshot; onCreateUser: (input: CreateUserInput) => Promise; + onUpdateUser: (userId: string, input: CreateUserInput) => Promise; onTogglePause: (userId: string) => Promise; onDeleteUser: (userId: string) => Promise; preferences: PanelPreferences; }) { const text = getPanelText(preferences.language); const [copiedId, setCopiedId] = useState(null); - const [isModalOpen, setIsModalOpen] = useState(false); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [editTargetId, setEditTargetId] = useState(null); const [deleteTargetId, setDeleteTargetId] = useState(null); + const [pendingUserAction, setPendingUserAction] = useState<{ userId: string; action: 'edit' | 'pause' | 'delete' } | null>(null); const servicesById = useMemo( () => new Map(snapshot.system.services.map((service) => [service.id, service] as const)), @@ -425,6 +478,7 @@ function UsersTab({ const assignableServices = snapshot.system.services.filter((service) => service.enabled && service.assignable); const deleteTarget = snapshot.userRecords.find((user) => user.id === deleteTargetId) ?? null; + const editTarget = snapshot.userRecords.find((user) => user.id === editTargetId) ?? null; const handleCopy = async (userId: string, proxyLink: string) => { await navigator.clipboard.writeText(proxyLink); @@ -432,6 +486,33 @@ function UsersTab({ window.setTimeout(() => setCopiedId((value) => (value === userId ? null : value)), 1200); }; + const handlePauseToggle = async (userId: string) => { + setPendingUserAction({ userId, action: 'pause' }); + try { + await onTogglePause(userId); + } finally { + setPendingUserAction((current) => + current?.userId === userId && current.action === 'pause' ? null : current, + ); + } + }; + + const handleDeleteConfirm = async () => { + if (!deleteTarget) { + return; + } + + setPendingUserAction({ userId: deleteTarget.id, action: 'delete' }); + try { + await onDeleteUser(deleteTarget.id); + setDeleteTargetId(null); + } finally { + setPendingUserAction((current) => + current?.userId === deleteTarget.id && current.action === 'delete' ? null : current, + ); + } + }; + return ( <>
@@ -449,7 +530,7 @@ function UsersTab({ {snapshot.users.nearQuota} {text.users.nearQuota} {snapshot.users.exceeded} {text.users.exceeded} - @@ -488,6 +569,9 @@ function UsersTab({ : ''; const exhausted = isQuotaExceeded(user.usedBytes, user.quotaBytes); const displayStatus = user.paused ? 'paused' : user.status; + const isPendingRow = pendingUserAction?.userId === user.id; + const isPausePending = isPendingRow && pendingUserAction?.action === 'pause'; + const isDeletePending = isPendingRow && pendingUserAction?.action === 'delete'; return ( @@ -511,31 +595,47 @@ function UsersTab({ {formatQuotaStateLabel(user.usedBytes, user.quotaBytes, preferences.language)} {formatTrafficShare(user.usedBytes, snapshot.traffic.totalBytes)} - + {copiedId === user.id ? : } +
- - + +
@@ -547,12 +647,35 @@ function UsersTab({
- {isModalOpen ? ( - setIsModalOpen(false)} - onCreate={onCreateUser} + onClose={() => setIsCreateModalOpen(false)} + onSubmit={onCreateUser} + preferences={preferences} + /> + ) : null} + + {editTarget ? ( + setEditTargetId(null)} + onSubmit={async (input) => { + setPendingUserAction({ userId: editTarget.id, action: 'edit' }); + try { + await onUpdateUser(editTarget.id, input); + setEditTargetId(null); + } finally { + setPendingUserAction((current) => + current?.userId === editTarget.id && current.action === 'edit' ? null : current, + ); + } + }} preferences={preferences} /> ) : null} @@ -561,10 +684,8 @@ function UsersTab({ setDeleteTargetId(null)} - onConfirm={async () => { - await onDeleteUser(deleteTarget.id); - setDeleteTargetId(null); - }} + isSubmitting={pendingUserAction?.userId === deleteTarget.id && pendingUserAction.action === 'delete'} + onConfirm={handleDeleteConfirm} preferences={preferences} /> ) : null} @@ -572,6 +693,104 @@ function UsersTab({ ); } +function ActionIconButton({ + ariaLabel, + title, + tone, + disabled = false, + busy = false, + onClick, + children, +}: { + ariaLabel: string; + title?: string; + tone: 'secondary' | 'danger'; + disabled?: boolean; + busy?: boolean; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +function CopyIcon() { + return ( + + ); +} + +function CheckIcon() { + return ( + + ); +} + +function EditIcon() { + return ( + + ); +} + +function PauseIcon() { + return ( + + ); +} + +function ResumeIcon() { + return ( + + ); +} + +function TrashIcon() { + return ( + + ); +} + export default function App() { const [preferences, setPreferences] = useState(() => { const loaded = loadPanelPreferences(); @@ -766,7 +985,7 @@ export default function App() { setSnapshot((current) => fallback(current)); }; - const handleRuntimeAction = async (action: 'start' | 'restart') => { + const handleRuntimeAction = async (action: 'start' | 'stop' | 'restart') => { await mutateSnapshot( () => fetch(`/api/runtime/${action}`, { @@ -778,8 +997,15 @@ export default function App() { ...current, service: { ...current.service, - status: 'live', - lastEvent: action === 'restart' ? 'Runtime restarted from panel' : 'Runtime start requested from panel', + status: action === 'stop' ? 'idle' : 'live', + pidLabel: action === 'stop' ? 'pid -' : current.service.pidLabel, + uptimeLabel: action === 'stop' ? 'uptime -' : current.service.uptimeLabel, + lastEvent: + action === 'restart' + ? 'Runtime restarted from panel' + : action === 'stop' + ? 'Runtime stop requested from panel' + : 'Runtime start requested from panel', }, }), ); @@ -858,6 +1084,65 @@ export default function App() { ); }; + const handleUpdateUser = async (userId: string, input: CreateUserInput) => { + const validated = validateCreateUserInput(input, snapshot.system.services); + + if ( + snapshot.userRecords.some( + (user) => user.id !== userId && user.username.toLowerCase() === validated.username.toLowerCase(), + ) + ) { + throw new Error('Username already exists.'); + } + + let payload: DashboardSnapshot | null; + try { + payload = await requestSnapshot(() => + fetch(`/api/users/${userId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...buildAuthHeaders(session.token), + }, + body: JSON.stringify(validated), + }), + ); + } catch (error) { + if (error instanceof SessionExpiredError) { + resetSession(); + throw new Error('Panel session expired. Sign in again.'); + } + + throw error; + } + + if (!payload) { + setSnapshot((current) => + withDerivedSnapshot({ + ...current, + service: { + ...current.service, + lastEvent: `User ${validated.username} updated from panel`, + }, + userRecords: current.userRecords.map((user) => + user.id === userId + ? { + ...user, + username: validated.username, + password: validated.password, + serviceId: validated.serviceId, + quotaBytes: quotaMbToBytes(validated.quotaMb), + } + : user, + ), + }), + ); + return; + } + + setSnapshot(payload); + }; + const handleDeleteUser = async (userId: string) => { await mutateSnapshot( () => @@ -963,6 +1248,7 @@ export default function App() {