refactor(decnet_web/Config): extract UsersTab

USER MANAGEMENT panel into its own tab. Owns the per-row UI
state (delete-confirm, reset-password popup) plus the add-user
form state; mutations come in via prop. Errors on per-row
operations stay on window.alert (matches existing behavior); the
add form uses the inline FormMsg chip.

- New Config/tabs/UsersTab.tsx
- UsersTab.test.tsx covers row rendering with the must-change
  badge, the two-step delete confirm flow, the add-user submit
  payload (trimmed username + selected role), and the success
  chip after a successful add.
This commit is contained in:
2026-05-09 05:24:55 -04:00
parent 8807da218b
commit be35228191
2 changed files with 314 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UsersTab } from './UsersTab';
import type { UserEntry } from '../types';
const users: UserEntry[] = [
{ uuid: 'u-1', username: 'alice', role: 'admin', must_change_password: false },
{ uuid: 'u-2', username: 'bob', role: 'viewer', must_change_password: true },
];
const okMutation = async () => ({ ok: true } as const);
describe('UsersTab', () => {
it('renders one row per user with the must-change badge when set', () => {
render(
<UsersTab
users={users}
onDeleteUser={okMutation}
onSetUserRole={okMutation}
onResetUserPassword={okMutation}
onAddUser={okMutation}
/>,
);
expect(screen.getByText('alice')).toBeInTheDocument();
expect(screen.getByText('bob')).toBeInTheDocument();
expect(screen.getByText('MUST CHANGE PASSWORD')).toBeInTheDocument();
});
it('two-step delete only fires onDeleteUser after CONFIRM', async () => {
const onDeleteUser = vi.fn(okMutation);
const user = userEvent.setup();
render(
<UsersTab
users={users}
onDeleteUser={onDeleteUser}
onSetUserRole={okMutation}
onResetUserPassword={okMutation}
onAddUser={okMutation}
/>,
);
const deleteButtons = screen.getAllByText('DELETE');
await user.click(deleteButtons[0]); // alice -> arms confirm
expect(onDeleteUser).not.toHaveBeenCalled();
expect(screen.getByText('CONFIRM?')).toBeInTheDocument();
await user.click(screen.getByText('YES'));
expect(onDeleteUser).toHaveBeenCalledWith('u-1');
});
it('add-user form fires onAddUser with trimmed input + selected role', async () => {
const onAddUser = vi.fn(okMutation);
const user = userEvent.setup();
render(
<UsersTab
users={users}
onDeleteUser={okMutation}
onSetUserRole={okMutation}
onResetUserPassword={okMutation}
onAddUser={onAddUser}
/>,
);
const usernameInput = screen.getAllByRole('textbox')[0];
const passwordInput = document.querySelector('input[type="password"]') as HTMLInputElement;
await user.type(usernameInput, ' charlie ');
await user.type(passwordInput, 'longenoughpw');
await user.click(screen.getByText('ADD USER'));
expect(onAddUser).toHaveBeenCalledWith({
username: 'charlie',
password: 'longenoughpw',
role: 'viewer',
});
});
it('shows the success chip after a successful add', async () => {
const user = userEvent.setup();
render(
<UsersTab
users={users}
onDeleteUser={okMutation}
onSetUserRole={okMutation}
onResetUserPassword={okMutation}
onAddUser={okMutation}
/>,
);
await user.type(screen.getAllByRole('textbox')[0], 'dave');
const pwInput = document.querySelector('input[type="password"]') as HTMLInputElement;
await user.type(pwInput, 'longenoughpw');
await user.click(screen.getByText('ADD USER'));
expect(await screen.findByText('USER CREATED')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,222 @@
import React, { useState } from 'react';
import { Key, Trash2, UserPlus } from '../../../icons';
import type { FormMsg, UserEntry } from '../types';
type MutationResult = { ok: true } | { ok: false; reason: string };
interface Props {
users: UserEntry[];
onDeleteUser: (uuid: string) => Promise<MutationResult>;
onSetUserRole: (uuid: string, role: string) => Promise<MutationResult>;
onResetUserPassword: (uuid: string, newPassword: string) => Promise<MutationResult>;
onAddUser: (input: {
username: string;
password: string;
role: 'admin' | 'viewer';
}) => Promise<MutationResult>;
}
/** USER MANAGEMENT tab — table of operators with per-row inline
* controls (role select, reset-password popup, two-step delete
* confirm) plus the "add user" form below the table. Surfaces
* errors via window.alert for the per-row mutations (matches the
* current behavior) and an inline FormMsg chip for the add form. */
export const UsersTab: React.FC<Props> = ({
users,
onDeleteUser,
onSetUserRole,
onResetUserPassword,
onAddUser,
}) => {
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [resetTarget, setResetTarget] = useState<string | null>(null);
const [resetPassword, setResetPassword] = useState('');
const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newRole, setNewRole] = useState<'admin' | 'viewer'>('viewer');
const [adding, setAdding] = useState(false);
const [msg, setMsg] = useState<FormMsg | null>(null);
const handleDelete = async (uuid: string) => {
const r = await onDeleteUser(uuid);
if (r.ok) {
setConfirmDelete(null);
} else {
alert(r.reason);
}
};
const handleRoleChange = async (uuid: string, role: string) => {
const r = await onSetUserRole(uuid, role);
if (!r.ok) alert(r.reason);
};
const handleResetPassword = async (uuid: string) => {
if (!resetPassword.trim() || resetPassword.length < 8) {
alert('Password must be at least 8 characters');
return;
}
const r = await onResetUserPassword(uuid, resetPassword);
if (r.ok) {
setResetTarget(null);
setResetPassword('');
} else {
alert(r.reason);
}
};
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!newUsername.trim() || !newPassword.trim()) return;
setAdding(true);
setMsg(null);
const r = await onAddUser({
username: newUsername.trim(),
password: newPassword,
role: newRole,
});
if (r.ok) {
setNewUsername('');
setNewPassword('');
setNewRole('viewer');
setMsg({ type: 'success', text: 'USER CREATED' });
} else {
setMsg({ type: 'error', text: r.reason });
}
setAdding(false);
};
return (
<div className="config-panel">
<div className="users-table-container">
<table className="users-table">
<thead>
<tr>
<th>USERNAME</th>
<th>ROLE</th>
<th>STATUS</th>
<th>ACTIONS</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.uuid}>
<td>{user.username}</td>
<td>
<span className={`role-badge ${user.role}`}>{user.role.toUpperCase()}</span>
</td>
<td>
{user.must_change_password && (
<span className="must-change-badge">MUST CHANGE PASSWORD</span>
)}
</td>
<td>
<div className="user-actions">
<select
className="role-select"
value={user.role}
onChange={(e) => handleRoleChange(user.uuid, e.target.value)}
>
<option value="admin">admin</option>
<option value="viewer">viewer</option>
</select>
{resetTarget === user.uuid ? (
<div className="confirm-dialog">
<input
type="password"
placeholder="New password"
value={resetPassword}
onChange={(e) => setResetPassword(e.target.value)}
style={{ width: '140px' }}
/>
<button className="action-btn" onClick={() => handleResetPassword(user.uuid)}>
SET
</button>
<button className="action-btn" onClick={() => { setResetTarget(null); setResetPassword(''); }}>
CANCEL
</button>
</div>
) : (
<button className="action-btn" onClick={() => setResetTarget(user.uuid)}>
<Key size={12} />
RESET
</button>
)}
{confirmDelete === user.uuid ? (
<div className="confirm-dialog">
<span>CONFIRM?</span>
<button className="action-btn danger" onClick={() => handleDelete(user.uuid)}>
YES
</button>
<button className="action-btn" onClick={() => setConfirmDelete(null)}>
NO
</button>
</div>
) : (
<button
className="action-btn danger"
onClick={() => setConfirmDelete(user.uuid)}
>
<Trash2 size={12} />
DELETE
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="add-user-section">
<form className="add-user-form" onSubmit={handleAdd}>
<div className="form-group">
<label>USERNAME</label>
<input
type="text"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
required
minLength={1}
maxLength={64}
/>
</div>
<div className="form-group">
<label>PASSWORD</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
maxLength={72}
/>
</div>
<div className="form-group">
<label>ROLE</label>
<select
value={newRole}
onChange={(e) => setNewRole(e.target.value as 'admin' | 'viewer')}
>
<option value="viewer">viewer</option>
<option value="admin">admin</option>
</select>
</div>
<button type="submit" className="save-btn" disabled={adding}>
<UserPlus size={14} />
{adding ? 'CREATING...' : 'ADD USER'}
</button>
{msg && (
<span className={msg.type === 'success' ? 'config-success' : 'config-error'}>
{msg.text}
</span>
)}
</form>
</div>
</div>
);
};