Fixed User Management UI Issues

This commit is contained in:
2025-09-19 19:51:48 -06:00
parent 1582c01c41
commit 8075781bb0
2 changed files with 309 additions and 203 deletions

View File

@@ -21,11 +21,37 @@ import {
TextField, TextField,
Select, Select,
FormControl, FormControl,
InputLabel InputLabel,
Popover
} from "@mui/material"; } from "@mui/material";
import MoreVertIcon from "@mui/icons-material/MoreVert"; import MoreVertIcon from "@mui/icons-material/MoreVert";
import FilterListIcon from "@mui/icons-material/FilterList";
import { ConfirmDeleteDialog } from "../Dialogs.jsx"; import { ConfirmDeleteDialog } from "../Dialogs.jsx";
/* ---------- Formatting helpers to keep this page in lockstep with Device_List ---------- */
const tablePaperSx = { m: 2, p: 0, bgcolor: "#1e1e1e" };
const tableSx = {
minWidth: 820,
"& th, & td": {
color: "#ddd",
borderColor: "#2a2a2a",
fontSize: 13,
py: 0.75
},
"& th .MuiTableSortLabel-root": { color: "#ddd" },
"& th .MuiTableSortLabel-root.Mui-active": { color: "#ddd" }
};
const menuPaperSx = { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" };
const filterFieldSx = {
input: { color: "#fff" },
minWidth: 220,
"& .MuiOutlinedInput-root": {
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
}
};
/* -------------------------------------------------------------------- */
function formatTs(tsSec) { function formatTs(tsSec) {
if (!tsSec) return "-"; if (!tsSec) return "-";
const d = new Date((tsSec || 0) * 1000); const d = new Date((tsSec || 0) * 1000);
@@ -61,6 +87,20 @@ export default function UserManagement() {
const [warnMessage, setWarnMessage] = useState(""); const [warnMessage, setWarnMessage] = useState("");
const [me, setMe] = useState(null); const [me, setMe] = useState(null);
// Columns and filters
const columns = useMemo(() => ([
{ id: "display_name", label: "Display Name" },
{ id: "username", label: "User Name" },
{ id: "last_login", label: "Last Login" },
{ id: "role", label: "User Role" },
{ id: "actions", label: "" }
]), []);
const [filters, setFilters] = useState({}); // id -> string
const [filterAnchor, setFilterAnchor] = useState(null); // { id, anchorEl }
const openFilter = (id) => (e) => setFilterAnchor({ id, anchorEl: e.currentTarget });
const closeFilter = () => setFilterAnchor(null);
const onFilterChange = (id) => (e) => setFilters((prev) => ({ ...prev, [id]: e.target.value }));
const fetchUsers = useCallback(async () => { const fetchUsers = useCallback(async () => {
try { try {
const res = await fetch("/api/users", { credentials: "include" }); const res = await fetch("/api/users", { credentials: "include" });
@@ -75,7 +115,7 @@ export default function UserManagement() {
fetchUsers(); fetchUsers();
(async () => { (async () => {
try { try {
const resp = await fetch('/api/auth/me', { credentials: 'include' }); const resp = await fetch("/api/auth/me", { credentials: "include" });
if (resp.ok) { if (resp.ok) {
const who = await resp.json(); const who = await resp.json();
setMe(who); setMe(who);
@@ -89,15 +129,28 @@ export default function UserManagement() {
else { setOrderBy(col); setOrder("asc"); } else { setOrderBy(col); setOrder("asc"); }
}; };
const sorted = useMemo(() => { const filteredSorted = useMemo(() => {
const applyFilters = (r) => {
for (const [key, val] of Object.entries(filters || {})) {
if (!val) continue;
const needle = String(val).toLowerCase();
let hay = "";
if (key === "last_login") hay = String(formatTs(r.last_login));
else hay = String(r[key] ?? "");
if (!hay.toLowerCase().includes(needle)) return false;
}
return true;
};
const dir = order === "asc" ? 1 : -1; const dir = order === "asc" ? 1 : -1;
const arr = [...rows]; const arr = rows.filter(applyFilters);
arr.sort((a, b) => { arr.sort((a, b) => {
if (orderBy === "last_login") return ((a.last_login || 0) - (b.last_login || 0)) * dir; if (orderBy === "last_login") return ((a.last_login || 0) - (b.last_login || 0)) * dir;
return String(a[orderBy] ?? "").toLowerCase().localeCompare(String(b[orderBy] ?? "").toLowerCase()) * dir; return String(a[orderBy] ?? "").toLowerCase()
.localeCompare(String(b[orderBy] ?? "").toLowerCase()) * dir;
}); });
return arr; return arr;
}, [rows, orderBy, order]); }, [rows, filters, orderBy, order]);
const openMenu = (evt, user) => { const openMenu = (evt, user) => {
setMenuAnchor({ mouseX: evt.clientX, mouseY: evt.clientY, anchorEl: evt.currentTarget }); setMenuAnchor({ mouseX: evt.clientX, mouseY: evt.clientY, anchorEl: evt.currentTarget });
@@ -121,7 +174,7 @@ export default function UserManagement() {
setConfirmDeleteOpen(false); setConfirmDeleteOpen(false);
if (!user) return; if (!user) return;
try { try {
const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}`, { method: 'DELETE', credentials: 'include' }); const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}`, { method: "DELETE", credentials: "include" });
const data = await resp.json(); const data = await resp.json();
if (!resp.ok) { if (!resp.ok) {
setWarnMessage(data?.error || "Failed to delete user"); setWarnMessage(data?.error || "Failed to delete user");
@@ -138,7 +191,7 @@ export default function UserManagement() {
const openChangeRole = (user) => { const openChangeRole = (user) => {
if (!user) return; if (!user) return;
const nextRole = (String(user.role || 'User').toLowerCase() === 'admin') ? 'User' : 'Admin'; const nextRole = (String(user.role || "User").toLowerCase() === "admin") ? "User" : "Admin";
setChangeRoleTarget(user); setChangeRoleTarget(user);
setChangeRoleNext(nextRole); setChangeRoleNext(nextRole);
setConfirmChangeRoleOpen(true); setConfirmChangeRoleOpen(true);
@@ -151,9 +204,9 @@ export default function UserManagement() {
if (!user || !nextRole) return; if (!user || !nextRole) return;
try { try {
const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}/role`, { const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}/role`, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
credentials: 'include', credentials: "include",
body: JSON.stringify({ role: nextRole }) body: JSON.stringify({ role: nextRole })
}); });
const data = await resp.json(); const data = await resp.json();
@@ -173,14 +226,14 @@ export default function UserManagement() {
const doResetPassword = async () => { const doResetPassword = async () => {
const user = menuUser; const user = menuUser;
if (!user) return; if (!user) return;
const pw = newPassword || ''; const pw = newPassword || "";
if (!pw.trim()) return; if (!pw.trim()) return;
try { try {
const hash = await sha512(pw); const hash = await sha512(pw);
const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}/reset_password`, { const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}/reset_password`, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
credentials: 'include', credentials: "include",
body: JSON.stringify({ password_sha512: hash }) body: JSON.stringify({ password_sha512: hash })
}); });
const data = await resp.json(); const data = await resp.json();
@@ -200,102 +253,154 @@ export default function UserManagement() {
const openCreate = () => { setCreateOpen(true); setCreateForm({ username: "", display_name: "", password: "", role: "User" }); }; const openCreate = () => { setCreateOpen(true); setCreateForm({ username: "", display_name: "", password: "", role: "User" }); };
const doCreate = async () => { const doCreate = async () => {
const u = (createForm.username || '').trim(); const u = (createForm.username || "").trim();
const dn = (createForm.display_name || u).trim(); const dn = (createForm.display_name || u).trim();
const pw = (createForm.password || '').trim(); const pw = (createForm.password || "").trim();
const role = (createForm.role || 'User'); const role = (createForm.role || "User");
if (!u || !pw) return; if (!u || !pw) return;
try { try {
const hash = await sha512(pw); const hash = await sha512(pw);
const resp = await fetch('/api/users', { const resp = await fetch("/api/users", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
credentials: 'include', credentials: "include",
body: JSON.stringify({ username: u, display_name: dn, password_sha512: hash, role }) body: JSON.stringify({ username: u, display_name: dn, password_sha512: hash, role })
}); });
const data = await resp.json(); const data = await resp.json();
if (!resp.ok) { if (!resp.ok) {
alert(data?.error || 'Failed to create user'); alert(data?.error || "Failed to create user");
return; return;
} }
setCreateOpen(false); setCreateOpen(false);
await fetchUsers(); await fetchUsers();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
alert('Failed to create user'); alert("Failed to create user");
} }
}; };
return ( return (
<> <>
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}> <Paper sx={tablePaperSx} elevation={2}>
<Box sx={{ p: 2, pb: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <Box sx={{ p: 2, pb: 1, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<Box> <Box>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>User Management</Typography> <Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
<Typography sx={{ color: '#aaa', fontSize: '0.85rem', mt: 0.5 }}> User Management
Create, Edit, and Remove Borealis Server Operators </Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}>
Manage authorized users of the Borealis Automation Platform.
</Typography> </Typography>
</Box> </Box>
<Box>
<Button <Button
variant="outlined" variant="outlined"
size="small" size="small"
onClick={openCreate} onClick={openCreate}
sx={{ color: '#58a6ff', borderColor: '#58a6ff', textTransform: 'none' }} sx={{ color: "#58a6ff", borderColor: "#58a6ff", textTransform: "none" }}
> >
Create User Create User
</Button> </Button>
</Box> </Box>
</Box>
<Table size="small" sx={{ minWidth: 700 }}> <Table size="small" sx={tableSx}>
<TableHead> <TableHead>
<TableRow> <TableRow>
{[ {/* Leading checkbox gutter to match Devices table rhythm */}
{ id: 'display_name', label: 'Display Name' }, <TableCell padding="checkbox" />
{ id: 'username', label: 'User Name' }, {columns.map((col) => (
{ id: 'last_login', label: 'Last Login' }, <TableCell
{ id: 'role', label: 'User Role' }, key={col.id}
{ id: 'actions', label: '' }, sortDirection={["actions"].includes(col.id) ? false : (orderBy === col.id ? order : false)}
].map((col) => ( >
<TableCell key={col.id} sortDirection={['actions'].includes(col.id) ? false : (orderBy === col.id ? order : false)}> {col.id !== "actions" ? (
{col.id !== 'actions' ? ( <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<TableSortLabel <TableSortLabel
active={orderBy === col.id} active={orderBy === col.id}
direction={orderBy === col.id ? order : 'asc'} direction={orderBy === col.id ? order : "asc"}
onClick={() => handleSort(col.id)} onClick={() => handleSort(col.id)}
> >
{col.label} {col.label}
</TableSortLabel> </TableSortLabel>
<IconButton
size="small"
onClick={openFilter(col.id)}
sx={{ color: filters[col.id] ? "#58a6ff" : "#888" }}
>
<FilterListIcon fontSize="inherit" />
</IconButton>
</Box>
) : null} ) : null}
</TableCell> </TableCell>
))} ))}
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{sorted.map((u) => ( {filteredSorted.map((u) => (
<TableRow key={u.username} hover> <TableRow key={u.username} hover>
{/* Body gutter to stay aligned with header */}
<TableCell padding="checkbox" />
<TableCell>{u.display_name || u.username}</TableCell> <TableCell>{u.display_name || u.username}</TableCell>
<TableCell>{u.username}</TableCell> <TableCell>{u.username}</TableCell>
<TableCell>{formatTs(u.last_login)}</TableCell> <TableCell>{formatTs(u.last_login)}</TableCell>
<TableCell>{u.role || 'User'}</TableCell> <TableCell>{u.role || "User"}</TableCell>
<TableCell align="right" sx={{ width: 1 }}> <TableCell align="right">
<IconButton size="small" onClick={(e) => openMenu(e, u)} sx={{ color: '#aaa' }}> <IconButton size="small" onClick={(e) => openMenu(e, u)} sx={{ color: "#ccc" }}>
<MoreVertIcon fontSize="inherit" /> <MoreVertIcon fontSize="inherit" />
</IconButton> </IconButton>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
{filteredSorted.length === 0 && (
<TableRow>
<TableCell colSpan={columns.length + 1} sx={{ color: "#888" }}>
No users found.
</TableCell>
</TableRow>
)}
</TableBody> </TableBody>
</Table> </Table>
{/* Filter popover (styled to match Device_List) */}
<Popover
open={Boolean(filterAnchor)}
anchorEl={filterAnchor?.anchorEl || null}
onClose={closeFilter}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
PaperProps={{ sx: { bgcolor: "#1e1e1e", p: 1 } }}
>
{filterAnchor && (
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<TextField
autoFocus
size="small"
placeholder={`Filter ${columns.find((c) => c.id === filterAnchor.id)?.label || ""}`}
value={filters[filterAnchor.id] || ""}
onChange={onFilterChange(filterAnchor.id)}
onKeyDown={(e) => { if (e.key === "Escape") closeFilter(); }}
sx={filterFieldSx}
/>
<Button
variant="outlined"
size="small"
onClick={() => {
setFilters((prev) => ({ ...prev, [filterAnchor.id]: "" }));
closeFilter();
}}
sx={{ textTransform: "none", borderColor: "#555", color: "#bbb" }}
>
Clear
</Button>
</Box>
)}
</Popover>
<Menu <Menu
anchorEl={menuAnchor?.anchorEl} anchorEl={menuAnchor?.anchorEl}
open={Boolean(menuAnchor)} open={Boolean(menuAnchor)}
onClose={closeMenu} onClose={closeMenu}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }} transformOrigin={{ vertical: "top", horizontal: "right" }}
PaperProps={{ sx: { bgcolor: '#1e1e1e', color: '#fff' } }} PaperProps={{ sx: menuPaperSx }}
> >
<MenuItem <MenuItem
disabled={me && menuUser && String(me.username).toLowerCase() === String(menuUser.username).toLowerCase()} disabled={me && menuUser && String(me.username).toLowerCase() === String(menuUser.username).toLowerCase()}
@@ -307,10 +412,10 @@ export default function UserManagement() {
<MenuItem onClick={() => { const u = menuUser; closeMenu(); openChangeRole(u); }}>Change Role</MenuItem> <MenuItem onClick={() => { const u = menuUser; closeMenu(); openChangeRole(u); }}>Change Role</MenuItem>
</Menu> </Menu>
<Dialog open={resetOpen} onClose={() => setResetOpen(false)} PaperProps={{ sx: { bgcolor: '#121212', color: '#fff' } }}> <Dialog open={resetOpen} onClose={() => setResetOpen(false)} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Reset Password</DialogTitle> <DialogTitle>Reset Password</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText sx={{ color: '#ccc' }}> <DialogContentText sx={{ color: "#ccc" }}>
Enter a new password for {menuUser?.username}. Enter a new password for {menuUser?.username}.
</DialogContentText> </DialogContentText>
<TextField <TextField
@@ -335,12 +440,12 @@ export default function UserManagement() {
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setResetOpen(false)} sx={{ color: '#58a6ff' }}>Cancel</Button> <Button onClick={() => setResetOpen(false)} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={doResetPassword} sx={{ color: '#58a6ff' }}>OK</Button> <Button onClick={doResetPassword} sx={{ color: "#58a6ff" }}>OK</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} PaperProps={{ sx: { bgcolor: '#121212', color: '#fff' } }}> <Dialog open={createOpen} onClose={() => setCreateOpen(false)} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Create User</DialogTitle> <DialogTitle>Create User</DialogTitle>
<DialogContent> <DialogContent>
<TextField <TextField
@@ -382,15 +487,15 @@ export default function UserManagement() {
}} }}
/> />
<FormControl fullWidth sx={{ mt: 2 }}> <FormControl fullWidth sx={{ mt: 2 }}>
<InputLabel sx={{ color: '#aaa' }}>Role</InputLabel> <InputLabel sx={{ color: "#aaa" }}>Role</InputLabel>
<Select <Select
native native
value={createForm.role} value={createForm.role}
onChange={(e) => setCreateForm((p) => ({ ...p, role: e.target.value }))} onChange={(e) => setCreateForm((p) => ({ ...p, role: e.target.value }))}
sx={{ sx={{
backgroundColor: '#2a2a2a', backgroundColor: "#2a2a2a",
color: '#ccc', color: "#ccc",
borderColor: '#444' borderColor: "#444"
}} }}
> >
<option value="User">User</option> <option value="User">User</option>
@@ -399,20 +504,21 @@ export default function UserManagement() {
</FormControl> </FormControl>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setCreateOpen(false)} sx={{ color: '#58a6ff' }}>Cancel</Button> <Button onClick={() => setCreateOpen(false)} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={doCreate} sx={{ color: '#58a6ff' }}>Create</Button> <Button onClick={doCreate} sx={{ color: "#58a6ff" }}>Create</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</Paper> </Paper>
<ConfirmDeleteDialog <ConfirmDeleteDialog
open={confirmDeleteOpen} open={confirmDeleteOpen}
message={`Are you sure you want to delete user '${deleteTarget?.username || ''}'?`} message={`Are you sure you want to delete user '${deleteTarget?.username || ""}'?`}
onCancel={() => setConfirmDeleteOpen(false)} onCancel={() => setConfirmDeleteOpen(false)}
onConfirm={doDelete} onConfirm={doDelete}
/> />
<ConfirmDeleteDialog <ConfirmDeleteDialog
open={confirmChangeRoleOpen} open={confirmChangeRoleOpen}
message={changeRoleTarget ? `Change role for '${changeRoleTarget.username}' to ${changeRoleNext}?` : ''} message={changeRoleTarget ? `Change role for '${changeRoleTarget.username}' to ${changeRoleNext}?` : ""}
onCancel={() => setConfirmChangeRoleOpen(false)} onCancel={() => setConfirmChangeRoleOpen(false)}
onConfirm={doChangeRole} onConfirm={doChangeRole}
/> />

View File

@@ -325,7 +325,7 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
}} }}
> >
<Typography sx={{ fontSize: "0.85rem", color: "#58a6ff" }}> <Typography sx={{ fontSize: "0.85rem", color: "#58a6ff" }}>
<b>Admin</b> <b>Admin Settings</b>
</Typography> </Typography>
</AccordionSummary> </AccordionSummary>
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}> <AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>