mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 21:41:57 -06:00
Added Device Conflict Resolution
This commit is contained in:
@@ -8,6 +8,8 @@ from typing import Any, Callable, Dict, List, Optional
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from Modules.guid_utils import normalize_guid
|
||||||
|
|
||||||
|
|
||||||
VALID_TTL_HOURS = {1, 3, 6, 12, 24}
|
VALID_TTL_HOURS = {1, 3, 6, 12, 24}
|
||||||
|
|
||||||
@@ -40,6 +42,72 @@ def register(
|
|||||||
return str(row[0])
|
return str(row[0])
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _hostname_conflict(
|
||||||
|
cur: sqlite3.Cursor,
|
||||||
|
hostname: Optional[str],
|
||||||
|
pending_guid: Optional[str],
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
if not hostname:
|
||||||
|
return None
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT d.guid, ds.site_id, s.name
|
||||||
|
FROM devices d
|
||||||
|
LEFT JOIN device_sites ds ON ds.device_hostname = d.hostname
|
||||||
|
LEFT JOIN sites s ON s.id = ds.site_id
|
||||||
|
WHERE d.hostname = ?
|
||||||
|
""",
|
||||||
|
(hostname,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
existing_guid = normalize_guid(row[0])
|
||||||
|
pending_norm = normalize_guid(pending_guid)
|
||||||
|
if existing_guid and pending_norm and existing_guid == pending_norm:
|
||||||
|
return None
|
||||||
|
site_id_raw = row[1]
|
||||||
|
site_id = None
|
||||||
|
if site_id_raw is not None:
|
||||||
|
try:
|
||||||
|
site_id = int(site_id_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
site_id = None
|
||||||
|
site_name = row[2] or ""
|
||||||
|
return {
|
||||||
|
"guid": existing_guid or None,
|
||||||
|
"site_id": site_id,
|
||||||
|
"site_name": site_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _suggest_alternate_hostname(
|
||||||
|
cur: sqlite3.Cursor,
|
||||||
|
hostname: Optional[str],
|
||||||
|
pending_guid: Optional[str],
|
||||||
|
) -> Optional[str]:
|
||||||
|
base = (hostname or "").strip()
|
||||||
|
if not base:
|
||||||
|
return None
|
||||||
|
base = base[:253]
|
||||||
|
candidate = base
|
||||||
|
pending_norm = normalize_guid(pending_guid)
|
||||||
|
suffix = 1
|
||||||
|
while True:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT guid FROM devices WHERE hostname = ?",
|
||||||
|
(candidate,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return candidate
|
||||||
|
existing_guid = normalize_guid(row[0])
|
||||||
|
if pending_norm and existing_guid == pending_norm:
|
||||||
|
return candidate
|
||||||
|
candidate = f"{base}-{suffix}"
|
||||||
|
suffix += 1
|
||||||
|
if suffix > 50:
|
||||||
|
return pending_norm or candidate
|
||||||
|
|
||||||
@blueprint.before_request
|
@blueprint.before_request
|
||||||
def _check_admin():
|
def _check_admin():
|
||||||
result = require_admin()
|
result = require_admin()
|
||||||
@@ -177,6 +245,7 @@ def register(
|
|||||||
@blueprint.route("/api/admin/device-approvals", methods=["GET"])
|
@blueprint.route("/api/admin/device-approvals", methods=["GET"])
|
||||||
def list_device_approvals():
|
def list_device_approvals():
|
||||||
status = request.args.get("status", "pending")
|
status = request.args.get("status", "pending")
|
||||||
|
approvals: List[Dict[str, Any]] = []
|
||||||
conn = db_conn_factory()
|
conn = db_conn_factory()
|
||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
@@ -203,17 +272,16 @@ def register(
|
|||||||
sql += " ORDER BY created_at ASC"
|
sql += " ORDER BY created_at ASC"
|
||||||
cur.execute(sql, params)
|
cur.execute(sql, params)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
approvals = []
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
record_guid = row[2]
|
||||||
|
hostname = row[3]
|
||||||
|
conflict = _hostname_conflict(cur, hostname, record_guid)
|
||||||
approvals.append(
|
approvals.append(
|
||||||
{
|
{
|
||||||
"id": row[0],
|
"id": row[0],
|
||||||
"approval_reference": row[1],
|
"approval_reference": row[1],
|
||||||
"guid": row[2],
|
"guid": record_guid,
|
||||||
"hostname_claimed": row[3],
|
"hostname_claimed": hostname,
|
||||||
"ssl_key_fingerprint_claimed": row[4],
|
"ssl_key_fingerprint_claimed": row[4],
|
||||||
"enrollment_code_id": row[5],
|
"enrollment_code_id": row[5],
|
||||||
"status": row[6],
|
"status": row[6],
|
||||||
@@ -222,11 +290,24 @@ def register(
|
|||||||
"created_at": row[9],
|
"created_at": row[9],
|
||||||
"updated_at": row[10],
|
"updated_at": row[10],
|
||||||
"approved_by_user_id": row[11],
|
"approved_by_user_id": row[11],
|
||||||
|
"hostname_conflict": conflict,
|
||||||
|
"alternate_hostname": _suggest_alternate_hostname(cur, hostname, record_guid)
|
||||||
|
if conflict
|
||||||
|
else None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
return jsonify({"approvals": approvals})
|
return jsonify({"approvals": approvals})
|
||||||
|
|
||||||
def _set_approval_status(approval_id: str, status: str, guid: Optional[str] = None):
|
def _set_approval_status(
|
||||||
|
approval_id: str,
|
||||||
|
status: str,
|
||||||
|
*,
|
||||||
|
guid: Optional[str] = None,
|
||||||
|
resolution: Optional[str] = None,
|
||||||
|
):
|
||||||
user = current_user() or {}
|
user = current_user() or {}
|
||||||
username = user.get("username") or ""
|
username = user.get("username") or ""
|
||||||
|
|
||||||
@@ -268,8 +349,12 @@ def register(
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
log("server", f"device approval {approval_id} -> {status} by {username}")
|
resolution_note = f" ({resolution})" if resolution else ""
|
||||||
return {"status": status}, 200
|
log("server", f"device approval {approval_id} -> {status}{resolution_note} by {username}")
|
||||||
|
payload: Dict[str, Any] = {"status": status}
|
||||||
|
if resolution:
|
||||||
|
payload["conflict_resolution"] = resolution
|
||||||
|
return payload, 200
|
||||||
|
|
||||||
@blueprint.route("/api/admin/device-approvals/<approval_id>/approve", methods=["POST"])
|
@blueprint.route("/api/admin/device-approvals/<approval_id>/approve", methods=["POST"])
|
||||||
def approve_device(approval_id: str):
|
def approve_device(approval_id: str):
|
||||||
@@ -277,7 +362,18 @@ def register(
|
|||||||
guid = payload.get("guid")
|
guid = payload.get("guid")
|
||||||
if guid:
|
if guid:
|
||||||
guid = str(guid).strip()
|
guid = str(guid).strip()
|
||||||
result, status_code = _set_approval_status(approval_id, "approved", guid=guid)
|
resolution_val = payload.get("conflict_resolution")
|
||||||
|
resolution = None
|
||||||
|
if isinstance(resolution_val, str):
|
||||||
|
cleaned = resolution_val.strip().lower()
|
||||||
|
if cleaned:
|
||||||
|
resolution = cleaned
|
||||||
|
result, status_code = _set_approval_status(
|
||||||
|
approval_id,
|
||||||
|
"approved",
|
||||||
|
guid=guid,
|
||||||
|
resolution=resolution,
|
||||||
|
)
|
||||||
return jsonify(result), status_code
|
return jsonify(result), status_code
|
||||||
|
|
||||||
@blueprint.route("/api/admin/device-approvals/<approval_id>/deny", methods=["POST"])
|
@blueprint.route("/api/admin/device-approvals/<approval_id>/deny", methods=["POST"])
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
FormControl,
|
FormControl,
|
||||||
IconButton,
|
IconButton,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
@@ -76,6 +81,7 @@ function DeviceApprovals() {
|
|||||||
const [feedback, setFeedback] = useState(null);
|
const [feedback, setFeedback] = useState(null);
|
||||||
const [guidInputs, setGuidInputs] = useState({});
|
const [guidInputs, setGuidInputs] = useState({});
|
||||||
const [actioningId, setActioningId] = useState(null);
|
const [actioningId, setActioningId] = useState(null);
|
||||||
|
const [conflictPrompt, setConflictPrompt] = useState(null);
|
||||||
|
|
||||||
const loadApprovals = useCallback(async () => {
|
const loadApprovals = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -126,25 +132,49 @@ function DeviceApprovals() {
|
|||||||
setGuidInputs((prev) => ({ ...prev, [id]: value }));
|
setGuidInputs((prev) => ({ ...prev, [id]: value }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleApprove = useCallback(
|
const submitApproval = useCallback(
|
||||||
async (record) => {
|
async (record, overrides = {}) => {
|
||||||
if (!record?.id) return;
|
if (!record?.id) return;
|
||||||
setActioningId(record.id);
|
setActioningId(record.id);
|
||||||
setFeedback(null);
|
setFeedback(null);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const guid = (guidInputs[record.id] || "").trim();
|
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`, {
|
const resp = await fetch(`/api/admin/device-approvals/${encodeURIComponent(record.id)}/approve`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(guid ? { guid } : {}),
|
body: JSON.stringify(Object.keys(payload).length ? payload : {}),
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
|
||||||
const body = await resp.json().catch(() => ({}));
|
const body = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok) {
|
||||||
throw new Error(body.error || `Approval failed (${resp.status})`);
|
throw new Error(body.error || `Approval failed (${resp.status})`);
|
||||||
}
|
}
|
||||||
setFeedback({ type: "success", message: "Enrollment approved" });
|
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();
|
await loadApprovals();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setFeedback({ type: "error", message: err.message || "Unable to approve request" });
|
setFeedback({ type: "error", message: err.message || "Unable to approve request" });
|
||||||
@@ -155,6 +185,73 @@ function DeviceApprovals() {
|
|||||||
[guidInputs, loadApprovals]
|
[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(
|
const handleDeny = useCallback(
|
||||||
async (record) => {
|
async (record) => {
|
||||||
if (!record?.id) return;
|
if (!record?.id) return;
|
||||||
@@ -303,7 +400,7 @@ function DeviceApprovals() {
|
|||||||
<span>
|
<span>
|
||||||
<IconButton
|
<IconButton
|
||||||
color="success"
|
color="success"
|
||||||
onClick={() => handleApprove(record)}
|
onClick={() => startApprove(record)}
|
||||||
disabled={actioningId === record.id}
|
disabled={actioningId === record.id}
|
||||||
>
|
>
|
||||||
{actioningId === record.id ? (
|
{actioningId === record.id ? (
|
||||||
@@ -341,6 +438,48 @@ function DeviceApprovals() {
|
|||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
</Paper>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,8 +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 EnrollmentCodes from "./Devices/Enrollment_Codes.jsx";
|
||||||
import DeviceApprovals from "./Admin/Device_Approvals.jsx";
|
import DeviceApprovals from "./Devices/Device_Approvals.jsx";
|
||||||
|
|
||||||
// Networking Imports
|
// Networking Imports
|
||||||
import { io } from "socket.io-client";
|
import { io } from "socket.io-client";
|
||||||
|
|||||||
487
Data/Server/WebUI/src/Devices/Device_Approvals.jsx
Normal file
487
Data/Server/WebUI/src/Devices/Device_Approvals.jsx
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
////////// 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);
|
||||||
Reference in New Issue
Block a user