Fleshing-Out Implementation of Credential Management for Ansible Playbooks

This commit is contained in:
2025-10-11 02:14:56 -06:00
parent b07f52dbb5
commit 01202e8ac2
10 changed files with 2310 additions and 110 deletions

View File

@@ -0,0 +1,549 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
InputLabel,
MenuItem,
Select,
TextField,
Typography,
IconButton,
Tooltip,
CircularProgress
} from "@mui/material";
import UploadIcon from "@mui/icons-material/UploadFile";
import ClearIcon from "@mui/icons-material/Clear";
const CREDENTIAL_TYPES = [
{ value: "machine", label: "Machine" },
{ value: "domain", label: "Domain" },
{ value: "token", label: "Token" }
];
const CONNECTION_TYPES = [
{ value: "ssh", label: "SSH" },
{ value: "winrm", label: "WinRM" }
];
const BECOME_METHODS = [
{ value: "", label: "None" },
{ value: "sudo", label: "sudo" },
{ value: "su", label: "su" },
{ value: "runas", label: "runas" },
{ value: "enable", label: "enable" }
];
function emptyForm() {
return {
name: "",
description: "",
site_id: "",
credential_type: "machine",
connection_type: "ssh",
username: "",
password: "",
private_key: "",
private_key_passphrase: "",
become_method: "",
become_username: "",
become_password: ""
};
}
function normalizeSiteId(value) {
if (value === null || typeof value === "undefined" || value === "") return "";
const num = Number(value);
if (Number.isNaN(num)) return "";
return String(num);
}
export default function CredentialEditor({
open,
mode = "create",
credential,
onClose,
onSaved
}) {
const isEdit = mode === "edit" && credential && credential.id;
const [form, setForm] = useState(emptyForm);
const [sites, setSites] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [passwordDirty, setPasswordDirty] = useState(false);
const [privateKeyDirty, setPrivateKeyDirty] = useState(false);
const [passphraseDirty, setPassphraseDirty] = useState(false);
const [becomePasswordDirty, setBecomePasswordDirty] = useState(false);
const [clearPassword, setClearPassword] = useState(false);
const [clearPrivateKey, setClearPrivateKey] = useState(false);
const [clearPassphrase, setClearPassphrase] = useState(false);
const [clearBecomePassword, setClearBecomePassword] = useState(false);
const [fetchingDetail, setFetchingDetail] = useState(false);
const credentialId = credential?.id;
useEffect(() => {
if (!open) return;
let canceled = false;
(async () => {
try {
const resp = await fetch("/api/sites");
if (!resp.ok) return;
const data = await resp.json();
if (canceled) return;
const parsed = Array.isArray(data?.sites)
? data.sites
.filter((s) => s && s.id)
.map((s) => ({
id: s.id,
name: s.name || `Site ${s.id}`
}))
: [];
parsed.sort((a, b) => String(a.name || "").localeCompare(String(b.name || "")));
setSites(parsed);
} catch {
if (!canceled) setSites([]);
}
})();
return () => {
canceled = true;
};
}, [open]);
useEffect(() => {
if (!open) return;
setError("");
setPasswordDirty(false);
setPrivateKeyDirty(false);
setPassphraseDirty(false);
setBecomePasswordDirty(false);
setClearPassword(false);
setClearPrivateKey(false);
setClearPassphrase(false);
setClearBecomePassword(false);
if (isEdit && credentialId) {
const applyData = (detail) => {
const next = emptyForm();
next.name = detail?.name || "";
next.description = detail?.description || "";
next.site_id = normalizeSiteId(detail?.site_id);
next.credential_type = (detail?.credential_type || "machine").toLowerCase();
next.connection_type = (detail?.connection_type || "ssh").toLowerCase();
next.username = detail?.username || "";
next.become_method = (detail?.become_method || "").toLowerCase();
next.become_username = detail?.become_username || "";
setForm(next);
};
if (credential?.name) {
applyData(credential);
} else {
setFetchingDetail(true);
(async () => {
try {
const resp = await fetch(`/api/credentials/${credentialId}`);
if (resp.ok) {
const data = await resp.json();
applyData(data?.credential || {});
}
} catch {
/* ignore */
} finally {
setFetchingDetail(false);
}
})();
}
} else {
setForm(emptyForm());
}
}, [open, isEdit, credentialId, credential]);
const currentCredentialFlags = useMemo(() => ({
hasPassword: Boolean(credential?.has_password),
hasPrivateKey: Boolean(credential?.has_private_key),
hasPrivateKeyPassphrase: Boolean(credential?.has_private_key_passphrase),
hasBecomePassword: Boolean(credential?.has_become_password)
}), [credential]);
const disableSave = loading || fetchingDetail;
const updateField = (key) => (event) => {
const value = event?.target?.value ?? "";
setForm((prev) => ({ ...prev, [key]: value }));
if (key === "password") {
setPasswordDirty(true);
setClearPassword(false);
} else if (key === "private_key") {
setPrivateKeyDirty(true);
setClearPrivateKey(false);
} else if (key === "private_key_passphrase") {
setPassphraseDirty(true);
setClearPassphrase(false);
} else if (key === "become_password") {
setBecomePasswordDirty(true);
setClearBecomePassword(false);
}
};
const handlePrivateKeyUpload = async (event) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
setForm((prev) => ({ ...prev, private_key: text }));
setPrivateKeyDirty(true);
setClearPrivateKey(false);
} catch {
setError("Unable to read private key file.");
} finally {
event.target.value = "";
}
};
const handleCancel = () => {
if (loading) return;
onClose && onClose();
};
const validate = () => {
if (!form.name.trim()) {
setError("Credential name is required.");
return false;
}
setError("");
return true;
};
const buildPayload = () => {
const payload = {
name: form.name.trim(),
description: form.description.trim(),
credential_type: (form.credential_type || "machine").toLowerCase(),
connection_type: (form.connection_type || "ssh").toLowerCase(),
username: form.username.trim(),
become_method: form.become_method.trim(),
become_username: form.become_username.trim()
};
const siteId = normalizeSiteId(form.site_id);
if (siteId) {
payload.site_id = Number(siteId);
} else {
payload.site_id = null;
}
if (passwordDirty) {
payload.password = form.password;
}
if (privateKeyDirty) {
payload.private_key = form.private_key;
}
if (passphraseDirty) {
payload.private_key_passphrase = form.private_key_passphrase;
}
if (becomePasswordDirty) {
payload.become_password = form.become_password;
}
if (clearPassword) payload.clear_password = true;
if (clearPrivateKey) payload.clear_private_key = true;
if (clearPassphrase) payload.clear_private_key_passphrase = true;
if (clearBecomePassword) payload.clear_become_password = true;
return payload;
};
const handleSave = async () => {
if (!validate()) return;
setLoading(true);
setError("");
const payload = buildPayload();
try {
const resp = await fetch(
isEdit ? `/api/credentials/${credentialId}` : "/api/credentials",
{
method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}
);
const data = await resp.json();
if (!resp.ok) {
throw new Error(data?.error || `Request failed (${resp.status})`);
}
onSaved && onSaved(data?.credential || null);
} catch (err) {
setError(String(err.message || err));
} finally {
setLoading(false);
}
};
const title = isEdit ? "Edit Credential" : "Create Credential";
const helperStyle = { fontSize: 12, color: "#8a8a8a", mt: 0.5 };
return (
<Dialog
open={open}
onClose={handleCancel}
maxWidth="md"
fullWidth
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
>
<DialogTitle sx={{ pb: 1 }}>{title}</DialogTitle>
<DialogContent dividers sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{fetchingDetail && (
<Box sx={{ display: "flex", alignItems: "center", gap: 1, color: "#aaa" }}>
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
<Typography variant="body2">Loading credential details</Typography>
</Box>
)}
{error && (
<Box sx={{ bgcolor: "#2c1c1c", border: "1px solid #663939", borderRadius: 1, p: 1 }}>
<Typography variant="body2" sx={{ color: "#ff8080" }}>{error}</Typography>
</Box>
)}
<TextField
label="Name"
value={form.name}
onChange={updateField("name")}
required
disabled={disableSave}
sx={{
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
"& label": { color: "#888" }
}}
/>
<TextField
label="Description"
value={form.description}
onChange={updateField("description")}
disabled={disableSave}
multiline
minRows={2}
sx={{
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
"& label": { color: "#888" }
}}
/>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 2 }}>
<FormControl sx={{ minWidth: 220 }} size="small" disabled={disableSave}>
<InputLabel sx={{ color: "#aaa" }}>Site</InputLabel>
<Select
value={form.site_id}
label="Site"
onChange={updateField("site_id")}
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
>
<MenuItem value="">(None)</MenuItem>
{sites.map((site) => (
<MenuItem key={site.id} value={String(site.id)}>
{site.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl sx={{ minWidth: 180 }} size="small" disabled={disableSave}>
<InputLabel sx={{ color: "#aaa" }}>Credential Type</InputLabel>
<Select
value={form.credential_type}
label="Credential Type"
onChange={updateField("credential_type")}
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
>
{CREDENTIAL_TYPES.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
))}
</Select>
</FormControl>
<FormControl sx={{ minWidth: 180 }} size="small" disabled={disableSave}>
<InputLabel sx={{ color: "#aaa" }}>Connection</InputLabel>
<Select
value={form.connection_type}
label="Connection"
onChange={updateField("connection_type")}
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
>
{CONNECTION_TYPES.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
))}
</Select>
</FormControl>
</Box>
<TextField
label="Username"
value={form.username}
onChange={updateField("username")}
disabled={disableSave}
sx={{
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
"& label": { color: "#888" }
}}
/>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<TextField
label="Password"
type="password"
value={form.password}
onChange={updateField("password")}
disabled={disableSave}
sx={{
flex: 1,
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
"& label": { color: "#888" }
}}
/>
{isEdit && currentCredentialFlags.hasPassword && !passwordDirty && !clearPassword && (
<Tooltip title="Clear stored password">
<IconButton size="small" onClick={() => setClearPassword(true)} sx={{ color: "#ff8080" }}>
<ClearIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
{isEdit && currentCredentialFlags.hasPassword && !passwordDirty && !clearPassword && (
<Typography sx={helperStyle}>Stored password will remain unless you change or clear it.</Typography>
)}
{clearPassword && (
<Typography sx={{ ...helperStyle, color: "#ffaaaa" }}>Password will be removed when saving.</Typography>
)}
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-start" }}>
<TextField
label="SSH Private Key"
value={form.private_key}
onChange={updateField("private_key")}
disabled={disableSave}
multiline
minRows={4}
maxRows={12}
sx={{
flex: 1,
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff", fontFamily: "monospace" },
"& label": { color: "#888" }
}}
/>
<Button
variant="outlined"
component="label"
startIcon={<UploadIcon />}
disabled={disableSave}
sx={{ alignSelf: "center", borderColor: "#58a6ff", color: "#58a6ff" }}
>
Upload
<input type="file" hidden accept=".pem,.key,.txt" onChange={handlePrivateKeyUpload} />
</Button>
{isEdit && currentCredentialFlags.hasPrivateKey && !privateKeyDirty && !clearPrivateKey && (
<Tooltip title="Clear stored private key">
<IconButton size="small" onClick={() => setClearPrivateKey(true)} sx={{ color: "#ff8080" }}>
<ClearIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
{isEdit && currentCredentialFlags.hasPrivateKey && !privateKeyDirty && !clearPrivateKey && (
<Typography sx={helperStyle}>Private key is stored. Upload or paste a new one to replace, or clear it.</Typography>
)}
{clearPrivateKey && (
<Typography sx={{ ...helperStyle, color: "#ffaaaa" }}>Private key will be removed when saving.</Typography>
)}
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<TextField
label="Private Key Passphrase"
type="password"
value={form.private_key_passphrase}
onChange={updateField("private_key_passphrase")}
disabled={disableSave}
sx={{
flex: 1,
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
"& label": { color: "#888" }
}}
/>
{isEdit && currentCredentialFlags.hasPrivateKeyPassphrase && !passphraseDirty && !clearPassphrase && (
<Tooltip title="Clear stored passphrase">
<IconButton size="small" onClick={() => setClearPassphrase(true)} sx={{ color: "#ff8080" }}>
<ClearIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
{isEdit && currentCredentialFlags.hasPrivateKeyPassphrase && !passphraseDirty && !clearPassphrase && (
<Typography sx={helperStyle}>A passphrase is stored for this key.</Typography>
)}
{clearPassphrase && (
<Typography sx={{ ...helperStyle, color: "#ffaaaa" }}>Key passphrase will be removed when saving.</Typography>
)}
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<FormControl sx={{ minWidth: 180 }} size="small" disabled={disableSave}>
<InputLabel sx={{ color: "#aaa" }}>Privilege Escalation</InputLabel>
<Select
value={form.become_method}
label="Privilege Escalation"
onChange={updateField("become_method")}
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
>
{BECOME_METHODS.map((opt) => (
<MenuItem key={opt.value || "none"} value={opt.value}>{opt.label}</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="Escalation Username"
value={form.become_username}
onChange={updateField("become_username")}
disabled={disableSave}
sx={{
flex: 1,
minWidth: 200,
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
"& label": { color: "#888" }
}}
/>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<TextField
label="Escalation Password"
type="password"
value={form.become_password}
onChange={updateField("become_password")}
disabled={disableSave}
sx={{
flex: 1,
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
"& label": { color: "#888" }
}}
/>
{isEdit && currentCredentialFlags.hasBecomePassword && !becomePasswordDirty && !clearBecomePassword && (
<Tooltip title="Clear stored escalation password">
<IconButton size="small" onClick={() => setClearBecomePassword(true)} sx={{ color: "#ff8080" }}>
<ClearIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
{isEdit && currentCredentialFlags.hasBecomePassword && !becomePasswordDirty && !clearBecomePassword && (
<Typography sx={helperStyle}>Escalation password is stored.</Typography>
)}
{clearBecomePassword && (
<Typography sx={{ ...helperStyle, color: "#ffaaaa" }}>Escalation password will be removed when saving.</Typography>
)}
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={handleCancel} sx={{ color: "#58a6ff" }} disabled={loading}>
Cancel
</Button>
<Button
onClick={handleSave}
variant="outlined"
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
disabled={disableSave}
>
{loading ? <CircularProgress size={18} sx={{ color: "#58a6ff" }} /> : "Save"}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,320 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Box,
Button,
IconButton,
Menu,
MenuItem,
Paper,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TableSortLabel,
Typography,
CircularProgress
} from "@mui/material";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import AddIcon from "@mui/icons-material/Add";
import RefreshIcon from "@mui/icons-material/Refresh";
import LockIcon from "@mui/icons-material/Lock";
import WifiIcon from "@mui/icons-material/Wifi";
import ComputerIcon from "@mui/icons-material/Computer";
import CredentialEditor from "./Credential_Editor.jsx";
import { ConfirmDeleteDialog } from "../Dialogs.jsx";
const tablePaperSx = { m: 2, p: 0, bgcolor: "#1e1e1e", borderRadius: 2 };
const tableSx = {
minWidth: 840,
"& th, & td": {
color: "#ddd",
borderColor: "#2a2a2a",
fontSize: 13,
py: 0.9
},
"& th .MuiTableSortLabel-root": { color: "#ddd" },
"& th .MuiTableSortLabel-root.Mui-active": { color: "#ddd" }
};
const columns = [
{ id: "name", label: "Name" },
{ id: "credential_type", label: "Credential Type" },
{ id: "connection_type", label: "Connection" },
{ id: "site_name", label: "Site" },
{ id: "username", label: "Username" },
{ id: "updated_at", label: "Updated" },
{ id: "actions", label: "" }
];
function formatTs(ts) {
if (!ts) return "-";
const date = new Date(Number(ts) * 1000);
if (Number.isNaN(date?.getTime())) return "-";
return `${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
}
function titleCase(value) {
if (!value) return "-";
const lower = String(value).toLowerCase();
return lower.replace(/(^|\s)\w/g, (c) => c.toUpperCase());
}
function connectionIcon(connection) {
const val = (connection || "").toLowerCase();
if (val === "ssh") return <LockIcon fontSize="small" sx={{ mr: 0.6, color: "#58a6ff" }} />;
if (val === "winrm") return <WifiIcon fontSize="small" sx={{ mr: 0.6, color: "#58a6ff" }} />;
return <ComputerIcon fontSize="small" sx={{ mr: 0.6, color: "#58a6ff" }} />;
}
export default function CredentialList({ isAdmin = false }) {
const [rows, setRows] = useState([]);
const [orderBy, setOrderBy] = useState("name");
const [order, setOrder] = useState("asc");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [menuAnchor, setMenuAnchor] = useState(null);
const [menuRow, setMenuRow] = useState(null);
const [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState("create");
const [editingCredential, setEditingCredential] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const sortedRows = useMemo(() => {
const sorted = [...rows];
sorted.sort((a, b) => {
const aVal = (a?.[orderBy] ?? "").toString().toLowerCase();
const bVal = (b?.[orderBy] ?? "").toString().toLowerCase();
if (aVal < bVal) return order === "asc" ? -1 : 1;
if (aVal > bVal) return order === "asc" ? 1 : -1;
return 0;
});
return sorted;
}, [rows, order, orderBy]);
const fetchCredentials = useCallback(async () => {
setLoading(true);
setError("");
try {
const resp = await fetch("/api/credentials");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const list = Array.isArray(data?.credentials) ? data.credentials : [];
list.sort((a, b) => String(a?.name || "").localeCompare(String(b?.name || "")));
setRows(list);
} catch (err) {
setRows([]);
setError(String(err.message || err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchCredentials();
}, [fetchCredentials]);
const handleSort = (columnId) => () => {
if (orderBy === columnId) {
setOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setOrderBy(columnId);
setOrder("asc");
}
};
const openMenu = (event, row) => {
setMenuAnchor(event.currentTarget);
setMenuRow(row);
};
const closeMenu = () => {
setMenuAnchor(null);
setMenuRow(null);
};
const handleCreate = () => {
setEditorMode("create");
setEditingCredential(null);
setEditorOpen(true);
};
const handleEdit = (row) => {
closeMenu();
setEditorMode("edit");
setEditingCredential(row);
setEditorOpen(true);
};
const handleDelete = (row) => {
closeMenu();
setDeleteTarget(row);
};
const doDelete = async () => {
if (!deleteTarget?.id) return;
setDeleteBusy(true);
try {
const resp = await fetch(`/api/credentials/${deleteTarget.id}`, { method: "DELETE" });
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data?.error || `HTTP ${resp.status}`);
}
setDeleteTarget(null);
await fetchCredentials();
} catch (err) {
setError(String(err.message || err));
} finally {
setDeleteBusy(false);
}
};
const handleEditorSaved = async () => {
setEditorOpen(false);
setEditingCredential(null);
await fetchCredentials();
};
if (!isAdmin) {
return (
<Paper sx={{ m: 2, p: 3, bgcolor: "#1e1e1e" }}>
<Typography variant="h6" sx={{ color: "#ff8080" }}>
Access denied
</Typography>
<Typography variant="body2" sx={{ color: "#bbb" }}>
You do not have permission to manage credentials.
</Typography>
</Paper>
);
}
return (
<>
<Paper sx={tablePaperSx} elevation={3}>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", p: 2, borderBottom: "1px solid #2a2a2a" }}>
<Box>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0.3 }}>
Credentials
</Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}>
Stored credentials for remote automation tasks and Ansible playbook runs.
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1 }}>
<Button
variant="outlined"
size="small"
startIcon={<RefreshIcon />}
sx={{ borderColor: "#58a6ff", color: "#58a6ff" }}
onClick={fetchCredentials}
disabled={loading}
>
Refresh
</Button>
<Button
variant="contained"
size="small"
startIcon={<AddIcon />}
sx={{ bgcolor: "#58a6ff", color: "#0b0f19" }}
onClick={handleCreate}
>
New Credential
</Button>
</Box>
</Box>
{loading && (
<Box sx={{ display: "flex", alignItems: "center", gap: 1, color: "#7db7ff", px: 2, py: 1.5 }}>
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
<Typography variant="body2">Loading credentials</Typography>
</Box>
)}
{error && (
<Box sx={{ px: 2, py: 1.5, color: "#ff8080" }}>
<Typography variant="body2">{error}</Typography>
</Box>
)}
<Table size="small" sx={tableSx}>
<TableHead>
<TableRow>
{columns.map((col) => (
<TableCell key={col.id} align={col.id === "actions" ? "right" : "left"}>
{col.id === "actions" ? null : (
<TableSortLabel
active={orderBy === col.id}
direction={orderBy === col.id ? order : "asc"}
onClick={handleSort(col.id)}
>
{col.label}
</TableSortLabel>
)}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{!sortedRows.length && !loading ? (
<TableRow>
<TableCell colSpan={columns.length} sx={{ color: "#888", textAlign: "center", py: 4 }}>
No credentials have been created yet.
</TableCell>
</TableRow>
) : (
sortedRows.map((row) => (
<TableRow key={row.id} hover>
<TableCell>{row.name || "-"}</TableCell>
<TableCell>{titleCase(row.credential_type)}</TableCell>
<TableCell>
<Box sx={{ display: "flex", alignItems: "center" }}>
{connectionIcon(row.connection_type)}
{titleCase(row.connection_type)}
</Box>
</TableCell>
<TableCell>{row.site_name || "-"}</TableCell>
<TableCell>{row.username || "-"}</TableCell>
<TableCell>{formatTs(row.updated_at || row.created_at)}</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={(e) => openMenu(e, row)} sx={{ color: "#7db7ff" }}>
<MoreVertIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Paper>
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={closeMenu} elevation={2} PaperProps={{ sx: { bgcolor: "#1f1f1f", color: "#f5f5f5" } }}>
<MenuItem onClick={() => handleEdit(menuRow)}>Edit</MenuItem>
<MenuItem onClick={() => handleDelete(menuRow)} sx={{ color: "#ff8080" }}>
Delete
</MenuItem>
</Menu>
<CredentialEditor
open={editorOpen}
mode={editorMode}
credential={editingCredential}
onClose={() => {
setEditorOpen(false);
setEditingCredential(null);
}}
onSaved={handleEditorSaved}
/>
<ConfirmDeleteDialog
open={Boolean(deleteTarget)}
onCancel={() => setDeleteTarget(null)}
onConfirm={doDelete}
confirmDisabled={deleteBusy}
message={
deleteTarget
? `Delete credential '${deleteTarget.name || ""}'? Any jobs referencing it will require an update.`
: ""
}
/>
</>
);
}

View File

@@ -0,0 +1,680 @@
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 (
<>
<Paper sx={tablePaperSx} elevation={2}>
<Box sx={{ p: 2, pb: 1, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<Box>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
User Management
</Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}>
Manage authorized users of the Borealis Automation Platform.
</Typography>
</Box>
<Button
variant="outlined"
size="small"
onClick={openCreate}
sx={{ color: "#58a6ff", borderColor: "#58a6ff", textTransform: "none" }}
>
Create User
</Button>
</Box>
<Table size="small" sx={tableSx}>
<TableHead>
<TableRow>
{/* Leading checkbox gutter to match Devices table rhythm */}
<TableCell padding="checkbox" />
{columns.map((col) => (
<TableCell
key={col.id}
sortDirection={["actions"].includes(col.id) ? false : (orderBy === col.id ? order : false)}
>
{col.id !== "actions" ? (
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<TableSortLabel
active={orderBy === col.id}
direction={orderBy === col.id ? order : "asc"}
onClick={() => handleSort(col.id)}
>
{col.label}
</TableSortLabel>
<IconButton
size="small"
onClick={openFilter(col.id)}
sx={{ color: filters[col.id] ? "#58a6ff" : "#888" }}
>
<FilterListIcon fontSize="inherit" />
</IconButton>
</Box>
) : null}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{filteredSorted.map((u) => (
<TableRow key={u.username} hover>
{/* Body gutter to stay aligned with header */}
<TableCell padding="checkbox" />
<TableCell>{u.display_name || u.username}</TableCell>
<TableCell>{u.username}</TableCell>
<TableCell>{formatTs(u.last_login)}</TableCell>
<TableCell>{u.role || "User"}</TableCell>
<TableCell align="center">
<Checkbox
size="small"
checked={Boolean(u.mfa_enabled)}
disabled={Boolean(mfaBusyUser && String(mfaBusyUser).toLowerCase() === String(u.username).toLowerCase())}
onChange={(event) => {
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}` }}
/>
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={(e) => openMenu(e, u)} sx={{ color: "#ccc" }}>
<MoreVertIcon fontSize="inherit" />
</IconButton>
</TableCell>
</TableRow>
))}
{filteredSorted.length === 0 && (
<TableRow>
<TableCell colSpan={columns.length + 1} sx={{ color: "#888" }}>
No users found.
</TableCell>
</TableRow>
)}
</TableBody>
</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
anchorEl={menuAnchor?.anchorEl}
open={Boolean(menuAnchor)}
onClose={closeMenu}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
PaperProps={{ sx: menuPaperSx }}
>
<MenuItem
disabled={me && menuUser && String(me.username).toLowerCase() === String(menuUser.username).toLowerCase()}
onClick={() => { const u = menuUser; closeMenu(); confirmDelete(u); }}
>
Delete User
</MenuItem>
<MenuItem onClick={() => { const u = menuUser; closeMenu(); openReset(u); }}>Reset Password</MenuItem>
<MenuItem
disabled={me && menuUser && String(me.username).toLowerCase() === String(menuUser.username).toLowerCase()}
onClick={() => { const u = menuUser; closeMenu(); openChangeRole(u); }}
>
Change Role
</MenuItem>
<MenuItem onClick={() => { const u = menuUser; closeMenu(); openResetMfa(u); }}>
Reset MFA
</MenuItem>
</Menu>
<Dialog open={resetOpen} onClose={() => setResetOpen(false)} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Reset Password</DialogTitle>
<DialogContent>
<DialogContentText sx={{ color: "#ccc" }}>
Enter a new password for {resetTarget?.username}.
</DialogContentText>
<TextField
autoFocus
margin="dense"
fullWidth
label="New Password"
type="password"
variant="outlined"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#2a2a2a",
color: "#ccc",
"& fieldset": { borderColor: "#444" },
"&:hover fieldset": { borderColor: "#666" }
},
label: { color: "#aaa" },
mt: 1
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => { setResetOpen(false); setResetTarget(null); }} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={doResetPassword} sx={{ color: "#58a6ff" }}>OK</Button>
</DialogActions>
</Dialog>
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Create User</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
fullWidth
label="Username"
variant="outlined"
value={createForm.username}
onChange={(e) => 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
}}
/>
<TextField
margin="dense"
fullWidth
label="Display Name (optional)"
variant="outlined"
value={createForm.display_name}
onChange={(e) => 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
}}
/>
<TextField
margin="dense"
fullWidth
label="Password"
type="password"
variant="outlined"
value={createForm.password}
onChange={(e) => 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
}}
/>
<FormControl fullWidth sx={{ mt: 2 }}>
<InputLabel sx={{ color: "#aaa" }}>Role</InputLabel>
<Select
native
value={createForm.role}
onChange={(e) => setCreateForm((p) => ({ ...p, role: e.target.value }))}
sx={{
backgroundColor: "#2a2a2a",
color: "#ccc",
borderColor: "#444"
}}
>
<option value="User">User</option>
<option value="Admin">Admin</option>
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={() => setCreateOpen(false)} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={doCreate} sx={{ color: "#58a6ff" }}>Create</Button>
</DialogActions>
</Dialog>
</Paper>
<ConfirmDeleteDialog
open={confirmDeleteOpen}
message={`Are you sure you want to delete user '${deleteTarget?.username || ""}'?`}
onCancel={() => setConfirmDeleteOpen(false)}
onConfirm={doDelete}
/>
<ConfirmDeleteDialog
open={confirmChangeRoleOpen}
message={changeRoleTarget ? `Change role for '${changeRoleTarget.username}' to ${changeRoleNext}?` : ""}
onCancel={() => setConfirmChangeRoleOpen(false)}
onConfirm={doChangeRole}
/>
<ConfirmDeleteDialog
open={resetMfaOpen}
message={resetMfaTarget ? `Reset MFA enrollment for '${resetMfaTarget.username}'? This clears their existing authenticator.` : ""}
onCancel={() => { setResetMfaOpen(false); setResetMfaTarget(null); }}
onConfirm={doResetMfa}
/>
<ConfirmDeleteDialog
open={warnOpen}
message={warnMessage}
onCancel={() => setWarnOpen(false)}
onConfirm={() => setWarnOpen(false)}
/>
</>
);
}