Updated Atgents.md and Approval Queue Logic

This commit is contained in:
2025-10-20 05:10:05 -06:00
parent 81516b64ee
commit 7cbd1334d7
4 changed files with 42 additions and 613 deletions

157
AGENTS.md
View File

@@ -1,4 +1,10 @@
# Borealis Agents
## Architecture At A Glance
- `Borealis.ps1` is the starting point for every aspect of Borealis. It bootstraps dependencies, configures bundled Python virtual environments, and deploys the agents and server from a singular script.
- Bundled assets live under `Data/Agent`, `Data/Server`, and `Dependencies`. Launching an agent or server copies the necessary data from these `Data/` directories into sibling `Agent/` and `Server/` directories at runtime so the development tree stays clean and the runtime stays portable.
- The server stack spans NodeJS + Vite for live development and Python Flask (`Data/Server/server.py`) for the production frontend (when not using the Vite dev server) and for API endpoints to the Borealis Server backend.
The `script_engines.py` helper exposes a PowerShell runner for potential server-side orchestration, but no current Flask route invokes it; agent-side script execution lives under the roles in `Data/Agent`.
- Agents run inside the packaged Python venv (`Data/Agent` mirrored to `Agent/`). `agent.py` handles the primary connection and hot-loads roles from `Data/Agent/Roles` at agent startup.
## Logging Policy (Centralized, Rotated)
- **Log Locations**
- Agent: `<ProjectRoot>/Logs/Agent/<service>.log`
@@ -30,43 +36,43 @@
- **Convergence**
- This policy applies to all new contributions.
- When modifying existing code, migrate ad-hoc logging into this pattern.
## Overview
Borealis pairs a no-code workflow canvas with a rapidly evolving remote management stack. The long-term goal is to orchestrate scripts, schedules, and workflows against distributed agents while keeping everything self-contained and portable.
Today the stable core focuses on workflow-driven API and automation scenarios. RMM-level inventory, patching, and fleet coordination exist in early form.
## Architecture At A Glance
- `Borealis.ps1` is the starting point for every aspect of Borealis. It bootstraps dependencies, configures bundled Python virtual environments, and deploys the agents and server from a singular script.
- Bundled assets live under `Data/Agent`, `Data/Server`, and `Dependencies`. Launching an agent or server copies the necessary data from these `Data/` directories into sibling `Agent/` and `Server/` directories so the development tree stays clean and the runtime stays portable.
- The server stack spans NodeJS + Vite for live development and Python Flask (`Data/Server/server.py`) for the production frontend (when not using the Vite dev server) and APIs, backed by Python helpers (`Data/Server/Python_API_Endpoints`) for OCR, scripting, and other services. The `script_engines.py` helper exposes a PowerShell runner for potential server-side orchestration, but no current Flask route invokes it; agent-side script execution lives under the roles in `Data/Agent`.
- Agents run inside the packaged Python venv (`Data/Agent` mirrored to `Agent/`). `agent.py` handles the primary connection and hot-loads roles from `Data/Agent/Roles` at startup.
- **Troubleshooting Issues via Logs with the Operator**
- When troubleshooting issues with the operator, you must ensure that you are adding extensive logging to whatever feature you are trying to troubleshoot
- If the operator reports successfully resolving an issue, you are to ask them if they want you to remove the extra logging functionality or if they want to keep it.
- When troubleshooting, logs will have the <timestamp>-<service-name>-<log-data> structure to every line of the logs.
## Dependencies & Packaging
`Dependencies/` holds the installers/download payloads Borealis bootstraps on first launch: Python, 7-Zip, AutoHotkey, and NodeJS. Versions are hard-pinned in `Borealis.ps1`; upgrading any runtime requires updating those version constants before repackaging. Nothing self-updates, so Codex should coordinate dependency bumps carefully and test both server and agent bootstrap paths.
## Agent Responsibilities
## Security Breakdowns
The process that agents go through when authenticating securely with a Borealis server can be a little complex, so I have included a few sequence diagrams below along with a summary of the (current) security posture of Borealis to go over the core systems so you can visually understand what is going on behind-the-scenes.
### Communication Channels
Agents establish TLS-secured REST calls to the Flask backend on port 5000 and keep an authenticated WebSocket session for interactive features such as screenshot capture. Future plans include WebRTC for higher-performance remote desktop. Every agent now performs an enrollment handshake (see **Secure Enrollment & Tokens** below) prior to opening either channel; all API access is bound to short-lived Ed25519-signed JWTs.
### Secure Enrollment & Tokens
- On first launch the agent generates an Ed25519 identity and stores the private key under `Certificates/Agent/Identity/<Context>/agent_identity_private.ed25519` (protected with DPAPI on Windows or chmod 600 elsewhere). The public key is retained alongside it as Base64 (`agent_identity_public.ed25519`) and fingerprinted with SHA-256.
- Enrollment starts with an installer code (minted in the Web UI) and proves key possession by signing the server nonce. Upon operator approval the server issues:
- The canonical device GUID (persisted to `guid.txt` alongside the key material).
- A short-lived access token (EdDSA/JWT) and a long-lived refresh token (stored encrypted via DPAPI and hashed server-side).
- The server TLS certificate and script-signing public key so the agent can pin both for future sessions.
- Scripts delivered over REST are signed with the server's Ed25519 code-signing key. The agent validates the signature before anything is queued for execution.
- Access tokens are automatically refreshed before expiry. Refresh failures trigger a re-enrollment.
- All REST calls (heartbeat, script polling, device details, service check-in) use these tokens; WebSocket connections include the `Authorization` header as well.
- Specify the installer code via `--installer-code <code>`, `BOREALIS_INSTALLER_CODE`, or by adding `"installer_code": "<code>"` to `Agent/Borealis/Settings/agent_settings.json`.
### Security Overview
#### Overall
- Borealis enforces mutual trust: each agent presents a unique Ed25519 identity to the server, the server issues EdDSA-signed (Ed25519) access tokens bound to that fingerprint, and both sides pin the generated Borealis root CA.
- End-to-end TLS everywhere: the server ships an ECDSA P-384 root + leaf chain and only serves TLS1.3; agents require TLS1.2+ and "pin" (store the server certificate for future verification) the delivered bundle for both REST and WebSocket traffic, eliminating Man-in-the-middle avenues.
- Device enrollment is gated by enrollment/installer codes (*They have configurable expiration and usage limits*) and an operator approval queue; replay-resistant nonces plus rate limits (40req/min/IP, 12req/min/fingerprint) prevent brute force or code reuse.
- All device APIs now require Authorization: Bearer headers and a service-context (e.g. SYSTEM or CURRENTUSER) marker; missing, expired, mismatched, or revoked credentials are rejected before any business logic runs. Operator-driven revoking / device quarantining logic is not yet implemented.
- Replay and credential theft defenses layer in DPoP proof validation (thumbprint binding) on the server side and short-lived access tokens (15min) with 30-day refresh tokens hashed via SHA-256.
- Centralized logging under Logs/Server and Logs/Agent captures enrollment approvals, rate-limit hits, signature failures, and auth anomalies for post-incident review.
#### Server Security
- Auto-manages PKI: a persistent Borealis root CA (ECDSA SECP384R1) signs leaf certificates that include localhost SANs, tightened filesystem permissions, and a combined bundle for agent identity / cert pinning.
- Script delivery is code-signed with an Ed25519 key stored under Certificates/Server/Code-Signing; agents refuse any payload whose signature or hash does not match the pinned public key.
- Device authentication checks GUID normalization, SSL fingerprint matches, token version counters, and quarantine flags before admitting requests; missing rows with valid tokens auto-recover into placeholder records to avoid accidental lockouts.
- Refresh tokens are never stored in cleartext, only SHA-256 hashes plus DPoP bindings land in SQLite, and reuse after revocation/expiry returns explicit error codes.
- Enrollment workflow queues approvals, detects hostname/fingerprint conflicts, offers merge/overwrite options, and records auditor identities so trust decisions are traceable.
- Background jobs prune expired enrollment codes and refresh tokens, keeping the attack surface small without silently deleting active credentials.
#### Agent
- Generates device-wide Ed25519 key pairs on first launch, storing them under Certificates/Agent/Identity/ with DPAPI protection on Windows (chmod600 elsewhere) and persisting the server-issued GUID alongside.
- Stores refresh/access tokens encrypted (DPAPI) with companion metadata that pins them to the expected server certificate fingerprint; mismatches or refresh failures trigger a clean re-enrollment.
- Imports the servers TLS bundle into a dedicated ssl.SSLContext, reuses it for the REST session, and injects it into the Socket.IO engine so WebSockets enjoy the same pinning and hostname checks.
- Treats every script payload as hostile until verified: only Ed25519 signatures from the server are accepted, missing/invalid signatures are logged and dropped, and the trusted signing key is updated only after successful verification between the agent and the server.
- Operates outbound-only; there are no listener ports, and every API/WebSocket call flows through AgentHttpClient.ensure_authenticated, forcing token refresh logic before retrying.
- Logs bootstrap, enrollment, token refresh, and signature events to daily-rotated files under Logs/Agent, giving operators visibility without leaking secrets outside the project root.
### Execution Contexts
The agent runs in the interactive user session. SYSTEM-level script execution is provided by the ScriptExec SYSTEM role using ephemeral scheduled tasks; no separate supervisor or watchdog is required.
### Logging & State
All runtime logs live under `Logs/<ServiceName>` relative to the project root (`Logs/Agent` for the agent family). Logs rotate daily and adopt the `<service>.log.YYYY-MM-DD` suffix on rollover; nothing is deleted automatically. The project avoids writing to `%ProgramData%`, `%AppData%`, or other system directories so the entire footprint stays under the Borealis folder. Configuration and state currently live alongside the agent code.
## Roles & Extensibility
- Roles live under `Data/Agent/Roles/` and are autodiscovered at startup; no changes are needed in `agent.py` when adding new roles.
- Naming convention: `role_<Purpose>.py` per role.
@@ -88,71 +94,10 @@ All runtime logs live under `Logs/<ServiceName>` relative to the project root (`
- Roles are “hotloaded” on startup only (no dynamic import while running).
- Roles must avoid blocking the main event loop and be resilient to restarts.
## Packaging Notes
- `Borealis.ps1` deploys `agent.py`, `role_manager.py`, `Roles/`, and `Python_API_Endpoints/` into `Agent/Borealis/`.
- If packaging a singlefile EXE (PyInstaller), ensure `Roles/` and `Python_API_Endpoints/` are included as data files so role autodiscovery works at runtime.
## Migration Summary
- Replaced monolithic role code with modular roles under `Data/Agent/Roles/`.
- Removed legacy helpers: `agent_supervisor.py`, `agent_roles.py`, `tray_launcher.py`, `agent_info.py`, and `script_agent.py` (functionality is now inside roles).
- `agent.py` contains only core transport/config logic and role loading.
## Operational Guidance
- Launch or test a single agent locally with `.\\Borealis.ps1 -Agent` (or combine with `-AgentAction install|repair|launch|remove` as needed). The same entry point manages the server (`-Server`) with either Vite or Flask flags.
- When debugging, tail files under `Logs/Agent`. Use the PowerShell packaging scripts in `Data/Agent/Scripts` to reinstall the user logon scheduled task if it drifts.
- Agent installs/repairs now stop only Agent venv Python processes (scoped to `Agent\\*`) and no longer kill global `node.exe`. This prevents accidental termination of the dev WebUI (Vite/esbuild) when working on agents.
- Known stability gaps include suspected Python memory leaks in both the server and agents under multi-day workloads, occasional heartbeat mismatches, and the flashing watchdog console window. A more robust keepalive should eventually remove the watchdog dependency.
- Expect the agent to remain running for days or weeks; contributions should focus on reconnect logic, light resource usage, and graceful shutdown/restart semantics.
## New: Agent Launch Model, Tasks, and Logging
- SYSTEM mode is launched via a wrapper to guarantee WorkingDirectory and capture stdout/stderr:
- `Agent\\Borealis\\launch_service.ps1` is registered as the scheduled task action for the SYSTEM agent.
- The wrapper runs `Agent\\Scripts\\pythonw.exe Agent\\Borealis\\agent.py --system-service --config SYSTEM` with `Set-Location` to `Agent\\Borealis` and redirects output to `%ProgramData%\\Borealis\\svc.out.log` and `svc.err.log`.
- This avoids 0x1/0x2 Task Scheduler errors on hosts where WorkingDirectory is ignored.
- UserHelper (interactive) is still a direct task action to `pythonw.exe "Agent\\Borealis\\agent.py" --config CURRENTUSER`.
- Config files and inheritance:
- Base config now lives at `<ProjectRoot>\\Agent\\Borealis\\Settings\\agent_settings.json`.
- On first run per-suffix, the agent seeds: `Agent\\Borealis\\Settings\\agent_settings_SYSTEM.json` (SYSTEM) and `Agent\\Borealis\\Settings\\agent_settings_CURRENTUSER.json` (interactive) from the base when present.
- Server URL is stored in `<ProjectRoot>\\Agent\\Borealis\\Settings\\server_url.txt`. The deployment script prompts for it on install/repair; press Enter to accept the default `http://localhost:5000`.
- Logging:
- Early bootstrap log: `<ProjectRoot>\\Logs\\Agent\\bootstrap.log` (helps verify launch + mode).
- Main logs: `<ProjectRoot>\\Logs\\Agent\\agent.log`, `agent.error.log`.
- Wrapper logs (SYSTEM task): `%ProgramData%\\Borealis\\svc.out.log`, `svc.err.log`.
- Last SYSTEM script for debugging: `<ProjectRoot>\\Logs\\Agent\\system_last.ps1`.
## Recommended Dev Flows
- Start the server in Flask-only or dev mode before the agent so WebSocket connect succeeds:
- Flask quick start: `.\\Borealis.ps1 -Server -Flask -Quick`.
- Dev UI separately (if needed): `cd Server\\web-interface && npm run dev`.
- Launch/repair agent (elevated PowerShell): `.\\Borealis.ps1 -Agent -AgentAction install`.
- Manual short-run agent checks (non-blocking):
- `Start-Process .\\Agent\\Scripts\\pythonw.exe -ArgumentList '".\\Agent\\Borealis\\agent.py" --system-service --config SYSTEM'`
- Verify logs under `Logs\\Agent` and presence of `Agent\\Borealis\\Settings\\agent_settings_SYSTEM.json` and `Agent\\Borealis\\Settings\\server_url.txt`.
## Troubleshooting Checklist
- Agent task “Ready” with 0x1: ensure the SYSTEM task uses `launch_service.ps1` and that WorkingDirectory is `Agent\\Borealis`.
- No logs/configs created: verify venv exists under `Agent\\Scripts` and that wrapper points at the right paths.
- Agent connects but Devices empty: check `agent.error.log` for aiohttp errors and confirm the URL in `Agent\\Borealis\\Settings\\server_url.txt` is reachable; device details post occurs once on connect and then every ~5 minutes.
- Quick jobs “Running” forever: ensure SYSTEM and UserHelper agents are both running; check `system_last.ps1` and wrapper logs for PowerShell errors.
## State & Persistence
`database.db` currently stores device inventory, runtime facts, and job history. Workflow and scheduling metadata are not yet persisted, and no internal scheduler exists beyond WebUI prototypes. Planned scheduling work will need schema updates and migration guidance once implemented.
## Platform Parity
Windows is the reference environment today. `Borealis.ps1` owns the full deployment story, while `Borealis.sh` lags significantly and lacks the same packaging logic. Linux support needs feature parity (virtual environments, supervisor equivalents, and role loading) before macOS work resumes.
## Roadmap & Priorities
- Harden the agent core: modular role loading, reliable reconnect/keepalive, and watchdog replacement.
- Build inventory on demand (process lists, installed software, update metadata) and prepare for patch management workflows similar to commercial RMM tooling.
- Deliver the advanced scheduling matrix: workflows that trigger on timers or external API states, evaluate conditions, and fan out to script roles running as SYSTEM or the interactive user.
- Design a first-class update mechanism that can stage new agent builds, restart gracefully, and hot-detect new roles once they land on disk.
- Clean up deployment ergonomics so agents tolerate weeks of uptime without manual intervention and can accept hot-loaded role updates.
## Security Outlook
Security and authentication are intentionally deferred. There is currently no agent/server handshake, credential model, or ACL on powerful endpoints, so deployments must remain in controlled environments. A future milestone will introduce mutual registration, scoped API tokens, and hardened remote execution surfaces; until then, prioritize resilience and modularity while acknowledging the risk.
## Ansible Support (Unfinished — Do Not Use)
Important: The Ansible integration is not productionready. Do not rely on it for jobs, quick jobs, or troubleshooting. The current implementation is a workinprogress and will change.
- Status
@@ -173,34 +118,4 @@ Important: The Ansible integration is not productionready. Do not rely on it
- Databasefed credential management (per device/site/global), stored securely and surfaced to playbook runs.
- Firstclass selection of connection types (local | PSRP | WinRM) from the UI and scheduler, with perrun credential binding.
- Reliable live output and cancel semantics; hardened recap ingestion and history.
- Verified packaging of required Ansible components and Windows collections inside the agent venv.
## Current State Highlights
This section summarizes what is considered usable vs. experimental today.
- Stable/Usable
- Agent heartbeat, reconnect logic (ongoing hardening), and device registration.
- Device inventory collection (SYSTEM role) with periodic updates.
- Script execution roles:
- Current user (interactive PowerShell)
- SYSTEM (PowerShell via ephemeral Scheduled Tasks)
- Screenshot capture role with Socket.IO updates.
- Unified SQLite database (`database.db`) for users, sites, device details, scheduled jobs, and activity history.
- Web UI for device list/details, scheduling basics, assemblies (scripts/workflows) management.
- Experimental/WIP
- Scheduling matrix beyond basic intervals and immediate/once semantics.
- Longrunning agent stability under multiday workloads (memory/keepalive are being improved).
- Any Ansiblerelated feature (see above) — not supported.
- Terminology
- “Assemblies” consolidates Scripts/Workflows (and future Playbooks) in the UI. Treat Playbooks as nonfunctional until Ansible support matures.
- Verified packaging of required Ansible components and Windows collections inside the agent venv.

View File

@@ -246,7 +246,8 @@ def register(
@blueprint.route("/api/admin/device-approvals", methods=["GET"])
def list_device_approvals():
status = request.args.get("status", "pending")
status_raw = request.args.get("status")
status = (status_raw or "").strip().lower()
approvals: List[Dict[str, Any]] = []
conn = db_conn_factory()
try:
@@ -268,8 +269,8 @@ def register(
approved_by_user_id
FROM device_approvals
"""
if status:
sql += " WHERE status = ?"
if status and status != "all":
sql += " WHERE LOWER(status) = ?"
params.append(status)
sql += " ORDER BY created_at ASC"
cur.execute(sql, params)

View File

@@ -1,487 +0,0 @@
////////// 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,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
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 [conflictPrompt, setConflictPrompt] = 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 submitApproval = useCallback(
async (record, overrides = {}) => {
if (!record?.id) return;
setActioningId(record.id);
setFeedback(null);
setError("");
try {
const manualGuid = (guidInputs[record.id] || "").trim();
const payload = {};
const overrideGuidRaw = overrides.guid;
let overrideGuid = "";
if (typeof overrideGuidRaw === "string") {
overrideGuid = overrideGuidRaw.trim();
} else if (overrideGuidRaw != null) {
overrideGuid = String(overrideGuidRaw).trim();
}
if (overrideGuid) {
payload.guid = overrideGuid;
} else if (manualGuid) {
payload.guid = manualGuid;
}
const resolutionRaw = overrides.conflictResolution || overrides.resolution;
if (typeof resolutionRaw === "string" && resolutionRaw.trim()) {
payload.conflict_resolution = resolutionRaw.trim().toLowerCase();
}
const resp = await fetch(`/api/admin/device-approvals/${encodeURIComponent(record.id)}/approve`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.keys(payload).length ? payload : {}),
});
const body = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(body.error || `Approval failed (${resp.status})`);
}
const appliedResolution = (body.conflict_resolution || payload.conflict_resolution || "").toLowerCase();
let successMessage = "Enrollment approved";
if (appliedResolution === "overwrite") {
successMessage = "Enrollment approved; existing device overwritten";
} else if (appliedResolution === "coexist") {
successMessage = "Enrollment approved; devices will co-exist";
}
setFeedback({ type: "success", message: successMessage });
await loadApprovals();
} catch (err) {
setFeedback({ type: "error", message: err.message || "Unable to approve request" });
} finally {
setActioningId(null);
}
},
[guidInputs, loadApprovals]
);
const startApprove = useCallback(
(record) => {
if (!record?.id) return;
const status = normalizeStatus(record.status);
if (status !== "pending") return;
const manualGuid = (guidInputs[record.id] || "").trim();
const conflict = record.hostname_conflict;
if (conflict && !manualGuid) {
const fallbackAlternate =
record.alternate_hostname ||
(record.hostname_claimed ? `${record.hostname_claimed}-1` : "");
setConflictPrompt({
record,
conflict,
alternate: fallbackAlternate || "",
});
return;
}
submitApproval(record);
},
[guidInputs, submitApproval]
);
const handleConflictCancel = useCallback(() => {
setConflictPrompt(null);
}, []);
const handleConflictOverwrite = useCallback(() => {
if (!conflictPrompt?.record) {
setConflictPrompt(null);
return;
}
const { record, conflict } = conflictPrompt;
setConflictPrompt(null);
const conflictGuid = conflict?.guid != null ? String(conflict.guid).trim() : "";
submitApproval(record, {
guid: conflictGuid,
conflictResolution: "overwrite",
});
}, [conflictPrompt, submitApproval]);
const handleConflictCoexist = useCallback(() => {
if (!conflictPrompt?.record) {
setConflictPrompt(null);
return;
}
const { record } = conflictPrompt;
setConflictPrompt(null);
submitApproval(record, {
conflictResolution: "coexist",
});
}, [conflictPrompt, submitApproval]);
const conflictRecord = conflictPrompt?.record;
const conflictInfo = conflictPrompt?.conflict;
const conflictHostname = conflictRecord?.hostname_claimed || conflictRecord?.hostname || "";
const conflictSiteName = conflictInfo?.site_name || "";
const conflictSiteDescriptor = conflictInfo
? conflictSiteName
? `under site ${conflictSiteName}`
: "under site (not assigned)"
: "under site (not assigned)";
const conflictAlternate =
conflictPrompt?.alternate ||
(conflictHostname ? `${conflictHostname}-1` : "hostname-1");
const conflictGuidDisplay = conflictInfo?.guid || "";
const handleDeny = useCallback(
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={() => startApprove(record)}
disabled={actioningId === record.id}
>
{actioningId === record.id ? (
<CircularProgress color="success" size={20} />
) : (
<ApproveIcon fontSize="small" />
)}
</IconButton>
</span>
</Tooltip>
<Tooltip title="Deny enrollment">
<span>
<IconButton
color="error"
onClick={() => handleDeny(record)}
disabled={actioningId === record.id}
>
<DenyIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Stack>
</Stack>
) : (
<Typography variant="body2" color="text.secondary">
No actions available
</Typography>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
<Dialog
open={Boolean(conflictPrompt)}
onClose={handleConflictCancel}
maxWidth="sm"
fullWidth
>
<DialogTitle>Hostname Conflict</DialogTitle>
<DialogContent dividers>
<Stack spacing={2}>
<DialogContentText>
{conflictHostname
? `Device ${conflictHostname} already exists in the database ${conflictSiteDescriptor}.`
: `A device with this hostname already exists in the database ${conflictSiteDescriptor}.`}
</DialogContentText>
<DialogContentText>
Do you want this device to overwrite the existing device, or allow both to co-exist?
</DialogContentText>
<DialogContentText>
{`Device will be renamed ${conflictAlternate} if you choose to allow both to co-exist.`}
</DialogContentText>
{conflictGuidDisplay ? (
<Typography variant="body2" color="text.secondary">
Existing device GUID: {conflictGuidDisplay}
</Typography>
) : null}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={handleConflictCancel}>Cancel</Button>
<Button onClick={handleConflictCoexist} color="info" variant="outlined">
Allow Both
</Button>
<Button
onClick={handleConflictOverwrite}
color="primary"
variant="contained"
disabled={!conflictGuidDisplay}
>
Overwrite Existing
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
export default React.memo(DeviceApprovals);

View File

@@ -37,12 +37,12 @@ import {
} from "@mui/icons-material";
const STATUS_OPTIONS = [
{ value: "all", label: "All" },
{ 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 = {
@@ -75,7 +75,7 @@ const normalizeStatus = (status) => {
function DeviceApprovals() {
const [approvals, setApprovals] = useState([]);
const [statusFilter, setStatusFilter] = useState("pending");
const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [feedback, setFeedback] = useState(null);