Private
Public Access
1
0

feat: add host self-enrollment workflow v0.1.7
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 5s
CI Pipeline / Clippy Lints (push) Successful in 53s
CI Pipeline / Rust Unit Tests (push) Successful in 1m11s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped

This commit is contained in:
2026-05-16 16:58:00 +00:00
parent f183c8edf8
commit da3dffd81f
17 changed files with 841 additions and 55 deletions

View File

@ -1,7 +1,7 @@
{
"name": "patch-manager-ui",
"private": true,
"version": "0.1.5",
"version": "0.1.7",
"type": "module",
"scripts": {
"dev": "vite",

View File

@ -372,3 +372,26 @@ export const usersApi = {
adminDisableMfa: (id: string) => apiClient.delete(`/users/${id}/mfa`),
disableMfa: (password: string) => apiClient.delete('/auth/mfa', { data: { password } }),
}
// ── Enrollment API (Admin) ────────────────────────────────────────────────
export interface EnrollmentRequest {
id: string
machine_id: string
fqdn: string
ip_address: string
os_details: Record<string, unknown>
polling_token: string
created_at: string
expires_at: string
}
export const enrollmentApi = {
listPending: (): Promise<EnrollmentRequest[]> =>
apiClient.get<EnrollmentRequest[]>('/admin/enrollments').then(r => r.data),
approve: (id: string): Promise<void> =>
apiClient.post(`/admin/enrollments/${id}/approve`).then(() => {}),
deny: (id: string): Promise<void> =>
apiClient.delete(`/admin/enrollments/${id}/deny`).then(() => {}),
}

View File

@ -5,11 +5,11 @@ import {
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
TablePagination, TextField, Toolbar, Tooltip, Typography,
} from '@mui/material'
import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Cancel as CancelIcon, Remove as RemoveIcon } from '@mui/icons-material'
import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Cancel as CancelIcon, Remove as RemoveIcon, Pending as PendingIcon, GppMaybe as GppMaybeIcon, CheckCircleOutline as CheckCircleOutlineIcon, WarningAmber as WarningAmberIcon } from '@mui/icons-material'
import { useNavigate } from 'react-router-dom'
import { apiClient, hostsApi } from '../api/client'
import { apiClient, hostsApi, enrollmentApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
import type { Host, HostHealthStatus } from '../types'
import type { Host, HostHealthStatus, EnrollmentRequest, EnrollmentConflictResponse } from '../types'
const statusColor = (s: HostHealthStatus) =>
s === 'healthy' ? 'success' : s === 'degraded' ? 'warning' : s === 'unreachable' ? 'error' : 'default'
@ -28,6 +28,14 @@ export default function HostsPage() {
const [deleteTarget, setDeleteTarget] = useState<Host | null>(null)
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({ open: false, message: '', severity: 'success' })
// ── Enrollment state ────────────────────────────────────────────────────
const [showPending, setShowPending] = useState(false)
const [pendingEnrollments, setPendingEnrollments] = useState<EnrollmentRequest[]>([])
const [pendingCount, setPendingCount] = useState(0)
const [denyTarget, setDenyTarget] = useState<EnrollmentRequest | null>(null)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [conflictModal, setConflictModal] = useState<{ request: EnrollmentRequest; existingHost: Host } | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
@ -39,6 +47,14 @@ export default function HostsPage() {
finally { setLoading(false) }
}, [page, rowsPerPage])
const loadPending = useCallback(async () => {
try {
const data = await enrollmentApi.listPending()
setPendingEnrollments(data)
setPendingCount(data.length)
} catch { /* handled by interceptor */ }
}, [])
const handleRefresh = async (e: React.MouseEvent, hostId: string) => {
e.stopPropagation()
setRefreshing(hostId)
@ -63,7 +79,62 @@ export default function HostsPage() {
}
}
useEffect(() => { load() }, [load])
// ── Enrollment action handlers ──────────────────────────────────────────
const handleApprove = async (req: EnrollmentRequest) => {
setActionLoading(req.id)
try {
await enrollmentApi.approve(req.id)
setSnackbar({ open: true, message: `Host "${req.fqdn}" approved`, severity: 'success' })
load(); loadPending()
} catch (err: unknown) {
const errObj = err as { response?: { status?: number; data?: EnrollmentConflictResponse }; message?: string }
const status = errObj?.response?.status
if (status === 409 && errObj.response?.data) {
const conflictData = errObj.response.data as EnrollmentConflictResponse
setConflictModal({ request: req, existingHost: conflictData.conflict.existing_host })
} else {
setSnackbar({ open: true, message: `Failed to approve "${req.fqdn}": ${errObj?.message || 'Unknown error'}`, severity: 'error' })
}
} finally {
setActionLoading(null)
}
}
const handleDeny = async () => {
if (!denyTarget) return
setActionLoading(denyTarget.id)
try {
await enrollmentApi.deny(denyTarget.id)
setSnackbar({ open: true, message: `Enrollment "${denyTarget.fqdn}" denied`, severity: 'success' })
loadPending()
} catch {
setSnackbar({ open: true, message: `Failed to deny enrollment`, severity: 'error' })
} finally {
setActionLoading(null)
setDenyTarget(null)
}
}
const handleConflictResolve = async (action: 'overwrite' | 'cancel') => {
if (!conflictModal) return
if (action === 'cancel') {
setConflictModal(null)
return
}
// For overwrite: delete the existing host first, then approve
try {
await hostsApi.delete(conflictModal.existingHost.id)
await enrollmentApi.approve(conflictModal.request.id)
setSnackbar({ open: true, message: `Overwrote existing host and approved "${conflictModal.request.fqdn}"`, severity: 'success' })
load(); loadPending()
} catch {
setSnackbar({ open: true, message: `Failed to resolve conflict`, severity: 'error' })
} finally {
setConflictModal(null)
}
}
useEffect(() => { load(); loadPending() }, [load, loadPending])
const filtered = hosts.filter(h =>
h.fqdn.toLowerCase().includes(search.toLowerCase()) ||
@ -83,9 +154,21 @@ export default function HostsPage() {
<Container maxWidth="xl" sx={{ mt: 3 }}>
<Toolbar disableGutters sx={{ mb: 2 }}>
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>Hosts</Typography>
<Tooltip title="Show pending enrollments">
<Button
variant={showPending ? "contained" : "outlined"}
color="warning"
startIcon={<PendingIcon />}
onClick={() => setShowPending(s => !s)}
sx={{ mr: 1 }}
endIcon={pendingCount > 0 ? <Chip label={pendingCount} size="small" color="warning" variant="filled" sx={{ ml: 0.5 }} /> : undefined}
>
Pending
</Button>
</Tooltip>
<TextField size="small" placeholder="Search..." value={search}
onChange={e => setSearch(e.target.value)} sx={{ mr: 2 }} />
<Tooltip title="Refresh"><IconButton onClick={load}><RefreshIcon /></IconButton></Tooltip>
<Tooltip title="Refresh"><IconButton onClick={() => { load(); loadPending() }}><RefreshIcon /></IconButton></Tooltip>
{canWrite && <Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/hosts/new')} sx={{ ml: 1 }}>Add Host</Button>}
</Toolbar>
{loading ? <Box display="flex" justifyContent="center" mt="4"><CircularProgress /></Box> : (
@ -104,53 +187,90 @@ export default function HostsPage() {
</TableRow>
</TableHead>
<TableBody>
{filtered.map(h => (
<TableRow key={h.id} hover sx={{ cursor: 'pointer' }}
onClick={() => navigate(`/hosts/${h.id}`)}>
<TableCell>{h.fqdn}</TableCell>
<TableCell>{h.display_name}</TableCell>
<TableCell>{h.ip_address}</TableCell>
<TableCell>{h.os_name ?? h.os_family ?? '—'}</TableCell>
<TableCell>
<Chip size="small" label={h.health_status} color={statusColor(h.health_status)} />
</TableCell>
<TableCell>
{h.health_check_status === 'all_healthy' ? (
<Tooltip title="All checks healthy"><CheckCircleIcon color="success" fontSize="small" /></Tooltip>
) : h.health_check_status === 'some_unhealthy' ? (
<Tooltip title="Some checks unhealthy"><CancelIcon color="error" fontSize="small" /></Tooltip>
) : (
<Tooltip title="No checks configured"><RemoveIcon color="disabled" fontSize="small" /></Tooltip>
)}
</TableCell>
<TableCell>{h.agent_version ?? '—'}</TableCell>
{canWrite && <TableCell onClick={e => e.stopPropagation()}>
<Tooltip title="Request refresh">
<IconButton size="small" color="primary"
disabled={refreshing === h.id}
onClick={(e) => handleRefresh(e, h.id)}>
{refreshing === h.id
? <CircularProgress size={16} />
: <RefreshIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Tooltip title="Delete"><IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); setDeleteTarget(h) }}>
<DeleteIcon fontSize="small" />
</IconButton></Tooltip>
</TableCell>}
</TableRow>
))}
{showPending ? (
pendingEnrollments.map(req => (
<TableRow key={req.id} hover sx={{ backgroundColor: '#fff8e1' }}>
<TableCell>
<Box display="flex" alignItems="center" gap={1}>
<GppMaybeIcon color="warning" fontSize="small" />
{req.fqdn}
</Box>
</TableCell>
<TableCell>{req.fqdn}</TableCell>
<TableCell>{req.ip_address}</TableCell>
<TableCell>{(req.os_details['name'] as string) ?? 'Unknown'}</TableCell>
<TableCell><Chip size="small" label="pending" color="warning" /></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
{canWrite && <TableCell onClick={e => e.stopPropagation()}>
<Tooltip title="Approve">
<IconButton size="small" color="success"
disabled={actionLoading === req.id}
onClick={(e) => { e.stopPropagation(); handleApprove(req) }}>
{actionLoading === req.id ? <CircularProgress size={16} /> : <CheckCircleOutlineIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Tooltip title="Deny">
<IconButton size="small" color="error"
disabled={actionLoading === req.id}
onClick={(e) => { e.stopPropagation(); setDenyTarget(req) }}>
<CancelIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>}
</TableRow>
))
) : (
filtered.map(h => (
<TableRow key={h.id} hover sx={{ cursor: 'pointer' }}
onClick={() => navigate(`/hosts/${h.id}`)}>
<TableCell>{h.fqdn}</TableCell>
<TableCell>{h.display_name}</TableCell>
<TableCell>{h.ip_address}</TableCell>
<TableCell>{h.os_name ?? h.os_family ?? '—'}</TableCell>
<TableCell>
<Chip size="small" label={h.health_status} color={statusColor(h.health_status)} />
</TableCell>
<TableCell>
{h.health_check_status === 'all_healthy' ? (
<Tooltip title="All checks healthy"><CheckCircleIcon color="success" fontSize="small" /></Tooltip>
) : h.health_check_status === 'some_unhealthy' ? (
<Tooltip title="Some checks unhealthy"><CancelIcon color="error" fontSize="small" /></Tooltip>
) : (
<Tooltip title="No checks configured"><RemoveIcon color="disabled" fontSize="small" /></Tooltip>
)}
</TableCell>
<TableCell>{h.agent_version ?? '—'}</TableCell>
{canWrite && <TableCell onClick={e => e.stopPropagation()}>
<Tooltip title="Request refresh">
<IconButton size="small" color="primary"
disabled={refreshing === h.id}
onClick={(e) => handleRefresh(e, h.id)}>
{refreshing === h.id
? <CircularProgress size={16} />
: <RefreshIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Tooltip title="Delete"><IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); setDeleteTarget(h) }}>
<DeleteIcon fontSize="small" />
</IconButton></Tooltip>
</TableCell>}
</TableRow>
))
)}
</TableBody>
</Table>
<TablePagination
component="div"
count={total}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleChangeRowsPerPage}
rowsPerPageOptions={[10, 25, 50, 100]}
/>
{!showPending && (
<TablePagination
component="div"
count={total}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleChangeRowsPerPage}
rowsPerPageOptions={[10, 25, 50, 100]}
/>
)}
</TableContainer>
)}
@ -164,6 +284,52 @@ export default function HostsPage() {
<Button onClick={handleDelete} color="error" variant="contained">Delete</Button>
</DialogActions>
</Dialog>
{/* ── Deny Confirmation Dialog ─────────────────────────────────── */}
<Dialog open={denyTarget !== null} onClose={() => setDenyTarget(null)}>
<DialogTitle>Confirm Deny</DialogTitle>
<DialogContent>
Are you sure you want to deny the enrollment for &ldquo;{denyTarget?.fqdn}&rdquo;? This action cannot be undone.
</DialogContent>
<DialogActions>
<Button onClick={() => setDenyTarget(null)}>Cancel</Button>
<Button onClick={handleDeny} color="error" variant="contained" disabled={actionLoading === denyTarget?.id}>
{actionLoading === denyTarget?.id ? <CircularProgress size={20} /> : 'Deny'}
</Button>
</DialogActions>
</Dialog>
{/* ── Conflict Modal ───────────────────────────────────────────── */}
<Dialog open={conflictModal !== null} onClose={() => setConflictModal(null)}>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<WarningAmberIcon color="warning" /> Host Collision Detected
</DialogTitle>
<DialogContent dividers>
<Typography variant="body2" color="text.secondary" gutterBottom>
Approving &ldquo;{conflictModal?.request.fqdn}&rdquo; conflicts with an existing host:
</Typography>
<Paper variant="outlined" sx={{ p: 2, mt: 1, mb: 2 }}>
<Typography variant="subtitle2">Existing Host</Typography>
<Typography>FQDN: {conflictModal?.existingHost.fqdn}</Typography>
<Typography>IP: {conflictModal?.existingHost.ip_address}</Typography>
<Typography>ID: {conflictModal?.existingHost.id}</Typography>
</Paper>
<Typography variant="body2" color="text.secondary">
Options:
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => handleConflictResolve('cancel')}>Cancel</Button>
<Button
onClick={() => handleConflictResolve('overwrite')}
color="error"
variant="contained"
>
Overwrite Existing Host
</Button>
</DialogActions>
</Dialog>
<Snackbar open={snackbar.open} autoHideDuration={4000} onClose={() => setSnackbar(s => ({ ...s, open: false }))}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
<Alert severity={snackbar.severity} onClose={() => setSnackbar(s => ({ ...s, open: false }))}

View File

@ -376,3 +376,23 @@ export interface UpdateHealthCheckRequest {
basic_auth_pass?: string
target_host_id?: string | null
}
// ── Enrollment (Self-Enrollment) ─────────────────────────────────────────
export interface EnrollmentRequest {
id: string
machine_id: string
fqdn: string
ip_address: string
os_details: Record<string, unknown>
polling_token: string // hashed token stored in DB
created_at: string
expires_at: string
}
export interface EnrollmentConflictResponse {
error: string
conflict: {
existing_host: Host
message: string
}
}