Show per-PC credentials across the admin interface
- Endpoints (shared key) page: the key is presented as what PCs use until they enroll, with enrollment progress, a link to Enrollment, rewritten guidance, and rotate/revoke confirmations that say how many PCs are not enrolled yet. - Settings cards describe the server certificate as what PCs actually receive, on IIS or Docker; Enrollment has its own icon (the icon set lacked the one the Server Certificate card already asked for). - Server Certificate page checks the certificate the site presents at its public address and compares its pin with the certificate file, warning when they differ. The host comes from site settings only; the check is a TLS handshake, never an HTTP request. - Fleet list: Credential column and filter (enrolled, pending, re-enroll blocked, shared key, not reporting), computed in the list query. - PC detail: credential state, prefix and dates for fleet readers, state only for others, with admin Revoke (optionally blocking re-enrollment) and Allow re-enrollment. - Dashboard: pending enrollment requests, and PCs blocked from re-enrolling for more than seven days. - Runbook: where to find each of these in the app.
This commit is contained in:
@@ -5,15 +5,17 @@
|
||||
</div>
|
||||
|
||||
<p class="setting-description">
|
||||
Manage how shop-floor endpoint agents authenticate to this server, build
|
||||
the agent for this site, and set what it reports and how often.
|
||||
Manage the shared key PCs use until they enroll, build the agent for this
|
||||
site, and set what it reports and how often. Each enrolled PC
|
||||
authenticates with its own credential instead; see Settings > Endpoints
|
||||
> Enrollment.
|
||||
</p>
|
||||
|
||||
<!-- ====================================================================
|
||||
Key management
|
||||
Shared key
|
||||
================================================================= -->
|
||||
<section class="settings-section">
|
||||
<h3>Key management</h3>
|
||||
<h3>Shared key (used until PCs enroll)</h3>
|
||||
<p class="key-warning">
|
||||
STORE YOUR KEY SECURELY. IT IS SHOWN ONLY ONCE AND CANNOT BE RECOVERED.
|
||||
</p>
|
||||
@@ -25,12 +27,40 @@
|
||||
paste the raw key into a chat message or a plain file.
|
||||
</p>
|
||||
<p class="field-hint">
|
||||
The endpoint agent reports with a single managed key scoped to endpoint
|
||||
telemetry only. It is separate from every other collector, so rotating
|
||||
or revoking it stops all endpoint-agent traffic immediately without
|
||||
touching the PC or GE-Enforce collectors.
|
||||
Every PC installed with the shared key holds this same key. It lets a
|
||||
PC that has not enrolled yet report and ask for its own credential. An enrolled PC is refused it and
|
||||
uses its credential instead, so rotating or revoking the key only
|
||||
affects PCs that have not enrolled. It is scoped to endpoint telemetry
|
||||
and separate from every other collector.
|
||||
</p>
|
||||
|
||||
<div v-if="enrollment" class="card form-card enrollment-summary">
|
||||
<div class="enrollment-counts">
|
||||
<span class="enrollment-count">
|
||||
<strong>{{ enrollment.enrolled }}</strong> of {{ enrollment.total }} PCs enrolled
|
||||
</span>
|
||||
<span class="enrollment-count">
|
||||
<strong>{{ enrollment.notenrolled }}</strong> still on the shared key
|
||||
</span>
|
||||
<span class="enrollment-count">
|
||||
<strong>{{ enrollment.requests }}</strong>
|
||||
pending enrollment {{ enrollment.requests === 1 ? 'request' : 'requests' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" :style="{ width: enrolledPercent + '%' }"></div>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
<template v-if="!enrollment.sharedkeyallowed">
|
||||
<strong>The shared key is switched off</strong> in Enrollment, so PCs
|
||||
that have not enrolled already cannot report with it.
|
||||
</template>
|
||||
Approve requests, move PCs over and retire the shared key in
|
||||
<router-link :to="{ name: 'endpoints-enrollment-settings' }">
|
||||
Settings > Endpoints > Enrollment</router-link>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<div v-if="message" class="settings-success">{{ message }}</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
@@ -49,8 +79,8 @@
|
||||
<template v-else>Not used yet.</template>
|
||||
</p>
|
||||
<p v-else class="field-hint">
|
||||
No endpoint agent key is active. Generate one to let agents report
|
||||
with a managed credential.
|
||||
No shared key is active. PCs that have not enrolled cannot report
|
||||
or auto-enroll until one is generated.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -85,9 +115,10 @@
|
||||
<div v-if="secret" class="card form-card secret-card">
|
||||
<h4>Save this key now</h4>
|
||||
<p class="field-hint">
|
||||
Anyone with this key can report as an endpoint agent for this site.
|
||||
Treat it like a password. If it is lost you must regenerate, which
|
||||
revokes the old key and forces every agent to be reconfigured.
|
||||
Anyone with this key can report as a not-yet-enrolled endpoint agent
|
||||
for this site and ask to enroll. Treat it like a password. If it is
|
||||
lost you must regenerate, which revokes the old key; PCs that have not
|
||||
enrolled then need the new one.
|
||||
</p>
|
||||
<p class="field-hint">
|
||||
<strong>Recommended:</strong> before you leave this page, open the
|
||||
@@ -110,53 +141,57 @@
|
||||
<summary><h4>How it works</h4></summary>
|
||||
<ul class="field-hint info-list">
|
||||
<li>
|
||||
<strong>Generate</strong> mints a fresh key and, on the first
|
||||
generation, turns off the bootstrap <code>.env</code> fallback so
|
||||
the managed key becomes the only accepted credential. The full
|
||||
secret is shown once - store it in the agent config as the
|
||||
<code>X-API-Key</code> header.
|
||||
<strong>Until a PC enrolls</strong> it reports with this shared key.
|
||||
Agents from 1.20 use it once more to ask for their own credential,
|
||||
and from then on send that credential instead.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Regenerate</strong> is a hard cutover: the previous key is
|
||||
revoked in the same moment the new one is minted. Every deployed
|
||||
agent must be reconfigured with the new key before it can report
|
||||
again.
|
||||
<strong>Enrolled PCs ignore it.</strong> The server refuses the shared
|
||||
key for any PC that holds a credential, so regenerating or revoking
|
||||
it does not touch them.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Revoke</strong> deactivates the key and leaves it off. All
|
||||
endpoint-agent reporting stops until you generate a new key. This is
|
||||
the kill switch.
|
||||
<strong>Generate</strong> mints a key and, the first time, turns off
|
||||
the bootstrap <code>.env</code> fallback. The full secret is shown
|
||||
once; it goes into the agent config as the <code>X-API-Key</code>
|
||||
header.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Regenerate</strong> revokes the previous key in the same
|
||||
moment. PCs that have not enrolled must be given the new key before
|
||||
they can report or enroll again.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Revoke</strong> leaves no shared key. PCs that have not
|
||||
enrolled stop reporting and can no longer auto-enroll; enrolled PCs
|
||||
carry on.
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<details class="collapsible">
|
||||
<summary><h4>When to rotate</h4></summary>
|
||||
<summary><h4>When to rotate or retire</h4></summary>
|
||||
<ul class="field-hint info-list">
|
||||
<li>
|
||||
<strong>An installer or agent left the secure environment.</strong>
|
||||
If a build carrying the key is copied off the shop floor - onto an
|
||||
engineer's laptop, a USB stick, or a machine that leaves the site -
|
||||
treat the key as compromised and regenerate immediately.
|
||||
<strong>At cutover.</strong> Once every PC has enrolled, switch the
|
||||
shared key off in Enrollment, clear it from every place it lives,
|
||||
then regenerate so any copy you missed is dead. The steps are in the
|
||||
per-PC credentials rollout runbook.
|
||||
</li>
|
||||
<li>
|
||||
<strong>A device is lost, stolen, or decommissioned</strong> while
|
||||
still holding the key, and you cannot confirm the key was wiped.
|
||||
<strong>An installer or key left the secure environment.</strong> A
|
||||
build or file carrying the key copied onto a laptop, a USB stick or a
|
||||
machine that leaves the site. Regenerate; only PCs that have not
|
||||
enrolled need the new key.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Staff offboarding.</strong> Someone who could read the key
|
||||
from a config or build pipeline is leaving; rotate as part of the
|
||||
offboarding checklist.
|
||||
from a config or build pipeline is leaving.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Scheduled hygiene.</strong> Rotate on a routine cadence
|
||||
(for example quarterly) so a silently leaked key has a bounded
|
||||
lifetime.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Suspicious traffic.</strong> The Fleet view shows reports
|
||||
from a host you do not recognize - revoke now, investigate, then
|
||||
regenerate and redeploy to known-good endpoints.
|
||||
<strong>Suspicious traffic.</strong> The Fleet view shows reports from
|
||||
a host you do not recognize. Revoke now, investigate, then regenerate
|
||||
for the PCs that still need it.
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
@@ -532,7 +567,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import api from '@/api'
|
||||
|
||||
@@ -545,6 +580,9 @@ const secret = ref('')
|
||||
const copied = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
// Enrollment progress, from the same counts the Enrollment page shows. Null
|
||||
// when the read fails; the key controls work without it.
|
||||
const enrollment = ref(null)
|
||||
|
||||
// Adoption tool: build a one-shot, agentless enrollment download.
|
||||
const adoptbaseurl = ref('')
|
||||
@@ -613,6 +651,40 @@ function filenameFromResponse(response, fallback) {
|
||||
return match ? decodeURIComponent(match[1]) : fallback
|
||||
}
|
||||
|
||||
const enrolledPercent = computed(() => {
|
||||
const total = enrollment.value?.total || 0
|
||||
return total ? Math.round((enrollment.value.enrolled / total) * 100) : 0
|
||||
})
|
||||
|
||||
async function loadEnrollment() {
|
||||
try {
|
||||
const response = await api.get('/endpoints/enrollment', { params: { perpage: 1 } })
|
||||
const data = response.data.data || {}
|
||||
const counts = data.counts || {}
|
||||
const enrolled = counts.enrolled || 0
|
||||
// pending PCs asked but hold no credential yet: still on the shared key.
|
||||
const notenrolled = (counts.notenrolled || 0) + (counts.pending || 0)
|
||||
enrollment.value = {
|
||||
enrolled,
|
||||
notenrolled,
|
||||
total: enrolled + notenrolled,
|
||||
requests: (data.requests || []).filter(
|
||||
(entry) => entry.status === 'pending' && entry.reason !== 'reenroll blocked').length,
|
||||
sharedkeyallowed: data.settings?.sharedkeyallowed !== false
|
||||
}
|
||||
} catch (loadError) {
|
||||
enrollment.value = null
|
||||
console.error(loadError)
|
||||
}
|
||||
}
|
||||
|
||||
// The number of PCs a change to the shared key reaches, for the confirms.
|
||||
function notEnrolledPhrase() {
|
||||
if (!enrollment.value) return 'Every PC that has not enrolled'
|
||||
const count = enrollment.value.notenrolled
|
||||
return `${count} PC${count === 1 ? '' : 's'} not enrolled`
|
||||
}
|
||||
|
||||
function applyStatus(data) {
|
||||
key.value = data.key || null
|
||||
envkeyfallback.value = !!data.envkeyfallback
|
||||
@@ -632,6 +704,7 @@ onMounted(async () => {
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
loadEnrollment()
|
||||
try {
|
||||
const response = await api.get('/endpoints/siteagent')
|
||||
const status = response.data.data || {}
|
||||
@@ -656,8 +729,9 @@ function applyBinaryStatus(binkey, current, versions) {
|
||||
|
||||
async function regenerate() {
|
||||
if (key.value &&
|
||||
!window.confirm('Regenerate the key? The current key stops working '
|
||||
+ 'immediately and every agent must be reconfigured.')) {
|
||||
!window.confirm('Regenerate the shared key? The current key stops working '
|
||||
+ `immediately. ${notEnrolledPhrase()} will need the new key before they `
|
||||
+ 'can report or enroll. Enrolled PCs are not affected.')) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
@@ -684,8 +758,9 @@ async function regenerate() {
|
||||
}
|
||||
|
||||
async function revoke() {
|
||||
if (!window.confirm('Revoke the key? All endpoint-agent reporting stops '
|
||||
+ 'until a new key is generated.')) {
|
||||
if (!window.confirm('Revoke the shared key? '
|
||||
+ `${notEnrolledPhrase()} will lose access: they stop reporting and cannot `
|
||||
+ 'enroll until a new key is generated. Enrolled PCs keep working.')) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
@@ -959,6 +1034,24 @@ function editConfiguration() {
|
||||
}
|
||||
.secret-value { padding: 0.5rem; background: var(--color-surface-2, #1e1e1e); }
|
||||
.settings-section { margin-bottom: 2rem; }
|
||||
.enrollment-counts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.enrollment-count { font-size: 0.9rem; }
|
||||
.progress-track {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--color-border, #333);
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--color-success, #4caf50);
|
||||
}
|
||||
.settings-section > h3 { margin-bottom: 0.25rem; }
|
||||
.info-list { margin: 0.25rem 0 0; padding-left: 1.25rem; }
|
||||
.info-list li { margin-bottom: 0.5rem; }
|
||||
|
||||
@@ -41,6 +41,12 @@
|
||||
<option value="quiet">Quiet</option>
|
||||
<option value="never">Never reported</option>
|
||||
</select>
|
||||
<select v-model="credentialstate" class="form-control fleet-filter" @change="applyFilters">
|
||||
<option value="">All credentials</option>
|
||||
<option v-for="option in credentialOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="computertypeid" class="form-control fleet-filter" @change="applyFilters">
|
||||
<option value="">All PC types</option>
|
||||
<option v-for="type in computertypes" :key="type.computertypeid" :value="type.computertypeid">
|
||||
@@ -103,6 +109,7 @@
|
||||
<th>PC Type</th>
|
||||
<th>Health</th>
|
||||
<th>Agent Version</th>
|
||||
<th>Credential</th>
|
||||
<th>Logged-in User</th>
|
||||
<th>Uptime</th>
|
||||
<th>Last Reported</th>
|
||||
@@ -137,12 +144,17 @@
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td>{{ item.agentversion || '-' }}</td>
|
||||
<td>
|
||||
<span v-if="item.credentialstate" class="credential-chip"
|
||||
:class="item.credentialstate">{{ credentialLabel(item.credentialstate) }}</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td>{{ item.loggedinuser || '-' }}</td>
|
||||
<td>{{ item.uptimedisplay || '-' }}</td>
|
||||
<td>{{ formatRelative(item.lastreportedat) }}</td>
|
||||
</tr>
|
||||
<tr v-if="endpoints.length === 0">
|
||||
<td :colspan="cancommand ? 11 : 10" style="text-align: center; color: var(--text-light);">
|
||||
<td :colspan="cancommand ? 12 : 11" style="text-align: center; color: var(--text-light);">
|
||||
No endpoints found
|
||||
</td>
|
||||
</tr>
|
||||
@@ -192,6 +204,17 @@ const sort = ref('status')
|
||||
const summary = ref(null)
|
||||
const criticalcount = ref(0)
|
||||
const computertypeid = ref('')
|
||||
// Which credential the PC reports with, computed server-side so the filter
|
||||
// holds across pages. Read-only here: revoke and re-enrollment live on the PC
|
||||
// detail page and the Enrollment settings page.
|
||||
const credentialstate = ref('')
|
||||
const credentialOptions = [
|
||||
{ value: 'enrolled', label: 'Enrolled' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'reenrollblocked', label: 'Re-enroll blocked' },
|
||||
{ value: 'sharedkey', label: 'Shared key (not enrolled)' },
|
||||
{ value: 'notreporting', label: 'Not reporting' }
|
||||
]
|
||||
const locationid = ref('')
|
||||
const problemsonly = ref(false)
|
||||
const computertypes = ref([])
|
||||
@@ -329,6 +352,7 @@ async function loadEndpoints() {
|
||||
if (search.value) params.search = search.value
|
||||
if (status.value) params.status = status.value
|
||||
if (sort.value) params.sort = sort.value
|
||||
if (credentialstate.value) params.credentialstate = credentialstate.value
|
||||
if (computertypeid.value) params.computertypeid = computertypeid.value
|
||||
if (locationid.value) params.locationid = locationid.value
|
||||
|
||||
@@ -368,6 +392,11 @@ function openPc(item) {
|
||||
}
|
||||
}
|
||||
|
||||
function credentialLabel(value) {
|
||||
const option = credentialOptions.find(entry => entry.value === value)
|
||||
return option ? option.label : value
|
||||
}
|
||||
|
||||
function statusLabel(value) {
|
||||
return { reporting: 'Reporting', quiet: 'Quiet', never: 'Never' }[value] || '-'
|
||||
}
|
||||
@@ -394,6 +423,7 @@ function setStatus(value) {
|
||||
|
||||
function clearFilters() {
|
||||
status.value = ''
|
||||
credentialstate.value = ''
|
||||
problemsonly.value = false
|
||||
applyFilters()
|
||||
}
|
||||
@@ -515,6 +545,22 @@ function changePerPage(newPerPage) {
|
||||
|
||||
.fleet-filter { max-width: 200px; }
|
||||
|
||||
.credential-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;
|
||||
}
|
||||
.credential-chip.enrolled { color: var(--success-dark); }
|
||||
.credential-chip.pending { color: var(--warning-dark); }
|
||||
.credential-chip.reenrollblocked { color: var(--danger); }
|
||||
[data-theme="dark"] .credential-chip.enrolled { color: var(--success); }
|
||||
[data-theme="dark"] .credential-chip.pending { color: var(--warning); }
|
||||
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
</div>
|
||||
|
||||
<p class="setting-description">
|
||||
Manage the TLS certificate that nginx uses for HTTPS (port 5001).
|
||||
Agents validate this certificate using Windows trusted roots or
|
||||
thumbprint pinning (ServerCertThumbprint in appsettings.json).
|
||||
The TLS certificate PCs see when they connect to this site, whether it is
|
||||
served by IIS or by the Docker stack. Its public key pin goes into the
|
||||
Set-ShopDBServerPins.ps1 entry, and the certificate itself into the
|
||||
trusted root entry for agents older than 1.20.
|
||||
</p>
|
||||
|
||||
<!-- Certificate status -->
|
||||
@@ -97,6 +98,56 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Live check: what the public address really presents -->
|
||||
<section class="settings-section">
|
||||
<h3>What PCs receive</h3>
|
||||
<p class="field-hint">
|
||||
Connects to this site's base URL and reads the certificate it presents,
|
||||
the way a PC does. An IIS binding or a proxy in front of the app can
|
||||
present a different certificate than the file above.
|
||||
</p>
|
||||
<div class="card form-card">
|
||||
<div class="form-group live-check">
|
||||
<label>Presented public key pin (SPKI SHA-256)</label>
|
||||
<p v-if="liveLoading" class="field-hint">Checking {{ live?.url || 'the site address' }}...</p>
|
||||
<template v-else-if="live">
|
||||
<p v-if="!live.configured" class="field-hint live-unconfigured">
|
||||
No site base URL is set (site_base_url, under Settings, Email /
|
||||
SMTP, or in the setup wizard), so there is no address to check.
|
||||
</p>
|
||||
<template v-else>
|
||||
<p class="field-hint">Checked <span class="mono">{{ live.url }}</span></p>
|
||||
<p v-if="live.error" class="live-error">{{ live.error }}</p>
|
||||
<template v-else>
|
||||
<p class="field-hint mono" :class="live.matchesfile ? 'live-match' : 'live-mismatch'">
|
||||
{{ live.presentedspkisha256 }}
|
||||
</p>
|
||||
<p v-if="live.matchesfile" class="live-match live-status">
|
||||
Matches the pin of the certificate file above.
|
||||
</p>
|
||||
<div v-else class="live-warning">
|
||||
<strong>The site presents a different certificate than the file above.</strong>
|
||||
PCs will be pinned to the certificate they actually receive; use the
|
||||
presented pin.
|
||||
<div class="live-pins">
|
||||
<span>File pin:</span>
|
||||
<span class="mono">{{ cert?.spkisha256 || 'no certificate file' }}</span>
|
||||
<span>Presented pin:</span>
|
||||
<span class="mono">{{ live.presentedspkisha256 }}</span>
|
||||
<span>Presented thumbprint:</span>
|
||||
<span class="mono">{{ live.presentedsha1thumbprint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<button class="btn btn-secondary btn-sm live-recheck" :disabled="liveLoading" @click="loadLive">
|
||||
{{ liveLoading ? 'Checking...' : 'Recheck' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Generate self-signed certificate -->
|
||||
<section class="settings-section">
|
||||
<h3>Generate self-signed certificate</h3>
|
||||
@@ -299,6 +350,8 @@ const busy = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
const cert = ref(null)
|
||||
const live = ref(null)
|
||||
const liveLoading = ref(false)
|
||||
const thumbprintCopied = ref(false)
|
||||
const pinCopied = ref(false)
|
||||
|
||||
@@ -315,8 +368,28 @@ const uploadForm = ref({
|
||||
|
||||
onMounted(() => {
|
||||
loadCertificate()
|
||||
loadLive()
|
||||
})
|
||||
|
||||
// What the site's public address actually presents. A proxy or IIS binding
|
||||
// can serve a different certificate than the file this page manages, and a PC
|
||||
// pins what it receives.
|
||||
async function loadLive() {
|
||||
liveLoading.value = true
|
||||
try {
|
||||
const response = await api.get('/endpoints/settings/server-cert/live')
|
||||
live.value = response.data.data || null
|
||||
} catch (err) {
|
||||
live.value = {
|
||||
configured: true,
|
||||
url: live.value?.url || '',
|
||||
error: `Could not run the check: ${err.message}`
|
||||
}
|
||||
} finally {
|
||||
liveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCertificate() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
@@ -357,6 +430,7 @@ async function generateCertificate() {
|
||||
if (response.data.status === 'success') {
|
||||
message.value = 'Certificate generated successfully. Nginx is restarting...'
|
||||
await loadCertificate()
|
||||
loadLive()
|
||||
} else {
|
||||
error.value = response.data.data?.error?.message || 'Failed to generate certificate'
|
||||
}
|
||||
@@ -405,6 +479,7 @@ async function uploadCertificate() {
|
||||
if (response.data.status === 'success') {
|
||||
message.value = 'Certificate uploaded successfully. Nginx is restarting...'
|
||||
await loadCertificate()
|
||||
loadLive()
|
||||
// Clear file inputs
|
||||
document.getElementById('cert-file').value = ''
|
||||
document.getElementById('key-file').value = ''
|
||||
@@ -529,6 +604,29 @@ pre.code-block {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.live-match { color: var(--success-dark); }
|
||||
.live-mismatch { color: var(--danger); }
|
||||
.live-status { font-size: 13px; margin: 4px 0; }
|
||||
[data-theme="dark"] .live-match { color: var(--success); }
|
||||
|
||||
.live-error,
|
||||
.live-warning {
|
||||
margin: 8px 0;
|
||||
padding: 10px 12px;
|
||||
border-left: 3px solid var(--danger);
|
||||
background: rgba(220, 53, 69, 0.1);
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.live-pins {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 4px 12px;
|
||||
margin-top: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.info-list li {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
133
plugins/endpoints/frontend/views/agentKeySettings.spec.js
Normal file
133
plugins/endpoints/frontend/views/agentKeySettings.spec.js
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Endpoints (shared key) settings page.
|
||||
*
|
||||
* The shared key only matters to PCs that have not enrolled, so the page must
|
||||
* say how far enrollment has got, and the regenerate and revoke confirms must
|
||||
* name how many PCs the change actually reaches.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises, RouterLinkStub } from '@vue/test-utils'
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }
|
||||
}))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
|
||||
import api from '@/api'
|
||||
import AgentKeySettings from './AgentKeySettings.vue'
|
||||
|
||||
const KEY = { tokenprefix: 'abcd', createddate: null, lastusedat: null }
|
||||
|
||||
function enrollmentPayload(settings = { sharedkeyallowed: true, autoenroll: true }) {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
settings,
|
||||
counts: { enrolled: 30, notenrolled: 8, pending: 2 },
|
||||
computers: [],
|
||||
requests: [
|
||||
{ requestid: 1, status: 'pending', reason: 'serial mismatch' },
|
||||
{ requestid: 2, status: 'pending', reason: 'awaiting approval' },
|
||||
{ requestid: 3, status: 'pending', reason: 'reenroll blocked' },
|
||||
{ requestid: 4, status: 'issued', reason: null }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function render(payload = enrollmentPayload()) {
|
||||
api.get.mockImplementation((path) => {
|
||||
if (path === '/endpoints/agentkey') {
|
||||
return Promise.resolve({ data: { data: { key: KEY, envkeyfallback: false } } })
|
||||
}
|
||||
if (path === '/endpoints/enrollment') return Promise.resolve(payload)
|
||||
return Promise.resolve({ data: { data: {} } })
|
||||
})
|
||||
api.post.mockResolvedValue({ data: { data: { key: KEY } } })
|
||||
api.delete.mockResolvedValue({ data: { data: { key: null } } })
|
||||
const wrapper = mount(AgentKeySettings, { global: { stubs: { RouterLink: RouterLinkStub } } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('AgentKeySettings shared key section', () => {
|
||||
beforeEach(() => {
|
||||
api.get.mockReset()
|
||||
api.post.mockReset()
|
||||
api.delete.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('titles the section for the per-PC model', async () => {
|
||||
const wrapper = await render()
|
||||
expect(wrapper.text()).toContain('Shared key (used until PCs enroll)')
|
||||
expect(wrapper.text()).not.toContain('Key management')
|
||||
expect(wrapper.text()).toMatch(/Enrolled PCs ignore it/)
|
||||
expect(wrapper.text()).toMatch(/At cutover/)
|
||||
})
|
||||
|
||||
it('shows enrollment progress from one small enrollment read', async () => {
|
||||
const wrapper = await render()
|
||||
expect(api.get).toHaveBeenCalledWith('/endpoints/enrollment', { params: { perpage: 1 } })
|
||||
const summary = wrapper.find('.enrollment-summary').text()
|
||||
expect(summary).toContain('30 of 40 PCs enrolled')
|
||||
expect(summary).toContain('10 still on the shared key')
|
||||
// A blocked PC asking again is not a request anyone has to decide.
|
||||
expect(summary).toContain('2 pending enrollment requests')
|
||||
const link = wrapper.findComponent(RouterLinkStub)
|
||||
expect(link.props('to')).toEqual({ name: 'endpoints-enrollment-settings' })
|
||||
})
|
||||
|
||||
it('regenerate confirm names the PCs that need the new key', async () => {
|
||||
const confirmSpy = vi.fn(() => true)
|
||||
vi.stubGlobal('confirm', confirmSpy)
|
||||
const wrapper = await render()
|
||||
const button = wrapper.findAll('button').find(b => b.text() === 'Regenerate key')
|
||||
await button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmSpy.mock.calls[0][0]).toMatch(/10 PCs not enrolled will need the new key/)
|
||||
expect(confirmSpy.mock.calls[0][0]).toMatch(/Enrolled PCs are not affected/)
|
||||
expect(api.post).toHaveBeenCalledWith('/endpoints/agentkey/regenerate')
|
||||
})
|
||||
|
||||
it('revoke confirm names the PCs that lose access', async () => {
|
||||
const confirmSpy = vi.fn(() => true)
|
||||
vi.stubGlobal('confirm', confirmSpy)
|
||||
const wrapper = await render()
|
||||
const button = wrapper.findAll('button').find(b => b.text() === 'Revoke key')
|
||||
await button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmSpy.mock.calls[0][0]).toMatch(/10 PCs not enrolled will lose access/)
|
||||
expect(api.delete).toHaveBeenCalledWith('/endpoints/agentkey')
|
||||
})
|
||||
|
||||
it('sends nothing when revoke is declined', async () => {
|
||||
vi.stubGlobal('confirm', vi.fn(() => false))
|
||||
const wrapper = await render()
|
||||
const button = wrapper.findAll('button').find(b => b.text() === 'Revoke key')
|
||||
await button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still manages the key when the enrollment read fails', async () => {
|
||||
api.get.mockImplementation((path) => {
|
||||
if (path === '/endpoints/enrollment') return Promise.reject(new Error('403'))
|
||||
if (path === '/endpoints/agentkey') {
|
||||
return Promise.resolve({ data: { data: { key: KEY, envkeyfallback: false } } })
|
||||
}
|
||||
return Promise.resolve({ data: { data: {} } })
|
||||
})
|
||||
vi.stubGlobal('confirm', vi.fn(() => true))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const wrapper = mount(AgentKeySettings, { global: { stubs: { RouterLink: RouterLinkStub } } })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.enrollment-summary').exists()).toBe(false)
|
||||
const button = wrapper.findAll('button').find(b => b.text() === 'Revoke key')
|
||||
expect(button.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
99
plugins/endpoints/frontend/views/endpointsList.spec.js
Normal file
99
plugins/endpoints/frontend/views/endpointsList.spec.js
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Fleet list Credential column.
|
||||
*
|
||||
* The state is computed by the list route so that filtering by it survives
|
||||
* pagination; the page must send the filter to the server rather than narrowing
|
||||
* the rows it already has, and must render the server's value, not derive one.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
endpointsApi: {
|
||||
list: vi.fn(),
|
||||
dashboardSummary: vi.fn(),
|
||||
dashboardStability: vi.fn(),
|
||||
enqueueCommand: vi.fn()
|
||||
},
|
||||
computersApi: { types: { listAll: vi.fn(() => Promise.resolve([])) } },
|
||||
locationsApi: { listAll: vi.fn(() => Promise.resolve([])) }
|
||||
}))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: 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('@/composables/listQuery', () => ({
|
||||
useListQuery: () => ({
|
||||
page: ref(1), search: ref(''), setPage: vi.fn(), setSearch: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('@/components/PaginationBar.vue', () => ({
|
||||
default: { name: 'PaginationBar', template: '<div />' }
|
||||
}))
|
||||
|
||||
import { endpointsApi } from '@/api'
|
||||
import EndpointsList from './EndpointsList.vue'
|
||||
|
||||
function row(assetid, hostname, credentialstate) {
|
||||
return {
|
||||
assetid, hostname, computerid: assetid, status: 'reporting', machines: [],
|
||||
health: null, credentialstate
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
endpointsApi.dashboardSummary.mockResolvedValue({ data: { data: null } })
|
||||
endpointsApi.dashboardStability.mockResolvedValue({ data: { data: [] } })
|
||||
endpointsApi.list.mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
row(1, 'PC-ENROLLED', 'enrolled'),
|
||||
row(2, 'PC-PENDING', 'pending'),
|
||||
row(3, 'PC-BLOCKED', 'reenrollblocked'),
|
||||
row(4, 'PC-SHARED', 'sharedkey'),
|
||||
row(5, 'PC-SILENT', 'notreporting')
|
||||
],
|
||||
meta: { pagination: { totalpages: 1 } }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('EndpointsList credential column', () => {
|
||||
it('renders a chip per server state', async () => {
|
||||
const wrapper = mount(EndpointsList)
|
||||
await flushPromises()
|
||||
|
||||
const chips = wrapper.findAll('.credential-chip').map(chip => chip.text())
|
||||
expect(chips).toEqual([
|
||||
'Enrolled', 'Pending', 'Re-enroll blocked', 'Shared key (not enrolled)',
|
||||
'Not reporting'
|
||||
])
|
||||
expect(wrapper.find('.credential-chip.reenrollblocked').exists()).toBe(true)
|
||||
expect(wrapper.findAll('th').map(th => th.text())).toContain('Credential')
|
||||
})
|
||||
|
||||
it('sends the credential filter to the server', async () => {
|
||||
const wrapper = mount(EndpointsList)
|
||||
await flushPromises()
|
||||
|
||||
const select = wrapper.findAll('select.fleet-filter')
|
||||
.find(element => element.text().includes('All credentials'))
|
||||
await select.setValue('sharedkey')
|
||||
await flushPromises()
|
||||
|
||||
const params = endpointsApi.list.mock.calls.at(-1)[0]
|
||||
expect(params.credentialstate).toBe('sharedkey')
|
||||
expect(params.perpage).toBe(20)
|
||||
})
|
||||
|
||||
it('offers no credential actions on the list', async () => {
|
||||
const wrapper = mount(EndpointsList)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).not.toMatch(/Revoke|Allow re-enrollment/)
|
||||
})
|
||||
})
|
||||
98
plugins/endpoints/frontend/views/serverCertSettings.spec.js
Normal file
98
plugins/endpoints/frontend/views/serverCertSettings.spec.js
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Server Certificate live check.
|
||||
*
|
||||
* A PC pins the certificate the site presents, which a proxy or IIS binding can
|
||||
* make different from the file this page manages. The page must show the
|
||||
* presented pin, warn plainly when it differs, and let the admin recheck.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: { get: vi.fn(), post: vi.fn() }
|
||||
}))
|
||||
|
||||
import api from '@/api'
|
||||
import ServerCertSettings from './ServerCertSettings.vue'
|
||||
|
||||
const FILEPIN = 'a'.repeat(64)
|
||||
const OTHERPIN = 'b'.repeat(64)
|
||||
|
||||
function certificate() {
|
||||
return {
|
||||
data: {
|
||||
status: 'success',
|
||||
data: {
|
||||
subject_cn: 'shopdb.example.test', issuer: 'shopdb.example.test',
|
||||
not_before: null, not_after: null, thumbprint: 'AA:BB',
|
||||
spkisha256: FILEPIN, sha1thumbprint: 'CC', san_list: [],
|
||||
expires_in_days: 300, is_self_signed: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function render(live) {
|
||||
api.get.mockImplementation((path) => Promise.resolve(
|
||||
path === '/endpoints/settings/server-cert/live'
|
||||
? { data: { status: 'success', data: live } }
|
||||
: certificate()))
|
||||
const wrapper = mount(ServerCertSettings)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('ServerCertSettings live check', () => {
|
||||
beforeEach(() => {
|
||||
api.get.mockReset()
|
||||
})
|
||||
|
||||
it('shows the presented pin in green when it matches the file', async () => {
|
||||
const wrapper = await render({
|
||||
configured: true, url: 'https://shopdb.example.test', presentedspkisha256: FILEPIN,
|
||||
presentedsha1thumbprint: 'CC', matchesfile: true, error: null
|
||||
})
|
||||
const check = wrapper.find('.live-check')
|
||||
expect(check.find('.live-match').text()).toContain(FILEPIN)
|
||||
expect(check.text()).toContain('Matches the pin of the certificate file above')
|
||||
expect(check.find('.live-warning').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('warns in red with both pins when the site presents another certificate', async () => {
|
||||
const wrapper = await render({
|
||||
configured: true, url: 'https://shopdb.example.test', presentedspkisha256: OTHERPIN,
|
||||
presentedsha1thumbprint: 'DD', matchesfile: false, error: null
|
||||
})
|
||||
const warning = wrapper.find('.live-warning')
|
||||
expect(warning.exists()).toBe(true)
|
||||
expect(warning.text()).toContain(
|
||||
'PCs will be pinned to the certificate they actually receive; use the presented pin')
|
||||
expect(warning.text()).toContain(FILEPIN)
|
||||
expect(warning.text()).toContain(OTHERPIN)
|
||||
expect(wrapper.find('.live-mismatch').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('says when no site address is configured', async () => {
|
||||
const wrapper = await render({ configured: false })
|
||||
expect(wrapper.find('.live-unconfigured').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the check error', async () => {
|
||||
const wrapper = await render({
|
||||
configured: true, url: 'https://shopdb.example.test', presentedspkisha256: null,
|
||||
presentedsha1thumbprint: null, matchesfile: false, error: 'Could not read the certificate'
|
||||
})
|
||||
expect(wrapper.find('.live-error').text()).toContain('Could not read the certificate')
|
||||
})
|
||||
|
||||
it('rechecks on demand', async () => {
|
||||
const wrapper = await render({ configured: false })
|
||||
const before = api.get.mock.calls.filter(
|
||||
([path]) => path === '/endpoints/settings/server-cert/live').length
|
||||
await wrapper.find('.live-recheck').trigger('click')
|
||||
await flushPromises()
|
||||
const after = api.get.mock.calls.filter(
|
||||
([path]) => path === '/endpoints/settings/server-cert/live').length
|
||||
expect(after).toBe(before + 1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user