diff --git a/Data/Server/WebUI/src/Admin/User_Management.jsx b/Data/Server/WebUI/src/Admin/User_Management.jsx index ef0151b..f52407e 100644 --- a/Data/Server/WebUI/src/Admin/User_Management.jsx +++ b/Data/Server/WebUI/src/Admin/User_Management.jsx @@ -21,11 +21,37 @@ import { TextField, Select, FormControl, - InputLabel + InputLabel, + Popover } from "@mui/material"; import MoreVertIcon from "@mui/icons-material/MoreVert"; +import FilterListIcon from "@mui/icons-material/FilterList"; 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) { if (!tsSec) return "-"; const d = new Date((tsSec || 0) * 1000); @@ -61,6 +87,20 @@ export default function UserManagement() { const [warnMessage, setWarnMessage] = useState(""); 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 () => { try { const res = await fetch("/api/users", { credentials: "include" }); @@ -75,7 +115,7 @@ export default function UserManagement() { fetchUsers(); (async () => { try { - const resp = await fetch('/api/auth/me', { credentials: 'include' }); + const resp = await fetch("/api/auth/me", { credentials: "include" }); if (resp.ok) { const who = await resp.json(); setMe(who); @@ -89,15 +129,28 @@ export default function UserManagement() { 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 arr = [...rows]; + const arr = rows.filter(applyFilters); arr.sort((a, b) => { 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; - }, [rows, orderBy, order]); + }, [rows, filters, orderBy, order]); const openMenu = (evt, user) => { setMenuAnchor({ mouseX: evt.clientX, mouseY: evt.clientY, anchorEl: evt.currentTarget }); @@ -121,7 +174,7 @@ export default function UserManagement() { setConfirmDeleteOpen(false); if (!user) return; 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(); if (!resp.ok) { setWarnMessage(data?.error || "Failed to delete user"); @@ -138,7 +191,7 @@ export default function UserManagement() { const openChangeRole = (user) => { 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); setChangeRoleNext(nextRole); setConfirmChangeRoleOpen(true); @@ -151,9 +204,9 @@ export default function UserManagement() { if (!user || !nextRole) return; try { const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}/role`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", body: JSON.stringify({ role: nextRole }) }); const data = await resp.json(); @@ -173,14 +226,14 @@ export default function UserManagement() { const doResetPassword = async () => { const user = menuUser; if (!user) return; - const pw = newPassword || ''; + const pw = newPassword || ""; if (!pw.trim()) return; try { const hash = await sha512(pw); const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}/reset_password`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", body: JSON.stringify({ password_sha512: hash }) }); const data = await resp.json(); @@ -200,228 +253,281 @@ export default function UserManagement() { const openCreate = () => { setCreateOpen(true); setCreateForm({ username: "", display_name: "", password: "", role: "User" }); }; const doCreate = async () => { - const u = (createForm.username || '').trim(); + const u = (createForm.username || "").trim(); const dn = (createForm.display_name || u).trim(); - const pw = (createForm.password || '').trim(); - const role = (createForm.role || 'User'); + const pw = (createForm.password || "").trim(); + const role = (createForm.role || "User"); if (!u || !pw) return; try { const hash = await sha512(pw); - const resp = await fetch('/api/users', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', + const resp = await fetch("/api/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", body: JSON.stringify({ username: u, display_name: dn, password_sha512: hash, role }) }); const data = await resp.json(); if (!resp.ok) { - alert(data?.error || 'Failed to create user'); + alert(data?.error || "Failed to create user"); return; } setCreateOpen(false); await fetchUsers(); } catch (e) { console.error(e); - alert('Failed to create user'); + alert("Failed to create user"); } }; return ( <> - - - - User Management - - Create, Edit, and Remove Borealis Server Operators - - - + + + + + User Management + + + Manage authorized users of the Borealis Automation Platform. + + - - - - - {[ - { 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: '' }, - ].map((col) => ( - - {col.id !== 'actions' ? ( - handleSort(col.id)} - > - {col.label} - - ) : null} - - ))} - - - - {sorted.map((u) => ( - - {u.display_name || u.username} - {u.username} - {formatTs(u.last_login)} - {u.role || 'User'} - - openMenu(e, u)} sx={{ color: '#aaa' }}> - - - +
+ + + {/* Leading checkbox gutter to match Devices table rhythm */} + + {columns.map((col) => ( + + {col.id !== "actions" ? ( + + handleSort(col.id)} + > + {col.label} + + + + + + ) : null} + + ))} - ))} - -
+ - - { const u = menuUser; closeMenu(); confirmDelete(u); }} + + {filteredSorted.map((u) => ( + + {/* Body gutter to stay aligned with header */} + + {u.display_name || u.username} + {u.username} + {formatTs(u.last_login)} + {u.role || "User"} + + openMenu(e, u)} sx={{ color: "#ccc" }}> + + + + + ))} + {filteredSorted.length === 0 && ( + + + No users found. + + + )} + + + + {/* Filter popover (styled to match Device_List) */} + - Delete User - - { openReset(); }}>Reset Password - { const u = menuUser; closeMenu(); openChangeRole(u); }}>Change Role - + {filterAnchor && ( + + c.id === filterAnchor.id)?.label || ""}`} + value={filters[filterAnchor.id] || ""} + onChange={onFilterChange(filterAnchor.id)} + onKeyDown={(e) => { if (e.key === "Escape") closeFilter(); }} + sx={filterFieldSx} + /> + + + )} + - setResetOpen(false)} PaperProps={{ sx: { bgcolor: '#121212', color: '#fff' } }}> - Reset Password - - - Enter a new password for {menuUser?.username}. - - setNewPassword(e.target.value)} - sx={{ - "& .MuiOutlinedInput-root": { - backgroundColor: "#2a2a2a", - color: "#ccc", - "& fieldset": { borderColor: "#444" }, - "&:hover fieldset": { borderColor: "#666" } - }, - label: { color: "#aaa" }, - mt: 1 - }} - /> - - - - - - + + { const u = menuUser; closeMenu(); confirmDelete(u); }} + > + Delete User + + { openReset(); }}>Reset Password + { const u = menuUser; closeMenu(); openChangeRole(u); }}>Change Role + - setCreateOpen(false)} PaperProps={{ sx: { bgcolor: '#121212', color: '#fff' } }}> - Create User - - setCreateForm((p) => ({ ...p, username: e.target.value }))} - sx={{ - "& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } }, - label: { color: "#aaa" }, mt: 1 - }} - /> - setCreateForm((p) => ({ ...p, display_name: e.target.value }))} - sx={{ - "& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } }, - label: { color: "#aaa" }, mt: 1 - }} - /> - setCreateForm((p) => ({ ...p, password: e.target.value }))} - sx={{ - "& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } }, - label: { color: "#aaa" }, mt: 1 - }} - /> - - Role - - - - - - - - -
- setConfirmDeleteOpen(false)} - onConfirm={doDelete} - /> - setConfirmChangeRoleOpen(false)} - onConfirm={doChangeRole} - /> - setWarnOpen(false)} - onConfirm={() => setWarnOpen(false)} - /> + /> + + + + + + + + setCreateOpen(false)} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}> + Create User + + setCreateForm((p) => ({ ...p, username: e.target.value }))} + sx={{ + "& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } }, + label: { color: "#aaa" }, mt: 1 + }} + /> + setCreateForm((p) => ({ ...p, display_name: e.target.value }))} + sx={{ + "& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } }, + label: { color: "#aaa" }, mt: 1 + }} + /> + setCreateForm((p) => ({ ...p, password: e.target.value }))} + sx={{ + "& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } }, + label: { color: "#aaa" }, mt: 1 + }} + /> + + Role + + + + + + + + + + + setConfirmDeleteOpen(false)} + onConfirm={doDelete} + /> + setConfirmChangeRoleOpen(false)} + onConfirm={doChangeRole} + /> + setWarnOpen(false)} + onConfirm={() => setWarnOpen(false)} + /> ); } diff --git a/Data/Server/WebUI/src/Navigation_Sidebar.jsx b/Data/Server/WebUI/src/Navigation_Sidebar.jsx index dd6e94a..694e99e 100644 --- a/Data/Server/WebUI/src/Navigation_Sidebar.jsx +++ b/Data/Server/WebUI/src/Navigation_Sidebar.jsx @@ -325,7 +325,7 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) { }} > - Admin + Admin Settings