- 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.
990 lines
31 KiB
Vue
990 lines
31 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="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>
|