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} /> )} { const u = menuUser; closeMenu(); confirmDelete(u); }} > Delete User { const u = menuUser; closeMenu(); openReset(u); }}>Reset Password { const u = menuUser; closeMenu(); openChangeRole(u); }} > Change Role { const u = menuUser; closeMenu(); openResetMfa(u); }}> Reset MFA setResetOpen(false)} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}> Reset Password Enter a new password for {resetTarget?.username}. setNewPassword(e.target.value)} sx={{ "& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } }, label: { color: "#aaa" }, mt: 1 }} /> 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} /> { setResetMfaOpen(false); setResetMfaTarget(null); }} onConfirm={doResetMfa} /> setWarnOpen(false)} onConfirm={() => setWarnOpen(false)} /> ); }