Make main the lean product a site receives

main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

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

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

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

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

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

DECISIONS THIS ENCODES, rather than defers:

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

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

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
This commit is contained in:
cproudlock
2026-09-19 11:24:40 -04:00
parent cb3975049c
commit 534f179ee6
675 changed files with 115759 additions and 60239 deletions

View File

@@ -24,15 +24,37 @@ export default [
component: () => import('./views/MonitorsSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
},
// TEMPORARY / DEV ONLY. A manual bench to exercise the update_app and
// run_script delivery path end to end while Cameron builds the real UI.
// Gated on endpoints.deploy inside the view; remove once the production
// delivery UI lands.
{
path: 'endpoints/delivery-test',
name: 'endpoints-delivery-test',
component: () => import('./views/DeliveryTestBench.vue'),
meta: { requiresAuth: true, plugin: 'endpoints' }
path: 'settings/endpoints/server-cert',
name: 'endpoints-server-cert-settings',
component: () => import('./views/ServerCertSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
},
// requiresPermission, not requiresAdmin: endpoints.runscript is grantable to
// non-admin operators, and the sibling pages' admin gate would lock the
// holders of the grant out of the library they are trusted to manage. The
// API enforces the same permission, so this only keeps the menu honest.
{
path: 'settings/endpoints/scripts',
name: 'endpoints-scripts-settings',
component: () => import('./views/SystemScriptsSettings.vue'),
meta: {
requiresAuth: true,
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

@@ -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 &gt; Endpoints
&gt; 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 &gt; Endpoints &gt; 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>
@@ -222,10 +257,11 @@
<div class="card form-card">
<p class="field-hint">
Produce an instance-locked agent package stamped with this server's
URL, the endpoint agent key, and the reporting cadence, so it deploys
with no manual configuration on the client. The download includes the
agent, a non-secret <code>appsettings.json</code>, and an
<code>install.ps1</code> that writes the credentials to the registry
URL, server pin, optionally the endpoint agent key, and the reporting
cadence, so it deploys with no manual configuration on the client. The
download includes the agent, a non-secret
<code>appsettings.json</code>, and an <code>install.ps1</code> that
writes the settings to the registry
(<code>HKLM\SOFTWARE\GE\ShopDB</code>) - the key never lands in
appsettings.
</p>
@@ -245,14 +281,46 @@
placeholder="https://shopdb.example"
/>
<label class="field-label" for="agent-key">Endpoint agent key</label>
<label class="checkbox-row">
<input v-model="agentincludesharedkey" type="checkbox" />
<span>
Include shared key. When off, the package holds no key and each PC
requests enrollment for an admin to approve in Settings &gt;
Endpoints &gt; Enrollment.
</span>
</label>
<template v-if="agentincludesharedkey">
<label class="field-label" for="agent-key">Endpoint agent key</label>
<input
id="agent-key"
v-model="agentcollectorkey"
class="form-control mono"
type="text"
placeholder="Paste the endpoint agent key generated above"
/>
</template>
<label class="field-label" for="agent-nextpin">
Next server pin (optional)
</label>
<input
id="agent-key"
v-model="agentcollectorkey"
id="agent-nextpin"
v-model.trim="agentnextserverpin"
class="form-control mono"
type="text"
placeholder="Paste the endpoint agent key generated above"
maxlength="64"
placeholder="64 lowercase hex characters"
/>
<p class="field-hint">
Current server pin:
<code>{{ agentserverpin || 'not available (no server certificate found)' }}</code>.
Add the next certificate's pin only ahead of a planned certificate
rotation.
</p>
<p v-if="agentnextpininvalid()" class="field-error">
The next server pin must be exactly 64 lowercase hex characters.
</p>
<label class="field-label" for="agent-cadence">
Reporting cadence (seconds)
@@ -287,9 +355,11 @@
/>
<p class="field-hint">
For a reverse-proxy or internal-CA server this app cannot see, upload
the server's public certificate (or issuing CA) in PEM format. It is
validated, bundled with the package, and imported into the endpoint's
Trusted Root store by <code>install.ps1</code>. Leave empty when the
the server's public certificate in PEM format. It is validated,
bundled with the package, pinned, and imported into the endpoint's
Trusted Root store by <code>install.ps1</code> (a CA-flagged
certificate is bundled and pinned but never imported, so only agents
1.20 and newer will trust the server). Leave empty when the
server uses a publicly trusted certificate. An upload takes precedence
over the bundled server certificate above.
</p>
@@ -298,15 +368,183 @@
<button
class="btn btn-primary"
type="button"
:disabled="agentbusy || !agentbaseurl || !agentcollectorkey"
:disabled="agentbusy || !agentbaseurl
|| (agentincludesharedkey && !agentcollectorkey)
|| agentnextpininvalid()"
@click="buildSiteAgent"
>
{{ agentbusy ? 'Building...' : 'Build site agent' }}
</button>
</div>
<p v-if="agentmessage" class="field-hint">{{ agentmessage }}</p>
<p v-if="agentwarning" class="field-warning">{{ agentwarning }}</p>
<p v-if="agenterror" class="field-error">{{ agenterror }}</p>
<!-- ================================================================
One-line install. An admin mints a short-lived token and hands a
tech one command; the token only fetches the installer. The
package it serves never carries the shared key, so the PC waits
in Pending until an admin approves its enrollment. -->
<div class="card bootstrap-block">
<h4>One-line install</h4>
<p class="field-hint">
Generate a short-lived token and give a technician the command.
On a PC with no agent, run it from an <strong>elevated</strong>
PowerShell. The token only fetches the installer: it carries no
shared key and provisions nothing on its own. A managed bay should
keep getting the agent from GE-Enforce instead.
</p>
<div class="bootstrap-inputs">
<div>
<label class="field-label" for="bootstrap-hours">
Valid for (hours)
</label>
<input
id="bootstrap-hours"
v-model.number="bootstraphours"
class="form-control"
type="number"
min="1"
max="168"
/>
</div>
<div>
<label class="field-label" for="bootstrap-maxuses">
Maximum uses
</label>
<input
id="bootstrap-maxuses"
v-model.number="bootstrapmaxuses"
class="form-control"
type="number"
min="1"
max="500"
/>
</div>
</div>
<div class="button-row">
<button
class="btn btn-primary"
type="button"
:disabled="bootstrapbusy"
@click="generateBootstrapToken"
>
{{ bootstrapbusy ? 'Generating...' : 'Generate install command' }}
</button>
</div>
<template v-if="bootstrapcommand">
<p class="field-warning">
Copy this now. The token is shown once and cannot be recovered.
</p>
<label class="field-label" for="bootstrap-command">
Install command
</label>
<div class="bootstrap-command-row">
<input
id="bootstrap-command"
class="form-control mono"
type="text"
readonly
:value="bootstrapcommand"
/>
<button
class="btn btn-secondary"
type="button"
@click="copyBootstrapCommand"
>
{{ bootstrapcopied ? 'Copied' : 'Copy' }}
</button>
</div>
<p v-if="bootstrapscripturl" class="field-hint">
Or save the script and run it on the PC instead:
<a
class="bootstrap-download"
:href="bootstrapscripturl"
download="shopdb-bootstrap.ps1"
>
Download .ps1
</a>
(the same token; it carries the secret, so treat the file the
way you treat the command).
</p>
<p v-if="bootstraptoken" class="field-hint">
Expires {{ formatDate(bootstraptoken.expiresat) }}.
{{ bootstraptoken.remaining }} of
{{ bootstraptoken.maxuses }} uses remaining.
</p>
</template>
<p class="field-hint">
The PC installs with no shared key and then waits for approval in
Settings &gt; Endpoints &gt; Enrollment.
</p>
<!-- The deprecated query form of the bootstrap token. Rendered here
because this is the page that mints the command the setting
governs: turning it off invalidates every command already handed
out that carries ?t=. -->
<label class="checkbox-row">
<input
type="checkbox"
class="toggle-querytoken"
:checked="querytokenallowed"
:disabled="querytokenbusy"
@change="onQueryTokenToggle($event)"
/>
<span>
<strong>Accept the token in the URL.</strong> On by default:
the pasted one-line command and the Download .ps1 link above both
send the token as <code>?t=</code>, which is the only form a
browser download link can use. The token lands in the IIS and
proxy logs that way, so the header form
(<code>X-Endpoint-Bootstrap-Token</code>) replaces it. Turn this off
and every install command still in a technician's hands stops
working immediately - re-issue them first.
</span>
</label>
<p v-if="querytokenerror" class="field-error">{{ querytokenerror }}</p>
<table v-if="bootstraptokens.length" class="versions-table">
<thead>
<tr>
<th>Token</th>
<th>State</th>
<th>Expires</th>
<th>Uses</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="entry in bootstraptokens" :key="entry.tokenid">
<td class="mono">{{ entry.tokenprefix }}</td>
<td>{{ entry.state }}</td>
<td>{{ formatDate(entry.expiresat) }}</td>
<td>{{ entry.usecount }} / {{ entry.maxuses }}</td>
<td>
<button
v-if="entry.state === 'active'"
class="btn-link"
type="button"
:disabled="bootstrapbusy"
@click="revokeBootstrapToken(entry.tokenid)"
>
Revoke
</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="field-hint">No install tokens yet.</p>
<p v-if="bootstrapmessage" class="field-hint">
{{ bootstrapmessage }}
</p>
<p v-if="bootstraperror" class="field-error">{{ bootstraperror }}</p>
</div>
<!-- ================================================================
Agent binary versions: upload a new build, keep the last few,
and roll the current pointer back to a retained version. Staging
@@ -319,9 +557,9 @@
<div
v-for="binary in binaries"
:key="binary.key"
class="binary-block"
class="card binary-block"
>
<h4>{{ binary.label }}</h4>
<h4 class="binary-title">{{ binary.label }}</h4>
<p class="field-hint">
Upload {{ binary.name }} updated EXE. Uploading here will update the
binaries available for your "Build Site Agent" install package.
@@ -355,7 +593,7 @@
<button
class="btn btn-secondary"
type="button"
:disabled="binary.busy || !binary.version || !binary.file"
:disabled="binary.busy || binary.detecting || !binary.file"
@click="uploadBinary(binary.key)"
>
{{ binary.busy ? 'Uploading...' : 'Upload current release' }}
@@ -370,8 +608,9 @@
Upload legacy build for rollback
</label>
<p class="field-warning">
WARNING: confirm agent version before uploading from the EXE
properties and enter the version number.
The version is read from the EXE itself. Leave this blank to use the
binary's own file version, or enter a dotted-numeric override only
when the build carries no readable version.
</p>
<div class="binary-upload">
<input
@@ -389,7 +628,7 @@
<button
class="btn btn-secondary"
type="button"
:disabled="binary.busy || !binary.legacyversion || !binary.legacyfile"
:disabled="binary.busy || !binary.legacyfile"
@click="uploadLegacy(binary.key)"
>
{{ binary.busy ? 'Uploading...' : 'Upload legacy build' }}
@@ -495,7 +734,7 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import api from '@/api'
@@ -508,6 +747,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('')
@@ -528,6 +770,38 @@ const agentcertfile = ref(null)
const agentbusy = ref(false)
const agentmessage = ref('')
const agenterror = ref('')
const agentwarning = ref('')
const agentincludesharedkey = ref(true)
const agentnextserverpin = ref('')
const agentserverpin = ref('')
// One-line install tokens. bootstrapcommand holds the secret ONLY for the
// response that minted it: the list read never carries one, so a reload leaves
// the field empty rather than re-showing a token nobody can recover.
const bootstraphours = ref(24)
const bootstrapmaxuses = ref(25)
const bootstrapbusy = ref(false)
const bootstrapcommand = ref('')
const bootstrapscripturl = ref('')
const bootstraptoken = ref(null)
const bootstrapcopied = ref(false)
const bootstraptokens = ref([])
const bootstrapmessage = ref('')
const bootstraperror = ref('')
// plugin.endpoints.bootstrapquerytoken, read and written through the core
// settings API: the endpoints plugin has no settings route of its own for it,
// and the key is declared in get_settings_defaults so the upsert on PUT gets
// the boolean valuetype and the description from that declaration.
const QUERY_TOKEN_SETTING = 'plugin.endpoints.bootstrapquerytoken'
const querytokenallowed = ref(true)
const querytokenbusy = ref(false)
const querytokenerror = ref('')
function agentnextpininvalid() {
const pin = agentnextserverpin.value
return !!pin && !/^[0-9a-f]{64}$/.test(pin)
}
// Versioned agent/tray binaries. Each block owns its own upload form, retained
// version list, and busy/message state so the two rows act independently. The
@@ -568,6 +842,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.pendingcomputers || 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
@@ -587,12 +895,17 @@ onMounted(async () => {
} finally {
loading.value = false
}
loadEnrollment()
loadBootstrapTokens()
loadQueryTokenSetting()
try {
const response = await api.get('/endpoints/siteagent')
const status = response.data.data || {}
agentstaged.value = !!status.agentstaged
certavailable.value = !!status.certavailable
agentbundlecert.value = !!status.certavailable
agentincludesharedkey.value = status.sharedkeyallowed !== false
agentserverpin.value = status.serverpin || ''
applyBinaryStatus('agent', status.agentcurrent, status.agentversions)
applyBinaryStatus('tray', status.traycurrent, status.trayversions)
} catch (statusError) {
@@ -600,6 +913,117 @@ onMounted(async () => {
}
})
async function loadQueryTokenSetting() {
try {
const response = await api.get(`/settings/${QUERY_TOKEN_SETTING}`)
// The backend reads it as bool(Setting.get(key, True)), so only an
// explicit false is off. A row seeded as boolean comes back as a real
// false; an older row that was created as a string comes back as 'false'.
const value = response.data.data?.value
querytokenallowed.value = !(value === false || value === 'false')
} catch (loadError) {
// 404 = the row was never seeded (plugin installed before the key was
// declared). The backend default is on, so show on.
if (loadError.response?.status !== 404) console.error(loadError)
querytokenallowed.value = true
}
}
async function onQueryTokenToggle(event) {
const turningOn = event.target.checked
if (!turningOn) {
const answer = prompt(
'Stop accepting the token in the URL?\n\n'
+ 'Every install command already handed out uses ?t= and stops '
+ 'working at once, including the Download .ps1 links. Re-issue them '
+ 'before turning this off.\n\n'
+ 'Type OFF to confirm.')
if (!answer || answer.trim().toUpperCase() !== 'OFF') {
event.target.checked = true
return
}
}
querytokenbusy.value = true
querytokenerror.value = ''
try {
// PUT upserts, so this works on a site whose row was never seeded.
await api.put(`/settings/${QUERY_TOKEN_SETTING}`, { value: turningOn })
querytokenallowed.value = turningOn
} catch (saveError) {
querytokenerror.value = saveError.response?.status === 403
? 'Only an administrator can change this setting'
: 'Could not save the setting'
event.target.checked = querytokenallowed.value
console.error(saveError)
} finally {
querytokenbusy.value = false
}
}
async function loadBootstrapTokens() {
try {
const response = await api.get('/endpoints/siteagent/bootstrap/tokens')
bootstraptokens.value = response.data.data?.tokens || []
} catch (loadError) {
bootstraptokens.value = []
console.error(loadError)
}
}
async function generateBootstrapToken() {
bootstrapbusy.value = true
bootstrapmessage.value = ''
bootstraperror.value = ''
bootstrapcommand.value = ''
bootstrapscripturl.value = ''
bootstraptoken.value = null
bootstrapcopied.value = false
try {
const response = await api.post('/endpoints/siteagent/bootstrap/tokens', {
hours: bootstraphours.value,
maxuses: bootstrapmaxuses.value
})
const data = response.data.data || {}
bootstrapcommand.value = data.command || ''
bootstrapscripturl.value = data.scripturl || ''
bootstraptoken.value = data.token || null
bootstrapmessage.value = 'Install command generated.'
await loadBootstrapTokens()
} catch (generateError) {
bootstraperror.value = generateError.response?.data?.data?.error?.message
|| 'Could not generate an install token'
console.error(generateError)
} finally {
bootstrapbusy.value = false
}
}
async function copyBootstrapCommand() {
try {
await navigator.clipboard.writeText(bootstrapcommand.value)
bootstrapcopied.value = true
} catch (copyError) {
console.error(copyError)
}
}
async function revokeBootstrapToken(tokenid) {
bootstrapbusy.value = true
bootstrapmessage.value = ''
bootstraperror.value = ''
try {
await api.post(`/endpoints/siteagent/bootstrap/tokens/${tokenid}/revoke`)
bootstrapmessage.value = 'Install token revoked.'
await loadBootstrapTokens()
} catch (revokeError) {
bootstraperror.value = revokeError.response?.data?.data?.error?.message
|| 'Could not revoke the install token'
console.error(revokeError)
} finally {
bootstrapbusy.value = false
}
}
function applyBinaryStatus(binkey, current, versions) {
const binary = binaryFor(binkey)
if (!binary) return
@@ -609,8 +1033,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
@@ -637,8 +1062,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
@@ -713,10 +1139,17 @@ async function buildSiteAgent() {
agentbusy.value = true
agentmessage.value = ''
agenterror.value = ''
agentwarning.value = ''
try {
const form = new FormData()
form.append('baseurl', agentbaseurl.value)
form.append('collectorkey', agentcollectorkey.value)
if (agentincludesharedkey.value) {
form.append('collectorkey', agentcollectorkey.value)
}
form.append('includesharedkey', agentincludesharedkey.value ? 'true' : 'false')
if (agentnextserverpin.value) {
form.append('nextserverpin', agentnextserverpin.value)
}
form.append('cadenceseconds', agentcadenceseconds.value)
form.append('bundlecert', agentbundlecert.value ? 'true' : 'false')
if (agentcertfile.value) {
@@ -738,6 +1171,7 @@ async function buildSiteAgent() {
link.remove()
window.URL.revokeObjectURL(url)
agentmessage.value = 'Site agent built.'
agentwarning.value = response.headers?.['x-shopdb-build-warning'] || ''
} catch (buildError) {
if (buildError.response?.status === 400 && buildError.response?.data) {
agenterror.value = await readBlobError(buildError.response.data)
@@ -756,7 +1190,8 @@ async function detectVersion(binkey, file) {
const form = new FormData()
form.append('file', file)
const response = await api.post(
`/endpoints/siteagent/binaries/${binkey}/detect`, form)
`/endpoints/siteagent/binaries/${binkey}/detect`, form,
{ headers: { 'Content-Type': 'multipart/form-data' } })
return response.data.data?.version || ''
}
@@ -789,7 +1224,7 @@ function onLegacyFileChange(binkey, event) {
async function uploadBinary(binkey) {
const binary = binaryFor(binkey)
if (!binary || !binary.file || !binary.version) return
if (!binary || !binary.file) return
binary.busy = true
binary.message = ''
binary.error = ''
@@ -798,7 +1233,8 @@ async function uploadBinary(binkey) {
form.append('version', binary.version)
form.append('file', binary.file)
const response = await api.post(
`/endpoints/siteagent/binaries/${binkey}`, form)
`/endpoints/siteagent/binaries/${binkey}`, form,
{ headers: { 'Content-Type': 'multipart/form-data' } })
const data = response.data.data || {}
applyBinaryStatus(binkey, data.current, data.versions)
binary.version = ''
@@ -817,7 +1253,7 @@ async function uploadBinary(binkey) {
async function uploadLegacy(binkey) {
const binary = binaryFor(binkey)
if (!binary || !binary.legacyfile || !binary.legacyversion) return
if (!binary || !binary.legacyfile) return
binary.busy = true
binary.message = ''
binary.error = ''
@@ -827,7 +1263,8 @@ async function uploadLegacy(binkey) {
form.append('file', binary.legacyfile)
form.append('makecurrent', 'false')
const response = await api.post(
`/endpoints/siteagent/binaries/${binkey}`, form)
`/endpoints/siteagent/binaries/${binkey}`, form,
{ headers: { 'Content-Type': 'multipart/form-data' } })
const data = response.data.data || {}
applyBinaryStatus(binkey, data.current, data.versions)
binary.legacyversion = ''
@@ -903,13 +1340,39 @@ 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; }
/* Each agent is its own nested card inside the collapsible, so the two
binaries read as distinct units instead of one continuous wall of
controls separated by a hairline rule. */
.binary-block {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #333);
margin-top: 1rem;
padding: 1rem;
}
.binary-block:first-of-type { margin-top: 0.75rem; }
.binary-title {
margin: 0 0 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border, #333);
}
.binary-upload {
display: flex;
@@ -969,6 +1432,23 @@ function editConfiguration() {
padding: 0.3rem 0.5rem;
border-bottom: 1px solid var(--color-border, #333);
}
.bootstrap-block {
margin-top: 1rem;
padding: 1rem;
}
.bootstrap-inputs {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
.bootstrap-inputs .form-control { max-width: 9rem; }
.bootstrap-command-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.bootstrap-command-row .form-control { flex: 1; }
.current-tag {
font-size: 0.8rem;
color: var(--color-success, #4caf50);

View File

@@ -1,327 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>Delivery Test Bench</h2>
</div>
<!--
TEMPORARY / DEV ONLY. This view exists to exercise the update_app and
run_script delivery path end to end and to take notes for the real UI
(owned by Cameron). It is gated on endpoints.deploy. Remove it once the
production delivery UI lands. Nothing here is a supported operator surface.
-->
<div class="card dev-note">
<strong>Development test vehicle.</strong>
This bench pushes an allowlisted delivery command to one endpoint and reads
back the result. The agent only runs a script name it already holds in its
local allowlist, and only installs a ShopDB-hosted package pinned by digest.
Both actions require the <code>endpoints.deploy</code> permission.
</div>
<div v-if="!candeploy" class="card load-error">
Your account lacks the <code>endpoints.deploy</code> permission, so no
delivery command can be queued. Ask an administrator to grant it.
</div>
<template v-else>
<!-- 1. Pick the target endpoint -->
<div class="card bench-section">
<h3>1. Target endpoint</h3>
<div class="bench-row">
<input
v-model="endpointSearch"
type="text"
class="form-control"
placeholder="Search hostname or asset..."
@keyup.enter="loadEndpoints"
/>
<button class="btn btn-secondary btn-sm" :disabled="busy" @click="loadEndpoints">Search</button>
</div>
<select v-model="selectedAssetId" class="form-control" :disabled="busy">
<option value="">Select an endpoint...</option>
<option v-for="row in endpoints" :key="row.assetid" :value="row.assetid">
{{ row.hostname }} (asset {{ row.assetid }})
</option>
</select>
</div>
<!-- 2. Run a ShopDB-hosted script from the library -->
<div class="card bench-section">
<h3>2. Run script</h3>
<p class="bench-hint">
Pick a script from the ShopDB script library. The agent fetches the
script body by digest with its collector credential and runs it in the
script's configured run context (system or the interactive user).
</p>
<div class="bench-row">
<select v-model="selectedScriptId" class="form-control" :disabled="busy">
<option value="">Select script...</option>
<option v-for="script in scripts" :key="script.id" :value="script.id">
{{ script.scriptname }} ({{ script.runcontext }})
<template v-if="!script.sha256"> (no body - upload first)</template>
</option>
</select>
<button
class="btn btn-warning btn-sm"
:disabled="busy || !selectedAssetId || !selectedScriptId"
@click="pushScript"
>
Push run_script
</button>
</div>
</div>
<!-- 3. Install/update an application -->
<div class="card bench-section">
<h3>3. Install/update application</h3>
<p class="bench-hint">
Pick an application, then the app-level install source (the installer
uploaded on the application itself). The agent fetches the pinned
package by digest with its collector credential and runs it silently.
</p>
<div class="bench-row">
<select v-model="selectedAppId" class="form-control" :disabled="busy" @change="loadSources">
<option value="">Select application...</option>
<option v-for="app in applications" :key="app.appid" :value="app.appid">
{{ app.appname }}
</option>
</select>
<select v-model="selectedSource" class="form-control" :disabled="busy || !selectedAppId">
<option value="">Select install source...</option>
<option v-for="source in installSources" :key="source.value" :value="source.value">
{{ source.label }}
</option>
</select>
<button
class="btn btn-warning btn-sm"
:disabled="busy || !selectedAssetId || !selectedSource"
@click="pushUpdateApp"
>
Push update_app
</button>
</div>
</div>
<!-- 4. Command results (the Web UI log pane) -->
<div class="card bench-section">
<div class="bench-row bench-row-between">
<h3>4. Command log (Web UI side)</h3>
<button class="btn btn-secondary btn-sm" :disabled="busy || !selectedAssetId" @click="loadCommands">
Refresh
</button>
</div>
<p v-if="!selectedAssetId" class="bench-hint">Select an endpoint to see its recent commands.</p>
<table v-else-if="commands.length" class="table bench-table">
<thead>
<tr>
<th>When</th>
<th>Action</th>
<th>Target</th>
<th>Status</th>
<th>Result</th>
</tr>
</thead>
<tbody>
<tr v-for="command in commands" :key="command.commandid">
<td>{{ formatWhen(command.createdat) }}</td>
<td>{{ command.action }}</td>
<td>{{ command.target || '-' }}</td>
<td><span class="status-badge" :class="statusClass(command.status)">{{ command.status }}</span></td>
<td class="result-cell">{{ command.resultmessage || '-' }}</td>
</tr>
</tbody>
</table>
<p v-else class="bench-hint">No commands recorded for this endpoint yet.</p>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { endpointsApi, applicationsApi, scriptsApi } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const authStore = useAuthStore()
const toast = useToast()
// Gated in the UI for convenience only; every enqueue is re-checked server-side.
const candeploy = computed(() => authStore.hasPermission('endpoints.deploy'))
const busy = ref(false)
const endpointSearch = ref('')
const endpoints = ref([])
const selectedAssetId = ref('')
const scripts = ref([])
const selectedScriptId = ref('')
const applications = ref([])
const selectedAppId = ref('')
const installSources = ref([])
const selectedSource = ref('')
const commands = ref([])
onMounted(() => {
if (!candeploy.value) return
loadEndpoints()
loadApplications()
loadScripts()
})
async function loadEndpoints() {
busy.value = true
try {
const params = { perpage: 100 }
if (endpointSearch.value) params.search = endpointSearch.value
const response = await endpointsApi.list(params)
endpoints.value = response.data.data || []
} catch (error) {
toast.error(apiError(error, 'Could not load endpoints'))
endpoints.value = []
} finally {
busy.value = false
}
}
async function loadApplications() {
try {
applications.value = await applicationsApi.listAll()
} catch (error) {
applications.value = []
}
}
async function loadScripts() {
try {
scripts.value = await scriptsApi.listAll()
} catch (error) {
scripts.value = []
}
}
// Build the install-source options for the chosen application: the app-level
// source (the installer uploaded on the application itself). The wire target is
// 'app:<id>', which the server resolves into a pinned delivery descriptor.
async function loadSources() {
selectedSource.value = ''
installSources.value = []
if (!selectedAppId.value) return
const sources = []
const app = applications.value.find(row => String(row.appid) === String(selectedAppId.value))
if (app && app.installpath) {
sources.push({
value: `app:${app.appid}`,
label: app.installersha256
? 'App-level installer'
: 'App-level installer (no digest - upload package first)',
})
}
installSources.value = sources
}
async function pushScript() {
await enqueue('run_script', `script:${selectedScriptId.value}`, 'run_script')
}
async function pushUpdateApp() {
await enqueue('update_app', selectedSource.value, 'update_app')
}
async function enqueue(action, target, label) {
if (!selectedAssetId.value) return
busy.value = true
try {
await endpointsApi.enqueueCommand(selectedAssetId.value, action, target)
toast.success(`${label} queued`)
await loadCommands()
} catch (error) {
// 409 = a command is already in flight for this endpoint (at-most-one queue).
const status = error.response?.status
if (status === 409) {
toast.info('A command is already in flight for this endpoint')
} else {
toast.error(apiError(error, `${label} failed`))
}
} finally {
busy.value = false
}
}
async function loadCommands() {
if (!selectedAssetId.value) return
try {
const response = await endpointsApi.listCommands(selectedAssetId.value)
commands.value = response.data.data || []
} catch (error) {
commands.value = []
}
}
function formatWhen(iso) {
if (!iso) return '-'
const date = new Date(iso)
return Number.isNaN(date.getTime()) ? iso : date.toLocaleString()
}
function statusClass(status) {
if (status === 'succeeded') return 'ok'
if (status === 'failed') return 'bad'
if (status === 'expired') return 'bad'
if (status === 'running') return 'warn'
return ''
}
</script>
<style scoped>
.dev-note {
border-left: 3px solid var(--warning, #d6a012);
}
.bench-section h3 {
margin-top: 0;
}
.bench-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
align-items: center;
}
.bench-row-between {
justify-content: space-between;
}
.bench-hint {
color: var(--text-light);
font-size: 0.9rem;
}
.bench-table {
width: 100%;
}
.result-cell {
max-width: 32rem;
white-space: pre-wrap;
word-break: break-word;
}
.status-badge {
padding: 0.1rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.85rem;
}
.status-badge.ok {
color: var(--success, #2e7d32);
}
.status-badge.bad {
color: var(--danger, #c62828);
}
.status-badge.warn {
color: var(--warning, #d6a012);
}
.load-error {
color: var(--danger, #c62828);
}
</style>

View File

@@ -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">
@@ -61,6 +67,16 @@
</select>
</div>
<!-- Placeholder for the staged agent rollout. Shown only to operators who
hold the grant it will require, so it reads as "not built yet" to the
people waiting on it rather than as a missing permission to everyone
else. This page is due a refactor; the utility lands with that work. -->
<div v-if="canagentupdate" class="rollout-note">
Bulk Agent Update utility coming soon. Until then, agents update through
the existing installer push, and an endpoint behind the published build is
flagged on its own detail page.
</div>
<!-- Actions live here rather than per row. A tenth column of buttons
pushed itself off the edge of a nowrap table, and selecting one row
costs a single click while giving one command path instead of two. -->
@@ -87,10 +103,13 @@
<input type="checkbox" :checked="allselected" @change="toggleAll" title="Select all on this page" />
</th>
<th>Status</th>
<th>Alias</th>
<th>Hostname</th>
<th>Machine</th>
<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>
@@ -110,6 +129,7 @@
<td>
<span class="status-dot" :class="item.status"></span>{{ statusLabel(item.status) }}
</td>
<td>{{ item.alias || '-' }}</td>
<td>{{ item.hostname || '-' }}</td>
<td>
<!-- What stops if this PC is dead. On a shop floor that is
@@ -123,12 +143,18 @@
class="health-pill" :class="item.health.level">{{ item.health.label }}</span>
<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 ? 9 : 8" 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>
@@ -178,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([])
@@ -190,6 +227,9 @@ const toast = useToast()
// Gated in the UI for convenience only; every enqueue is re-checked server-side.
const cancommand = computed(() => authStore.hasPermission('endpoints.command'))
const canrestart = computed(() => authStore.hasPermission('endpoints.restart'))
// Reserved for the staged rollout. Agent replacement is the control plane
// modifying itself, so it stays separate from ordinary app deployment.
const canagentupdate = computed(() => authStore.hasPermission('endpoints.agentupdate'))
const allselected = computed(() =>
endpoints.value.length > 0 && selected.value.length === endpoints.value.length)
@@ -274,23 +314,26 @@ onMounted(() => {
async function loadFilterOptions() {
// listAll, not list: perpage is clamped to 100 server-side, so a long type or
// location list would silently lose its tail in a dropdown.
try {
computertypes.value = await computersApi.types.listAll()
} catch (error) {
computertypes.value = []
}
try {
locations.value = await locationsApi.listAll()
} catch (error) {
locations.value = []
}
// Issued together: neither list feeds the other, so awaiting them in sequence
// only added a round trip to first paint. Each keeps its own fallback so one
// failing dropdown does not empty the other.
const [types, places] = await Promise.all([
computersApi.types.listAll().catch(() => []),
locationsApi.listAll().catch(() => [])
])
computertypes.value = types
locations.value = places
}
async function loadSummary() {
try {
const response = await endpointsApi.dashboardSummary()
// Issued together: the stability call does not depend on the summary, so
// sequencing them just stacked the slower request behind the faster one.
const [response, stability] = await Promise.all([
endpointsApi.dashboardSummary(),
endpointsApi.dashboardStability()
])
summary.value = response.data.data || null
const stability = await endpointsApi.dashboardStability()
criticalcount.value = (stability.data.data || []).length
} catch (error) {
// The strip is a convenience, not the page. If it fails the table still
@@ -309,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
@@ -348,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] || '-'
}
@@ -374,6 +423,7 @@ function setStatus(value) {
function clearFilters() {
status.value = ''
credentialstate.value = ''
problemsonly.value = false
applyFilters()
}
@@ -495,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;
@@ -508,6 +574,18 @@ function changePerPage(newPerPage) {
.bulk-count { font-size: 0.9rem; margin-right: 0.25rem; }
/* Deliberately quieter than .bulk-bar: this announces something that does not
exist yet, so it must not compete with controls that actually do work. */
.rollout-note {
padding: 0.6rem 0.9rem;
margin-bottom: 0.75rem;
font-size: 0.9rem;
color: var(--text-light);
background: var(--bg-card);
border: 1px dashed var(--border);
border-radius: 0.25rem;
}
.select-cell { width: 1%; }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,640 @@
<template>
<div>
<div class="page-header">
<h2>Server Certificate</h2>
</div>
<p class="setting-description">
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 -->
<section class="settings-section">
<h3>Current certificate</h3>
<div class="card form-card">
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="loading" class="field-hint">Loading certificate details...</div>
<template v-else-if="cert">
<div v-if="cert.expires_in_days < 30" class="warning-banner">
<strong>Warning:</strong> Certificate expires in {{ cert.expires_in_days }} days
({{ formatDate(cert.not_after) }}). Renew soon to avoid service disruption.
</div>
<div class="form-group">
<label>Common Name (CN)</label>
<p class="field-hint mono">{{ cert.subject_cn }}</p>
</div>
<div class="form-group">
<label>Subject Alternative Names</label>
<p class="field-hint mono">{{ cert.san_list.join(', ') }}</p>
</div>
<div class="form-group">
<label>Valid From</label>
<p class="field-hint">{{ formatDate(cert.not_before) }}</p>
</div>
<div class="form-group">
<label>Valid Until</label>
<p class="field-hint" :class="{ 'expiry-warning': cert.expires_in_days < 30 }">
{{ formatDate(cert.not_after) }}
<span v-if="cert.expires_in_days >= 0">
({{ cert.expires_in_days }} days remaining)
</span>
<span v-else class="expired-text">
(EXPIRED {{ Math.abs(cert.expires_in_days) }} days ago)
</span>
</p>
</div>
<div class="form-group">
<label>Thumbprint (SHA256)</label>
<p class="field-hint mono">{{ cert.thumbprint }}</p>
<button class="btn btn-secondary btn-sm" @click="copyThumbprint">
{{ thumbprintCopied ? 'Copied' : 'Copy thumbprint' }}
</button>
<p class="field-hint">
Use this value for ServerCertThumbprint in agent appsettings.json
to enable certificate pinning (prevents MITM attacks).
</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>
<span v-if="cert.is_self_signed" class="badge">Self-signed</span>
</div>
</template>
<template v-else>
<p class="field-hint">No certificate file found at certs/server.crt</p>
</template>
</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>
<p class="field-hint">
Create a new self-signed certificate for development or air-gapped sites.
The certificate will be valid for 1 year (365 days).
</p>
<div class="card form-card">
<div class="form-group">
<label for="cn">Common Name (CN)</label>
<input
id="cn"
v-model="generateForm.cn"
type="text"
class="form-control"
placeholder="hostname or IP"
/>
<p class="field-hint">
Hostname or IP that agents use to connect, exactly as it appears in
the agent's base URL.
</p>
</div>
<div class="form-group">
<label for="san">Subject Alternative Names (SANs)</label>
<input
id="san"
v-model="generateForm.san"
type="text"
class="form-control"
placeholder="DNS:hostname,IP:address"
/>
<p class="field-hint">
Comma-separated list of DNS:hostname and IP:address entries. Include
every name and address a bay may use to reach this server; a name
that is missing here fails certificate validation on the endpoint.
</p>
</div>
<div class="form-group">
<label for="days">Validity (days)</label>
<input
id="days"
v-model.number="generateForm.days"
type="number"
class="form-control"
min="1"
max="3650"
/>
<p class="field-hint">
Certificate lifetime (recommended: 365 for self-signed)
</p>
</div>
<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. 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">
<button
class="btn btn-primary"
@click="generateCertificate"
:disabled="busy || !generateForm.cn"
>
{{ busy ? 'Generating...' : 'Generate self-signed certificate' }}
</button>
</div>
</div>
</section>
<!-- Upload certificate -->
<section class="settings-section">
<h3>Upload certificate</h3>
<p class="field-hint">
Upload a certificate from a trusted CA or internal PKI. You must provide
both the certificate (.crt or .pem) and private key (.key).
</p>
<div class="card form-card">
<div class="form-group">
<label for="cert-file">Certificate file</label>
<input
id="cert-file"
type="file"
accept=".crt,.pem"
@change="onCertFileChange"
class="form-control"
/>
<p class="field-hint">
PEM-encoded certificate (.crt or .pem)
</p>
</div>
<div class="form-group">
<label for="key-file">Private key file</label>
<input
id="key-file"
type="file"
accept=".key,.pem"
@change="onKeyFileChange"
class="form-control"
/>
<p class="field-hint">
PEM-encoded private key (.key or .pem, unencrypted)
</p>
</div>
<div class="warning-banner" v-if="cert">
<strong>Warning:</strong> Uploading a new certificate will replace the current one.
Nginx will be restarted automatically.
</div>
<div class="button-row">
<button
class="btn btn-primary"
@click="uploadCertificate"
:disabled="busy || !uploadForm.certFile || !uploadForm.keyFile"
>
{{ busy ? 'Uploading...' : 'Upload certificate' }}
</button>
</div>
</div>
</section>
<!-- Documentation -->
<section class="settings-section">
<div class="card form-card">
<details class="collapsible">
<summary><h4>Certificate requirements</h4></summary>
<ul class="field-hint info-list">
<li>
<strong>Format:</strong> PEM-encoded X.509 certificate and RSA/ECDSA private key
</li>
<li>
<strong>Private key:</strong> Must be unencrypted (no passphrase)
</li>
<li>
<strong>Common Name:</strong> Must match the hostname agents use to connect
</li>
<li>
<strong>Subject Alternative Names:</strong> Include all hostnames and IPs agents might use
</li>
<li>
<strong>Trust:</strong> Self-signed certs require ServerCertThumbprint pinning on agents,
or agents must disable validation (development only)
</li>
</ul>
</details>
<details class="collapsible">
<summary><h4>Agent certificate pinning</h4></summary>
<p class="field-hint">
To prevent MITM attacks with self-signed certificates, configure agents to validate
the server certificate thumbprint:
</p>
<ol class="field-hint info-list">
<li>Copy the SHA256 thumbprint from the "Current certificate" section above</li>
<li>Update each agent's appsettings.json:
<pre class="code-block">{
"ServerCertThumbprint": "AA:BB:CC:..."
}</pre>
</li>
<li>Restart the ShopDbEndpointAgent service on each agent</li>
</ol>
<p class="field-hint">
<strong>Important:</strong> When you renew the server certificate, you must update
ServerCertThumbprint on all agents before the old certificate expires, or agents
will reject connections.
</p>
</details>
<details class="collapsible">
<summary><h4>Air-gapped deployment</h4></summary>
<p class="field-hint">
For air-gapped sites, generate the certificate on the work PC and include it in
the deployment bundle:
</p>
<ol class="field-hint info-list">
<li>Generate certificate using this page (use site-specific CN/SANs)</li>
<li>Certificate files are saved to certs/ directory (already in git)</li>
<li>Commit and bundle the code (certificate included automatically)</li>
<li>Deploy bundle to site - nginx picks up certificate on startup</li>
</ol>
</details>
</div>
</section>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '@/api'
const loading = ref(false)
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)
// No baked host or address (ADR-015: no site literals in product code). One
// site's LAN address was shipped here as the default SAN, which every other
// site would have generated a certificate for. The operator types what this
// server is actually reached by; the form refuses to submit empty.
const generateForm = ref({
cn: '',
san: '',
days: 365
})
const uploadForm = ref({
certFile: null,
keyFile: null
})
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 = ''
try {
const response = await api.get('/endpoints/settings/server-cert')
if (response.data.status === 'success') {
cert.value = response.data.data
} else {
error.value = response.data.data?.error?.message || 'Failed to load certificate'
}
} catch (err) {
error.value = `Failed to load certificate: ${err.message}`
} finally {
loading.value = false
}
}
async function generateCertificate() {
if (!generateForm.value.cn) {
error.value = 'Common Name is required'
return
}
if (!confirm(
'Generate a new self-signed certificate? This will replace the current certificate. ' +
'Agents using thumbprint pinning will need to be reconfigured.'
)) {
return
}
busy.value = true
error.value = ''
message.value = ''
try {
const response = await api.post('/endpoints/settings/server-cert/generate', generateForm.value)
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'
}
} catch (err) {
error.value = `Failed to generate certificate: ${err.message}`
} finally {
busy.value = false
}
}
function onCertFileChange(event) {
uploadForm.value.certFile = event.target.files[0]
}
function onKeyFileChange(event) {
uploadForm.value.keyFile = event.target.files[0]
}
async function uploadCertificate() {
if (!uploadForm.value.certFile || !uploadForm.value.keyFile) {
error.value = 'Both certificate and private key files are required'
return
}
if (!confirm(
'Upload new certificate? This will replace the current certificate and restart nginx.'
)) {
return
}
busy.value = true
error.value = ''
message.value = ''
try {
const formData = new FormData()
formData.append('cert', uploadForm.value.certFile)
formData.append('key', uploadForm.value.keyFile)
const response = await api.post('/endpoints/settings/server-cert/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
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 = ''
uploadForm.value.certFile = null
uploadForm.value.keyFile = null
} else {
error.value = response.data.data?.error?.message || 'Failed to upload certificate'
}
} catch (err) {
error.value = `Failed to upload certificate: ${err.message}`
} finally {
busy.value = false
}
}
function copyThumbprint() {
navigator.clipboard.writeText(cert.value.thumbprint)
thumbprintCopied.value = true
setTimeout(() => {
thumbprintCopied.value = false
}, 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)
return date.toLocaleString()
}
</script>
<style scoped>
.warning-banner {
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
padding: 12px;
margin-bottom: 16px;
color: #856404;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.warning-banner {
background: #664d03;
border-color: #ffca2c;
color: #ffecb5;
}
}
.expiry-warning {
color: #d63384;
font-weight: 600;
}
.expired-text {
color: #dc3545;
font-weight: bold;
}
.badge {
display: inline-block;
padding: 4px 8px;
background: #6c757d;
color: white;
border-radius: 4px;
font-size: 12px;
margin-left: 8px;
}
.mono {
font-family: 'Courier New', monospace;
font-size: 13px;
}
.btn-sm {
padding: 4px 12px;
font-size: 13px;
margin-top: 8px;
}
.code-block,
pre.code-block {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 12px;
margin: 8px 0;
font-family: 'Courier New', monospace;
font-size: 13px;
overflow-x: auto;
color: #212529;
white-space: pre;
}
/* Dark mode support for code blocks */
@media (prefers-color-scheme: dark) {
.code-block {
background: #2b2b2b !important;
border-color: #444 !important;
color: #e0e0e0 !important;
}
/* Ensure pre tags inside lists also get dark mode */
.info-list .code-block,
.field-hint .code-block,
pre.code-block {
background: #2b2b2b !important;
border-color: #444 !important;
color: #e0e0e0 !important;
}
}
.info-list {
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;
}
</style>

View File

@@ -0,0 +1,443 @@
<template>
<div>
<div class="page-header">
<h2>System Scripts</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Script</button>
</div>
<p class="setting-description">
PowerShell the site agent can be told to run on an endpoint. A script is
stored here once, pinned by digest, and fetched by the agent at push time
so the bytes that run are the bytes that were reviewed.
</p>
<div class="warning-banner">
<strong>Every script here runs as SYSTEM.</strong>
There is no user context and no prompt: the agent is a service, so a
script cannot see a signed-in person's desktop, mapped drives, or
credentials. Scripts are intended to TRIGGER an already registered
scheduled task on the endpoint, which is what does the real work. Anything
written to act "as the logged-on user" will not behave the way it reads.
</div>
<details class="explainer">
<summary>How does this work?</summary>
<div class="explainer-body">
<h4>Delivery</h4>
<p>
The body is stored here and hashed. At push time the agent downloads it
with its own credential and checks the digest before running anything,
so the bytes that run are the bytes that were reviewed. The body never
travels on the command channel and is deleted from the endpoint after
the run.
</p>
<h4>Runs in</h4>
<p>
<strong>System</strong> is the agent's own service account: no desktop,
no mapped drives, no signed-in person's credentials.
<strong>User</strong> runs in the interactive session instead, and only
works when somebody is actually signed in.
</p>
<h4>Run in folder</h4>
<p>
The working directory for the run. Blank means the agent's staging
folder, which is right for a self-contained script. Set it when the
script calls a tool installed separately on the endpoint by relative
path. If the folder is missing the run fails outright rather than
running somewhere unintended.
</p>
<h4>When a line fails</h4>
<p>
This is the one worth understanding. PowerShell treats most errors as
<em>non-terminating</em>: it writes the error, carries on to the next
line, and still exits zero. Because the command's verdict comes from
that exit code, <strong>Continue</strong> means a script can report
success having accomplished nothing.
</p>
<p>
<strong>Stop</strong> makes the first error end the run, so the verdict
means what you would assume it means. It is not the default because it
changes how a script behaves: a body that tolerates a failing cleanup,
probes with a cmdlet that errors when something is absent, or loops
past bad items will start aborting partway through, leaving the
endpoint half-configured. Read the script first, then switch it on.
</p>
<h4>Where the output goes</h4>
<p>
Script output is always written to the agent log on the endpoint. It is
sent back to ShopDB only when the run fails, so routine output stays on
the machine that produced it. Long output is truncated and marked as
such.
</p>
<p class="hint">
In Stop mode the agent adds one line to the top of the script, so an
error reported on line N refers to line N-1 of the body shown here.
</p>
</div>
</details>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Runs in</th>
<th>On error</th>
<th>Body</th>
<th>Digest</th>
<th>Active</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="s in scripts" :key="s.scriptid">
<td>{{ s.scriptname }}</td>
<td class="cell-truncate" :title="s.scriptdescription">
{{ s.scriptdescription || '-' }}
</td>
<td class="cell-truncate mono" :title="s.workingdirectory">
{{ s.workingdirectory || 'Agent staging folder' }}
</td>
<td>
<span v-if="s.errormode === 'strict'" class="badge badge-secondary">
Stop on first error
</span>
<span v-else class="badge badge-warning" title="A failing line does not fail the command">
Continue
</span>
<span
v-if="s.captureoutput"
class="badge badge-secondary"
title="A successful run returns its output to ShopDB"
>
Returns output
</span>
</td>
<td>
<span v-if="s.hasbody" class="badge badge-secondary">{{ s.filename }}</span>
<span v-else class="badge badge-warning">No body - cannot run</span>
</td>
<td class="cell-truncate mono" :title="s.sha256">{{ shortdigest(s) }}</td>
<td>{{ s.isactive ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(s)">Edit</button>
<button class="btn btn-danger btn-sm" @click="remove(s)">Delete</button>
</td>
</tr>
<tr v-if="scripts.length === 0">
<td colspan="8" style="text-align:center; color: var(--text-light);">
No scripts
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay">
<div class="modal modal-lg">
<div class="modal-header">
<h3>{{ editing ? 'Edit Script' : 'Add Script' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="warning-banner">
<strong>This script will run as SYSTEM on every endpoint it is
pushed to.</strong>
Write it to trigger a scheduled task that already exists on the
endpoint. It has no user session, so anything depending on a
person being signed in will not work.
</div>
<div class="form-group">
<label>Name *</label>
<input v-model="form.scriptname" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description <span class="hint">(what it triggers, and why)</span></label>
<textarea v-model="form.scriptdescription" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>
Script file <span class="hint">(.ps1, replaces the stored body)</span>
</label>
<input type="file" accept=".ps1" class="form-control" @change="pickfile" />
<p v-if="editing && editing.hasbody" class="hint">
Current: {{ editing.filename }}. Uploading replaces it and
restamps the digest.
</p>
<p v-else-if="editing" class="hint">
No body uploaded yet. A script without a body cannot be pushed.
</p>
</div>
<div class="form-group">
<label>
Run in folder
<span class="hint">(optional, endpoint-local absolute path)</span>
</label>
<input
v-model="form.workingdirectory"
type="text"
class="form-control mono"
placeholder="C:\Program Files\CorporateTools"
/>
<details class="field-help">
<summary>How this works</summary>
<p>
Leave blank to run from the agent's staging folder. Set this only
when the script calls a tool already installed on the endpoint by
relative path. The folder must exist on the endpoint or the push
fails rather than running somewhere else.
</p>
</details>
</div>
<div class="form-group">
<label>
When a line fails
<span class="hint">(how the run is judged)</span>
</label>
<select v-model="form.errormode" class="form-control">
<option value="default">Continue - report success if the script reaches the end</option>
<option value="strict">Stop on the first error and fail the command</option>
</select>
<details class="field-help">
<summary>How this works</summary>
<p>
PowerShell normally writes an error and carries on, and still
exits zero, so a script can report success having done none of
its work. Stop makes the first failure end the run and fail the
command. Leave this on Continue unless you have read the script
and know it does not rely on a step being allowed to fail.
</p>
</details>
</div>
<div class="form-group">
<label>
<input v-model="form.captureoutput" type="checkbox" />
Return output to ShopDB on success
</label>
<details class="field-help">
<summary>How this works</summary>
<p>
A failed run always sends its output back, so you can diagnose
it from the Run Scripts history without going to the machine.
A successful run does not, unless you tick this.
</p>
<p>
Leave it off for scripts whose output could contain anything
sensitive - account names, share paths, anything a Write-Host
left behind. Turn it on for scripts where seeing the output is
the only way to tell the run did real work. Either way the
full output is written to the agent log on the endpoint.
</p>
</details>
</div>
<div class="form-group">
<label>
<input v-model="form.isactive" type="checkbox" />
Active
</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { scriptsApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const toast = useToast()
const scripts = ref([])
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const pendingfile = ref(null)
const form = ref(emptyForm())
function emptyForm() {
return {
scriptname: '',
scriptdescription: '',
workingdirectory: '',
errormode: 'default',
captureoutput: false,
isactive: true
}
}
function shortdigest(s) {
return s.sha256 ? s.sha256.slice(0, 12) : '-'
}
async function loadData() {
loading.value = true
try {
const response = await scriptsApi.list({ active: 'false' })
scripts.value = response.data.data || []
} catch (err) {
toast.error(apiError(err, 'Failed to load scripts'))
} finally {
loading.value = false
}
}
function pickfile(event) {
pendingfile.value = event.target.files[0] || null
}
function openModal(script = null) {
error.value = ''
pendingfile.value = null
editing.value = script
form.value = script
? {
scriptname: script.scriptname,
scriptdescription: script.scriptdescription || '',
workingdirectory: script.workingdirectory || '',
errormode: script.errormode || 'default',
captureoutput: !!script.captureoutput,
isactive: script.isactive
}
: emptyForm()
showModal.value = true
}
function closeModal() {
showModal.value = false
editing.value = null
pendingfile.value = null
}
async function save() {
saving.value = true
error.value = ''
try {
const payload = {
scriptname: form.value.scriptname,
scriptdescription: form.value.scriptdescription,
workingdirectory: form.value.workingdirectory,
errormode: form.value.errormode,
captureoutput: form.value.captureoutput,
isactive: form.value.isactive
}
let scriptid
if (editing.value) {
await scriptsApi.update(editing.value.scriptid, payload)
scriptid = editing.value.scriptid
} else {
const created = await scriptsApi.create(payload)
scriptid = created.data.data.scriptid
}
// Second call on purpose: the body is multipart and the catalog row is
// JSON. A failure here leaves a bodyless row, which the list marks as
// unrunnable rather than pretending it is ready to push.
if (pendingfile.value) {
await scriptsApi.uploadBody(scriptid, pendingfile.value)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save script')
} finally {
saving.value = false
}
}
async function remove(s) {
if (!confirm(`Delete script "${s.scriptname}"?`)) return
try {
await scriptsApi.delete(s.scriptid)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
onMounted(loadData)
</script>
<style scoped>
.cell-truncate {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mono { font-family: monospace; }
.hint { color: var(--text-light); font-weight: normal; }
.warning-banner {
border: 1px solid var(--warning);
border-left-width: 4px;
border-radius: 4px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
background: rgba(184, 134, 11, 0.08);
}
.explainer {
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 1rem;
background: var(--bg-card);
color: var(--text);
}
.explainer summary {
cursor: pointer;
padding: 0.6rem 1rem;
font-weight: 600;
color: var(--text);
}
.explainer-body {
padding: 0 1rem 0.75rem;
}
.explainer-body h4 {
margin: 0.9rem 0 0.25rem;
font-size: 0.95rem;
color: var(--text);
}
.explainer-body p {
margin: 0 0 0.5rem;
color: var(--text-light);
line-height: 1.5;
}
.field-help {
margin-top: 0.4rem;
}
.field-help summary {
cursor: pointer;
color: var(--text-light);
font-size: 0.85rem;
}
.field-help p {
margin: 0.35rem 0 0;
color: var(--text-light);
font-size: 0.85rem;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,210 @@
/**
* One-line install block on the endpoints agent key page.
*
* The token is shown exactly once, so the two things worth pinning are that the
* command rendered is the one the server returned, and that reloading the page
* does NOT put a token back on screen - the list read carries no secret, and a
* field that repopulated from it would be lying about what is recoverable.
*/
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 TOKENS_URL = '/endpoints/siteagent/bootstrap/tokens'
const SECRET = 'sdbb_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG'
const SCRIPT_URL =
`https://shopdb.test/shopdb/api/endpoints/siteagent/bootstrap.ps1?t=${SECRET}`
// Download then run: Defender kills the `irm ... | iex` shape before it runs.
const TEMP = 'C:\\Windows\\Temp\\shopdb-bootstrap.ps1'
const COMMAND =
'powershell -ExecutionPolicy Bypass -c "try { Invoke-WebRequest '
+ `-UseBasicParsing '${SCRIPT_URL}' -OutFile '${TEMP}'; & '${TEMP}' }`
+ ` finally { Remove-Item '${TEMP}' -Force -ErrorAction SilentlyContinue }"`
const MINTED_TOKEN = {
tokenid: 7,
tokenprefix: 'sdbb_012345',
createdat: '2026-09-15T10:00:00Z',
expiresat: '2026-09-16T10:00:00Z',
maxuses: 25,
usecount: 0,
remaining: 25,
revokedat: null,
state: 'active'
}
function listResponse(tokens) {
return { data: { data: { tokens } } }
}
async function render(tokens = []) {
api.get.mockImplementation((path) => {
if (path === TOKENS_URL) return Promise.resolve(listResponse(tokens))
if (path === '/endpoints/agentkey') {
return Promise.resolve({ data: { data: { key: null, envkeyfallback: false } } })
}
return Promise.resolve({ data: { data: {} } })
})
const wrapper = mount(AgentKeySettings, {
global: { stubs: { RouterLink: RouterLinkStub } }
})
await flushPromises()
return wrapper
}
function generateButton(wrapper) {
return wrapper.findAll('button')
.find((button) => button.text() === 'Generate install command')
}
describe('AgentKeySettings one-line install', () => {
beforeEach(() => {
api.get.mockReset()
api.post.mockReset()
api.delete.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('says the PC waits for approval and that managed bays use GE-Enforce', async () => {
const wrapper = await render()
const block = wrapper.find('.bootstrap-block').text()
expect(block).toContain('One-line install')
expect(block).toMatch(/waits for approval in\s+Settings > Endpoints > Enrollment/)
expect(block).toContain('GE-Enforce')
expect(block).toContain('elevated')
})
it('generate posts the hours and maximum uses', async () => {
api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
})
const wrapper = await render()
await wrapper.find('#bootstrap-hours').setValue(48)
await wrapper.find('#bootstrap-maxuses').setValue(5)
await generateButton(wrapper).trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith(TOKENS_URL, { hours: 48, maxuses: 5 })
})
it('defaults to 24 hours and 25 uses', async () => {
api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
})
const wrapper = await render()
expect(wrapper.find('#bootstrap-hours').element.value).toBe('24')
expect(wrapper.find('#bootstrap-maxuses').element.value).toBe('25')
await generateButton(wrapper).trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith(TOKENS_URL, { hours: 24, maxuses: 25 })
})
it('renders the returned command, the once-only warning and the expiry', async () => {
api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
})
const wrapper = await render()
await generateButton(wrapper).trigger('click')
await flushPromises()
const field = wrapper.find('#bootstrap-command')
expect(field.exists()).toBe(true)
expect(field.element.value).toBe(COMMAND)
expect(field.element.value).toContain(SECRET)
expect(field.attributes('readonly')).toBeDefined()
const block = wrapper.find('.bootstrap-block').text()
expect(block).toContain('The token is shown once and cannot be recovered')
expect(block).toContain('25 of')
expect(block).toContain('uses remaining')
})
it('offers the same token as a Download .ps1 link', async () => {
api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
})
const wrapper = await render()
expect(wrapper.find('.bootstrap-download').exists()).toBe(false)
await generateButton(wrapper).trigger('click')
await flushPromises()
const link = wrapper.find('.bootstrap-download')
expect(link.attributes('href')).toBe(SCRIPT_URL)
expect(link.attributes('download')).toBe('shopdb-bootstrap.ps1')
expect(wrapper.find('#bootstrap-command').element.value).not.toContain(' | iex')
})
it('copies the command to the clipboard', async () => {
const writeText = vi.fn(() => Promise.resolve())
vi.stubGlobal('navigator', { clipboard: { writeText } })
api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
})
const wrapper = await render()
await generateButton(wrapper).trigger('click')
await flushPromises()
const copy = wrapper.findAll('button').find((button) => button.text() === 'Copy')
await copy.trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith(COMMAND)
expect(wrapper.find('.bootstrap-block').text()).toContain('Copied')
})
it('lists live tokens and revoke posts to that token', async () => {
api.post.mockResolvedValue({ data: { data: { token: MINTED_TOKEN } } })
const wrapper = await render([MINTED_TOKEN])
expect(api.get).toHaveBeenCalledWith(TOKENS_URL)
const rows = wrapper.findAll('.bootstrap-block tbody tr')
expect(rows).toHaveLength(1)
expect(rows[0].text()).toContain('sdbb_012345')
expect(rows[0].text()).toContain('0 / 25')
const revoke = wrapper.findAll('button').find((button) => button.text() === 'Revoke')
await revoke.trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith(`${TOKENS_URL}/7/revoke`)
})
it('offers no Revoke on a token that is already spent', async () => {
const wrapper = await render([
{ ...MINTED_TOKEN, state: 'revoked', usecount: 2 }
])
expect(wrapper.findAll('button').find((button) => button.text() === 'Revoke'))
.toBeUndefined()
})
it('does not show the secret again after a reload', async () => {
api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
})
const first = await render()
await generateButton(first).trigger('click')
await flushPromises()
expect(first.find('#bootstrap-command').exists()).toBe(true)
// The reload sees the token in the list read, which carries no secret.
const reloaded = await render([MINTED_TOKEN])
expect(reloaded.find('#bootstrap-command').exists()).toBe(false)
expect(reloaded.text()).not.toContain(SECRET)
expect(reloaded.text()).not.toContain('The token is shown once')
// The row is still there, by prefix only.
expect(reloaded.find('.bootstrap-block tbody tr').text()).toContain('sdbb_012345')
})
})

View 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, pendingcomputers: 2, pending: 3 },
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)
})
})

View 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/)
})
})

View File

@@ -0,0 +1,345 @@
/**
* 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'] },
{ assetid: 13, hostname: 'PC-TRUST', agentversion: '1.19.2', lastreportedat: null, state: 'blocked',
blockers: ['server certificate not trusted by this PC'] },
{ assetid: 14, hostname: 'PC-TRUST-OFFLINE', agentversion: '1.19.2', lastreportedat: null, state: 'blocked',
blockers: ['offline', 'server certificate not trusted by this PC'] }
],
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,
maxpendingenrollments: 500 },
counts: { enrolled: 40, notenrolled: 58, pendingcomputers: 1, pending: 2 },
computers: [
{ assetid: 21, hostname: 'PC-ENROLLED', serialnumber: 'S1', state: 'enrolled',
credentialprefix: 'sdbe_abcdefg', issuedat: null, lastusedat: null, enrolledvia: 'auto',
reenrollblocked: false, reenrollblockedreason: null },
{ assetid: 22, hostname: 'PC-ROLLEDBACK', serialnumber: 'S2', state: 'revoked',
credentialprefix: 'sdbe_hijklmn', issuedat: null, lastusedat: null, enrolledvia: 'auto',
reenrollblocked: true, reenrollblockedreason: 'rollback' }
],
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('offers a trust-only blocked PC for retry, not one with another blocker too', async () => {
const wrapper = await render()
const rows = wrapper.findAll('.migration-row')
const trust = rows.find(r => r.text().includes('PC-TRUST') && !r.text().includes('OFFLINE'))
const mixed = rows.find(r => r.text().includes('PC-TRUST-OFFLINE'))
expect(trust.find('input[type="checkbox"]').exists()).toBe(true)
expect(trust.find('.retry-hint').text()).toBe('Retry after fixing trust')
expect(mixed.find('input[type="checkbox"]').exists()).toBe(false)
expect(mixed.find('.retry-hint').exists()).toBe(false)
})
it('includes a trust-blocked PC in Upgrade selected', async () => {
vi.stubGlobal('confirm', vi.fn(() => true))
const wrapper = await render()
const rows = wrapper.findAll('.migration-row')
await rows[0].find('input[type="checkbox"]').setValue(true)
const trust = rows.find(r => r.text().includes('PC-TRUST') && !r.text().includes('OFFLINE'))
await trust.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, 13] })
})
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(confirmSpy.mock.calls[0][0]).toMatch(
/older than 1\.20 must trust the server certificate before they can download the upgrade/)
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 })
})
// Approving a one-off PC onto a computer recorded under another hostname is
// refused by the server, because the PC authenticates by hostname afterwards.
// The page has to show that message and let the admin answer it, not swallow
// it as "could not approve".
function hostnameConflict() {
return {
response: {
status: 409,
data: {
data: {
error: {
code: 'CONFLICT',
message: 'That computer is recorded as PC-OLD, but the request comes from PC-NEW.',
details: {
hostnameconflict: true,
computerhostname: 'PC-OLD',
requesthostname: 'PC-NEW'
}
}
}
}
}
}
}
it('offers to set the hostname when approve comes back 409, then re-posts', async () => {
const confirmSpy = vi.fn(() => true)
vi.stubGlobal('confirm', confirmSpy)
const wrapper = await render()
await openCredentials(wrapper)
api.post.mockReset()
api.post.mockRejectedValueOnce(hostnameConflict())
api.post.mockResolvedValue({ data: { data: { hostnameresult: 'overwritten' } } })
await wrapper.find('.approve-open').trigger('click')
await wrapper.find('.approve-confirm').trigger('click')
await flushPromises()
expect(api.post.mock.calls).toEqual([
['/endpoints/enrollment/requests/45/approve', { assetid: 77 }],
['/endpoints/enrollment/requests/45/approve', { assetid: 77, sethostname: true }]
])
const asked = confirmSpy.mock.calls.at(-1)[0]
expect(asked).toContain('recorded as PC-OLD')
expect(asked).toContain('Set the hostname on this PC?')
})
it('sends nothing more when the hostname question is declined', async () => {
vi.stubGlobal('confirm', vi.fn().mockReturnValueOnce(true).mockReturnValueOnce(false))
const wrapper = await render()
await openCredentials(wrapper)
api.post.mockReset()
api.post.mockRejectedValue(hostnameConflict())
await wrapper.find('.approve-open').trigger('click')
await wrapper.find('.approve-confirm').trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledTimes(1)
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 () => {
const wrapper = await render()
await openCredentials(wrapper)
await wrapper.find('.revoke').trigger('click')
expect(api.post).not.toHaveBeenCalled()
expect(wrapper.find('.revoke-row').text()).toContain('Keep this PC from enrolling again')
expect(wrapper.find('.revoke-block').element.checked).toBe(false)
await wrapper.find('.revoke-confirm').trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/21/revoke')
})
it('sends blockreenroll with the reason when the block box is ticked', async () => {
const wrapper = await render()
await openCredentials(wrapper)
await wrapper.find('.revoke').trigger('click')
await wrapper.find('.revoke-block').setValue(true)
await wrapper.find('.revoke-reason').setValue(' rollback ')
await wrapper.find('.revoke-confirm').trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/21/revoke',
{ blockreenroll: true, reason: 'rollback' })
})
it('shows the block chip and allows re-enrollment after confirm', async () => {
const confirmSpy = vi.fn(() => true)
vi.stubGlobal('confirm', confirmSpy)
const wrapper = await render()
await openCredentials(wrapper)
const rows = wrapper.findAll('.credential-row')
const blocked = rows.find(r => r.text().includes('PC-ROLLEDBACK'))
const enrolled = rows.find(r => r.text().includes('PC-ENROLLED'))
expect(blocked.find('.block-chip').text()).toBe('Re-enroll blocked')
expect(enrolled.find('.block-chip').exists()).toBe(false)
expect(enrolled.find('.allow-reenroll').exists()).toBe(false)
await blocked.find('.allow-reenroll').trigger('click')
await flushPromises()
expect(confirmSpy).toHaveBeenCalledTimes(1)
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/22/allow-reenroll')
})
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)
})
it('saves a new pending request limit', async () => {
const wrapper = await render()
await openCredentials(wrapper)
const field = wrapper.find('.input-maxpending')
expect(field.element.value).toBe('500')
await field.setValue(25)
await field.trigger('change')
await flushPromises()
expect(api.put).toHaveBeenCalledWith('/endpoints/enrollment/settings',
{ maxpendingenrollments: 25 })
})
// The negative case is proved server side (tests/test_plugins/
// test_endpoints_enroll_limits.py: PUT with -1 is a 400). Asserting it here
// would only be asserting what vue-test-utils does to a number input.
})

View 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)
})
})

View File

@@ -0,0 +1,124 @@
/**
* System Scripts settings page.
*
* Two things are worth pinning here and nothing else is:
*
* 1. The page must NOT offer a run context. Scripts run as SYSTEM because the
* agent is a service with no user session; exposing a picker would let
* someone author a script that reads as "run as the logged-on user" and
* silently does not.
* 2. The SYSTEM warning must be present on both the list and the add/edit
* form. Someone pasting a script into the modal may never have read the
* page copy above it.
*
* Asserted against the file text, matching configurationPolicyEditable.spec.js.
*/
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const here = dirname(fileURLToPath(import.meta.url))
const source = readFileSync(join(here, 'SystemScriptsSettings.vue'), 'utf8')
const routes = readFileSync(join(here, '..', 'routes.js'), 'utf8')
describe('SystemScriptsSettings', () => {
it('warns that scripts run as SYSTEM on the list page and in the form', () => {
const warnings = source.match(/runs? as SYSTEM/g) || []
expect(warnings.length).toBeGreaterThanOrEqual(2)
})
it('does not offer a run context picker', () => {
expect(source).not.toMatch(/runcontext/i)
})
it('does not send a runcontext in its save payload', () => {
const payload = source.slice(source.indexOf('const payload = {'))
expect(payload.slice(0, 200)).not.toMatch(/runcontext/i)
})
it('flags a script with no body as unrunnable', () => {
expect(source).toMatch(/hasbody/)
expect(source).toMatch(/cannot run/i)
})
it('is gated on the standalone script permission, not admin', () => {
const route = routes.slice(routes.indexOf("'settings/endpoints/scripts'"))
expect(route).toMatch(/requiresPermission: 'endpoints\.runscript'/)
expect(route.slice(0, 300)).not.toMatch(/requiresAdmin/)
})
it('offers an optional working directory and sends it on save', () => {
expect(source).toMatch(/form\.workingdirectory/)
const payload = source.slice(source.indexOf('const payload = {'))
expect(payload.slice(0, 300)).toMatch(/workingdirectory/)
})
it('shows the staging default rather than an empty cell', () => {
// A blank cell reads as "unknown". The folder is the difference between a
// corporate tool being found and the script failing further in, so the
// list has to state the default out loud.
expect(source).toMatch(/Agent staging folder/)
})
it('offers an error mode and sends it on save', () => {
expect(source).toMatch(/form\.errormode/)
const payload = source.slice(source.indexOf('const payload = {'))
expect(payload.slice(0, 300)).toMatch(/errormode/)
})
it('defaults a new script to the permissive mode', () => {
// Strict changes how a body behaves partway through. Defaulting to it
// would silently alter every script authored from here on, so a new
// script has to start where the old ones already are.
const empty = source.slice(source.indexOf('function emptyForm'))
expect(empty.slice(0, 300)).toMatch(/errormode: 'default'/)
})
it('names both error modes by their consequence, not by their flag', () => {
// "default" and "strict" mean nothing to the person choosing. What they
// need to know is which one can report success having done nothing.
expect(source).toMatch(/Continue - report success if the script reaches the end/)
expect(source).toMatch(/Stop on the first error and fail the command/)
})
it('explains the non-terminating error trap that justifies the choice', () => {
// The whole feature exists because PowerShell exits zero after an error.
// If the page never says so, the dropdown looks like pedantry and stays
// on the setting that produces false green.
expect(source).toMatch(/non-terminating/i)
expect(source).toMatch(/exits? zero/i)
})
it('offers the explainer collapsed so it does not bury the page', () => {
expect(source).toMatch(/<details class="explainer">/)
expect(source).toMatch(/How does this work\?/)
expect(source).not.toMatch(/<details class="explainer" open>/)
})
it('collapses the long per-field prose in the form', () => {
// The modal is already tall enough to scroll. Three paragraphs of standing
// explanation between the inputs pushes Save off screen and gets skimmed
// past, which is the opposite of what the copy is for.
const helps = source.match(/<details class="field-help">/g) || []
expect(helps.length).toBeGreaterThanOrEqual(2)
expect(source).toMatch(/<summary>How this works<\/summary>/)
})
it('styles itself from defined theme tokens only', () => {
// --surface and --text-primary are not defined by this app's theme, so a
// var() fallback silently rendered a white panel on the dark background.
// A fallback colour is the bug: it looks correct in light mode and only
// shows up as a bright block for dark-mode users.
const styles = source.slice(source.indexOf('<style scoped>'))
expect(styles).not.toMatch(/--surface|--text-primary|--text-secondary|--bg-secondary/)
expect(styles).not.toMatch(/var\(--[a-z-]+,\s*#/)
})
it('states that output is kept locally and only returned on failure', () => {
// Operators otherwise assume either that nothing was captured or that
// every run is shipped back to the server.
const explainer = source.slice(source.indexOf('Where the output goes'))
expect(explainer.slice(0, 400)).toMatch(/only when the run fails/i)
})
})