mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-27 08:01:57 -06:00
Fleshing-Out Implementation of Credential Management for Ansible Playbooks
This commit is contained in:
549
Data/Server/WebUI/src/Access_Management/Credential_Editor.jsx
Normal file
549
Data/Server/WebUI/src/Access_Management/Credential_Editor.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
320
Data/Server/WebUI/src/Access_Management/Credential_List.jsx
Normal file
320
Data/Server/WebUI/src/Access_Management/Credential_List.jsx
Normal 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.`
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
680
Data/Server/WebUI/src/Access_Management/Users.jsx
Normal file
680
Data/Server/WebUI/src/Access_Management/Users.jsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user