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:
92
decnet_web/src/components/Config/tabs/UsersTab.test.tsx
Normal file
92
decnet_web/src/components/Config/tabs/UsersTab.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
222
decnet_web/src/components/Config/tabs/UsersTab.tsx
Normal file
222
decnet_web/src/components/Config/tabs/UsersTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user