mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-15 01:55:48 -07:00
Redesigned Device Approval Page
This commit is contained in:
@@ -1,6 +1,4 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/Server/WebUI/src/Admin/Device_Approvals.jsx
|
import React, { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
@@ -13,18 +11,11 @@ import {
|
|||||||
DialogContentText,
|
DialogContentText,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
FormControl,
|
FormControl,
|
||||||
IconButton,
|
|
||||||
InputLabel,
|
InputLabel,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Paper,
|
Paper,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TextField,
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -35,6 +26,32 @@ import {
|
|||||||
Refresh as RefreshIcon,
|
Refresh as RefreshIcon,
|
||||||
Security as SecurityIcon,
|
Security as SecurityIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
|
import { AgGridReact } from "ag-grid-react";
|
||||||
|
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
|
||||||
|
// NOTE: Do NOT import global AG Grid CSS to avoid affecting other pages.
|
||||||
|
// We rely on the Quartz theme class name + scoped CSS vars like the rest of MagicUI.
|
||||||
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
|
// MagicUI palette to match Enrollment Codes / Site List
|
||||||
|
const MAGIC_UI = {
|
||||||
|
shellBg:
|
||||||
|
"radial-gradient(120% 120% at 0% 0%, rgba(76, 186, 255, 0.16), transparent 55%), " +
|
||||||
|
"radial-gradient(120% 120% at 100% 0%, rgba(214, 130, 255, 0.18), transparent 60%), #040711",
|
||||||
|
panelBorder: "rgba(148, 163, 184, 0.35)",
|
||||||
|
textBright: "#e2e8f0",
|
||||||
|
accentA: "#7dd3fc",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Quartz theme instance (same params used across MagicUI pages)
|
||||||
|
const gridTheme = themeQuartz.withParams({
|
||||||
|
accentColor: "#8b5cf6",
|
||||||
|
backgroundColor: "#070b1a",
|
||||||
|
browserColorScheme: "dark",
|
||||||
|
fontFamily: { googleFont: "IBM Plex Sans" },
|
||||||
|
foregroundColor: "#f4f7ff",
|
||||||
|
headerFontSize: 13,
|
||||||
|
});
|
||||||
|
const themeClassName = gridTheme.themeName || "ag-theme-quartz";
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
{ value: "all", label: "All" },
|
{ value: "all", label: "All" },
|
||||||
@@ -73,7 +90,7 @@ const normalizeStatus = (status) => {
|
|||||||
return status.toLowerCase();
|
return status.toLowerCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
function DeviceApprovals() {
|
export default function DeviceApprovals() {
|
||||||
const [approvals, setApprovals] = useState([]);
|
const [approvals, setApprovals] = useState([]);
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -82,6 +99,7 @@ function DeviceApprovals() {
|
|||||||
const [guidInputs, setGuidInputs] = useState({});
|
const [guidInputs, setGuidInputs] = useState({});
|
||||||
const [actioningId, setActioningId] = useState(null);
|
const [actioningId, setActioningId] = useState(null);
|
||||||
const [conflictPrompt, setConflictPrompt] = useState(null);
|
const [conflictPrompt, setConflictPrompt] = useState(null);
|
||||||
|
const gridRef = useRef(null);
|
||||||
|
|
||||||
const loadApprovals = useCallback(async () => {
|
const loadApprovals = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -102,9 +120,7 @@ function DeviceApprovals() {
|
|||||||
}
|
}
|
||||||
}, [statusFilter]);
|
}, [statusFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { loadApprovals(); }, [loadApprovals]);
|
||||||
loadApprovals();
|
|
||||||
}, [loadApprovals]);
|
|
||||||
|
|
||||||
const dedupedApprovals = useMemo(() => {
|
const dedupedApprovals = useMemo(() => {
|
||||||
const normalized = approvals
|
const normalized = approvals
|
||||||
@@ -183,13 +199,9 @@ function DeviceApprovals() {
|
|||||||
}
|
}
|
||||||
const appliedResolution = (body.conflict_resolution || payload.conflict_resolution || "").toLowerCase();
|
const appliedResolution = (body.conflict_resolution || payload.conflict_resolution || "").toLowerCase();
|
||||||
let successMessage = "Enrollment approved";
|
let successMessage = "Enrollment approved";
|
||||||
if (appliedResolution === "overwrite") {
|
if (appliedResolution === "overwrite") successMessage = "Enrollment approved; existing device overwritten";
|
||||||
successMessage = "Enrollment approved; existing device overwritten";
|
else if (appliedResolution === "coexist") successMessage = "Enrollment approved; devices will co-exist";
|
||||||
} else if (appliedResolution === "coexist") {
|
else if (appliedResolution === "auto_merge_fingerprint") successMessage = "Enrollment approved; device reconnected with its existing identity";
|
||||||
successMessage = "Enrollment approved; devices will co-exist";
|
|
||||||
} else if (appliedResolution === "auto_merge_fingerprint") {
|
|
||||||
successMessage = "Enrollment approved; device reconnected with its existing identity";
|
|
||||||
}
|
|
||||||
setFeedback({ type: "success", message: successMessage });
|
setFeedback({ type: "success", message: successMessage });
|
||||||
await loadApprovals();
|
await loadApprovals();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -213,11 +225,7 @@ function DeviceApprovals() {
|
|||||||
const fallbackAlternate =
|
const fallbackAlternate =
|
||||||
record.alternate_hostname ||
|
record.alternate_hostname ||
|
||||||
(record.hostname_claimed ? `${record.hostname_claimed}-1` : "");
|
(record.hostname_claimed ? `${record.hostname_claimed}-1` : "");
|
||||||
setConflictPrompt({
|
setConflictPrompt({ record, conflict, alternate: fallbackAlternate || "" });
|
||||||
record,
|
|
||||||
conflict,
|
|
||||||
alternate: fallbackAlternate || "",
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
submitApproval(record);
|
submitApproval(record);
|
||||||
@@ -225,50 +233,6 @@ function DeviceApprovals() {
|
|||||||
[guidInputs, submitApproval]
|
[guidInputs, submitApproval]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConflictCancel = useCallback(() => {
|
|
||||||
setConflictPrompt(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleConflictOverwrite = useCallback(() => {
|
|
||||||
if (!conflictPrompt?.record) {
|
|
||||||
setConflictPrompt(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { record, conflict } = conflictPrompt;
|
|
||||||
setConflictPrompt(null);
|
|
||||||
const conflictGuid = conflict?.guid != null ? String(conflict.guid).trim() : "";
|
|
||||||
submitApproval(record, {
|
|
||||||
guid: conflictGuid,
|
|
||||||
conflictResolution: "overwrite",
|
|
||||||
});
|
|
||||||
}, [conflictPrompt, submitApproval]);
|
|
||||||
|
|
||||||
const handleConflictCoexist = useCallback(() => {
|
|
||||||
if (!conflictPrompt?.record) {
|
|
||||||
setConflictPrompt(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { record } = conflictPrompt;
|
|
||||||
setConflictPrompt(null);
|
|
||||||
submitApproval(record, {
|
|
||||||
conflictResolution: "coexist",
|
|
||||||
});
|
|
||||||
}, [conflictPrompt, submitApproval]);
|
|
||||||
|
|
||||||
const conflictRecord = conflictPrompt?.record;
|
|
||||||
const conflictInfo = conflictPrompt?.conflict;
|
|
||||||
const conflictHostname = conflictRecord?.hostname_claimed || conflictRecord?.hostname || "";
|
|
||||||
const conflictSiteName = conflictInfo?.site_name || "";
|
|
||||||
const conflictSiteDescriptor = conflictInfo
|
|
||||||
? conflictSiteName
|
|
||||||
? `under site ${conflictSiteName}`
|
|
||||||
: "under site (not assigned)"
|
|
||||||
: "under site (not assigned)";
|
|
||||||
const conflictAlternate =
|
|
||||||
conflictPrompt?.alternate ||
|
|
||||||
(conflictHostname ? `${conflictHostname}-1` : "hostname-1");
|
|
||||||
const conflictGuidDisplay = conflictInfo?.guid || "";
|
|
||||||
|
|
||||||
const handleDeny = useCallback(
|
const handleDeny = useCallback(
|
||||||
async (record) => {
|
async (record) => {
|
||||||
if (!record?.id) return;
|
if (!record?.id) return;
|
||||||
@@ -297,14 +261,165 @@ function DeviceApprovals() {
|
|||||||
[loadApprovals]
|
[loadApprovals]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const columns = useMemo(() => [
|
||||||
|
{
|
||||||
|
headerName: "Status",
|
||||||
|
field: "status",
|
||||||
|
valueGetter: (p) => normalizeStatus(p.data?.status),
|
||||||
|
cellRenderer: (params) => {
|
||||||
|
const status = params.value || "pending";
|
||||||
|
// mimic MUI Chip coloring via text hues
|
||||||
|
const color = status === "completed" ? "#34d399"
|
||||||
|
: status === "approved" ? "#60a5fa"
|
||||||
|
: status === "denied" || status === "expired" ? "#9aa0a6"
|
||||||
|
: "#fbbf24";
|
||||||
|
return <span style={{ color, fontWeight: 600 }}>{status}</span>;
|
||||||
|
},
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{ headerName: "Hostname", field: "hostname_claimed", minWidth: 180 },
|
||||||
|
{
|
||||||
|
headerName: "Fingerprint",
|
||||||
|
field: "ssl_key_fingerprint_claimed",
|
||||||
|
valueFormatter: (p) => formatFingerprint(p.value),
|
||||||
|
cellStyle: { fontFamily: "monospace", whiteSpace: "nowrap" },
|
||||||
|
minWidth: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headerName: "Enrollment Code",
|
||||||
|
field: "enrollment_code_id",
|
||||||
|
cellStyle: { fontFamily: "monospace" },
|
||||||
|
minWidth: 140,
|
||||||
|
},
|
||||||
|
{ headerName: "Created", field: "created_at", valueFormatter: (p) => formatDateTime(p.value), minWidth: 160 },
|
||||||
|
{ headerName: "Updated", field: "updated_at", valueFormatter: (p) => formatDateTime(p.value), minWidth: 160 },
|
||||||
|
{
|
||||||
|
headerName: "Approved By",
|
||||||
|
valueGetter: (p) => p.data?.approved_by_username || p.data?.approved_by_user_id || "—",
|
||||||
|
minWidth: 140,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headerName: "Actions",
|
||||||
|
cellRenderer: (params) => {
|
||||||
|
const record = params.data || {};
|
||||||
|
const status = normalizeStatus(record.status);
|
||||||
|
const showActions = status === "pending";
|
||||||
|
const guidValue = params.context.guidInputs[record.id] || "";
|
||||||
|
const { startApprove, handleDeny, handleGuidChange, actioningId } = params.context;
|
||||||
|
if (!showActions) {
|
||||||
|
return <Typography variant="body2" style={{ color: "#9aa0a6" }}>No actions available</Typography>;
|
||||||
|
}
|
||||||
|
const isBusy = actioningId === record.id;
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 3, display: "flex", flexDirection: "column", gap: 3 }}>
|
<Stack direction="row" spacing={8} alignItems="center">
|
||||||
<Stack direction="row" alignItems="center" spacing={2}>
|
<TextField
|
||||||
<SecurityIcon color="primary" />
|
size="small"
|
||||||
<Typography variant="h5">Device Approval Queue</Typography>
|
label="Optional GUID"
|
||||||
|
placeholder="Leave empty to auto-generate"
|
||||||
|
value={guidValue}
|
||||||
|
onChange={(e) => handleGuidChange(record.id, e.target.value)}
|
||||||
|
sx={{ minWidth: 220 }}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Tooltip title="Approve enrollment">
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
color="success"
|
||||||
|
variant="text"
|
||||||
|
onClick={() => startApprove(record)}
|
||||||
|
disabled={isBusy}
|
||||||
|
startIcon={isBusy ? <CircularProgress size={16} color="success" /> : <ApproveIcon fontSize="small" />}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Deny enrollment">
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
variant="text"
|
||||||
|
onClick={() => handleDeny(record)}
|
||||||
|
disabled={isBusy}
|
||||||
|
startIcon={<DenyIcon fontSize="small" />}
|
||||||
|
>
|
||||||
|
Deny
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
minWidth: 480,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
<Paper sx={{ p: 2, display: "flex", flexDirection: "column", gap: 2 }}>
|
const defaultColDef = useMemo(() => ({
|
||||||
|
sortable: true,
|
||||||
|
filter: true,
|
||||||
|
resizable: true,
|
||||||
|
minWidth: 140,
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
// Dialog helpers
|
||||||
|
const conflictRecord = conflictPrompt?.record;
|
||||||
|
const conflictInfo = conflictPrompt?.conflict;
|
||||||
|
const conflictHostname = conflictRecord?.hostname_claimed || conflictRecord?.hostname || "";
|
||||||
|
const conflictSiteName = conflictInfo?.site_name || "";
|
||||||
|
const conflictSiteDescriptor = conflictInfo
|
||||||
|
? conflictSiteName
|
||||||
|
? `under site ${conflictSiteName}`
|
||||||
|
: "under site (not assigned)"
|
||||||
|
: "under site (not assigned)";
|
||||||
|
const conflictAlternate =
|
||||||
|
conflictPrompt?.alternate ||
|
||||||
|
(conflictHostname ? `${conflictHostname}-1` : "hostname-1");
|
||||||
|
const conflictGuidDisplay = conflictInfo?.guid || "";
|
||||||
|
|
||||||
|
const handleConflictCancel = useCallback(() => setConflictPrompt(null), []);
|
||||||
|
const handleConflictOverwrite = useCallback(() => {
|
||||||
|
if (!conflictPrompt?.record) { setConflictPrompt(null); return; }
|
||||||
|
const { record, conflict } = conflictPrompt;
|
||||||
|
setConflictPrompt(null);
|
||||||
|
const conflictGuid = conflict?.guid != null ? String(conflict.guid).trim() : "";
|
||||||
|
submitApproval(record, { guid: conflictGuid, conflictResolution: "overwrite" });
|
||||||
|
}, [conflictPrompt, submitApproval]);
|
||||||
|
const handleConflictCoexist = useCallback(() => {
|
||||||
|
if (!conflictPrompt?.record) { setConflictPrompt(null); return; }
|
||||||
|
const { record } = conflictPrompt;
|
||||||
|
setConflictPrompt(null);
|
||||||
|
submitApproval(record, { conflictResolution: "coexist" });
|
||||||
|
}, [conflictPrompt, submitApproval]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
m: 0,
|
||||||
|
p: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: 0,
|
||||||
|
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||||
|
background: MAGIC_UI.shellBg,
|
||||||
|
boxShadow: "0 25px 80px rgba(6, 12, 30, 0.8)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
elevation={0}
|
||||||
|
>
|
||||||
|
{/* Page header (no solid backdrop so gradient reaches the top) */}
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<SecurityIcon sx={{ color: MAGIC_UI.accentA }} />
|
||||||
|
<Typography variant="h6" sx={{ color: MAGIC_UI.textBright, fontWeight: 700 }}>
|
||||||
|
Device Approval Queue
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
|
||||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||||
<InputLabel id="approval-status-filter-label">Status</InputLabel>
|
<InputLabel id="approval-status-filter-label">Status</InputLabel>
|
||||||
@@ -312,7 +427,7 @@ function DeviceApprovals() {
|
|||||||
labelId="approval-status-filter-label"
|
labelId="approval-status-filter-label"
|
||||||
label="Status"
|
label="Status"
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(event) => setStatusFilter(event.target.value)}
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
>
|
>
|
||||||
{STATUS_OPTIONS.map((option) => (
|
{STATUS_OPTIONS.map((option) => (
|
||||||
<MenuItem key={option.value} value={option.value}>
|
<MenuItem key={option.value} value={option.value}>
|
||||||
@@ -321,147 +436,61 @@ function DeviceApprovals() {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={loadApprovals} disabled={loading}>
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<RefreshIcon />}
|
|
||||||
onClick={loadApprovals}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{feedback ? (
|
{/* Feedback */}
|
||||||
|
{feedback && (
|
||||||
|
<Box sx={{ px: 3 }}>
|
||||||
<Alert severity={feedback.type} variant="outlined" onClose={() => setFeedback(null)}>
|
<Alert severity={feedback.type} variant="outlined" onClose={() => setFeedback(null)}>
|
||||||
{feedback.message}
|
{feedback.message}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
</Box>
|
||||||
|
)}
|
||||||
{error ? (
|
{error && (
|
||||||
|
<Box sx={{ px: 3 }}>
|
||||||
<Alert severity="error" variant="outlined">
|
<Alert severity="error" variant="outlined">
|
||||||
{error}
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 480 }}>
|
{/* Data Grid */}
|
||||||
<Table size="small" stickyHeader>
|
<Box
|
||||||
<TableHead>
|
className={themeClassName}
|
||||||
<TableRow>
|
sx={{ flex: 1, p: 2, overflow: "hidden" }}
|
||||||
<TableCell>Status</TableCell>
|
style={{
|
||||||
<TableCell>Hostname</TableCell>
|
"--ag-background-color": "#070b1a",
|
||||||
<TableCell>Fingerprint</TableCell>
|
"--ag-foreground-color": "#f4f7ff",
|
||||||
<TableCell>Enrollment Code</TableCell>
|
"--ag-header-background-color": "#0f172a",
|
||||||
<TableCell>Created</TableCell>
|
"--ag-header-foreground-color": "#cfe0ff",
|
||||||
<TableCell>Updated</TableCell>
|
"--ag-odd-row-background-color": "rgba(255,255,255,0.02)",
|
||||||
<TableCell>Approved By</TableCell>
|
"--ag-row-hover-color": "rgba(125,183,255,0.08)",
|
||||||
<TableCell align="right">Actions</TableCell>
|
"--ag-selected-row-background-color": "rgba(64,164,255,0.18)",
|
||||||
</TableRow>
|
"--ag-font-family": "'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif",
|
||||||
</TableHead>
|
"--ag-border-color": "rgba(125,183,255,0.18)",
|
||||||
<TableBody>
|
"--ag-row-border-color": "rgba(125,183,255,0.14)",
|
||||||
{loading ? (
|
"--ag-border-radius": "8px",
|
||||||
<TableRow>
|
}}
|
||||||
<TableCell colSpan={8} align="center">
|
>
|
||||||
<Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
|
<AgGridReact
|
||||||
<CircularProgress size={20} />
|
ref={gridRef}
|
||||||
<Typography variant="body2">Loading approvals…</Typography>
|
rowData={dedupedApprovals}
|
||||||
</Stack>
|
columnDefs={columns}
|
||||||
</TableCell>
|
defaultColDef={defaultColDef}
|
||||||
</TableRow>
|
animateRows
|
||||||
) : dedupedApprovals.length === 0 ? (
|
pagination
|
||||||
<TableRow>
|
paginationPageSize={20}
|
||||||
<TableCell colSpan={8} align="center">
|
context={{ startApprove, handleDeny, handleGuidChange, actioningId, guidInputs }}
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
No enrollment requests match this filter.
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
dedupedApprovals.map((record) => {
|
|
||||||
const status = normalizeStatus(record.status);
|
|
||||||
const showActions = status === "pending";
|
|
||||||
const guidValue = guidInputs[record.id] || "";
|
|
||||||
const approverDisplay = record.approved_by_username || record.approved_by_user_id;
|
|
||||||
return (
|
|
||||||
<TableRow hover key={record.id}>
|
|
||||||
<TableCell>
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label={status}
|
|
||||||
color={statusChipColor[status] || "default"}
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</Box>
|
||||||
<TableCell>{record.hostname_claimed || "—"}</TableCell>
|
|
||||||
<TableCell sx={{ fontFamily: "monospace", whiteSpace: "nowrap" }}>
|
{/* Conflict Dialog (unchanged logic) */}
|
||||||
{formatFingerprint(record.ssl_key_fingerprint_claimed)}
|
<Dialog open={Boolean(conflictPrompt)} onClose={handleConflictCancel} maxWidth="sm" fullWidth>
|
||||||
</TableCell>
|
|
||||||
<TableCell sx={{ fontFamily: "monospace" }}>
|
|
||||||
{record.enrollment_code_id || "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{formatDateTime(record.created_at)}</TableCell>
|
|
||||||
<TableCell>{formatDateTime(record.updated_at)}</TableCell>
|
|
||||||
<TableCell>{approverDisplay || "—"}</TableCell>
|
|
||||||
<TableCell align="right">
|
|
||||||
{showActions ? (
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems="center">
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
label="Optional GUID"
|
|
||||||
placeholder="Leave empty to auto-generate"
|
|
||||||
value={guidValue}
|
|
||||||
onChange={(event) => handleGuidChange(record.id, event.target.value)}
|
|
||||||
sx={{ minWidth: 200 }}
|
|
||||||
/>
|
|
||||||
<Stack direction="row" spacing={1}>
|
|
||||||
<Tooltip title="Approve enrollment">
|
|
||||||
<span>
|
|
||||||
<IconButton
|
|
||||||
color="success"
|
|
||||||
onClick={() => startApprove(record)}
|
|
||||||
disabled={actioningId === record.id}
|
|
||||||
>
|
|
||||||
{actioningId === record.id ? (
|
|
||||||
<CircularProgress color="success" size={20} />
|
|
||||||
) : (
|
|
||||||
<ApproveIcon fontSize="small" />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="Deny enrollment">
|
|
||||||
<span>
|
|
||||||
<IconButton
|
|
||||||
color="error"
|
|
||||||
onClick={() => handleDeny(record)}
|
|
||||||
disabled={actioningId === record.id}
|
|
||||||
>
|
|
||||||
<DenyIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
) : (
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
No actions available
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
</Paper>
|
|
||||||
<Dialog
|
|
||||||
open={Boolean(conflictPrompt)}
|
|
||||||
onClose={handleConflictCancel}
|
|
||||||
maxWidth="sm"
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
<DialogTitle>Hostname Conflict</DialogTitle>
|
<DialogTitle>Hostname Conflict</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
@@ -488,18 +517,11 @@ function DeviceApprovals() {
|
|||||||
<Button onClick={handleConflictCoexist} color="info" variant="outlined">
|
<Button onClick={handleConflictCoexist} color="info" variant="outlined">
|
||||||
Allow Both
|
Allow Both
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={handleConflictOverwrite} color="primary" variant="contained" disabled={!conflictGuidDisplay}>
|
||||||
onClick={handleConflictOverwrite}
|
|
||||||
color="primary"
|
|
||||||
variant="contained"
|
|
||||||
disabled={!conflictGuidDisplay}
|
|
||||||
>
|
|
||||||
Overwrite Existing
|
Overwrite Existing
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Box>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default React.memo(DeviceApprovals);
|
|
||||||
|
|||||||
Reference in New Issue
Block a user