diff --git a/decnet_web/src/components/Config/tabs/UsersTab.test.tsx b/decnet_web/src/components/Config/tabs/UsersTab.test.tsx new file mode 100644 index 00000000..caa1a7ce --- /dev/null +++ b/decnet_web/src/components/Config/tabs/UsersTab.test.tsx @@ -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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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(); + }); +}); diff --git a/decnet_web/src/components/Config/tabs/UsersTab.tsx b/decnet_web/src/components/Config/tabs/UsersTab.tsx new file mode 100644 index 00000000..45b2ffae --- /dev/null +++ b/decnet_web/src/components/Config/tabs/UsersTab.tsx @@ -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; + onSetUserRole: (uuid: string, role: string) => Promise; + onResetUserPassword: (uuid: string, newPassword: string) => Promise; + onAddUser: (input: { + username: string; + password: string; + role: 'admin' | 'viewer'; + }) => Promise; +} + +/** 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 = ({ + users, + onDeleteUser, + onSetUserRole, + onResetUserPassword, + onAddUser, +}) => { + const [confirmDelete, setConfirmDelete] = useState(null); + const [resetTarget, setResetTarget] = useState(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(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 ( +
+
+ + + + + + + + + + + {users.map((user) => ( + + + + + + + ))} + +
USERNAMEROLESTATUSACTIONS
{user.username} + {user.role.toUpperCase()} + + {user.must_change_password && ( + MUST CHANGE PASSWORD + )} + +
+ + + {resetTarget === user.uuid ? ( +
+ setResetPassword(e.target.value)} + style={{ width: '140px' }} + /> + + +
+ ) : ( + + )} + + {confirmDelete === user.uuid ? ( +
+ CONFIRM? + + +
+ ) : ( + + )} +
+
+
+ +
+
+
+ + setNewUsername(e.target.value)} + required + minLength={1} + maxLength={64} + /> +
+
+ + setNewPassword(e.target.value)} + required + minLength={8} + maxLength={72} + /> +
+
+ + +
+ + {msg && ( + + {msg.text} + + )} +
+
+
+ ); +};