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}
+
+ ))}
- ))}
-
-
+
-
+
+ {/* Filter popover (styled to match Device_List) */}
+
- Delete User
-
-
-
-
+ {filterAnchor && (
+
+ c.id === filterAnchor.id)?.label || ""}`}
+ value={filters[filterAnchor.id] || ""}
+ onChange={onFilterChange(filterAnchor.id)}
+ onKeyDown={(e) => { if (e.key === "Escape") closeFilter(); }}
+ sx={filterFieldSx}
+ />
+
+
+ )}
+
-
+
-
-
- 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