Add the enrollment page, move scripts to per-PC credentials, and document the rollout
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled

- Settings > Endpoints > Enrollment. Migration tab: readiness counts and
  progress, each PC's state with the reasons it is blocked, "Upgrade
  selected" and "Upgrade next 25", and live wave progress. Credentials tab:
  enrolled PCs with revoke, pending requests with approve and reject, and the
  shared key and auto-enroll switches. Turning the shared key off asks for
  confirmation and shows how many PCs are not enrolled.
- The server certificate page shows the SPKI pin to put in the GE-Enforce
  pin entry.
- The agent upgrade script and the asset and printer reporters send the PC's
  credential when it has one (over https, only to the issuing server), fall
  back to the shared key, check server pins, and log refusals clearly.
- New Set-ShopDBServerPins.ps1 (GE-Enforce entry) and Get-ShopDBServerPin.ps1.
- AGENT-CONTRACT.md documents per-PC credentials; the rollout runbook covers
  phases, gates, rollback, pin and wave entries, and every place the shared
  key lives and how to clear it at cutover.
- Removed the 09-14 certificate design notes and dated session reports, and
  two VM notes that carried a live collector key and advised plain http.
- Corrected the GE-Enforce collector note, which claimed a plain registry
  token is protected by ACL.
This commit is contained in:
cproudlock
2026-09-15 12:04:12 -04:00
parent a4c4bd1f40
commit 8b144a17bb
26 changed files with 2769 additions and 4058 deletions

View File

@@ -43,6 +43,18 @@ export default [
requiresPermission: 'endpoints.runscript',
plugin: 'endpoints'
}
},
// Readable by endpoints.fleet holders so they can watch a migration; every
// write on the page is admin-only and re-checked server-side.
{
path: 'settings/endpoints/enrollment',
name: 'endpoints-enrollment-settings',
component: () => import('./views/EnrollmentSettings.vue'),
meta: {
requiresAuth: true,
requiresPermission: 'endpoints.fleet',
plugin: 'endpoints'
}
}
]

View File

@@ -0,0 +1,989 @@
<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="row.state === 'ready'"
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(', ') || '-' }}</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>
<tr v-for="row in credentialRows" :key="row.assetid">
<td>{{ row.hostname || '-' }}</td>
<td>{{ row.serialnumber || '-' }}</td>
<td><span class="state-chip" :class="row.state">{{ stateLabel(row.state) }}</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="revoke(row)"
>
Un-enroll
</button>
</td>
</tr>
</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'))
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 ready (queued by a wave, went offline).
const readyIds = new Set(readyRows.value.map(row => row.assetid))
selected.value = selected.value.filter(id => readyIds.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.'
}
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)`)
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
}
}
async function revoke(row) {
const fallback = 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.'
if (!confirm(`Un-enroll ${row.hostname}?\n\nIts credential stops working immediately. ${fallback}`)) return
busy.value = true
try {
await api.post(`/endpoints/enrollment/assets/${row.assetid}/revoke`)
toast.success(`Un-enrolled ${row.hostname}`)
await loadEnrollment({ quiet: true })
} catch (error) {
toast.error(apiError(error, 'Could not un-enroll'))
} 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); }
.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; }
.mono {
font-family: 'Courier New', monospace;
font-size: 13px;
}
</style>

View File

@@ -66,6 +66,24 @@
</p>
</div>
<div class="form-group spki-pin">
<label>Public key pin (SPKI SHA-256)</label>
<p v-if="cert.spkisha256" class="field-hint mono">{{ cert.spkisha256 }}</p>
<p v-else class="field-hint">Not reported by this server.</p>
<button
v-if="cert.spkisha256"
class="btn btn-secondary btn-sm"
@click="copyPin"
>
{{ pinCopied ? 'Copied' : 'Copy pin' }}
</button>
<p class="field-hint">
Paste this into the GE-Enforce Set-ShopDBServerPins.ps1 entry
(-Pins) so agents 1.20 and later trust only this server's key.
It survives a renewal that keeps the same private key.
</p>
</div>
<div class="form-group">
<label>Issuer</label>
<p class="field-hint mono">{{ cert.issuer }}</p>
@@ -134,7 +152,11 @@
<div class="warning-banner" v-if="cert">
<strong>Warning:</strong> Generating a new certificate will replace the current one.
Agents using ServerCertThumbprint pinning will reject connections until you update
their appsettings.json with the new thumbprint.
their appsettings.json with the new thumbprint. A new certificate also has a new
key, so agents with ServerPins set will reject it until the pin entry carries the
new pin as well. Generating here gives no window to do that; to rotate without an
outage, compute the pin of a prepared certificate with Get-ShopDBServerPin.ps1, add
it to the pin entry alongside the old one, and upload that certificate below.
</div>
<div class="button-row">
@@ -278,6 +300,7 @@ const message = ref('')
const error = ref('')
const cert = ref(null)
const thumbprintCopied = ref(false)
const pinCopied = ref(false)
const generateForm = ref({
cn: 'localhost',
@@ -405,6 +428,14 @@ function copyThumbprint() {
}, 2000)
}
function copyPin() {
navigator.clipboard.writeText(cert.value.spkisha256)
pinCopied.value = true
setTimeout(() => {
pinCopied.value = false
}, 2000)
}
function formatDate(dateString) {
if (!dateString) return 'N/A'
const date = new Date(dateString)

View File

@@ -0,0 +1,200 @@
/**
* Enrollment settings page (per-PC credentials).
*
* Pins the wire shapes the page sends, because the server contract is exact:
* a wave is {"next": n} or {"assetids": [...]}, an approval carries the chosen
* assetid, and turning the shared key off must tell the admin how many PCs are
* not enrolled yet before anything is sent.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
vi.mock('@/api', () => ({
default: { get: vi.fn(), post: vi.fn(), put: vi.fn() },
computersApi: { list: vi.fn() }
}))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({ isAdmin: true, hasPermission: () => true })
}))
vi.mock('@/composables/toast', () => ({
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() })
}))
vi.mock('@/components/PaginationBar.vue', () => ({
default: { name: 'PaginationBar', template: '<div />' }
}))
import api, { computersApi } from '@/api'
import EnrollmentSettings from './EnrollmentSettings.vue'
function migrationPayload() {
return {
data: {
data: {
target: '1.20.0',
counts: { enrolled: 40, pending: 2, upgrading: 3, ready: 30, blocked: 5, notreporting: 20 },
computers: [
{ assetid: 11, hostname: 'PC-READY', agentversion: '1.19.2', lastreportedat: null, state: 'ready', blockers: [] },
{ assetid: 12, hostname: 'PC-BLOCKED', agentversion: '1.18.0', lastreportedat: null, state: 'blocked',
blockers: ['offline', 'no serial on record'] }
],
waves: [
{ waveid: 3, createdat: null, createdby: 'admin', size: 25, queued: 25, enrolled: 10, failed: 1, done: false }
]
},
meta: { pagination: { totalpages: 1 } }
}
}
}
function enrollmentPayload() {
return {
data: {
data: {
settings: { sharedkeyallowed: true, autoenroll: true },
counts: { enrolled: 40, notenrolled: 58, pending: 2 },
computers: [
{ assetid: 21, hostname: 'PC-ENROLLED', serialnumber: 'S1', state: 'enrolled',
credentialprefix: 'sdbe_abcdefg', issuedat: null, lastusedat: null, enrolledvia: 'auto' }
],
requests: [
{ requestid: 45, hostname: 'PC-NEW', serialnumber: 'S9', deviceid: null, reason: 'serial mismatch',
status: 'pending', matchedassetid: 77, requestedat: null, lastseenat: null, sourceip: null }
]
},
meta: { pagination: { totalpages: 1 } }
}
}
}
async function render() {
api.get.mockImplementation(path => Promise.resolve(
path === '/endpoints/enrollment/migration' ? migrationPayload() : enrollmentPayload()))
api.post.mockResolvedValue({ data: { data: { waveid: 4, queued: [11], skipped: [] } } })
api.put.mockResolvedValue({ data: { data: {} } })
const wrapper = mount(EnrollmentSettings)
await flushPromises()
return wrapper
}
async function openCredentials(wrapper) {
const tab = wrapper.findAll('.enroll-tab').find(button => button.text() === 'Credentials')
await tab.trigger('click')
await flushPromises()
}
describe('EnrollmentSettings', () => {
beforeEach(() => {
api.get.mockReset()
api.post.mockReset()
api.put.mockReset()
computersApi.list.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('renders the migration counts and enrolled progress', async () => {
const wrapper = await render()
expect(wrapper.find('[data-count="enrolled"]').text()).toBe('40')
expect(wrapper.find('[data-count="ready"]').text()).toBe('30')
expect(wrapper.find('[data-count="blocked"]').text()).toBe('5')
expect(wrapper.find('[data-count="notreporting"]').text()).toBe('20')
expect(wrapper.find('.progress-text').text()).toContain('40 of 100 enrolled')
})
it('shows every blocker for a blocked PC', async () => {
const wrapper = await render()
const row = wrapper.findAll('.migration-row').find(r => r.text().includes('PC-BLOCKED'))
expect(row.find('.blockers').text()).toBe('offline, no serial on record')
})
it('only offers a checkbox on ready rows', async () => {
const wrapper = await render()
const rows = wrapper.findAll('.migration-row')
expect(rows[0].find('input[type="checkbox"]').exists()).toBe(true)
expect(rows[1].find('input[type="checkbox"]').exists()).toBe(false)
})
it('posts {"next":25} for Upgrade next 25 after a confirm naming the count', async () => {
const confirmSpy = vi.fn(() => true)
vi.stubGlobal('confirm', confirmSpy)
const wrapper = await render()
await wrapper.find('.upgrade-next').trigger('click')
await flushPromises()
expect(confirmSpy).toHaveBeenCalledTimes(1)
expect(confirmSpy.mock.calls[0][0]).toMatch(/Upgrade 25 PC\(s\) to endpoint agent 1\.20\.0/)
expect(confirmSpy.mock.calls[0][0]).toMatch(/enrolls automatically/)
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { next: 25 })
})
it('sends nothing when the wave confirm is declined', async () => {
vi.stubGlobal('confirm', vi.fn(() => false))
const wrapper = await render()
await wrapper.find('.upgrade-next').trigger('click')
await flushPromises()
expect(api.post).not.toHaveBeenCalled()
})
it('posts the selected assetids for Upgrade selected', async () => {
vi.stubGlobal('confirm', vi.fn(() => true))
const wrapper = await render()
await wrapper.findAll('.migration-row')[0].find('input[type="checkbox"]').setValue(true)
await wrapper.find('.upgrade-selected').trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { assetids: [11] })
})
it('approves a request with the matched asset by default', async () => {
vi.stubGlobal('confirm', vi.fn(() => true))
const wrapper = await render()
await openCredentials(wrapper)
await wrapper.find('.approve-open').trigger('click')
expect(wrapper.find('.asset-picker').element.value).toBe('77')
await wrapper.find('.approve-confirm').trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/approve', { assetid: 77 })
})
it('rejects a request after confirm', async () => {
vi.stubGlobal('confirm', vi.fn(() => true))
const wrapper = await render()
await openCredentials(wrapper)
await wrapper.find('.reject').trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/reject')
})
it('un-enrolls through the revoke route', async () => {
vi.stubGlobal('confirm', vi.fn(() => true))
const wrapper = await render()
await openCredentials(wrapper)
await wrapper.find('.revoke').trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/21/revoke')
})
it('shows the not-enrolled count before turning the shared key off', async () => {
const promptSpy = vi.fn(() => 'OFF')
vi.stubGlobal('prompt', promptSpy)
const wrapper = await render()
await openCredentials(wrapper)
await wrapper.find('.toggle-sharedkey').setValue(false)
await flushPromises()
expect(promptSpy).toHaveBeenCalledTimes(1)
expect(promptSpy.mock.calls[0][0]).toMatch(/58 PC\(s\) are not enrolled yet/)
expect(api.put).toHaveBeenCalledWith('/endpoints/enrollment/settings',
{ sharedkeyallowed: false, autoenroll: true })
})
it('leaves the shared key on when the confirmation is not typed', async () => {
vi.stubGlobal('prompt', vi.fn(() => 'yes'))
const wrapper = await render()
await openCredentials(wrapper)
const toggle = wrapper.find('.toggle-sharedkey')
await toggle.setValue(false)
await flushPromises()
expect(api.put).not.toHaveBeenCalled()
expect(toggle.element.checked).toBe(true)
})
})