Polish user actions and runtime controls

This commit is contained in:
2026-04-02 02:51:32 +03:00
parent c04847b21c
commit 3adda67eb9
9 changed files with 556 additions and 85 deletions

View File

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

View File

@@ -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<void>;
onSubmit: (input: CreateUserInput) => Promise<void>;
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<HTMLFormElement>) => {
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 (
<div className="modal-backdrop" role="presentation" onClick={onClose}>
<section
aria-labelledby="add-user-title"
aria-labelledby="user-editor-title"
aria-modal="true"
className="modal-card"
role="dialog"
onClick={stopPropagation}
>
<div className="modal-header">
<h2 id="add-user-title">{text.users.addUser}</h2>
<button type="button" className="button-secondary" onClick={onClose}>
<h2 id="user-editor-title">{mode === 'create' ? text.users.addUser : text.users.editUser}</h2>
<button type="button" className="button-secondary" onClick={onClose} disabled={isSubmitting}>
{text.common.close}
</button>
</div>
@@ -187,6 +197,7 @@ function AddUserModal({
<input
autoFocus
placeholder="night-shift-01"
disabled={isSubmitting}
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
@@ -195,13 +206,14 @@ function AddUserModal({
{text.users.password}
<input
placeholder="generated-secret"
disabled={isSubmitting}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
<label>
{text.users.service}
<select value={serviceId} onChange={(event) => setServiceId(event.target.value)}>
<select disabled={isSubmitting} value={serviceId} onChange={(event) => setServiceId(event.target.value)}>
{services.map((service) => (
<option key={service.id} value={service.id}>
{service.name}
@@ -213,6 +225,7 @@ function AddUserModal({
{text.users.quotaMb}
<input
placeholder={text.common.optional}
disabled={isSubmitting}
value={quotaMb}
onChange={(event) => setQuotaMb(event.target.value)}
/>
@@ -227,10 +240,12 @@ function AddUserModal({
</div>
{error ? <p className="form-error modal-error">{error}</p> : null}
<div className="modal-actions">
<button type="button" className="button-secondary" onClick={onClose}>
<button type="button" className="button-secondary" onClick={onClose} disabled={isSubmitting}>
{text.common.cancel}
</button>
<button type="submit">{text.common.createUser}</button>
<button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>
{mode === 'create' ? text.common.createUser : text.common.saveUser}
</button>
</div>
</form>
</section>
@@ -240,11 +255,13 @@ function AddUserModal({
function ConfirmDeleteModal({
username,
isSubmitting,
onClose,
onConfirm,
preferences,
}: {
username: string;
isSubmitting: boolean;
onClose: () => void;
onConfirm: () => Promise<void>;
preferences: PanelPreferences;
@@ -284,10 +301,10 @@ function ConfirmDeleteModal({
{text.users.deletePromptSuffix}
</p>
<div className="modal-actions">
<button type="button" className="button-secondary" onClick={onClose}>
<button type="button" className="button-secondary" onClick={onClose} disabled={isSubmitting}>
{text.common.cancel}
</button>
<button type="button" className="button-danger" onClick={onConfirm}>
<button type="button" className="button-danger" onClick={onConfirm} disabled={isSubmitting} aria-busy={isSubmitting}>
{text.users.deleteAction}
</button>
</div>
@@ -302,11 +319,22 @@ function DashboardTab({
preferences,
}: {
snapshot: DashboardSnapshot;
onRuntimeAction: (action: 'start' | 'restart') => Promise<void>;
onRuntimeAction: (action: 'start' | 'stop' | 'restart') => Promise<void>;
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 (
<section className="page-grid">
@@ -334,10 +362,31 @@ function DashboardTab({
</div>
</dl>
<div className="actions-row">
<button type="button" onClick={() => onRuntimeAction('start')}>
<button
type="button"
onClick={() => handleRuntimeAction('start')}
disabled={pendingAction !== null || snapshot.service.status === 'live'}
aria-busy={pendingAction === 'start'}
className={pendingAction === 'start' ? 'is-busy' : undefined}
>
{text.dashboard.start}
</button>
<button type="button" className="button-secondary" onClick={() => onRuntimeAction('restart')}>
<button
type="button"
className={`button-secondary${pendingAction === 'stop' ? ' is-busy' : ''}`}
onClick={() => handleRuntimeAction('stop')}
disabled={pendingAction !== null || snapshot.service.status === 'idle'}
aria-busy={pendingAction === 'stop'}
>
{text.dashboard.stop}
</button>
<button
type="button"
className={`button-secondary${pendingAction === 'restart' ? ' is-busy' : ''}`}
onClick={() => handleRuntimeAction('restart')}
disabled={pendingAction !== null}
aria-busy={pendingAction === 'restart'}
>
{text.dashboard.restart}
</button>
</div>
@@ -403,20 +452,24 @@ function DashboardTab({
function UsersTab({
snapshot,
onCreateUser,
onUpdateUser,
onTogglePause,
onDeleteUser,
preferences,
}: {
snapshot: DashboardSnapshot;
onCreateUser: (input: CreateUserInput) => Promise<void>;
onUpdateUser: (userId: string, input: CreateUserInput) => Promise<void>;
onTogglePause: (userId: string) => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>;
preferences: PanelPreferences;
}) {
const text = getPanelText(preferences.language);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editTargetId, setEditTargetId] = useState<string | null>(null);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(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 (
<>
<section className="page-grid single-column">
@@ -449,7 +530,7 @@ function UsersTab({
<span>{snapshot.users.nearQuota} {text.users.nearQuota}</span>
<span>{snapshot.users.exceeded} {text.users.exceeded}</span>
</div>
<button type="button" onClick={() => setIsModalOpen(true)} disabled={assignableServices.length === 0}>
<button type="button" onClick={() => setIsCreateModalOpen(true)} disabled={assignableServices.length === 0}>
{text.users.newUser}
</button>
</div>
@@ -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 (
<tr key={user.id}>
@@ -511,31 +595,47 @@ function UsersTab({
<td>{formatQuotaStateLabel(user.usedBytes, user.quotaBytes, preferences.language)}</td>
<td>{formatTrafficShare(user.usedBytes, snapshot.traffic.totalBytes)}</td>
<td>
<button
type="button"
className={exhausted ? 'copy-button danger' : 'copy-button'}
<ActionIconButton
ariaLabel={copiedId === user.id ? text.common.copied : text.users.copyProxyLink}
title={copiedId === user.id ? text.common.copied : text.users.copyProxyLink}
tone={exhausted ? 'danger' : 'secondary'}
onClick={() => handleCopy(user.id, proxyLink)}
disabled={!service}
>
{copiedId === user.id ? text.common.copied : text.common.copy}
</button>
{copiedId === user.id ? <CheckIcon /> : <CopyIcon />}
</ActionIconButton>
</td>
<td>
<div className="row-actions">
<button
type="button"
className="button-secondary button-small"
onClick={() => onTogglePause(user.id)}
<ActionIconButton
ariaLabel={text.users.editAction}
title={text.users.editAction}
tone="secondary"
onClick={() => setEditTargetId(user.id)}
disabled={isPendingRow}
>
{user.paused ? text.users.resume : text.users.pause}
</button>
<button
type="button"
className="button-danger button-small"
<EditIcon />
</ActionIconButton>
<ActionIconButton
ariaLabel={user.paused ? text.users.resumeAction : text.users.pauseAction}
title={user.paused ? text.users.resumeAction : text.users.pauseAction}
tone="secondary"
onClick={() => handlePauseToggle(user.id)}
disabled={isPendingRow}
busy={isPausePending}
>
{user.paused ? <ResumeIcon /> : <PauseIcon />}
</ActionIconButton>
<ActionIconButton
ariaLabel={text.users.deleteActionLabel}
title={text.users.deleteActionLabel}
tone="danger"
onClick={() => setDeleteTargetId(user.id)}
disabled={isPendingRow}
busy={isDeletePending}
>
{text.common.delete}
</button>
<TrashIcon />
</ActionIconButton>
</div>
</td>
</tr>
@@ -547,12 +647,35 @@ function UsersTab({
</article>
</section>
{isModalOpen ? (
<AddUserModal
{isCreateModalOpen ? (
<UserEditorModal
mode="create"
host={snapshot.system.publicHost}
services={assignableServices}
onClose={() => setIsModalOpen(false)}
onCreate={onCreateUser}
onClose={() => setIsCreateModalOpen(false)}
onSubmit={onCreateUser}
preferences={preferences}
/>
) : null}
{editTarget ? (
<UserEditorModal
mode="edit"
host={snapshot.system.publicHost}
services={assignableServices}
initialValues={toUserEditorValues(editTarget)}
onClose={() => 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({
<ConfirmDeleteModal
username={deleteTarget.username}
onClose={() => 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 (
<button
type="button"
className={`icon-button ${tone}${busy ? ' is-busy' : ''}`}
aria-label={ariaLabel}
title={title ?? ariaLabel}
disabled={disabled}
aria-busy={busy}
onClick={onClick}
>
{children}
</button>
);
}
function CopyIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true">
<path
d="M7 3.5A2.5 2.5 0 0 1 9.5 1h6A2.5 2.5 0 0 1 18 3.5v8A2.5 2.5 0 0 1 15.5 14H15v1.5A2.5 2.5 0 0 1 12.5 18h-6A2.5 2.5 0 0 1 4 15.5v-8A2.5 2.5 0 0 1 6.5 5H7V3.5Zm1.5 1.5h4V3.5a1 1 0 0 0-1-1h-3a1 1 0 0 0-1 1V5Zm-2 1.5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-6Zm8.5 6a2.47 2.47 0 0 0 .5-1.5v-5a1 1 0 0 0-1-1H14v7.5h1Z"
fill="currentColor"
/>
</svg>
);
}
function CheckIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true">
<path
d="M16.7 5.3a1 1 0 0 1 0 1.4l-8 8a1 1 0 0 1-1.4 0l-4-4a1 1 0 1 1 1.4-1.4L8 12.59l7.3-7.3a1 1 0 0 1 1.4 0Z"
fill="currentColor"
/>
</svg>
);
}
function EditIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true">
<path
d="M13.59 2.59a2 2 0 0 1 2.82 0l1 1a2 2 0 0 1 0 2.82l-8.93 8.93a1 1 0 0 1-.46.26l-3.5.88a1 1 0 0 1-1.21-1.21l.88-3.5a1 1 0 0 1 .26-.46l8.93-8.93Zm1.76 2.88-1-1a.5.5 0 0 0-.7 0L5 13.11l-.49 1.95 1.95-.49 8.64-8.64a.5.5 0 0 0 0-.7Z"
fill="currentColor"
/>
</svg>
);
}
function PauseIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true">
<path
d="M6 3a1 1 0 0 1 1 1v12a1 1 0 1 1-2 0V4a1 1 0 0 1 1-1Zm8 0a1 1 0 0 1 1 1v12a1 1 0 1 1-2 0V4a1 1 0 0 1 1-1Z"
fill="currentColor"
/>
</svg>
);
}
function ResumeIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true">
<path
d="M6 4.8A1.8 1.8 0 0 1 8.77 3.3l6.4 4.2a1.8 1.8 0 0 1 0 3l-6.4 4.2A1.8 1.8 0 0 1 6 13.2V4.8Z"
fill="currentColor"
/>
</svg>
);
}
function TrashIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true">
<path
d="M8 2a1 1 0 0 0-1 1v1H4.5a.75.75 0 0 0 0 1.5H5l.68 9.18A2.5 2.5 0 0 0 8.17 17h3.66a2.5 2.5 0 0 0 2.49-2.32L15 5.5h.5a.75.75 0 0 0 0-1.5H13V3a1 1 0 0 0-1-1H8Zm3.5 2h-3V3.5h3V4Zm-3 3.25a.75.75 0 0 1 .75.75v5a.75.75 0 0 1-1.5 0V8a.75.75 0 0 1 .75-.75Zm3 0a.75.75 0 0 1 .75.75v5a.75.75 0 0 1-1.5 0V8a.75.75 0 0 1 .75-.75Z"
fill="currentColor"
/>
</svg>
);
}
export default function App() {
const [preferences, setPreferences] = useState<PanelPreferences>(() => {
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() {
<UsersTab
snapshot={snapshot}
onCreateUser={handleCreateUser}
onUpdateUser={handleUpdateUser}
onTogglePause={handleTogglePause}
onDeleteUser={handleDeleteUser}
preferences={preferences}
@@ -1171,3 +1457,20 @@ function getHashForTab(tab: TabId): string {
return '#dashboard';
}
}
function toUserEditorValues(user: ProxyUserRecord): CreateUserInput {
return {
username: user.username,
password: user.password,
serviceId: user.serviceId,
quotaMb: user.quotaBytes === null ? null : Math.round(user.quotaBytes / (1024 * 1024)),
};
}
function generateUsername(): string {
return `user-${Math.random().toString(36).slice(2, 8)}`;
}
function generatePassword(): string {
return `pw-${Math.random().toString(36).slice(2, 8)}-${Math.random().toString(36).slice(2, 6)}`;
}

View File

@@ -75,6 +75,11 @@ button:disabled {
opacity: 0.65;
}
button[aria-busy='true'],
.is-busy {
box-shadow: inset 0 0 0 999px rgba(255, 255, 255, 0.06);
}
.login-shell {
min-height: 100vh;
display: grid;
@@ -164,7 +169,6 @@ button:disabled {
button,
.button-secondary,
.copy-button,
.button-danger {
height: 36px;
padding: 0 12px;
@@ -175,8 +179,7 @@ button,
font-weight: 600;
}
.button-secondary,
.copy-button {
.button-secondary {
border-color: var(--border-strong);
background: var(--surface);
color: var(--text);
@@ -195,17 +198,34 @@ button,
font-size: 13px;
}
.copy-button {
.icon-button {
width: 32px;
min-width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 76px;
border-radius: 8px;
}
.copy-button.danger {
.icon-button.secondary {
border-color: var(--border-strong);
background: var(--surface);
color: var(--text);
}
.icon-button.danger {
border-color: color-mix(in srgb, var(--danger) 45%, var(--border-strong));
background: color-mix(in srgb, var(--danger) 12%, var(--surface));
color: var(--danger);
}
.icon-button svg {
width: 16px;
height: 16px;
}
.form-error {
margin: 0;
color: var(--danger);

View File

@@ -27,7 +27,9 @@ const text = {
delete: 'Delete',
copy: 'Copy',
copied: 'Copied',
edit: 'Edit',
createUser: 'Create user',
saveUser: 'Save user',
endpoint: 'Endpoint',
protocol: 'Protocol',
status: 'Status',
@@ -62,6 +64,7 @@ const text = {
dailyUsage: 'Daily usage',
attention: 'Attention',
start: 'Start',
stop: 'Stop',
restart: 'Restart',
},
users: {
@@ -80,12 +83,18 @@ const text = {
proxy: 'Proxy',
actions: 'Actions',
addUser: 'Add user',
editUser: 'Edit user',
username: 'Username',
password: 'Password',
service: 'Service',
quotaMb: 'Quota (MB)',
pause: 'Pause',
resume: 'Resume',
copyProxyLink: 'Copy proxy link',
editAction: 'Edit user',
pauseAction: 'Pause user',
resumeAction: 'Resume user',
deleteActionLabel: 'Delete user',
deleteTitle: 'Delete user',
deletePrompt: 'Remove profile',
deletePromptSuffix: '? This action will delete the user entry from the current panel state.',
@@ -144,7 +153,9 @@ const text = {
delete: 'Удалить',
copy: 'Копировать',
copied: 'Скопировано',
edit: 'Редактировать',
createUser: 'Создать пользователя',
saveUser: 'Сохранить пользователя',
endpoint: 'Точка входа',
protocol: 'Протокол',
status: 'Статус',
@@ -179,6 +190,7 @@ const text = {
dailyUsage: 'Дневное использование',
attention: 'Внимание',
start: 'Запустить',
stop: 'Остановить',
restart: 'Перезапустить',
},
users: {
@@ -197,12 +209,18 @@ const text = {
proxy: 'Прокси',
actions: 'Действия',
addUser: 'Добавить пользователя',
editUser: 'Редактирование пользователя',
username: 'Имя пользователя',
password: 'Пароль',
service: 'Сервис',
quotaMb: 'Лимит (МБ)',
pause: 'Пауза',
resume: 'Возобновить',
copyProxyLink: 'Копировать proxy URL',
editAction: 'Редактировать пользователя',
pauseAction: 'Поставить на паузу',
resumeAction: 'Возобновить пользователя',
deleteActionLabel: 'Удалить пользователя',
deleteTitle: 'Удаление пользователя',
deletePrompt: 'Удалить профиль',
deletePromptSuffix: '? Это действие удалит пользователя из текущего состояния панели.',