diff --git a/Data/Server/Modules/admin/routes.py b/Data/Server/Modules/admin/routes.py index 7268e62..7f44827 100644 --- a/Data/Server/Modules/admin/routes.py +++ b/Data/Server/Modules/admin/routes.py @@ -8,6 +8,8 @@ from typing import Any, Callable, Dict, List, Optional from flask import Blueprint, jsonify, request +from Modules.guid_utils import normalize_guid + VALID_TTL_HOURS = {1, 3, 6, 12, 24} @@ -40,6 +42,72 @@ def register( return str(row[0]) 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 def _check_admin(): result = require_admin() @@ -177,6 +245,7 @@ def register( @blueprint.route("/api/admin/device-approvals", methods=["GET"]) def list_device_approvals(): status = request.args.get("status", "pending") + approvals: List[Dict[str, Any]] = [] conn = db_conn_factory() try: cur = conn.cursor() @@ -203,30 +272,42 @@ def register( sql += " ORDER BY created_at ASC" cur.execute(sql, params) rows = cur.fetchall() + for row in rows: + record_guid = row[2] + hostname = row[3] + conflict = _hostname_conflict(cur, hostname, record_guid) + approvals.append( + { + "id": row[0], + "approval_reference": row[1], + "guid": record_guid, + "hostname_claimed": hostname, + "ssl_key_fingerprint_claimed": row[4], + "enrollment_code_id": row[5], + "status": row[6], + "client_nonce": row[7], + "server_nonce": row[8], + "created_at": row[9], + "updated_at": row[10], + "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() - approvals = [] - for row in rows: - approvals.append( - { - "id": row[0], - "approval_reference": row[1], - "guid": row[2], - "hostname_claimed": row[3], - "ssl_key_fingerprint_claimed": row[4], - "enrollment_code_id": row[5], - "status": row[6], - "client_nonce": row[7], - "server_nonce": row[8], - "created_at": row[9], - "updated_at": row[10], - "approved_by_user_id": row[11], - } - ) 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 {} username = user.get("username") or "" @@ -268,8 +349,12 @@ def register( conn.commit() finally: conn.close() - log("server", f"device approval {approval_id} -> {status} by {username}") - return {"status": status}, 200 + resolution_note = f" ({resolution})" if resolution else "" + 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//approve", methods=["POST"]) def approve_device(approval_id: str): @@ -277,7 +362,18 @@ def register( guid = payload.get("guid") if guid: 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 @blueprint.route("/api/admin/device-approvals//deny", methods=["POST"]) diff --git a/Data/Server/WebUI/src/Admin/Device_Approvals.jsx b/Data/Server/WebUI/src/Admin/Device_Approvals.jsx index f77dd4c..6ae916a 100644 --- a/Data/Server/WebUI/src/Admin/Device_Approvals.jsx +++ b/Data/Server/WebUI/src/Admin/Device_Approvals.jsx @@ -7,6 +7,11 @@ import { Button, Chip, CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, FormControl, IconButton, InputLabel, @@ -76,6 +81,7 @@ function DeviceApprovals() { 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); @@ -126,25 +132,49 @@ function DeviceApprovals() { setGuidInputs((prev) => ({ ...prev, [id]: value })); }, []); - const handleApprove = useCallback( - async (record) => { + const submitApproval = useCallback( + async (record, overrides = {}) => { if (!record?.id) return; setActioningId(record.id); setFeedback(null); setError(""); 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`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(guid ? { guid } : {}), + body: JSON.stringify(Object.keys(payload).length ? payload : {}), }); + const body = await resp.json().catch(() => ({})); if (!resp.ok) { - const body = await resp.json().catch(() => ({})); 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(); } catch (err) { setFeedback({ type: "error", message: err.message || "Unable to approve request" }); @@ -155,6 +185,73 @@ function DeviceApprovals() { [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; @@ -303,7 +400,7 @@ function DeviceApprovals() { handleApprove(record)} + onClick={() => startApprove(record)} disabled={actioningId === record.id} > {actioningId === record.id ? ( @@ -341,6 +438,48 @@ function DeviceApprovals() { + + Hostname Conflict + + + + {conflictHostname + ? `Device ${conflictHostname} already exists in the database ${conflictSiteDescriptor}.` + : `A device with this hostname already exists in the database ${conflictSiteDescriptor}.`} + + + Do you want this device to overwrite the existing device, or allow both to co-exist? + + + {`Device will be renamed ${conflictAlternate} if you choose to allow both to co-exist.`} + + {conflictGuidDisplay ? ( + + Existing device GUID: {conflictGuidDisplay} + + ) : null} + + + + + + + + ); } diff --git a/Data/Server/WebUI/src/App.jsx b/Data/Server/WebUI/src/App.jsx index a4ab9c1..071c5a8 100644 --- a/Data/Server/WebUI/src/App.jsx +++ b/Data/Server/WebUI/src/App.jsx @@ -46,8 +46,8 @@ import CredentialList from "./Access_Management/Credential_List.jsx"; import UserManagement from "./Access_Management/Users.jsx"; import GithubAPIToken from "./Access_Management/Github_API_Token.jsx"; import ServerInfo from "./Admin/Server_Info.jsx"; -import EnrollmentCodes from "./Admin/Enrollment_Codes.jsx"; -import DeviceApprovals from "./Admin/Device_Approvals.jsx"; +import EnrollmentCodes from "./Devices/Enrollment_Codes.jsx"; +import DeviceApprovals from "./Devices/Device_Approvals.jsx"; // Networking Imports import { io } from "socket.io-client"; diff --git a/Data/Server/WebUI/src/Devices/Device_Approvals.jsx b/Data/Server/WebUI/src/Devices/Device_Approvals.jsx new file mode 100644 index 0000000..6ae916a --- /dev/null +++ b/Data/Server/WebUI/src/Devices/Device_Approvals.jsx @@ -0,0 +1,487 @@ +////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: /Data/Server/WebUI/src/Admin/Device_Approvals.jsx + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + 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 ( + + + + Device Approval Queue + + + + + + Status + + + + + + + {feedback ? ( + setFeedback(null)}> + {feedback.message} + + ) : null} + + {error ? ( + + {error} + + ) : null} + + + + + + Status + Hostname + Fingerprint + Enrollment Code + Created + Updated + Approved By + Actions + + + + {loading ? ( + + + + + Loading approvals… + + + + ) : dedupedApprovals.length === 0 ? ( + + + + No enrollment requests match this filter. + + + + ) : ( + dedupedApprovals.map((record) => { + const status = normalizeStatus(record.status); + const showActions = status === "pending"; + const guidValue = guidInputs[record.id] || ""; + return ( + + + + + {record.hostname_claimed || "—"} + + {formatFingerprint(record.ssl_key_fingerprint_claimed)} + + + {record.enrollment_code_id || "—"} + + {formatDateTime(record.created_at)} + {formatDateTime(record.updated_at)} + {record.approved_by_user_id || "—"} + + {showActions ? ( + + handleGuidChange(record.id, event.target.value)} + sx={{ minWidth: 200 }} + /> + + + + startApprove(record)} + disabled={actioningId === record.id} + > + {actioningId === record.id ? ( + + ) : ( + + )} + + + + + + handleDeny(record)} + disabled={actioningId === record.id} + > + + + + + + + ) : ( + + No actions available + + )} + + + ); + }) + )} + +
+
+
+ + Hostname Conflict + + + + {conflictHostname + ? `Device ${conflictHostname} already exists in the database ${conflictSiteDescriptor}.` + : `A device with this hostname already exists in the database ${conflictSiteDescriptor}.`} + + + Do you want this device to overwrite the existing device, or allow both to co-exist? + + + {`Device will be renamed ${conflictAlternate} if you choose to allow both to co-exist.`} + + {conflictGuidDisplay ? ( + + Existing device GUID: {conflictGuidDisplay} + + ) : null} + + + + + + + + +
+ ); +} + +export default React.memo(DeviceApprovals); diff --git a/Data/Server/WebUI/src/Admin/Enrollment_Codes.jsx b/Data/Server/WebUI/src/Devices/Enrollment_Codes.jsx similarity index 100% rename from Data/Server/WebUI/src/Admin/Enrollment_Codes.jsx rename to Data/Server/WebUI/src/Devices/Enrollment_Codes.jsx