Files
shopdb-flask/plugins/endpoints/frontend/views/EnrollmentSettings.vue
cproudlock 1c2ba677c3 Let key-less PCs ask to enroll, and let an admin retry a trust-blocked PC
Found in the Windows VM re-test.

- A PC installed without the shared key got 401 from POST
  /api/endpoints/enroll while the shared key was allowed, so no request was
  recorded and no admin could approve it. Enroll now never refuses: a valid
  shared key only decides auto-issue on a clean match; no key or an invalid
  key creates or updates the one pending request for that hostname and
  serial. An approved request still issues only to its own nonce.
- A PC blocked with "server certificate not trusted by this PC" could never be
  queued again after trust was fixed, because the blocker comes from its last
  failed upgrade. Naming the PC in a wave now queues it when trust is its only
  blocker, and the response lists it under "retried"; "next n" still skips
  it. The Migration table lets such a PC be selected.
- counts.pending on GET /enrollment counts pending requests, including ones
  for unknown hostnames; counts.pendingcomputers keeps the per-computer figure.
2026-09-15 16:07:06 -04:00

1117 lines
36 KiB
Vue

<template>
<div>
<div class="page-header">
<h2>Enrollment</h2>
</div>
<p class="setting-description">
Each endpoint agent authenticates with its own credential, readable only
by SYSTEM on that PC. Agents from {{ target }} onward enroll themselves
with the shared key they already hold; older agents move over by
upgrading. Use Migration to move the fleet, and Credentials to approve
requests, un-enroll a PC, and retire the shared key.
</p>
<div class="enroll-tabs">
<button
type="button"
class="enroll-tab"
:class="{ active: tab === 'migration' }"
@click="tab = 'migration'"
>
Migration
</button>
<button
type="button"
class="enroll-tab"
:class="{ active: tab === 'credentials' }"
@click="tab = 'credentials'"
>
Credentials
</button>
</div>
<!-- ==================================================================
Migration
=============================================================== -->
<section v-if="tab === 'migration'" class="migration-tab">
<div v-if="migrationError" class="load-error">{{ migrationError }}</div>
<div class="enroll-tiles">
<button
v-for="tile in migrationTiles"
:key="tile.state"
type="button"
class="enroll-tile"
:class="[tile.tone, { on: migrationState === tile.state }]"
@click="toggleMigrationState(tile.state)"
>
<span class="n" :data-count="tile.state">{{ migrationCounts[tile.state] || 0 }}</span>
<span class="l">{{ tile.label }}</span>
</button>
</div>
<div class="progress-row">
<div class="progress-track">
<div class="progress-fill" :style="{ width: enrolledPercent + '%' }"></div>
</div>
<span class="progress-text">
{{ migrationCounts.enrolled || 0 }} of {{ migrationTotal }} enrolled
</span>
</div>
<div class="filters">
<input
v-model="migrationSearch"
type="text"
class="form-control"
placeholder="Search hostname..."
@input="debouncedMigrationSearch"
/>
<select v-model="migrationState" class="form-control enroll-filter" @change="reloadMigration">
<option value="">All states</option>
<option v-for="tile in migrationTiles" :key="tile.state" :value="tile.state">
{{ tile.label }}
</option>
</select>
</div>
<div v-if="isadmin" class="bulk-bar">
<span class="bulk-count">{{ selected.length }} selected</span>
<button
type="button"
class="btn btn-primary btn-sm upgrade-selected"
:disabled="busy || !selected.length"
@click="upgradeSelected"
>
Upgrade selected
</button>
<button
type="button"
class="btn btn-secondary btn-sm upgrade-next"
:disabled="busy || !(migrationCounts.ready > 0)"
@click="upgradeNext"
>
Upgrade next {{ waveSize }}
</button>
</div>
<div class="card">
<div v-if="migrationLoading" class="loading">Loading...</div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th v-if="isadmin" class="select-cell">
<input
type="checkbox"
:checked="allReadySelected"
:disabled="!readyRows.length"
title="Select every ready PC on this page"
@change="toggleAllReady"
/>
</th>
<th>Hostname</th>
<th>State</th>
<th>Agent version</th>
<th>Last reported</th>
<th>Blockers</th>
</tr>
</thead>
<tbody>
<tr v-if="!migrationRows.length">
<td :colspan="isadmin ? 6 : 5" class="empty-cell">No computers match.</td>
</tr>
<tr v-for="row in migrationRows" :key="row.assetid" class="migration-row">
<td v-if="isadmin" class="select-cell">
<input
v-if="isSelectable(row)"
v-model="selected"
type="checkbox"
:value="row.assetid"
/>
</td>
<td>{{ row.hostname || '-' }}</td>
<td><span class="state-chip" :class="row.state">{{ stateLabel(row.state) }}</span></td>
<td>{{ row.agentversion || '-' }}</td>
<td>{{ formatDate(row.lastreportedat) }}</td>
<td class="blockers">
{{ (row.blockers || []).join(', ') || '-' }}
<span v-if="isadmin && isTrustRetryable(row)" class="retry-hint">
Retry after fixing trust
</span>
</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar
:page="migrationPage"
:total-pages="migrationTotalPages"
@update:page="goToMigrationPage"
/>
</div>
<h3 class="section-title">Waves</h3>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th>Wave</th>
<th>Created</th>
<th>By</th>
<th>Size</th>
<th>Queued</th>
<th>Enrolled</th>
<th>Failed</th>
<th>Progress</th>
</tr>
</thead>
<tbody>
<tr v-if="!waves.length">
<td colspan="8" class="empty-cell">No waves yet.</td>
</tr>
<tr v-for="wave in waves" :key="wave.waveid">
<td>{{ wave.waveid }}</td>
<td>{{ formatDate(wave.createdat) }}</td>
<td>{{ wave.createdby || '-' }}</td>
<td>{{ wave.size }}</td>
<td>{{ wave.queued }}</td>
<td>{{ wave.enrolled }}</td>
<td>{{ wave.failed }}</td>
<td>
<div class="progress-track small">
<div class="progress-fill" :style="{ width: wavePercent(wave) + '%' }"></div>
</div>
<span class="field-hint">{{ wave.done ? 'Done' : 'In progress' }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ==================================================================
Credentials
=============================================================== -->
<section v-else class="credentials-tab">
<div v-if="enrollmentError" class="load-error">{{ enrollmentError }}</div>
<div class="card form-card">
<h3>Settings</h3>
<label class="checkbox-row">
<input
type="checkbox"
class="toggle-sharedkey"
:checked="enrollmentSettings.sharedkeyallowed"
:disabled="!isadmin || busy"
@change="onSharedKeyToggle($event)"
/>
<span>
<strong>Shared key allowed.</strong> PCs that have not enrolled may
still report with the fleet shared key. An enrolled PC is always
refused the shared key. Turn this off once every PC is enrolled.
</span>
</label>
<label class="checkbox-row">
<input
type="checkbox"
class="toggle-autoenroll"
:checked="enrollmentSettings.autoenroll"
:disabled="!isadmin || busy"
@change="onAutoEnrollToggle($event)"
/>
<span>
<strong>Auto-enroll.</strong> Issue a credential without approval
when the hostname matches exactly one computer and the serial number
on record matches. Anything else waits below for an admin.
</span>
</label>
</div>
<div class="enroll-tiles">
<div class="enroll-tile static ok">
<span class="n" data-count="enrolled">{{ enrollmentCounts.enrolled || 0 }}</span>
<span class="l">Enrolled</span>
</div>
<div class="enroll-tile static warn">
<span class="n" data-count="notenrolled">{{ enrollmentCounts.notenrolled || 0 }}</span>
<span class="l">Not enrolled</span>
</div>
<div class="enroll-tile static">
<span class="n" data-count="pending">{{ enrollmentCounts.pending || 0 }}</span>
<span class="l">Pending requests</span>
</div>
</div>
<h3 class="section-title">Requests</h3>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th>Hostname</th>
<th>Serial</th>
<th>Device id</th>
<th>Reason</th>
<th>Status</th>
<th>Source</th>
<th>Last seen</th>
<th v-if="isadmin"></th>
</tr>
</thead>
<tbody>
<tr v-if="!requests.length">
<td :colspan="isadmin ? 8 : 7" class="empty-cell">No enrollment requests.</td>
</tr>
<template v-for="request in requests" :key="request.requestid">
<tr class="request-row">
<td>{{ request.hostname }}</td>
<td>{{ request.serialnumber || '-' }}</td>
<td>{{ request.deviceid || '-' }}</td>
<td>{{ request.reason || '-' }}</td>
<td>{{ request.status }}</td>
<td>{{ request.sourceip || '-' }}</td>
<td>{{ formatDate(request.lastseenat) }}</td>
<td v-if="isadmin" class="action-cell">
<template v-if="request.status === 'pending'">
<button
type="button"
class="btn btn-primary btn-sm approve-open"
:disabled="busy"
@click="openApprove(request)"
>
Approve
</button>
<button
type="button"
class="btn btn-secondary btn-sm reject"
:disabled="busy"
@click="reject(request)"
>
Reject
</button>
</template>
</td>
</tr>
<tr v-if="approving && approving.requestid === request.requestid" class="approve-row">
<td :colspan="isadmin ? 8 : 7">
<div class="approve-form">
<label class="field-label">Issue the credential for computer</label>
<input
v-model="approving.search"
type="text"
class="form-control asset-search"
placeholder="Search hostname or serial..."
@input="debouncedAssetSearch"
/>
<select v-model="approving.assetid" class="form-control asset-picker">
<option :value="null">Choose a computer</option>
<option
v-for="option in approving.options"
:key="option.assetid"
:value="option.assetid"
>
{{ option.label }}
</option>
</select>
<button
type="button"
class="btn btn-primary btn-sm approve-confirm"
:disabled="busy || !approving.assetid"
@click="approve"
>
Approve
</button>
<button type="button" class="btn btn-secondary btn-sm" @click="approving = null">
Cancel
</button>
</div>
<p class="field-hint">
The PC receives its credential the next time it asks,
which is within 15 minutes. Only the PC that made this
request can collect it.
</p>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<h3 class="section-title">Computers</h3>
<div class="filters">
<input
v-model="enrollmentSearch"
type="text"
class="form-control"
placeholder="Search hostname..."
@input="debouncedEnrollmentSearch"
/>
<select v-model="enrollmentState" class="form-control enroll-filter" @change="reloadEnrollment">
<option value="">All states</option>
<option value="enrolled">Enrolled</option>
<option value="notenrolled">Not enrolled</option>
<option value="pending">Pending</option>
<option value="revoked">Revoked</option>
</select>
</div>
<div class="card">
<div v-if="enrollmentLoading" class="loading">Loading...</div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>Hostname</th>
<th>Serial</th>
<th>State</th>
<th>Credential</th>
<th>Issued</th>
<th>Last used</th>
<th>Via</th>
<th v-if="isadmin"></th>
</tr>
</thead>
<tbody>
<tr v-if="!credentialRows.length">
<td :colspan="isadmin ? 8 : 7" class="empty-cell">No computers match.</td>
</tr>
<template v-for="row in credentialRows" :key="row.assetid">
<tr class="credential-row">
<td>{{ row.hostname || '-' }}</td>
<td>{{ row.serialnumber || '-' }}</td>
<td>
<span class="state-chip" :class="row.state">{{ stateLabel(row.state) }}</span>
<span
v-if="row.reenrollblocked"
class="state-chip blocked block-chip"
:title="row.reenrollblockedreason || ''"
>Re-enroll blocked</span>
</td>
<td>
<code v-if="row.credentialprefix" class="mono">{{ row.credentialprefix }}...</code>
<span v-else>-</span>
</td>
<td>{{ formatDate(row.issuedat) }}</td>
<td>{{ formatDate(row.lastusedat) }}</td>
<td>{{ row.enrolledvia || '-' }}</td>
<td v-if="isadmin" class="action-cell">
<button
v-if="row.state === 'enrolled'"
type="button"
class="btn btn-danger btn-sm revoke"
:disabled="busy"
@click="openRevoke(row)"
>
Un-enroll
</button>
<button
v-if="row.reenrollblocked"
type="button"
class="btn btn-secondary btn-sm allow-reenroll"
:disabled="busy"
@click="allowReenroll(row)"
>
Allow re-enrollment
</button>
</td>
</tr>
<tr v-if="revoking && revoking.assetid === row.assetid" class="revoke-row">
<td :colspan="isadmin ? 8 : 7">
<p class="field-hint">
Un-enroll {{ revoking.hostname }}? Its credential stops working
immediately. {{ revokeFallback }}
</p>
<label class="checkbox-row">
<input v-model="revoking.block" type="checkbox" class="revoke-block" />
<span>
Keep this PC from enrolling again (use before rolling back to
an older agent, or for a compromised PC)
</span>
</label>
<div class="approve-form">
<input
v-if="revoking.block"
v-model="revoking.reason"
type="text"
maxlength="64"
class="form-control revoke-reason"
placeholder="Reason (optional)"
/>
<button
type="button"
class="btn btn-danger btn-sm revoke-confirm"
:disabled="busy"
@click="confirmRevoke"
>
Un-enroll
</button>
<button type="button" class="btn btn-secondary btn-sm" @click="revoking = null">
Cancel
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<PaginationBar
:page="enrollmentPage"
:total-pages="enrollmentTotalPages"
@update:page="goToEnrollmentPage"
/>
</div>
</section>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import api, { computersApi } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
import PaginationBar from '@/components/PaginationBar.vue'
const PERPAGE = 50
const POLL_MS = 30000
const authStore = useAuthStore()
const toast = useToast()
// UI convenience only; every write is re-checked server-side.
const isadmin = computed(() => authStore.isAdmin)
const tab = ref('migration')
const busy = ref(false)
const waveSize = 25
// ---- Migration --------------------------------------------------------
const migrationTiles = [
{ state: 'enrolled', label: 'Enrolled', tone: 'ok' },
{ state: 'ready', label: 'Ready', tone: 'ok' },
{ state: 'blocked', label: 'Blocked', tone: 'bad' },
{ state: 'upgrading', label: 'Upgrading', tone: '' },
{ state: 'pending', label: 'Pending', tone: 'warn' },
{ state: 'notreporting', label: 'Not reporting', tone: '' }
]
const target = ref('1.20.0')
const migrationCounts = ref({})
const migrationRows = ref([])
const waves = ref([])
const migrationPage = ref(1)
const migrationTotalPages = ref(1)
const migrationState = ref('')
const migrationSearch = ref('')
const migrationLoading = ref(false)
const migrationError = ref('')
const selected = ref([])
const migrationTotal = computed(() =>
migrationTiles.reduce((sum, tile) => sum + (Number(migrationCounts.value[tile.state]) || 0), 0))
const enrolledPercent = computed(() => {
if (!migrationTotal.value) return 0
return Math.round(100 * (Number(migrationCounts.value.enrolled) || 0) / migrationTotal.value)
})
const readyRows = computed(() => migrationRows.value.filter(row => row.state === 'ready'))
// The server cannot see a PC's trust store, so this blocker only clears when
// an admin queues the PC again after fixing trust. Next N never picks it.
const TRUST_BLOCKER = 'server certificate not trusted by this PC'
function isTrustRetryable(row) {
const blockers = row.blockers || []
return row.state === 'blocked' && blockers.length > 0
&& blockers.every(blocker => blocker === TRUST_BLOCKER)
}
function isSelectable(row) {
return row.state === 'ready' || isTrustRetryable(row)
}
const allReadySelected = computed(() =>
readyRows.value.length > 0 && readyRows.value.every(row => selected.value.includes(row.assetid)))
function toggleAllReady(event) {
selected.value = event.target.checked ? readyRows.value.map(row => row.assetid) : []
}
function toggleMigrationState(state) {
migrationState.value = migrationState.value === state ? '' : state
reloadMigration()
}
async function loadMigration({ quiet = false } = {}) {
if (!quiet) migrationLoading.value = true
try {
const params = { page: migrationPage.value, perpage: PERPAGE }
if (migrationState.value) params.state = migrationState.value
if (migrationSearch.value) params.search = migrationSearch.value
const response = await api.get('/endpoints/enrollment/migration', { params })
const data = response.data.data || {}
if (data.target) target.value = data.target
migrationCounts.value = data.counts || {}
migrationRows.value = data.computers || []
waves.value = data.waves || []
migrationTotalPages.value = response.data.meta?.pagination?.totalpages || 1
// Drop selections that are no longer selectable (queued by a wave, went offline).
const selectableIds = new Set(migrationRows.value.filter(isSelectable).map(row => row.assetid))
selected.value = selected.value.filter(id => selectableIds.has(id))
migrationError.value = ''
} catch (error) {
migrationError.value = apiError(error, 'Could not load migration status')
} finally {
migrationLoading.value = false
}
}
function reloadMigration() {
migrationPage.value = 1
loadMigration()
}
function goToMigrationPage(page) {
migrationPage.value = page
loadMigration()
}
let migrationSearchTimer = null
function debouncedMigrationSearch() {
clearTimeout(migrationSearchTimer)
migrationSearchTimer = setTimeout(reloadMigration, 300)
}
function waveConfirmText(count) {
return `Upgrade ${count} PC(s) to endpoint agent ${target.value}?\n\n`
+ 'Each PC downloads the new agent and restarts its service. Once the new '
+ 'agent is running it enrolls automatically with the shared key it already '
+ 'holds; no further action is needed unless it lands in Pending.\n\n'
+ 'PCs on agents older than 1.20 must trust the server certificate before '
+ 'they can download the upgrade (see runbook).'
}
async function startWave(body, count) {
if (!count) return
if (!confirm(waveConfirmText(count))) return
busy.value = true
try {
const response = await api.post('/endpoints/enrollment/migration/wave', body)
const data = response.data.data || {}
const queued = (data.queued || []).length
const skipped = data.skipped || []
if (queued) toast.success(`Wave ${data.waveid}: upgrade queued for ${queued} PC(s)`)
const retried = data.retried || []
if (retried.length) {
toast.info(`${retried.length} queued again despite the certificate trust blocker: `
+ retried.slice(0, 5).join(', '))
}
if (skipped.length) {
toast.info(`${skipped.length} skipped: `
+ skipped.slice(0, 5).map(item => `${item.assetid} (${item.reason})`).join(', '))
}
selected.value = []
await loadMigration({ quiet: true })
} catch (error) {
toast.error(apiError(error, 'Could not start the wave'))
} finally {
busy.value = false
}
}
function upgradeSelected() {
const assetids = [...selected.value]
return startWave({ assetids }, assetids.length)
}
function upgradeNext() {
const count = Math.min(waveSize, Number(migrationCounts.value.ready) || 0)
return startWave({ next: waveSize }, count)
}
function wavePercent(wave) {
if (!wave.size) return 0
return Math.round(100 * ((Number(wave.enrolled) || 0) + (Number(wave.failed) || 0)) / wave.size)
}
// ---- Credentials ------------------------------------------------------
const enrollmentSettings = ref({ sharedkeyallowed: true, autoenroll: true })
const enrollmentCounts = ref({})
const credentialRows = ref([])
const requests = ref([])
const enrollmentPage = ref(1)
const enrollmentTotalPages = ref(1)
const enrollmentState = ref('')
const enrollmentSearch = ref('')
const enrollmentLoading = ref(false)
const enrollmentError = ref('')
const approving = ref(null)
async function loadEnrollment({ quiet = false } = {}) {
if (!quiet) enrollmentLoading.value = true
try {
const params = { page: enrollmentPage.value, perpage: PERPAGE }
if (enrollmentState.value) params.state = enrollmentState.value
if (enrollmentSearch.value) params.search = enrollmentSearch.value
const response = await api.get('/endpoints/enrollment', { params })
const data = response.data.data || {}
enrollmentSettings.value = {
sharedkeyallowed: data.settings?.sharedkeyallowed !== false,
autoenroll: data.settings?.autoenroll !== false
}
enrollmentCounts.value = data.counts || {}
credentialRows.value = data.computers || []
requests.value = data.requests || []
enrollmentTotalPages.value = response.data.meta?.pagination?.totalpages || 1
enrollmentError.value = ''
} catch (error) {
enrollmentError.value = apiError(error, 'Could not load enrollment')
} finally {
enrollmentLoading.value = false
}
}
function reloadEnrollment() {
enrollmentPage.value = 1
loadEnrollment()
}
function goToEnrollmentPage(page) {
enrollmentPage.value = page
loadEnrollment()
}
let enrollmentSearchTimer = null
function debouncedEnrollmentSearch() {
clearTimeout(enrollmentSearchTimer)
enrollmentSearchTimer = setTimeout(reloadEnrollment, 300)
}
async function saveSettings(next) {
busy.value = true
try {
await api.put('/endpoints/enrollment/settings', next)
toast.success('Enrollment settings saved')
} catch (error) {
toast.error(apiError(error, 'Could not save enrollment settings'))
} finally {
busy.value = false
await loadEnrollment({ quiet: true })
}
}
function onSharedKeyToggle(event) {
const turningOn = event.target.checked
if (!turningOn) {
const notenrolled = Number(enrollmentCounts.value.notenrolled) || 0
const answer = prompt(
'Turn OFF the shared key?\n\n'
+ `${notenrolled} PC(s) are not enrolled yet. Every one of them stops `
+ 'reporting, stops receiving commands, and cannot enroll on its own until '
+ 'the shared key is turned back on or an admin approves its request.\n\n'
+ 'Type OFF to confirm.')
if (!answer || answer.trim().toUpperCase() !== 'OFF') {
event.target.checked = true
return
}
}
return saveSettings({
sharedkeyallowed: turningOn,
autoenroll: enrollmentSettings.value.autoenroll
})
}
function onAutoEnrollToggle(event) {
return saveSettings({
sharedkeyallowed: enrollmentSettings.value.sharedkeyallowed,
autoenroll: event.target.checked
})
}
function openApprove(request) {
const options = []
if (request.matchedassetid) {
options.push({ assetid: request.matchedassetid, label: `${request.hostname} (matched)` })
}
approving.value = {
requestid: request.requestid,
hostname: request.hostname,
assetid: request.matchedassetid || null,
search: '',
options
}
}
let assetSearchTimer = null
function debouncedAssetSearch() {
clearTimeout(assetSearchTimer)
assetSearchTimer = setTimeout(searchAssets, 300)
}
async function searchAssets() {
const current = approving.value
if (!current || !current.search) return
try {
const response = await computersApi.list({ search: current.search, perpage: 20 })
const found = (response.data.data || []).map(item => ({
assetid: item.assetid,
label: `${item.computer?.hostname || item.name || item.assetid}`
+ (item.serialnumber ? ` (${item.serialnumber})` : '')
}))
const keep = current.options.filter(option => option.assetid === current.assetid)
const merged = [...keep]
found.forEach(option => {
if (!merged.some(existing => existing.assetid === option.assetid)) merged.push(option)
})
current.options = merged
} catch (error) {
toast.error(apiError(error, 'Could not search computers'))
}
}
async function approve() {
const current = approving.value
if (!current || !current.assetid) return
const option = current.options.find(item => item.assetid === current.assetid)
if (!confirm(`Approve the request from ${current.hostname} and issue a credential for `
+ `${option ? option.label : 'asset ' + current.assetid}?`)) return
busy.value = true
try {
await api.post(`/endpoints/enrollment/requests/${current.requestid}/approve`,
{ assetid: current.assetid })
toast.success(`Approved ${current.hostname}`)
approving.value = null
await loadEnrollment({ quiet: true })
} catch (error) {
toast.error(apiError(error, 'Could not approve the request'))
} finally {
busy.value = false
}
}
async function reject(request) {
if (!confirm(`Reject the enrollment request from ${request.hostname}?`)) return
busy.value = true
try {
await api.post(`/endpoints/enrollment/requests/${request.requestid}/reject`)
toast.success(`Rejected ${request.hostname}`)
await loadEnrollment({ quiet: true })
} catch (error) {
toast.error(apiError(error, 'Could not reject the request'))
} finally {
busy.value = false
}
}
const revoking = ref(null)
const revokeFallback = computed(() => {
if (revoking.value?.block) {
return 'It stays un-enrolled and keeps reporting on the shared key while that is allowed.'
}
return enrollmentSettings.value.sharedkeyallowed
? 'It falls back to the shared key and enrolls again on its own.'
: 'The shared key is off, so it cannot report until an admin approves its new request.'
})
function openRevoke(row) {
revoking.value = { assetid: row.assetid, hostname: row.hostname, block: false, reason: '' }
}
async function confirmRevoke() {
const current = revoking.value
if (!current) return
busy.value = true
try {
const url = `/endpoints/enrollment/assets/${current.assetid}/revoke`
if (current.block) {
const body = { blockreenroll: true }
const reason = (current.reason || '').trim()
if (reason) body.reason = reason
await api.post(url, body)
} else {
await api.post(url)
}
toast.success(`Un-enrolled ${current.hostname}`)
revoking.value = null
await loadEnrollment({ quiet: true })
} catch (error) {
toast.error(apiError(error, 'Could not un-enroll'))
} finally {
busy.value = false
}
}
async function allowReenroll(row) {
if (!confirm(`Allow ${row.hostname} to enroll again?\n\n`
+ 'Its agent enrolls on its next attempt if it runs agent 1.20 or later.')) return
busy.value = true
try {
await api.post(`/endpoints/enrollment/assets/${row.assetid}/allow-reenroll`)
toast.success(`${row.hostname} may enroll again`)
await loadEnrollment({ quiet: true })
} catch (error) {
toast.error(apiError(error, 'Could not allow re-enrollment'))
} finally {
busy.value = false
}
}
// ---- Shared -----------------------------------------------------------
function stateLabel(state) {
return {
enrolled: 'Enrolled',
notenrolled: 'Not enrolled',
pending: 'Pending',
revoked: 'Revoked',
upgrading: 'Upgrading',
ready: 'Ready',
blocked: 'Blocked',
notreporting: 'Not reporting'
}[state] || state || '-'
}
function formatDate(value) {
if (!value) return '-'
return new Date(value).toLocaleString()
}
// Waves progress on their own; poll while this tab is open.
let pollTimer = null
function startPolling() {
stopPolling()
pollTimer = setInterval(() => {
if (tab.value === 'migration' && !busy.value) loadMigration({ quiet: true })
}, POLL_MS)
}
function stopPolling() {
if (pollTimer) clearInterval(pollTimer)
pollTimer = null
}
watch(tab, value => {
if (value === 'migration') loadMigration()
else loadEnrollment()
})
onMounted(() => {
loadMigration()
loadEnrollment({ quiet: true })
startPolling()
})
onBeforeUnmount(() => {
stopPolling()
clearTimeout(migrationSearchTimer)
clearTimeout(enrollmentSearchTimer)
clearTimeout(assetSearchTimer)
})
</script>
<style scoped>
.enroll-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border);
margin-bottom: 1rem;
}
.enroll-tab {
padding: 0.6rem 1.25rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
font: inherit;
font-weight: 500;
color: var(--text-light);
cursor: pointer;
}
.enroll-tab:hover { color: var(--text); }
.enroll-tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.load-error {
padding: 0.75rem 0;
color: var(--danger);
}
.enroll-tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.enroll-tile {
display: flex;
flex-direction: column;
align-items: flex-start;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0.25rem;
padding: 0.8rem 0.95rem;
font: inherit;
color: var(--text);
text-align: left;
cursor: pointer;
}
.enroll-tile.static { cursor: default; }
.enroll-tile:not(.static):hover,
.enroll-tile.on { border-color: var(--primary); }
.enroll-tile .n {
font-size: 1.6rem;
font-weight: 700;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.enroll-tile .l {
font-size: 0.8rem;
color: var(--text-light);
}
.enroll-tile.ok .n { color: var(--success-dark); }
.enroll-tile.warn .n { color: var(--warning-dark); }
.enroll-tile.bad .n { color: var(--danger); }
[data-theme="dark"] .enroll-tile.ok .n { color: var(--success); }
[data-theme="dark"] .enroll-tile.warn .n { color: var(--warning); }
.progress-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.progress-track {
flex: 1;
height: 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
overflow: hidden;
}
.progress-track.small {
height: 6px;
min-width: 80px;
}
.progress-fill {
height: 100%;
background: var(--success);
}
.progress-text {
font-size: 0.9rem;
color: var(--text-light);
white-space: nowrap;
}
.enroll-filter { max-width: 200px; }
.bulk-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.6rem 0.9rem;
margin-bottom: 0.75rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0.25rem;
}
.bulk-count { font-size: 0.9rem; margin-right: 0.25rem; }
.select-cell { width: 1%; }
.empty-cell {
text-align: center;
color: var(--text-light);
}
.state-chip {
display: inline-block;
font-size: 0.78rem;
font-weight: 500;
padding: 0.1rem 0.5rem;
border-radius: 10px;
background: var(--bg);
color: var(--text-light);
white-space: nowrap;
}
.state-chip.enrolled,
.state-chip.ready { color: var(--success-dark); }
.state-chip.pending,
.state-chip.upgrading { color: var(--warning-dark); }
.state-chip.blocked,
.state-chip.revoked { color: var(--danger); }
[data-theme="dark"] .state-chip.enrolled,
[data-theme="dark"] .state-chip.ready { color: var(--success); }
[data-theme="dark"] .state-chip.pending,
[data-theme="dark"] .state-chip.upgrading { color: var(--warning); }
.retry-hint {
display: block;
font-size: 0.8em;
color: var(--text-light);
}
.blockers {
font-size: 0.85rem;
color: var(--text-light);
}
.section-title { margin: 1.5rem 0 0.5rem; }
.checkbox-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin: 0.5rem 0;
}
.action-cell {
white-space: nowrap;
text-align: right;
}
.action-cell .btn + .btn { margin-left: 0.35rem; }
.approve-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.approve-form .form-control { max-width: 260px; }
.block-chip { margin-left: 0.35rem; }
.revoke-row .form-control { max-width: 260px; }
.mono {
font-family: 'Courier New', monospace;
font-size: 13px;
}
</style>