From 2edf5a1cf1780ae572f240ee1b18d0d37505b068 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Fri, 17 Oct 2025 18:02:45 -0600 Subject: [PATCH] feat: admin enrollment UI and agent keystore scaffolding --- Data/Agent/agent-requirements.txt | 1 + Data/Agent/agent.py | 24 ++ Data/Agent/security.py | 243 ++++++++++++ .../WebUI/src/Admin/Device_Approvals.jsx | 348 ++++++++++++++++++ .../WebUI/src/Admin/Enrollment_Codes.jsx | 346 +++++++++++++++++ Data/Server/WebUI/src/App.jsx | 40 +- Data/Server/WebUI/src/Navigation_Sidebar.jsx | 11 +- 7 files changed, 1003 insertions(+), 10 deletions(-) create mode 100644 Data/Agent/security.py create mode 100644 Data/Server/WebUI/src/Admin/Device_Approvals.jsx create mode 100644 Data/Server/WebUI/src/Admin/Enrollment_Codes.jsx diff --git a/Data/Agent/agent-requirements.txt b/Data/Agent/agent-requirements.txt index 157d100..4a37c88 100644 --- a/Data/Agent/agent-requirements.txt +++ b/Data/Agent/agent-requirements.txt @@ -5,6 +5,7 @@ python-socketio websocket-client eventlet aiohttp +cryptography # GUI-related dependencies (Qt for GUI components) PyQt5 diff --git a/Data/Agent/agent.py b/Data/Agent/agent.py index 36401c2..619435a 100644 --- a/Data/Agent/agent.py +++ b/Data/Agent/agent.py @@ -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 diff --git a/Data/Agent/security.py b/Data/Agent/security.py new file mode 100644 index 0000000..7f37f15 --- /dev/null +++ b/Data/Agent/security.py @@ -0,0 +1,243 @@ +#////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: /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 diff --git a/Data/Server/WebUI/src/Admin/Device_Approvals.jsx b/Data/Server/WebUI/src/Admin/Device_Approvals.jsx new file mode 100644 index 0000000..f77dd4c --- /dev/null +++ b/Data/Server/WebUI/src/Admin/Device_Approvals.jsx @@ -0,0 +1,348 @@ +////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: /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 ( + + + + Device Approval Queue + + + + + + Status + + + + + + + {feedback ? ( + setFeedback(null)}> + {feedback.message} + + ) : null} + + {error ? ( + + {error} + + ) : null} + + + + + + Status + Hostname + Fingerprint + Enrollment Code + Created + Updated + Approved By + Actions + + + + {loading ? ( + + + + + Loading approvals… + + + + ) : dedupedApprovals.length === 0 ? ( + + + + No enrollment requests match this filter. + + + + ) : ( + dedupedApprovals.map((record) => { + const status = normalizeStatus(record.status); + const showActions = status === "pending"; + const guidValue = guidInputs[record.id] || ""; + return ( + + + + + {record.hostname_claimed || "—"} + + {formatFingerprint(record.ssl_key_fingerprint_claimed)} + + + {record.enrollment_code_id || "—"} + + {formatDateTime(record.created_at)} + {formatDateTime(record.updated_at)} + {record.approved_by_user_id || "—"} + + {showActions ? ( + + handleGuidChange(record.id, event.target.value)} + sx={{ minWidth: 200 }} + /> + + + + handleApprove(record)} + disabled={actioningId === record.id} + > + {actioningId === record.id ? ( + + ) : ( + + )} + + + + + + handleDeny(record)} + disabled={actioningId === record.id} + > + + + + + + + ) : ( + + No actions available + + )} + + + ); + }) + )} + +
+
+
+
+ ); +} + +export default React.memo(DeviceApprovals); diff --git a/Data/Server/WebUI/src/Admin/Enrollment_Codes.jsx b/Data/Server/WebUI/src/Admin/Enrollment_Codes.jsx new file mode 100644 index 0000000..236fbd7 --- /dev/null +++ b/Data/Server/WebUI/src/Admin/Enrollment_Codes.jsx @@ -0,0 +1,346 @@ +////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: /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 ; + }; + + return ( + + + + Enrollment Installer Codes + + + + + + Filter + + + + + Duration + + + + + + + + + {feedback ? ( + setFeedback(null)} + variant="outlined" + > + {feedback.message} + + ) : null} + + {error ? ( + + {error} + + ) : null} + + + + + + Status + Installer Code + Expires At + Created By + Used At + Used By GUID + Actions + + + + {loading ? ( + + + + + Loading installer codes… + + + + ) : filteredCodes.length === 0 ? ( + + + + No installer codes match this filter. + + + + ) : ( + filteredCodes.map((record) => { + const status = determineStatus(record); + const disableDelete = status !== "active"; + return ( + + {renderStatusChip(record)} + {maskCode(record.code)} + {formatDateTime(record.expires_at)} + {record.created_by_user_id || "—"} + {formatDateTime(record.used_at)} + + {record.used_by_guid || "—"} + + + + + handleCopy(record.code)} + disabled={!record.code} + > + + + + + + + handleDelete(record.id)} + disabled={disableDelete} + > + + + + + + + ); + }) + )} + +
+
+
+
+ ); +} + +export default React.memo(EnrollmentCodes); diff --git a/Data/Server/WebUI/src/App.jsx b/Data/Server/WebUI/src/App.jsx index f04e0e8..a4ab9c1 100644 --- a/Data/Server/WebUI/src/App.jsx +++ b/Data/Server/WebUI/src/App.jsx @@ -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 ; + case "admin_enrollment_codes": + return ; + + case "admin_device_approvals": + return ; + case "workflow-editor": return ( diff --git a/Data/Server/WebUI/src/Navigation_Sidebar.jsx b/Data/Server/WebUI/src/Navigation_Sidebar.jsx index 471a817..f0e76b3 100644 --- a/Data/Server/WebUI/src/Navigation_Sidebar.jsx +++ b/Data/Server/WebUI/src/Navigation_Sidebar.jsx @@ -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 ( } label="Server Info" pageKey="server_info" /> + } label="Installer Codes" pageKey="admin_enrollment_codes" /> + } label="Device Approvals" pageKey="admin_device_approvals" /> );