Redesigned Device Approval Page

This commit is contained in:
2025-11-07 05:10:36 -07:00
parent bcd9d547f5
commit be0a03ab61

View File

@@ -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);