feat: admin enrollment UI and agent keystore scaffolding

This commit is contained in:
2025-10-17 18:02:45 -06:00
parent f63d5c4f83
commit 2edf5a1cf1
7 changed files with 1003 additions and 10 deletions

View File

@@ -5,6 +5,7 @@ python-socketio
websocket-client
eventlet
aiohttp
cryptography
# GUI-related dependencies (Qt for GUI components)
PyQt5

View File

@@ -28,6 +28,7 @@ except Exception:
import aiohttp
import socketio
from security import AgentKeyStore
# Centralized logging helpers (Agent)
def _agent_logs_root() -> str:
@@ -119,6 +120,10 @@ def _persist_agent_guid_local(guid: str):
guid = _normalize_agent_guid(guid)
if not guid:
return
try:
_key_store().save_guid(guid)
except Exception as exc:
_log_agent(f'Unable to persist guid via key store: {exc}', fname='agent.error.log')
path = _agent_guid_path()
try:
directory = os.path.dirname(path)
@@ -464,6 +469,9 @@ def _normalize_agent_guid(guid: str) -> str:
def _read_agent_guid_from_disk() -> str:
try:
ks_guid = _key_store().load_guid()
if ks_guid:
return _normalize_agent_guid(ks_guid)
path = _agent_guid_path()
if os.path.isfile(path):
with open(path, 'r', encoding='utf-8') as fh:
@@ -678,6 +686,22 @@ def _settings_dir():
return os.path.abspath(os.path.join(os.path.dirname(__file__), 'Settings'))
_KEY_STORE_INSTANCE = None
def _key_store() -> AgentKeyStore:
global _KEY_STORE_INSTANCE
if _KEY_STORE_INSTANCE is None:
scope = 'SYSTEM' if SYSTEM_SERVICE_MODE else 'CURRENTUSER'
_KEY_STORE_INSTANCE = AgentKeyStore(_settings_dir(), scope=scope)
return _KEY_STORE_INSTANCE
IDENTITY = _key_store().load_or_create_identity()
SSL_KEY_FINGERPRINT = IDENTITY.fingerprint
PUBLIC_KEY_B64 = IDENTITY.public_key_b64
def get_server_url() -> str:
"""Return the Borealis server URL from env or Agent/Borealis/Settings/server_url.txt.
- Strips UTF-8 BOM and whitespace

243
Data/Agent/security.py Normal file
View File

@@ -0,0 +1,243 @@
#////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/Agent/security.py
from __future__ import annotations
import base64
import hashlib
import json
import os
import platform
import stat
import time
from dataclasses import dataclass
from typing import Optional
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
IS_WINDOWS = platform.system().lower().startswith("win")
try:
if IS_WINDOWS:
import win32crypt # type: ignore
except Exception: # pragma: no cover - win32crypt missing
win32crypt = None # type: ignore
def _ensure_dir(path: str) -> None:
os.makedirs(path, exist_ok=True)
def _restrict_permissions(path: str) -> None:
try:
if not IS_WINDOWS:
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
except Exception:
pass
def _protect(data: bytes, *, scope_system: bool) -> bytes:
if not IS_WINDOWS or not win32crypt:
return data
flags = win32crypt.CRYPTPROTECT_LOCAL_MACHINE if scope_system else 0
protected = win32crypt.CryptProtectData(data, None, None, None, None, flags) # type: ignore[attr-defined]
return protected[1]
def _unprotect(data: bytes, *, scope_system: bool) -> bytes:
if not IS_WINDOWS or not win32crypt:
return data
flags = win32crypt.CRYPTPROTECT_LOCAL_MACHINE if scope_system else 0
unwrapped = win32crypt.CryptUnprotectData(data, None, None, None, None, flags) # type: ignore[attr-defined]
return unwrapped[1]
def _fingerprint_der(public_der: bytes) -> str:
digest = hashlib.sha256(public_der).hexdigest()
return digest.lower()
@dataclass
class AgentIdentity:
private_key: ed25519.Ed25519PrivateKey
public_key_der: bytes
public_key_b64: str
fingerprint: str
def sign(self, payload: bytes) -> bytes:
return self.private_key.sign(payload)
class AgentKeyStore:
def __init__(self, settings_dir: str, scope: str = "CURRENTUSER") -> None:
self.settings_dir = settings_dir
self.scope_system = scope.upper() == "SYSTEM"
_ensure_dir(self.settings_dir)
self._private_path = os.path.join(self.settings_dir, "agent_key.ed25519")
self._public_path = os.path.join(self.settings_dir, "agent_key.pub")
self._guid_path = os.path.join(self.settings_dir, "guid.txt")
self._access_token_path = os.path.join(self.settings_dir, "access.jwt")
self._refresh_token_path = os.path.join(self.settings_dir, "refresh.token")
self._token_meta_path = os.path.join(self.settings_dir, "access.meta.json")
# ------------------------------------------------------------------
# Identity management
# ------------------------------------------------------------------
def load_or_create_identity(self) -> AgentIdentity:
if os.path.isfile(self._private_path) and os.path.isfile(self._public_path):
try:
return self._load_identity()
except Exception:
pass
return self._create_identity()
def _load_identity(self) -> AgentIdentity:
with open(self._private_path, "rb") as fh:
protected = fh.read()
private_bytes = _unprotect(protected, scope_system=self.scope_system)
private_key = serialization.load_pem_private_key(private_bytes, password=None)
public_der = private_key.public_key().public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
with open(self._public_path, "r", encoding="utf-8") as fh:
public_b64 = fh.read().strip()
if not public_b64:
public_b64 = base64.b64encode(public_der).decode("ascii")
fingerprint = _fingerprint_der(public_der)
return AgentIdentity(private_key=private_key, public_key_der=public_der, public_key_b64=public_b64, fingerprint=fingerprint)
def _create_identity(self) -> AgentIdentity:
private_key = ed25519.Ed25519PrivateKey.generate()
private_bytes = private_key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
protected = _protect(private_bytes, scope_system=self.scope_system)
with open(self._private_path, "wb") as fh:
fh.write(protected)
_restrict_permissions(self._private_path)
public_der = private_key.public_key().public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
public_b64 = base64.b64encode(public_der).decode("ascii")
with open(self._public_path, "w", encoding="utf-8") as fh:
fh.write(public_b64)
_restrict_permissions(self._public_path)
fingerprint = _fingerprint_der(public_der)
return AgentIdentity(private_key=private_key, public_key_der=public_der, public_key_b64=public_b64, fingerprint=fingerprint)
# ------------------------------------------------------------------
# GUID helpers
# ------------------------------------------------------------------
def save_guid(self, guid: str) -> None:
if not guid:
return
with open(self._guid_path, "w", encoding="utf-8") as fh:
fh.write(str(guid).strip())
_restrict_permissions(self._guid_path)
def load_guid(self) -> Optional[str]:
if not os.path.isfile(self._guid_path):
return None
try:
with open(self._guid_path, "r", encoding="utf-8") as fh:
return fh.read().strip() or None
except Exception:
return None
# ------------------------------------------------------------------
# Token helpers
# ------------------------------------------------------------------
def save_access_token(self, token: str, *, expires_at: Optional[int] = None) -> None:
if token:
with open(self._access_token_path, "w", encoding="utf-8") as fh:
fh.write(token.strip())
_restrict_permissions(self._access_token_path)
if expires_at:
meta = self._load_token_meta()
meta["access_expires_at"] = int(expires_at)
self._store_token_meta(meta)
def load_access_token(self) -> Optional[str]:
if not os.path.isfile(self._access_token_path):
return None
try:
with open(self._access_token_path, "r", encoding="utf-8") as fh:
token = fh.read().strip()
return token or None
except Exception:
return None
def save_refresh_token(self, token: str) -> None:
if not token:
return
protected = _protect(token.encode("utf-8"), scope_system=self.scope_system)
with open(self._refresh_token_path, "wb") as fh:
fh.write(protected)
_restrict_permissions(self._refresh_token_path)
def load_refresh_token(self) -> Optional[str]:
if not os.path.isfile(self._refresh_token_path):
return None
try:
with open(self._refresh_token_path, "rb") as fh:
protected = fh.read()
raw = _unprotect(protected, scope_system=self.scope_system)
return raw.decode("utf-8")
except Exception:
return None
def clear_tokens(self) -> None:
for path in (self._access_token_path, self._refresh_token_path, self._token_meta_path):
try:
if os.path.isfile(path):
os.remove(path)
except Exception:
pass
# ------------------------------------------------------------------
# Token metadata (e.g., expiry, fingerprint binding)
# ------------------------------------------------------------------
def _load_token_meta(self) -> dict:
if not os.path.isfile(self._token_meta_path):
return {}
try:
with open(self._token_meta_path, "r", encoding="utf-8") as fh:
data = json.load(fh)
if isinstance(data, dict):
return data
except Exception:
pass
return {}
def _store_token_meta(self, meta: dict) -> None:
try:
with open(self._token_meta_path, "w", encoding="utf-8") as fh:
json.dump(meta, fh, indent=2)
_restrict_permissions(self._token_meta_path)
except Exception:
pass
def get_access_expiry(self) -> Optional[int]:
meta = self._load_token_meta()
expiry = meta.get("access_expires_at")
if isinstance(expiry, (int, float)):
return int(expiry)
return None
def set_access_binding(self, fingerprint: str) -> None:
meta = self._load_token_meta()
meta["ssl_key_fingerprint"] = fingerprint
meta["access_bound_at"] = int(time.time())
self._store_token_meta(meta)
def get_access_binding(self) -> Optional[str]:
meta = self._load_token_meta()
value = meta.get("ssl_key_fingerprint")
if isinstance(value, str) and value.strip():
return value.strip()
return None

View File

@@ -0,0 +1,348 @@
////////// 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 } from "react";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
FormControl,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import {
CheckCircleOutline as ApproveIcon,
HighlightOff as DenyIcon,
Refresh as RefreshIcon,
Security as SecurityIcon,
} from "@mui/icons-material";
const STATUS_OPTIONS = [
{ value: "pending", label: "Pending" },
{ value: "approved", label: "Approved" },
{ value: "completed", label: "Completed" },
{ value: "denied", label: "Denied" },
{ value: "expired", label: "Expired" },
{ value: "all", label: "All" },
];
const statusChipColor = {
pending: "warning",
approved: "info",
completed: "success",
denied: "default",
expired: "default",
};
const formatDateTime = (value) => {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
};
const formatFingerprint = (fp) => {
if (!fp) return "—";
const normalized = fp.replace(/[^a-f0-9]/gi, "").toLowerCase();
if (!normalized) return fp;
return normalized.match(/.{1,4}/g)?.join(" ") ?? normalized;
};
const normalizeStatus = (status) => {
if (!status) return "pending";
if (status === "completed") return "completed";
return status.toLowerCase();
};
function DeviceApprovals() {
const [approvals, setApprovals] = useState([]);
const [statusFilter, setStatusFilter] = useState("pending");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [feedback, setFeedback] = useState(null);
const [guidInputs, setGuidInputs] = useState({});
const [actioningId, setActioningId] = useState(null);
const loadApprovals = useCallback(async () => {
setLoading(true);
setError("");
try {
const query = statusFilter === "all" ? "" : `?status=${encodeURIComponent(statusFilter)}`;
const resp = await fetch(`/api/admin/device-approvals${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();
setApprovals(Array.isArray(data.approvals) ? data.approvals : []);
} catch (err) {
setError(err.message || "Unable to load device approvals");
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => {
loadApprovals();
}, [loadApprovals]);
const dedupedApprovals = useMemo(() => {
const normalized = approvals
.map((record) => ({ ...record, status: normalizeStatus(record.status) }))
.sort((a, b) => {
const left = new Date(a.created_at || 0).getTime();
const right = new Date(b.created_at || 0).getTime();
return left - right;
});
if (statusFilter !== "pending") {
return normalized;
}
const seen = new Set();
const unique = [];
for (const record of normalized) {
const key = record.ssl_key_fingerprint_claimed || record.hostname_claimed || record.id;
if (seen.has(key)) continue;
seen.add(key);
unique.push(record);
}
return unique;
}, [approvals, statusFilter]);
const handleGuidChange = useCallback((id, value) => {
setGuidInputs((prev) => ({ ...prev, [id]: value }));
}, []);
const handleApprove = useCallback(
async (record) => {
if (!record?.id) return;
setActioningId(record.id);
setFeedback(null);
setError("");
try {
const guid = (guidInputs[record.id] || "").trim();
const resp = await fetch(`/api/admin/device-approvals/${encodeURIComponent(record.id)}/approve`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(guid ? { guid } : {}),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new Error(body.error || `Approval failed (${resp.status})`);
}
setFeedback({ type: "success", message: "Enrollment approved" });
await loadApprovals();
} catch (err) {
setFeedback({ type: "error", message: err.message || "Unable to approve request" });
} finally {
setActioningId(null);
}
},
[guidInputs, loadApprovals]
);
const handleDeny = useCallback(
async (record) => {
if (!record?.id) return;
const confirmDeny = window.confirm("Deny this enrollment request?");
if (!confirmDeny) return;
setActioningId(record.id);
setFeedback(null);
setError("");
try {
const resp = await fetch(`/api/admin/device-approvals/${encodeURIComponent(record.id)}/deny`, {
method: "POST",
credentials: "include",
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new Error(body.error || `Deny failed (${resp.status})`);
}
setFeedback({ type: "success", message: "Enrollment denied" });
await loadApprovals();
} catch (err) {
setFeedback({ type: "error", message: err.message || "Unable to deny request" });
} finally {
setActioningId(null);
}
},
[loadApprovals]
);
return (
<Box sx={{ p: 3, display: "flex", flexDirection: "column", gap: 3 }}>
<Stack direction="row" alignItems="center" spacing={2}>
<SecurityIcon color="primary" />
<Typography variant="h5">Device Approval Queue</Typography>
</Stack>
<Paper sx={{ p: 2, display: "flex", flexDirection: "column", gap: 2 }}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel id="approval-status-filter-label">Status</InputLabel>
<Select
labelId="approval-status-filter-label"
label="Status"
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
>
{STATUS_OPTIONS.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={loadApprovals}
disabled={loading}
>
Refresh
</Button>
</Stack>
{feedback ? (
<Alert severity={feedback.type} variant="outlined" onClose={() => setFeedback(null)}>
{feedback.message}
</Alert>
) : null}
{error ? (
<Alert severity="error" variant="outlined">
{error}
</Alert>
) : null}
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 480 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Status</TableCell>
<TableCell>Hostname</TableCell>
<TableCell>Fingerprint</TableCell>
<TableCell>Enrollment Code</TableCell>
<TableCell>Created</TableCell>
<TableCell>Updated</TableCell>
<TableCell>Approved By</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} align="center">
<Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
<CircularProgress size={20} />
<Typography variant="body2">Loading approvals</Typography>
</Stack>
</TableCell>
</TableRow>
) : dedupedApprovals.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center">
<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] || "";
return (
<TableRow hover key={record.id}>
<TableCell>
<Chip
size="small"
label={status}
color={statusChipColor[status] || "default"}
variant="outlined"
/>
</TableCell>
<TableCell>{record.hostname_claimed || "—"}</TableCell>
<TableCell sx={{ fontFamily: "monospace", whiteSpace: "nowrap" }}>
{formatFingerprint(record.ssl_key_fingerprint_claimed)}
</TableCell>
<TableCell sx={{ fontFamily: "monospace" }}>
{record.enrollment_code_id || "—"}
</TableCell>
<TableCell>{formatDateTime(record.created_at)}</TableCell>
<TableCell>{formatDateTime(record.updated_at)}</TableCell>
<TableCell>{record.approved_by_user_id || "—"}</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={() => handleApprove(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>
</Box>
);
}
export default React.memo(DeviceApprovals);

View File

@@ -0,0 +1,346 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/Server/WebUI/src/Admin/Enrollment_Codes.jsx
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
FormControl,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tooltip,
Typography,
} from "@mui/material";
import {
ContentCopy as CopyIcon,
DeleteOutline as DeleteIcon,
Refresh as RefreshIcon,
Key as KeyIcon,
} from "@mui/icons-material";
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 statusColor = {
active: "success",
used: "default",
expired: "warning",
};
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("-");
};
const formatDateTime = (value) => {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
};
const determineStatus = (record) => {
if (!record) return "expired";
if (record.used_at) 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";
};
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 filteredCodes = useMemo(() => {
if (statusFilter === "all") return codes;
return codes.filter((code) => determineStatus(code) === statusFilter);
}, [codes, statusFilter]);
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 enrollment codes");
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => {
fetchCodes();
}, [fetchCodes]);
const handleGenerate = useCallback(async () => {
setGenerating(true);
setError("");
try {
const resp = await fetch("/api/admin/enrollment-codes", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ttl_hours: ttlHours }),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new Error(body.error || `Request failed (${resp.status})`);
}
const created = await resp.json();
setFeedback({ type: "success", message: `Installer code ${created.code} created` });
await fetchCodes();
} catch (err) {
setFeedback({ type: "error", message: err.message || "Failed to create code" });
} finally {
setGenerating(false);
}
}, [fetchCodes, ttlHours]);
const handleDelete = useCallback(
async (id) => {
if (!id) return;
const confirmDelete = window.confirm("Delete this unused installer code?");
if (!confirmDelete) return;
setError("");
try {
const resp = await fetch(`/api/admin/enrollment-codes/${encodeURIComponent(id)}`, {
method: "DELETE",
credentials: "include",
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new Error(body.error || `Request failed (${resp.status})`);
}
setFeedback({ type: "success", message: "Installer code deleted" });
await fetchCodes();
} catch (err) {
setFeedback({ type: "error", message: err.message || "Failed to delete code" });
}
},
[fetchCodes]
);
const handleCopy = useCallback((code) => {
if (!code) return;
try {
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(code);
setFeedback({ type: "success", message: "Code copied to clipboard" });
} else {
const textArea = document.createElement("textarea");
textArea.value = code;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
setFeedback({ type: "success", message: "Code copied to clipboard" });
}
} catch (err) {
setFeedback({ type: "error", message: err.message || "Unable to copy code" });
}
}, []);
const renderStatusChip = (record) => {
const status = determineStatus(record);
return <Chip size="small" label={status} color={statusColor[status] || "default"} variant="outlined" />;
};
return (
<Box sx={{ p: 3, display: "flex", flexDirection: "column", gap: 3 }}>
<Stack direction="row" alignItems="center" spacing={2}>
<KeyIcon color="primary" />
<Typography variant="h5">Enrollment Installer Codes</Typography>
</Stack>
<Paper sx={{ p: 2, display: "flex", flexDirection: "column", gap: 2 }}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel id="status-filter-label">Filter</InputLabel>
<Select
labelId="status-filter-label"
label="Filter"
value={statusFilter}
onChange={(event) => setStatusFilter(event.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: 180 }}>
<InputLabel id="ttl-select-label">Duration</InputLabel>
<Select
labelId="ttl-select-label"
label="Duration"
value={ttlHours}
onChange={(event) => setTtlHours(event.target.value)}
>
{TTL_PRESETS.map((preset) => (
<MenuItem key={preset.value} value={preset.value}>
{preset.label}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
color="primary"
onClick={handleGenerate}
disabled={generating}
startIcon={generating ? <CircularProgress size={16} color="inherit" /> : null}
>
{generating ? "Generating…" : "Generate Code"}
</Button>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={fetchCodes}
disabled={loading}
>
Refresh
</Button>
</Stack>
{feedback ? (
<Alert
severity={feedback.type}
onClose={() => setFeedback(null)}
variant="outlined"
>
{feedback.message}
</Alert>
) : null}
{error ? (
<Alert severity="error" variant="outlined">
{error}
</Alert>
) : null}
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 420 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Status</TableCell>
<TableCell>Installer Code</TableCell>
<TableCell>Expires At</TableCell>
<TableCell>Created By</TableCell>
<TableCell>Used At</TableCell>
<TableCell>Used By GUID</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} align="center">
<Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
<CircularProgress size={20} />
<Typography variant="body2">Loading installer codes</Typography>
</Stack>
</TableCell>
</TableRow>
) : filteredCodes.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center">
<Typography variant="body2" color="text.secondary">
No installer codes match this filter.
</Typography>
</TableCell>
</TableRow>
) : (
filteredCodes.map((record) => {
const status = determineStatus(record);
const disableDelete = status !== "active";
return (
<TableRow hover key={record.id}>
<TableCell>{renderStatusChip(record)}</TableCell>
<TableCell sx={{ fontFamily: "monospace" }}>{maskCode(record.code)}</TableCell>
<TableCell>{formatDateTime(record.expires_at)}</TableCell>
<TableCell>{record.created_by_user_id || "—"}</TableCell>
<TableCell>{formatDateTime(record.used_at)}</TableCell>
<TableCell sx={{ fontFamily: "monospace" }}>
{record.used_by_guid || "—"}
</TableCell>
<TableCell align="right">
<Tooltip title="Copy code">
<span>
<IconButton
size="small"
onClick={() => handleCopy(record.code)}
disabled={!record.code}
>
<CopyIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title={disableDelete ? "Only unused codes can be deleted" : "Delete code"}>
<span>
<IconButton
size="small"
onClick={() => handleDelete(record.id)}
disabled={disableDelete}
>
<DeleteIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Box>
);
}
export default React.memo(EnrollmentCodes);

View File

@@ -46,6 +46,8 @@ import CredentialList from "./Access_Management/Credential_List.jsx";
import UserManagement from "./Access_Management/Users.jsx";
import GithubAPIToken from "./Access_Management/Github_API_Token.jsx";
import ServerInfo from "./Admin/Server_Info.jsx";
import EnrollmentCodes from "./Admin/Enrollment_Codes.jsx";
import DeviceApprovals from "./Admin/Device_Approvals.jsx";
// Networking Imports
import { io } from "socket.io-client";
@@ -216,14 +218,18 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
}
case "access_credentials":
return "/access_management/credentials";
case "access_github_token":
return "/access_management/github_token";
case "access_users":
return "/access_management/users";
case "server_info":
return "/admin/server_info";
default:
return "/devices";
case "access_github_token":
return "/access_management/github_token";
case "access_users":
return "/access_management/users";
case "server_info":
return "/admin/server_info";
case "admin_enrollment_codes":
return "/admin/enrollment-codes";
case "admin_device_approvals":
return "/admin/device-approvals";
default:
return "/devices";
}
},
[assemblyEditorState, selectedDevice]
@@ -273,6 +279,8 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
if (path === "/access_management/github_token") return { page: "access_github_token", options: {} };
if (path === "/access_management/credentials") return { page: "access_credentials", options: {} };
if (path === "/admin/server_info") return { page: "server_info", 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 {
return { page: "devices", options: {} };
@@ -450,6 +458,14 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
items.push({ label: "Admin Settings" });
items.push({ label: "Server Info", page: "server_info" });
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" });
break;
case "filters":
items.push({ label: "Filters & Groups", page: "filters" });
items.push({ label: "Filters", page: "filters" });
@@ -876,6 +892,8 @@ 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'
|| currentPage === 'access_users'
@@ -1073,6 +1091,12 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
case "server_info":
return <ServerInfo isAdmin={isAdmin} />;
case "admin_enrollment_codes":
return <EnrollmentCodes />;
case "admin_device_approvals":
return <DeviceApprovals />;
case "workflow-editor":
return (
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 1, overflow: "hidden" }}>

View File

@@ -26,7 +26,9 @@ import {
Dns as ServerInfoIcon,
VpnKey as CredentialIcon,
PersonOutline as UserIcon,
GitHub as GitHubIcon
GitHub as GitHubIcon,
Key as KeyIcon,
AdminPanelSettings as AdminPanelSettingsIcon
} from "@mui/icons-material";
function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
@@ -352,7 +354,10 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
{/* Admin */}
{(() => {
if (!isAdmin) return null;
const groupActive = currentPage === "server_info";
const groupActive =
currentPage === "server_info" ||
currentPage === "admin_enrollment_codes" ||
currentPage === "admin_device_approvals";
return (
<Accordion
expanded={expandedNav.admin}
@@ -390,6 +395,8 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
</AccordionSummary>
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
<NavItem icon={<ServerInfoIcon fontSize="small" />} label="Server Info" pageKey="server_info" />
<NavItem icon={<KeyIcon fontSize="small" />} label="Installer Codes" pageKey="admin_enrollment_codes" />
<NavItem icon={<AdminPanelSettingsIcon fontSize="small" />} label="Device Approvals" pageKey="admin_device_approvals" />
</AccordionDetails>
</Accordion>
);