import React, { useEffect, useMemo, useState, useCallback } from "react";
import {
Paper,
Box,
Typography,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TableSortLabel,
IconButton,
Menu,
MenuItem,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
TextField,
Select,
FormControl,
InputLabel,
Checkbox,
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);
const date = d.toLocaleDateString("en-US", { month: "2-digit", day: "2-digit", year: "numeric" });
const time = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
return `${date} @ ${time}`;
}
async function sha512(text) {
const enc = new TextEncoder();
const data = enc.encode(text || "");
const buf = await crypto.subtle.digest("SHA-512", data);
const arr = Array.from(new Uint8Array(buf));
return arr.map((b) => b.toString(16).padStart(2, "0")).join("");
}
export default function UserManagement({ isAdmin = false }) {
const [rows, setRows] = useState([]); // {username, display_name, role, last_login}
const [orderBy, setOrderBy] = useState("username");
const [order, setOrder] = useState("asc");
const [menuAnchor, setMenuAnchor] = useState(null);
const [menuUser, setMenuUser] = useState(null);
const [resetOpen, setResetOpen] = useState(false);
const [resetTarget, setResetTarget] = useState(null);
const [newPassword, setNewPassword] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState({ username: "", display_name: "", password: "", role: "User" });
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [confirmChangeRoleOpen, setConfirmChangeRoleOpen] = useState(false);
const [changeRoleTarget, setChangeRoleTarget] = useState(null);
const [changeRoleNext, setChangeRoleNext] = useState(null);
const [warnOpen, setWarnOpen] = useState(false);
const [warnMessage, setWarnMessage] = useState("");
const [me, setMe] = useState(null);
const [mfaBusyUser, setMfaBusyUser] = useState(null);
const [resetMfaOpen, setResetMfaOpen] = useState(false);
const [resetMfaTarget, setResetMfaTarget] = 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: "mfa_enabled", label: "MFA" },
{ 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" });
const data = await res.json();
if (Array.isArray(data?.users)) {
setRows(
data.users.map((u) => ({
...u,
mfa_enabled: u && typeof u.mfa_enabled !== "undefined" ? (u.mfa_enabled ? 1 : 0) : 0
}))
);
} else {
setRows([]);
}
} catch {
setRows([]);
}
}, []);
useEffect(() => {
if (!isAdmin) return;
(async () => {
try {
const resp = await fetch("/api/auth/me", { credentials: "include" });
if (resp.ok) {
const who = await resp.json();
setMe(who);
}
} catch {}
})();
fetchUsers();
}, [fetchUsers, isAdmin]);
const handleSort = (col) => {
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
else { setOrderBy(col); setOrder("asc"); }
};
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.filter(applyFilters);
arr.sort((a, b) => {
if (orderBy === "last_login") return ((a.last_login || 0) - (b.last_login || 0)) * dir;
if (orderBy === "mfa_enabled") return ((a.mfa_enabled ? 1 : 0) - (b.mfa_enabled ? 1 : 0)) * dir;
return String(a[orderBy] ?? "").toLowerCase()
.localeCompare(String(b[orderBy] ?? "").toLowerCase()) * dir;
});
return arr;
}, [rows, filters, orderBy, order]);
const openMenu = (evt, user) => {
setMenuAnchor({ mouseX: evt.clientX, mouseY: evt.clientY, anchorEl: evt.currentTarget });
setMenuUser(user);
};
const closeMenu = () => { setMenuAnchor(null); setMenuUser(null); };
const confirmDelete = (user) => {
if (!user) return;
if (me && user.username && String(me.username).toLowerCase() === String(user.username).toLowerCase()) {
setWarnMessage("You cannot delete the user you are currently logged in as.");
setWarnOpen(true);
return;
}
setDeleteTarget(user);
setConfirmDeleteOpen(true);
};
const doDelete = async () => {
const user = deleteTarget;
setConfirmDeleteOpen(false);
if (!user) return;
try {
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");
setWarnOpen(true);
return;
}
await fetchUsers();
} catch (e) {
console.error(e);
setWarnMessage("Failed to delete user");
setWarnOpen(true);
}
};
const openChangeRole = (user) => {
if (!user) return;
if (me && user.username && String(me.username).toLowerCase() === String(user.username).toLowerCase()) {
setWarnMessage("You cannot change your own role.");
setWarnOpen(true);
return;
}
const nextRole = (String(user.role || "User").toLowerCase() === "admin") ? "User" : "Admin";
setChangeRoleTarget(user);
setChangeRoleNext(nextRole);
setConfirmChangeRoleOpen(true);
};
const doChangeRole = async () => {
const user = changeRoleTarget;
const nextRole = changeRoleNext;
setConfirmChangeRoleOpen(false);
if (!user || !nextRole) return;
try {
const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}/role`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ role: nextRole })
});
const data = await resp.json();
if (!resp.ok) {
setWarnMessage(data?.error || "Failed to change role");
setWarnOpen(true);
return;
}
await fetchUsers();
} catch (e) {
console.error(e);
setWarnMessage("Failed to change role");
setWarnOpen(true);
}
};
const openResetMfa = (user) => {
if (!user) return;
setResetMfaTarget(user);
setResetMfaOpen(true);
};
const doResetMfa = async () => {
const user = resetMfaTarget;
setResetMfaOpen(false);
setResetMfaTarget(null);
if (!user) return;
const username = user.username;
const keepEnabled = Boolean(user.mfa_enabled);
setMfaBusyUser(username);
try {
const resp = await fetch(`/api/users/${encodeURIComponent(username)}/mfa`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ enabled: keepEnabled, reset_secret: true })
});
const data = await resp.json();
if (!resp.ok) {
setWarnMessage(data?.error || "Failed to reset MFA for this user.");
setWarnOpen(true);
return;
}
await fetchUsers();
} catch (err) {
console.error(err);
setWarnMessage("Failed to reset MFA for this user.");
setWarnOpen(true);
} finally {
setMfaBusyUser(null);
}
};
const toggleMfa = async (user, enabled) => {
if (!user) return;
const previous = Boolean(user.mfa_enabled);
const nextFlag = enabled ? 1 : 0;
setRows((prev) =>
prev.map((r) =>
String(r.username).toLowerCase() === String(user.username).toLowerCase()
? { ...r, mfa_enabled: nextFlag }
: r
)
);
setMfaBusyUser(user.username);
try {
const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}/mfa`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ enabled })
});
const data = await resp.json();
if (!resp.ok) {
setRows((prev) =>
prev.map((r) =>
String(r.username).toLowerCase() === String(user.username).toLowerCase()
? { ...r, mfa_enabled: previous ? 1 : 0 }
: r
)
);
setWarnMessage(data?.error || "Failed to update MFA settings.");
setWarnOpen(true);
return;
}
await fetchUsers();
} catch (e) {
console.error(e);
setRows((prev) =>
prev.map((r) =>
String(r.username).toLowerCase() === String(user.username).toLowerCase()
? { ...r, mfa_enabled: previous ? 1 : 0 }
: r
)
);
setWarnMessage("Failed to update MFA settings.");
setWarnOpen(true);
} finally {
setMfaBusyUser(null);
}
};
const doResetPassword = async () => {
const user = resetTarget;
if (!user) return;
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",
body: JSON.stringify({ password_sha512: hash })
});
const data = await resp.json();
if (!resp.ok) {
alert(data?.error || "Failed to reset password");
return;
}
setResetOpen(false);
setResetTarget(null);
setNewPassword("");
} catch (e) {
console.error(e);
alert("Failed to reset password");
}
};
const openReset = (user) => {
if (!user) return;
setResetTarget(user);
setResetOpen(true);
setNewPassword("");
};
const openCreate = () => { setCreateOpen(true); setCreateForm({ username: "", display_name: "", password: "", role: "User" }); };
const doCreate = async () => {
const u = (createForm.username || "").trim();
const dn = (createForm.display_name || u).trim();
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",
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");
return;
}
setCreateOpen(false);
await fetchUsers();
} catch (e) {
console.error(e);
alert("Failed to create user");
}
};
if (!isAdmin) return null;
return (
<>
User Management
Manage authorized users of the Borealis Automation Platform.
{/* Leading checkbox gutter to match Devices table rhythm */}
{columns.map((col) => (
{col.id !== "actions" ? (
handleSort(col.id)}
>
{col.label}
) : null}
))}
{filteredSorted.map((u) => (
{/* Body gutter to stay aligned with header */}
{u.display_name || u.username}
{u.username}
{formatTs(u.last_login)}
{u.role || "User"}
{
event.stopPropagation();
toggleMfa(u, event.target.checked);
}}
onClick={(event) => event.stopPropagation()}
sx={{
color: "#888",
"&.Mui-checked": { color: "#58a6ff" }
}}
inputProps={{ "aria-label": `Toggle MFA for ${u.username}` }}
/>
openMenu(e, u)} sx={{ color: "#ccc" }}>
))}
{filteredSorted.length === 0 && (
No users found.
)}
{/* Filter popover (styled to match Device_List) */}
{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}
/>
{ setResetMfaOpen(false); setResetMfaTarget(null); }}
onConfirm={doResetMfa}
/>
setWarnOpen(false)}
onConfirm={() => setWarnOpen(false)}
/>
>
);
}