Files
shopdb-flask/plugins/endpoints/frontend/views/EnrollmentSettings.vue
cproudlock 534f179ee6 Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
2026-09-19 11:24:40 -04:00

1255 lines
41 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>
<label class="checkbox-row">
<input
type="checkbox"
class="toggle-revokeddenies"
:checked="enrollmentSettings.revokeddeniessharedkey"
:disabled="!isadmin || busy"
@change="onRevokedDeniesToggle($event)"
/>
<span>
<strong>A revoked PC loses the shared key too.</strong> (setting
<code>plugin.endpoints.revokeddeniessharedkey</code>) Off by
default, which is what the fleet does today: a PC whose credential
was revoked falls back to the shared key and keeps reporting, so a
credential rotation does not strand it. Turn this on and a revoke
becomes an eviction - that PC reports nothing and receives no
commands until it enrolls again - and the documented rollback of
revoke plus block re-enrollment onto an older agent that only knows
the shared key stops working. Only safe once every PC here is
enrolled.
</span>
</label>
<label class="number-row">
<span>
<strong>Pending request limit.</strong> How many requests may wait
here at once (setting
<code>plugin.endpoints.maxpendingenrollments</code>). At the limit,
a PC we have never seen is told to try later, while PCs already in
this list keep asking and keep being approved. 0 removes the limit.
/enroll takes no login, so this is what stops the queue growing
without bound.
</span>
<input
type="number"
min="0"
step="1"
class="input-maxpending"
v-model.number="pendingCapInput"
:disabled="!isadmin || busy"
@change="onPendingCapChange()"
/>
</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:
about a minute for one that started asking recently,
slowing to 15 minutes once it has been pending a while.
Only the PC that made this request can collect it.
If the computer record has no hostname, approving
records the one the PC reports, which it needs to
authenticate afterwards.
</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 DEFAULT_PENDING_CAP = 500
const enrollmentSettings = ref({
sharedkeyallowed: true,
autoenroll: true,
// Matches the seeded default (plugin.endpoints.revokeddeniessharedkey is
// false), so the box renders unchecked before the first read lands.
revokeddeniessharedkey: false,
maxpendingenrollments: DEFAULT_PENDING_CAP
})
// Bound with v-model rather than :value so the field holds what the operator
// typed until it is accepted or rejected; a one-way binding fights the edit.
const pendingCapInput = ref(DEFAULT_PENDING_CAP)
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,
// Opt-in, so anything but an explicit true reads as off.
revokeddeniessharedkey: data.settings?.revokeddeniessharedkey === true,
maxpendingenrollments: Number.isFinite(Number(data.settings?.maxpendingenrollments))
? Number(data.settings.maxpendingenrollments)
: DEFAULT_PENDING_CAP
}
pendingCapInput.value = enrollmentSettings.value.maxpendingenrollments
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 onRevokedDeniesToggle(event) {
const turningOn = event.target.checked
if (turningOn) {
// Typed confirm, like the shared-key switch: this one silences PCs that
// are reporting right now, and the revoke-plus-rollback recipe in the
// runbook stops working the moment it is on.
const answer = prompt(
'Deny the shared key to revoked PCs?\n\n'
+ 'Any PC whose credential was revoked stops reporting and stops '
+ 'receiving commands until it enrolls again. The revoke + block '
+ 're-enrollment rollback onto an older agent no longer works.\n\n'
+ 'Type DENY to confirm.')
if (!answer || answer.trim().toUpperCase() !== 'DENY') {
event.target.checked = false
return
}
}
return saveSettings({ revokeddeniessharedkey: turningOn })
}
function onPendingCapChange() {
const wanted = Math.trunc(Number(pendingCapInput.value))
if (!Number.isFinite(wanted) || wanted < 0) {
pendingCapInput.value = enrollmentSettings.value.maxpendingenrollments
toast.error('The pending request limit must be 0 or a whole number')
return
}
return saveSettings({ maxpendingenrollments: wanted })
}
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'))
}
}
// The approval also stamps the request's hostname onto the computer when that
// record has none - without it the PC collects its credential and then 403s on
// every call, because the server resolves the hostname it sends to nothing. A
// computer already carrying a DIFFERENT hostname comes back as a 409 naming
// both, and only an explicit second answer renames it.
async function postApprove(current, sethostname) {
const body = { assetid: current.assetid }
if (sethostname) body.sethostname = true
const response = await api.post(
`/endpoints/enrollment/requests/${current.requestid}/approve`, body)
const result = response?.data?.data?.hostnameresult
if (result === 'set') {
toast.success(`Approved ${current.hostname}; hostname recorded on that PC`)
} else if (result === 'overwritten') {
toast.success(`Approved ${current.hostname}; that PC was renamed to it`)
} else {
toast.success(`Approved ${current.hostname}`)
}
approving.value = null
await loadEnrollment({ quiet: true })
}
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 postApprove(current, false)
} catch (error) {
const details = error?.response?.data?.data?.error?.details
const message = apiError(error, 'Could not approve the request')
if (details?.hostnameconflict) {
if (confirm(`${message}\n\nSet the hostname on this PC?`)) {
try {
await postApprove(current, true)
return
} catch (retryError) {
toast.error(apiError(retryError, 'Could not approve the request'))
return
}
}
toast.error(message)
return
}
toast.error(message)
} 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;
}
.number-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin: 0.5rem 0;
}
.number-row input {
width: 6rem;
flex: none;
}
.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>