mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-17 19:55:48 -07:00
Simplified & Reworked Enrollment Code System to be Site-Specific
This commit is contained in:
@@ -48,7 +48,6 @@ import GithubAPIToken from "./Access_Management/Github_API_Token.jsx";
|
||||
import ServerInfo from "./Admin/Server_Info.jsx";
|
||||
import PageTemplate from "./Admin/Page_Template.jsx";
|
||||
import LogManagement from "./Admin/Log_Management.jsx";
|
||||
import EnrollmentCodes from "./Devices/Enrollment_Codes.jsx";
|
||||
import DeviceApprovals from "./Devices/Device_Approvals.jsx";
|
||||
|
||||
// Networking Imports
|
||||
@@ -230,8 +229,6 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
return "/admin/server_info";
|
||||
case "page_template":
|
||||
return "/admin/page_template";
|
||||
case "admin_enrollment_codes":
|
||||
return "/admin/enrollment-codes";
|
||||
case "admin_device_approvals":
|
||||
return "/admin/device-approvals";
|
||||
default:
|
||||
@@ -286,7 +283,6 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
if (path === "/access_management/credentials") return { page: "access_credentials", options: {} };
|
||||
if (path === "/admin/server_info") return { page: "server_info", options: {} };
|
||||
if (path === "/admin/page_template") return { page: "page_template", options: {} };
|
||||
if (path === "/admin/enrollment-codes") return { page: "admin_enrollment_codes", options: {} };
|
||||
if (path === "/admin/device-approvals") return { page: "admin_device_approvals", options: {} };
|
||||
return { page: "devices", options: {} };
|
||||
} catch {
|
||||
@@ -512,10 +508,6 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
items.push({ label: "Developer Tools" });
|
||||
items.push({ label: "Page Template", page: "page_template" });
|
||||
break;
|
||||
case "admin_enrollment_codes":
|
||||
items.push({ label: "Admin Settings", page: "server_info" });
|
||||
items.push({ label: "Installer Codes", page: "admin_enrollment_codes" });
|
||||
break;
|
||||
case "admin_device_approvals":
|
||||
items.push({ label: "Admin Settings", page: "server_info" });
|
||||
items.push({ label: "Device Approvals", page: "admin_device_approvals" });
|
||||
@@ -1045,7 +1037,6 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
|
||||
useEffect(() => {
|
||||
const requiresAdmin = currentPage === 'server_info'
|
||||
|| currentPage === 'admin_enrollment_codes'
|
||||
|| currentPage === 'admin_device_approvals'
|
||||
|| currentPage === 'access_credentials'
|
||||
|| currentPage === 'access_github_token'
|
||||
@@ -1199,9 +1190,6 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
case "page_template":
|
||||
return <PageTemplate isAdmin={isAdmin} />;
|
||||
|
||||
case "admin_enrollment_codes":
|
||||
return <EnrollmentCodes />;
|
||||
|
||||
case "admin_device_approvals":
|
||||
return <DeviceApprovals />;
|
||||
|
||||
|
||||
@@ -294,6 +294,12 @@ export default function DeviceApprovals() {
|
||||
minWidth: 100,
|
||||
Width: 100,
|
||||
},
|
||||
{
|
||||
headerName: "Site",
|
||||
field: "site_name",
|
||||
valueGetter: (p) => p.data?.site_name || (p.data?.site_id ? `Site ${p.data.site_id}` : "—"),
|
||||
minWidth: 160,
|
||||
},
|
||||
{ headerName: "Created", field: "created_at", valueFormatter: (p) => formatDateTime(p.value), minWidth: 160 },
|
||||
{ headerName: "Updated", field: "updated_at", valueFormatter: (p) => formatDateTime(p.value), minWidth: 160 },
|
||||
{
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
Stack,
|
||||
Alert,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
CircularProgress,
|
||||
Tooltip
|
||||
} from "@mui/material";
|
||||
import {
|
||||
ContentCopy as CopyIcon,
|
||||
DeleteOutline as DeleteIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Key as KeyIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
|
||||
// IMPORTANT: Do NOT import global AG Grid CSS here to avoid overriding other pages.
|
||||
// We rely on the project's existing CSS and themeQuartz class name like other MagicUI pages.
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
// Match the palette used on other pages (see Site_List / Device_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",
|
||||
panelBg:
|
||||
"linear-gradient(135deg, rgba(10, 16, 31, 0.98) 0%, rgba(6, 10, 24, 0.94) 60%, rgba(15, 6, 26, 0.96) 100%)",
|
||||
panelBorder: "rgba(148, 163, 184, 0.35)",
|
||||
textBright: "#e2e8f0",
|
||||
textMuted: "#94a3b8",
|
||||
accentA: "#7dd3fc",
|
||||
accentB: "#c084fc",
|
||||
};
|
||||
|
||||
// Generate a scoped Quartz theme class (same pattern as other 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 TTL_PRESETS = [
|
||||
{ value: 1, label: "1 hour" },
|
||||
{ value: 3, label: "3 hours" },
|
||||
{ value: 6, label: "6 hours" },
|
||||
{ value: 12, label: "12 hours" },
|
||||
{ value: 24, label: "24 hours" },
|
||||
];
|
||||
|
||||
const determineStatus = (record) => {
|
||||
if (!record) return "expired";
|
||||
const maxUses = Number.isFinite(record?.max_uses) ? record.max_uses : 1;
|
||||
const useCount = Number.isFinite(record?.use_count) ? record.use_count : 0;
|
||||
if (useCount >= Math.max(1, maxUses || 1)) return "used";
|
||||
if (!record.expires_at) return "expired";
|
||||
const expires = new Date(record.expires_at);
|
||||
if (Number.isNaN(expires.getTime())) return "expired";
|
||||
return expires.getTime() > Date.now() ? "active" : "expired";
|
||||
};
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const maskCode = (code) => {
|
||||
if (!code) return "—";
|
||||
const parts = code.split("-");
|
||||
if (parts.length <= 1) {
|
||||
const prefix = code.slice(0, 4);
|
||||
return `${prefix}${"•".repeat(Math.max(0, code.length - prefix.length))}`;
|
||||
}
|
||||
return parts
|
||||
.map((part, idx) => (idx === 0 || idx === parts.length - 1 ? part : "•".repeat(part.length)))
|
||||
.join("-");
|
||||
};
|
||||
|
||||
export default function EnrollmentCodes() {
|
||||
const [codes, setCodes] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [feedback, setFeedback] = useState(null);
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [ttlHours, setTtlHours] = useState(6);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [maxUses, setMaxUses] = useState(2);
|
||||
const gridRef = useRef(null);
|
||||
|
||||
const fetchCodes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const query = statusFilter === "all" ? "" : `?status=${encodeURIComponent(statusFilter)}`;
|
||||
const resp = await fetch(`/api/admin/enrollment-codes${query}`, { credentials: "include" });
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Request failed (${resp.status})`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
setCodes(Array.isArray(data.codes) ? data.codes : []);
|
||||
} catch (err) {
|
||||
setError(err.message || "Unable to load codes");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [statusFilter]);
|
||||
|
||||
useEffect(() => { fetchCodes(); }, [fetchCodes]);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
setGenerating(true);
|
||||
try {
|
||||
const resp = await fetch("/api/admin/enrollment-codes", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ttl_hours: ttlHours, max_uses: maxUses }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Request failed (${resp.status})`);
|
||||
}
|
||||
await fetchCodes();
|
||||
setFeedback({ type: "success", message: "New installer code created" });
|
||||
} catch (err) {
|
||||
setFeedback({ type: "error", message: err.message });
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}, [ttlHours, maxUses, fetchCodes]);
|
||||
|
||||
const handleCopy = (code) => {
|
||||
if (!code) return;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(code);
|
||||
setFeedback({ type: "success", message: "Code copied to clipboard" });
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!id) return;
|
||||
if (!window.confirm("Delete this installer code?")) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/admin/enrollment-codes/${id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Request failed (${resp.status})`);
|
||||
}
|
||||
await fetchCodes();
|
||||
setFeedback({ type: "success", message: "Code deleted" });
|
||||
} catch (err) {
|
||||
setFeedback({ type: "error", message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
headerName: "Status",
|
||||
field: "status",
|
||||
cellRenderer: (params) => {
|
||||
const status = determineStatus(params.data);
|
||||
const color =
|
||||
status === "active" ? "#34d399" :
|
||||
status === "used" ? "#7dd3fc" :
|
||||
"#fbbf24";
|
||||
return <span style={{ color, fontWeight: 600 }}>{status}</span>;
|
||||
},
|
||||
minWidth: 100
|
||||
},
|
||||
{
|
||||
headerName: "Installer Code",
|
||||
field: "code",
|
||||
cellRenderer: (params) => (
|
||||
<span style={{ fontFamily: "monospace", color: "#7dd3fc" }}>{maskCode(params.value)}</span>
|
||||
),
|
||||
minWidth: 340
|
||||
},
|
||||
{ headerName: "Expires At",
|
||||
field: "expires_at",
|
||||
valueFormatter: p => formatDateTime(p.value)
|
||||
},
|
||||
{ headerName: "Created By", field: "created_by_user_id" },
|
||||
{
|
||||
headerName: "Usage",
|
||||
valueGetter: (p) => `${p.data.use_count || 0} / ${p.data.max_uses || 1}`,
|
||||
cellStyle: { fontFamily: "monospace" },
|
||||
width: 120
|
||||
},
|
||||
{ headerName: "Last Used", field: "last_used_at", valueFormatter: p => formatDateTime(p.value) },
|
||||
{ headerName: "Used By GUID", field: "used_by_guid" },
|
||||
{
|
||||
headerName: "Actions",
|
||||
cellRenderer: (params) => {
|
||||
const record = params.data;
|
||||
const disableDelete = (record.use_count || 0) !== 0;
|
||||
return (
|
||||
<Stack direction="row" spacing={1} justifyContent="flex-end">
|
||||
<Tooltip title="Copy code">
|
||||
<span>
|
||||
<Button size="small" onClick={() => handleCopy(record.code)}>
|
||||
<CopyIcon fontSize="small" />
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={disableDelete ? "Only unused codes can be deleted" : "Delete code"}>
|
||||
<span>
|
||||
<Button size="small" disabled={disableDelete} onClick={() => handleDelete(record.id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
width: 160
|
||||
}
|
||||
], []);
|
||||
|
||||
const defaultColDef = useMemo(() => ({
|
||||
sortable: true,
|
||||
filter: true,
|
||||
resizable: true,
|
||||
flex: 1,
|
||||
minWidth: 140,
|
||||
}), []);
|
||||
|
||||
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}
|
||||
>
|
||||
{/* Hero header */}
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" justifyContent="space-between">
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<KeyIcon sx={{ color: MAGIC_UI.accentA }} />
|
||||
<Typography variant="h6" sx={{ color: MAGIC_UI.textBright, fontWeight: 700 }}>
|
||||
Enrollment Installer Codes
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={generating}
|
||||
startIcon={generating ? <CircularProgress size={16} color="inherit" /> : null}
|
||||
onClick={handleGenerate}
|
||||
sx={{ background: "linear-gradient(135deg,#7dd3fc,#c084fc)", borderRadius: 999 }}
|
||||
>
|
||||
{generating ? "Generating…" : "Generate Code"}
|
||||
</Button>
|
||||
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={fetchCodes} disabled={loading}>
|
||||
Refresh
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Controls */}
|
||||
<Box sx={{ p: 2, display: "flex", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select value={statusFilter} label="Status" onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="active">Active</MenuItem>
|
||||
<MenuItem value="used">Used</MenuItem>
|
||||
<MenuItem value="expired">Expired</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel>Duration</InputLabel>
|
||||
<Select value={ttlHours} label="Duration" onChange={(e) => setTtlHours(Number(e.target.value))}>
|
||||
{TTL_PRESETS.map((p) => (
|
||||
<MenuItem key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel>Allowed Uses</InputLabel>
|
||||
<Select value={maxUses} label="Allowed Uses" onChange={(e) => setMaxUses(Number(e.target.value))}>
|
||||
{[1, 2, 3, 5].map((n) => (
|
||||
<MenuItem key={n} value={n}>
|
||||
{n === 1 ? "Single use" : `${n} uses`}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{feedback && (
|
||||
<Box sx={{ px: 3 }}>
|
||||
<Alert severity={feedback.type} onClose={() => setFeedback(null)}>
|
||||
{feedback.message}
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
{error && (
|
||||
<Box sx={{ px: 3 }}>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Grid wrapper — all overrides are SCOPED to this instance via inline CSS vars */}
|
||||
<Box
|
||||
className={themeClassName}
|
||||
sx={{
|
||||
flex: 1,
|
||||
p: 2,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
// Inline style ensures the CSS variables only affect THIS grid instance
|
||||
style={{
|
||||
"--ag-background-color": "#070b1a",
|
||||
"--ag-foreground-color": "#f4f7ff",
|
||||
"--ag-header-background-color": "#0f172a",
|
||||
"--ag-header-foreground-color": "#cfe0ff",
|
||||
"--ag-odd-row-background-color": "rgba(255,255,255,0.02)",
|
||||
"--ag-row-hover-color": "rgba(125,183,255,0.08)",
|
||||
"--ag-selected-row-background-color": "rgba(64,164,255,0.18)",
|
||||
"--ag-font-family": "'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif",
|
||||
"--ag-border-color": "rgba(125,183,255,0.18)",
|
||||
"--ag-row-border-color": "rgba(125,183,255,0.14)",
|
||||
"--ag-border-radius": "8px",
|
||||
}}
|
||||
>
|
||||
<AgGridReact
|
||||
ref={gridRef}
|
||||
rowData={codes}
|
||||
columnDefs={columns}
|
||||
defaultColDef={defaultColDef}
|
||||
animateRows
|
||||
pagination
|
||||
paginationPageSize={20}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
VpnKey as CredentialIcon,
|
||||
PersonOutline as UserIcon,
|
||||
GitHub as GitHubIcon,
|
||||
Key as KeyIcon,
|
||||
Dashboard as PageTemplateIcon,
|
||||
AdminPanelSettings as AdminPanelSettingsIcon,
|
||||
ReceiptLong as LogsIcon,
|
||||
@@ -61,7 +60,6 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
||||
"winrm_devices",
|
||||
"agent_devices",
|
||||
"admin_device_approvals",
|
||||
"admin_enrollment_codes",
|
||||
].includes(currentPage),
|
||||
automation: ["jobs", "assemblies", "community"].includes(currentPage),
|
||||
filters: ["filters", "groups"].includes(currentPage),
|
||||
@@ -194,12 +192,6 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
||||
label="Device Approvals"
|
||||
pageKey="admin_device_approvals"
|
||||
/>
|
||||
<NavItem
|
||||
icon={<KeyIcon fontSize="small" />}
|
||||
label="Enrollment Codes"
|
||||
pageKey="admin_enrollment_codes"
|
||||
indent
|
||||
/>
|
||||
<NavItem
|
||||
icon={<DevicesIcon fontSize="small" />}
|
||||
label="Devices"
|
||||
|
||||
@@ -6,12 +6,15 @@ import {
|
||||
Button,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
} from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import LocationCityIcon from "@mui/icons-material/LocationCity";
|
||||
|
||||
import DeleteIcon from "@mui/icons-material/DeleteOutline";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
|
||||
import { CreateSiteDialog, ConfirmDeleteDialog, RenameSiteDialog } from "../Dialogs.jsx";
|
||||
@@ -69,6 +72,7 @@ export default function SiteList({ onOpenDevicesForSite }) {
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [rotatingId, setRotatingId] = useState(null);
|
||||
const gridRef = useRef(null);
|
||||
|
||||
const fetchSites = useCallback(async () => {
|
||||
@@ -83,6 +87,42 @@ export default function SiteList({ onOpenDevicesForSite }) {
|
||||
|
||||
useEffect(() => { fetchSites(); }, [fetchSites]);
|
||||
|
||||
const handleCopy = useCallback(async (code) => {
|
||||
const value = (code || "").trim();
|
||||
if (!value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} catch {
|
||||
window.prompt("Copy enrollment code", value);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRotate = useCallback(async (site) => {
|
||||
if (!site?.id) return;
|
||||
const confirmRotate = window.confirm(
|
||||
"Are you sure you want to rotate the enrollment code associated with this site? "
|
||||
+ "If there are automations that deploy agents to endpoints, the enrollment code associated with them will need to also be updated."
|
||||
);
|
||||
if (!confirmRotate) return;
|
||||
setRotatingId(site.id);
|
||||
try {
|
||||
const resp = await fetch("/api/sites/rotate_code", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ site_id: site.id }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
const updated = await resp.json();
|
||||
setRows((prev) => prev.map((row) => (row.id === site.id ? { ...row, ...updated } : row)));
|
||||
}
|
||||
} catch {
|
||||
// Silently fail the rotate if the request errors; grid will refresh on next fetch.
|
||||
} finally {
|
||||
setRotatingId(null);
|
||||
fetchSites();
|
||||
}
|
||||
}, [fetchSites]);
|
||||
|
||||
const columnDefs = useMemo(() => [
|
||||
{
|
||||
headerName: "",
|
||||
@@ -105,9 +145,51 @@ export default function SiteList({ onOpenDevicesForSite }) {
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
headerName: "Agent Enrollment Code",
|
||||
field: "enrollment_code",
|
||||
minWidth: 320,
|
||||
flex: 1.2,
|
||||
cellRenderer: (params) => {
|
||||
const code = params.value || "—";
|
||||
const site = params.data || {};
|
||||
const busy = rotatingId === site.id;
|
||||
return (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Tooltip title="Rotate Code">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleRotate(site)}
|
||||
disabled={busy}
|
||||
sx={{ color: MAGIC_UI.accentA, border: "1px solid rgba(148,163,184,0.35)" }}
|
||||
>
|
||||
{busy ? <CircularProgress size={16} color="inherit" /> : <RefreshIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Typography variant="body2" sx={{ fontFamily: "monospace", color: MAGIC_UI.textBright }}>
|
||||
{code}
|
||||
</Typography>
|
||||
<Tooltip title="Copy">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleCopy(code)}
|
||||
disabled={!code || code === "—"}
|
||||
sx={{ color: MAGIC_UI.textMuted }}
|
||||
>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ headerName: "Description", field: "description", minWidth: 220 },
|
||||
{ headerName: "Devices", field: "device_count", minWidth: 120 },
|
||||
], [onOpenDevicesForSite]);
|
||||
], [onOpenDevicesForSite, handleRotate, handleCopy, rotatingId]);
|
||||
|
||||
const defaultColDef = useMemo(() => ({
|
||||
sortable: true,
|
||||
|
||||
Reference in New Issue
Block a user