mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 21:41:57 -06:00
feat: admin enrollment UI and agent keystore scaffolding
This commit is contained in:
@@ -5,6 +5,7 @@ python-socketio
|
|||||||
websocket-client
|
websocket-client
|
||||||
eventlet
|
eventlet
|
||||||
aiohttp
|
aiohttp
|
||||||
|
cryptography
|
||||||
|
|
||||||
# GUI-related dependencies (Qt for GUI components)
|
# GUI-related dependencies (Qt for GUI components)
|
||||||
PyQt5
|
PyQt5
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ except Exception:
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
import socketio
|
import socketio
|
||||||
|
from security import AgentKeyStore
|
||||||
|
|
||||||
# Centralized logging helpers (Agent)
|
# Centralized logging helpers (Agent)
|
||||||
def _agent_logs_root() -> str:
|
def _agent_logs_root() -> str:
|
||||||
@@ -119,6 +120,10 @@ def _persist_agent_guid_local(guid: str):
|
|||||||
guid = _normalize_agent_guid(guid)
|
guid = _normalize_agent_guid(guid)
|
||||||
if not guid:
|
if not guid:
|
||||||
return
|
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()
|
path = _agent_guid_path()
|
||||||
try:
|
try:
|
||||||
directory = os.path.dirname(path)
|
directory = os.path.dirname(path)
|
||||||
@@ -464,6 +469,9 @@ def _normalize_agent_guid(guid: str) -> str:
|
|||||||
|
|
||||||
def _read_agent_guid_from_disk() -> str:
|
def _read_agent_guid_from_disk() -> str:
|
||||||
try:
|
try:
|
||||||
|
ks_guid = _key_store().load_guid()
|
||||||
|
if ks_guid:
|
||||||
|
return _normalize_agent_guid(ks_guid)
|
||||||
path = _agent_guid_path()
|
path = _agent_guid_path()
|
||||||
if os.path.isfile(path):
|
if os.path.isfile(path):
|
||||||
with open(path, 'r', encoding='utf-8') as fh:
|
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'))
|
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:
|
def get_server_url() -> str:
|
||||||
"""Return the Borealis server URL from env or Agent/Borealis/Settings/server_url.txt.
|
"""Return the Borealis server URL from env or Agent/Borealis/Settings/server_url.txt.
|
||||||
- Strips UTF-8 BOM and whitespace
|
- Strips UTF-8 BOM and whitespace
|
||||||
|
|||||||
243
Data/Agent/security.py
Normal file
243
Data/Agent/security.py
Normal 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
|
||||||
348
Data/Server/WebUI/src/Admin/Device_Approvals.jsx
Normal file
348
Data/Server/WebUI/src/Admin/Device_Approvals.jsx
Normal 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);
|
||||||
346
Data/Server/WebUI/src/Admin/Enrollment_Codes.jsx
Normal file
346
Data/Server/WebUI/src/Admin/Enrollment_Codes.jsx
Normal 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);
|
||||||
@@ -46,6 +46,8 @@ import CredentialList from "./Access_Management/Credential_List.jsx";
|
|||||||
import UserManagement from "./Access_Management/Users.jsx";
|
import UserManagement from "./Access_Management/Users.jsx";
|
||||||
import GithubAPIToken from "./Access_Management/Github_API_Token.jsx";
|
import GithubAPIToken from "./Access_Management/Github_API_Token.jsx";
|
||||||
import ServerInfo from "./Admin/Server_Info.jsx";
|
import ServerInfo from "./Admin/Server_Info.jsx";
|
||||||
|
import EnrollmentCodes from "./Admin/Enrollment_Codes.jsx";
|
||||||
|
import DeviceApprovals from "./Admin/Device_Approvals.jsx";
|
||||||
|
|
||||||
// Networking Imports
|
// Networking Imports
|
||||||
import { io } from "socket.io-client";
|
import { io } from "socket.io-client";
|
||||||
@@ -222,6 +224,10 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
|||||||
return "/access_management/users";
|
return "/access_management/users";
|
||||||
case "server_info":
|
case "server_info":
|
||||||
return "/admin/server_info";
|
return "/admin/server_info";
|
||||||
|
case "admin_enrollment_codes":
|
||||||
|
return "/admin/enrollment-codes";
|
||||||
|
case "admin_device_approvals":
|
||||||
|
return "/admin/device-approvals";
|
||||||
default:
|
default:
|
||||||
return "/devices";
|
return "/devices";
|
||||||
}
|
}
|
||||||
@@ -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/github_token") return { page: "access_github_token", options: {} };
|
||||||
if (path === "/access_management/credentials") return { page: "access_credentials", options: {} };
|
if (path === "/access_management/credentials") return { page: "access_credentials", options: {} };
|
||||||
if (path === "/admin/server_info") return { page: "server_info", 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: {} };
|
return { page: "devices", options: {} };
|
||||||
} catch {
|
} catch {
|
||||||
return { page: "devices", options: {} };
|
return { page: "devices", options: {} };
|
||||||
@@ -450,6 +458,14 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
|||||||
items.push({ label: "Admin Settings" });
|
items.push({ label: "Admin Settings" });
|
||||||
items.push({ label: "Server Info", page: "server_info" });
|
items.push({ label: "Server Info", page: "server_info" });
|
||||||
break;
|
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":
|
case "filters":
|
||||||
items.push({ label: "Filters & Groups", page: "filters" });
|
items.push({ label: "Filters & Groups", page: "filters" });
|
||||||
items.push({ label: "Filters", page: "filters" });
|
items.push({ label: "Filters", page: "filters" });
|
||||||
@@ -876,6 +892,8 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const requiresAdmin = currentPage === 'server_info'
|
const requiresAdmin = currentPage === 'server_info'
|
||||||
|
|| currentPage === 'admin_enrollment_codes'
|
||||||
|
|| currentPage === 'admin_device_approvals'
|
||||||
|| currentPage === 'access_credentials'
|
|| currentPage === 'access_credentials'
|
||||||
|| currentPage === 'access_github_token'
|
|| currentPage === 'access_github_token'
|
||||||
|| currentPage === 'access_users'
|
|| currentPage === 'access_users'
|
||||||
@@ -1073,6 +1091,12 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
|||||||
case "server_info":
|
case "server_info":
|
||||||
return <ServerInfo isAdmin={isAdmin} />;
|
return <ServerInfo isAdmin={isAdmin} />;
|
||||||
|
|
||||||
|
case "admin_enrollment_codes":
|
||||||
|
return <EnrollmentCodes />;
|
||||||
|
|
||||||
|
case "admin_device_approvals":
|
||||||
|
return <DeviceApprovals />;
|
||||||
|
|
||||||
case "workflow-editor":
|
case "workflow-editor":
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 1, overflow: "hidden" }}>
|
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 1, overflow: "hidden" }}>
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ import {
|
|||||||
Dns as ServerInfoIcon,
|
Dns as ServerInfoIcon,
|
||||||
VpnKey as CredentialIcon,
|
VpnKey as CredentialIcon,
|
||||||
PersonOutline as UserIcon,
|
PersonOutline as UserIcon,
|
||||||
GitHub as GitHubIcon
|
GitHub as GitHubIcon,
|
||||||
|
Key as KeyIcon,
|
||||||
|
AdminPanelSettings as AdminPanelSettingsIcon
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
|
|
||||||
function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
||||||
@@ -352,7 +354,10 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
|||||||
{/* Admin */}
|
{/* Admin */}
|
||||||
{(() => {
|
{(() => {
|
||||||
if (!isAdmin) return null;
|
if (!isAdmin) return null;
|
||||||
const groupActive = currentPage === "server_info";
|
const groupActive =
|
||||||
|
currentPage === "server_info" ||
|
||||||
|
currentPage === "admin_enrollment_codes" ||
|
||||||
|
currentPage === "admin_device_approvals";
|
||||||
return (
|
return (
|
||||||
<Accordion
|
<Accordion
|
||||||
expanded={expandedNav.admin}
|
expanded={expandedNav.admin}
|
||||||
@@ -390,6 +395,8 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
|||||||
</AccordionSummary>
|
</AccordionSummary>
|
||||||
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
||||||
<NavItem icon={<ServerInfoIcon fontSize="small" />} label="Server Info" pageKey="server_info" />
|
<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>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user