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.
1255 lines
41 KiB
Vue
1255 lines
41 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>Enrollment</h2>
|
|
</div>
|
|
|
|
<p class="setting-description">
|
|
Each endpoint agent authenticates with its own credential, readable only
|
|
by SYSTEM on that PC. Agents from {{ target }} onward enroll themselves
|
|
with the shared key they already hold; older agents move over by
|
|
upgrading. Use Migration to move the fleet, and Credentials to approve
|
|
requests, un-enroll a PC, and retire the shared key.
|
|
</p>
|
|
|
|
<div class="enroll-tabs">
|
|
<button
|
|
type="button"
|
|
class="enroll-tab"
|
|
:class="{ active: tab === 'migration' }"
|
|
@click="tab = 'migration'"
|
|
>
|
|
Migration
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="enroll-tab"
|
|
:class="{ active: tab === 'credentials' }"
|
|
@click="tab = 'credentials'"
|
|
>
|
|
Credentials
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ==================================================================
|
|
Migration
|
|
=============================================================== -->
|
|
<section v-if="tab === 'migration'" class="migration-tab">
|
|
<div v-if="migrationError" class="load-error">{{ migrationError }}</div>
|
|
|
|
<div class="enroll-tiles">
|
|
<button
|
|
v-for="tile in migrationTiles"
|
|
:key="tile.state"
|
|
type="button"
|
|
class="enroll-tile"
|
|
:class="[tile.tone, { on: migrationState === tile.state }]"
|
|
@click="toggleMigrationState(tile.state)"
|
|
>
|
|
<span class="n" :data-count="tile.state">{{ migrationCounts[tile.state] || 0 }}</span>
|
|
<span class="l">{{ tile.label }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="progress-row">
|
|
<div class="progress-track">
|
|
<div class="progress-fill" :style="{ width: enrolledPercent + '%' }"></div>
|
|
</div>
|
|
<span class="progress-text">
|
|
{{ migrationCounts.enrolled || 0 }} of {{ migrationTotal }} enrolled
|
|
</span>
|
|
</div>
|
|
|
|
<div class="filters">
|
|
<input
|
|
v-model="migrationSearch"
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="Search hostname..."
|
|
@input="debouncedMigrationSearch"
|
|
/>
|
|
<select v-model="migrationState" class="form-control enroll-filter" @change="reloadMigration">
|
|
<option value="">All states</option>
|
|
<option v-for="tile in migrationTiles" :key="tile.state" :value="tile.state">
|
|
{{ tile.label }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div v-if="isadmin" class="bulk-bar">
|
|
<span class="bulk-count">{{ selected.length }} selected</span>
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary btn-sm upgrade-selected"
|
|
:disabled="busy || !selected.length"
|
|
@click="upgradeSelected"
|
|
>
|
|
Upgrade selected
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-secondary btn-sm upgrade-next"
|
|
:disabled="busy || !(migrationCounts.ready > 0)"
|
|
@click="upgradeNext"
|
|
>
|
|
Upgrade next {{ waveSize }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div v-if="migrationLoading" class="loading">Loading...</div>
|
|
<div v-else class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th v-if="isadmin" class="select-cell">
|
|
<input
|
|
type="checkbox"
|
|
:checked="allReadySelected"
|
|
:disabled="!readyRows.length"
|
|
title="Select every ready PC on this page"
|
|
@change="toggleAllReady"
|
|
/>
|
|
</th>
|
|
<th>Hostname</th>
|
|
<th>State</th>
|
|
<th>Agent version</th>
|
|
<th>Last reported</th>
|
|
<th>Blockers</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="!migrationRows.length">
|
|
<td :colspan="isadmin ? 6 : 5" class="empty-cell">No computers match.</td>
|
|
</tr>
|
|
<tr v-for="row in migrationRows" :key="row.assetid" class="migration-row">
|
|
<td v-if="isadmin" class="select-cell">
|
|
<input
|
|
v-if="isSelectable(row)"
|
|
v-model="selected"
|
|
type="checkbox"
|
|
:value="row.assetid"
|
|
/>
|
|
</td>
|
|
<td>{{ row.hostname || '-' }}</td>
|
|
<td><span class="state-chip" :class="row.state">{{ stateLabel(row.state) }}</span></td>
|
|
<td>{{ row.agentversion || '-' }}</td>
|
|
<td>{{ formatDate(row.lastreportedat) }}</td>
|
|
<td class="blockers">
|
|
{{ (row.blockers || []).join(', ') || '-' }}
|
|
<span v-if="isadmin && isTrustRetryable(row)" class="retry-hint">
|
|
Retry after fixing trust
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<PaginationBar
|
|
:page="migrationPage"
|
|
:total-pages="migrationTotalPages"
|
|
@update:page="goToMigrationPage"
|
|
/>
|
|
</div>
|
|
|
|
<h3 class="section-title">Waves</h3>
|
|
<div class="card">
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Wave</th>
|
|
<th>Created</th>
|
|
<th>By</th>
|
|
<th>Size</th>
|
|
<th>Queued</th>
|
|
<th>Enrolled</th>
|
|
<th>Failed</th>
|
|
<th>Progress</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="!waves.length">
|
|
<td colspan="8" class="empty-cell">No waves yet.</td>
|
|
</tr>
|
|
<tr v-for="wave in waves" :key="wave.waveid">
|
|
<td>{{ wave.waveid }}</td>
|
|
<td>{{ formatDate(wave.createdat) }}</td>
|
|
<td>{{ wave.createdby || '-' }}</td>
|
|
<td>{{ wave.size }}</td>
|
|
<td>{{ wave.queued }}</td>
|
|
<td>{{ wave.enrolled }}</td>
|
|
<td>{{ wave.failed }}</td>
|
|
<td>
|
|
<div class="progress-track small">
|
|
<div class="progress-fill" :style="{ width: wavePercent(wave) + '%' }"></div>
|
|
</div>
|
|
<span class="field-hint">{{ wave.done ? 'Done' : 'In progress' }}</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ==================================================================
|
|
Credentials
|
|
=============================================================== -->
|
|
<section v-else class="credentials-tab">
|
|
<div v-if="enrollmentError" class="load-error">{{ enrollmentError }}</div>
|
|
|
|
<div class="card form-card">
|
|
<h3>Settings</h3>
|
|
<label class="checkbox-row">
|
|
<input
|
|
type="checkbox"
|
|
class="toggle-sharedkey"
|
|
:checked="enrollmentSettings.sharedkeyallowed"
|
|
:disabled="!isadmin || busy"
|
|
@change="onSharedKeyToggle($event)"
|
|
/>
|
|
<span>
|
|
<strong>Shared key allowed.</strong> PCs that have not enrolled may
|
|
still report with the fleet shared key. An enrolled PC is always
|
|
refused the shared key. Turn this off once every PC is enrolled.
|
|
</span>
|
|
</label>
|
|
<label class="checkbox-row">
|
|
<input
|
|
type="checkbox"
|
|
class="toggle-autoenroll"
|
|
:checked="enrollmentSettings.autoenroll"
|
|
:disabled="!isadmin || busy"
|
|
@change="onAutoEnrollToggle($event)"
|
|
/>
|
|
<span>
|
|
<strong>Auto-enroll.</strong> Issue a credential without approval
|
|
when the hostname matches exactly one computer and the serial number
|
|
on record matches. Anything else waits below for an admin.
|
|
</span>
|
|
</label>
|
|
<label class="checkbox-row">
|
|
<input
|
|
type="checkbox"
|
|
class="toggle-revokeddenies"
|
|
:checked="enrollmentSettings.revokeddeniessharedkey"
|
|
:disabled="!isadmin || busy"
|
|
@change="onRevokedDeniesToggle($event)"
|
|
/>
|
|
<span>
|
|
<strong>A revoked PC loses the shared key too.</strong> (setting
|
|
<code>plugin.endpoints.revokeddeniessharedkey</code>) Off by
|
|
default, which is what the fleet does today: a PC whose credential
|
|
was revoked falls back to the shared key and keeps reporting, so a
|
|
credential rotation does not strand it. Turn this on and a revoke
|
|
becomes an eviction - that PC reports nothing and receives no
|
|
commands until it enrolls again - and the documented rollback of
|
|
revoke plus block re-enrollment onto an older agent that only knows
|
|
the shared key stops working. Only safe once every PC here is
|
|
enrolled.
|
|
</span>
|
|
</label>
|
|
<label class="number-row">
|
|
<span>
|
|
<strong>Pending request limit.</strong> How many requests may wait
|
|
here at once (setting
|
|
<code>plugin.endpoints.maxpendingenrollments</code>). At the limit,
|
|
a PC we have never seen is told to try later, while PCs already in
|
|
this list keep asking and keep being approved. 0 removes the limit.
|
|
/enroll takes no login, so this is what stops the queue growing
|
|
without bound.
|
|
</span>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
step="1"
|
|
class="input-maxpending"
|
|
v-model.number="pendingCapInput"
|
|
:disabled="!isadmin || busy"
|
|
@change="onPendingCapChange()"
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="enroll-tiles">
|
|
<div class="enroll-tile static ok">
|
|
<span class="n" data-count="enrolled">{{ enrollmentCounts.enrolled || 0 }}</span>
|
|
<span class="l">Enrolled</span>
|
|
</div>
|
|
<div class="enroll-tile static warn">
|
|
<span class="n" data-count="notenrolled">{{ enrollmentCounts.notenrolled || 0 }}</span>
|
|
<span class="l">Not enrolled</span>
|
|
</div>
|
|
<div class="enroll-tile static">
|
|
<span class="n" data-count="pending">{{ enrollmentCounts.pending || 0 }}</span>
|
|
<span class="l">Pending requests</span>
|
|
</div>
|
|
</div>
|
|
|
|
<h3 class="section-title">Requests</h3>
|
|
<div class="card">
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Hostname</th>
|
|
<th>Serial</th>
|
|
<th>Device id</th>
|
|
<th>Reason</th>
|
|
<th>Status</th>
|
|
<th>Source</th>
|
|
<th>Last seen</th>
|
|
<th v-if="isadmin"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="!requests.length">
|
|
<td :colspan="isadmin ? 8 : 7" class="empty-cell">No enrollment requests.</td>
|
|
</tr>
|
|
<template v-for="request in requests" :key="request.requestid">
|
|
<tr class="request-row">
|
|
<td>{{ request.hostname }}</td>
|
|
<td>{{ request.serialnumber || '-' }}</td>
|
|
<td>{{ request.deviceid || '-' }}</td>
|
|
<td>{{ request.reason || '-' }}</td>
|
|
<td>{{ request.status }}</td>
|
|
<td>{{ request.sourceip || '-' }}</td>
|
|
<td>{{ formatDate(request.lastseenat) }}</td>
|
|
<td v-if="isadmin" class="action-cell">
|
|
<template v-if="request.status === 'pending'">
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary btn-sm approve-open"
|
|
:disabled="busy"
|
|
@click="openApprove(request)"
|
|
>
|
|
Approve
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-secondary btn-sm reject"
|
|
:disabled="busy"
|
|
@click="reject(request)"
|
|
>
|
|
Reject
|
|
</button>
|
|
</template>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="approving && approving.requestid === request.requestid" class="approve-row">
|
|
<td :colspan="isadmin ? 8 : 7">
|
|
<div class="approve-form">
|
|
<label class="field-label">Issue the credential for computer</label>
|
|
<input
|
|
v-model="approving.search"
|
|
type="text"
|
|
class="form-control asset-search"
|
|
placeholder="Search hostname or serial..."
|
|
@input="debouncedAssetSearch"
|
|
/>
|
|
<select v-model="approving.assetid" class="form-control asset-picker">
|
|
<option :value="null">Choose a computer</option>
|
|
<option
|
|
v-for="option in approving.options"
|
|
:key="option.assetid"
|
|
:value="option.assetid"
|
|
>
|
|
{{ option.label }}
|
|
</option>
|
|
</select>
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary btn-sm approve-confirm"
|
|
:disabled="busy || !approving.assetid"
|
|
@click="approve"
|
|
>
|
|
Approve
|
|
</button>
|
|
<button type="button" class="btn btn-secondary btn-sm" @click="approving = null">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
<p class="field-hint">
|
|
The PC receives its credential the next time it asks:
|
|
about a minute for one that started asking recently,
|
|
slowing to 15 minutes once it has been pending a while.
|
|
Only the PC that made this request can collect it.
|
|
If the computer record has no hostname, approving
|
|
records the one the PC reports, which it needs to
|
|
authenticate afterwards.
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<h3 class="section-title">Computers</h3>
|
|
<div class="filters">
|
|
<input
|
|
v-model="enrollmentSearch"
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="Search hostname..."
|
|
@input="debouncedEnrollmentSearch"
|
|
/>
|
|
<select v-model="enrollmentState" class="form-control enroll-filter" @change="reloadEnrollment">
|
|
<option value="">All states</option>
|
|
<option value="enrolled">Enrolled</option>
|
|
<option value="notenrolled">Not enrolled</option>
|
|
<option value="pending">Pending</option>
|
|
<option value="revoked">Revoked</option>
|
|
</select>
|
|
</div>
|
|
<div class="card">
|
|
<div v-if="enrollmentLoading" class="loading">Loading...</div>
|
|
<div v-else class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Hostname</th>
|
|
<th>Serial</th>
|
|
<th>State</th>
|
|
<th>Credential</th>
|
|
<th>Issued</th>
|
|
<th>Last used</th>
|
|
<th>Via</th>
|
|
<th v-if="isadmin"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="!credentialRows.length">
|
|
<td :colspan="isadmin ? 8 : 7" class="empty-cell">No computers match.</td>
|
|
</tr>
|
|
<template v-for="row in credentialRows" :key="row.assetid">
|
|
<tr class="credential-row">
|
|
<td>{{ row.hostname || '-' }}</td>
|
|
<td>{{ row.serialnumber || '-' }}</td>
|
|
<td>
|
|
<span class="state-chip" :class="row.state">{{ stateLabel(row.state) }}</span>
|
|
<span
|
|
v-if="row.reenrollblocked"
|
|
class="state-chip blocked block-chip"
|
|
:title="row.reenrollblockedreason || ''"
|
|
>Re-enroll blocked</span>
|
|
</td>
|
|
<td>
|
|
<code v-if="row.credentialprefix" class="mono">{{ row.credentialprefix }}...</code>
|
|
<span v-else>-</span>
|
|
</td>
|
|
<td>{{ formatDate(row.issuedat) }}</td>
|
|
<td>{{ formatDate(row.lastusedat) }}</td>
|
|
<td>{{ row.enrolledvia || '-' }}</td>
|
|
<td v-if="isadmin" class="action-cell">
|
|
<button
|
|
v-if="row.state === 'enrolled'"
|
|
type="button"
|
|
class="btn btn-danger btn-sm revoke"
|
|
:disabled="busy"
|
|
@click="openRevoke(row)"
|
|
>
|
|
Un-enroll
|
|
</button>
|
|
<button
|
|
v-if="row.reenrollblocked"
|
|
type="button"
|
|
class="btn btn-secondary btn-sm allow-reenroll"
|
|
:disabled="busy"
|
|
@click="allowReenroll(row)"
|
|
>
|
|
Allow re-enrollment
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="revoking && revoking.assetid === row.assetid" class="revoke-row">
|
|
<td :colspan="isadmin ? 8 : 7">
|
|
<p class="field-hint">
|
|
Un-enroll {{ revoking.hostname }}? Its credential stops working
|
|
immediately. {{ revokeFallback }}
|
|
</p>
|
|
<label class="checkbox-row">
|
|
<input v-model="revoking.block" type="checkbox" class="revoke-block" />
|
|
<span>
|
|
Keep this PC from enrolling again (use before rolling back to
|
|
an older agent, or for a compromised PC)
|
|
</span>
|
|
</label>
|
|
<div class="approve-form">
|
|
<input
|
|
v-if="revoking.block"
|
|
v-model="revoking.reason"
|
|
type="text"
|
|
maxlength="64"
|
|
class="form-control revoke-reason"
|
|
placeholder="Reason (optional)"
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="btn btn-danger btn-sm revoke-confirm"
|
|
:disabled="busy"
|
|
@click="confirmRevoke"
|
|
>
|
|
Un-enroll
|
|
</button>
|
|
<button type="button" class="btn btn-secondary btn-sm" @click="revoking = null">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<PaginationBar
|
|
:page="enrollmentPage"
|
|
:total-pages="enrollmentTotalPages"
|
|
@update:page="goToEnrollmentPage"
|
|
/>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
|
import api, { computersApi } from '@/api'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { useToast } from '@/composables/toast'
|
|
import { apiError } from '@/utils/apiError'
|
|
import PaginationBar from '@/components/PaginationBar.vue'
|
|
|
|
const PERPAGE = 50
|
|
const POLL_MS = 30000
|
|
|
|
const authStore = useAuthStore()
|
|
const toast = useToast()
|
|
// UI convenience only; every write is re-checked server-side.
|
|
const isadmin = computed(() => authStore.isAdmin)
|
|
|
|
const tab = ref('migration')
|
|
const busy = ref(false)
|
|
const waveSize = 25
|
|
|
|
// ---- Migration --------------------------------------------------------
|
|
|
|
const migrationTiles = [
|
|
{ state: 'enrolled', label: 'Enrolled', tone: 'ok' },
|
|
{ state: 'ready', label: 'Ready', tone: 'ok' },
|
|
{ state: 'blocked', label: 'Blocked', tone: 'bad' },
|
|
{ state: 'upgrading', label: 'Upgrading', tone: '' },
|
|
{ state: 'pending', label: 'Pending', tone: 'warn' },
|
|
{ state: 'notreporting', label: 'Not reporting', tone: '' }
|
|
]
|
|
|
|
const target = ref('1.20.0')
|
|
const migrationCounts = ref({})
|
|
const migrationRows = ref([])
|
|
const waves = ref([])
|
|
const migrationPage = ref(1)
|
|
const migrationTotalPages = ref(1)
|
|
const migrationState = ref('')
|
|
const migrationSearch = ref('')
|
|
const migrationLoading = ref(false)
|
|
const migrationError = ref('')
|
|
const selected = ref([])
|
|
|
|
const migrationTotal = computed(() =>
|
|
migrationTiles.reduce((sum, tile) => sum + (Number(migrationCounts.value[tile.state]) || 0), 0))
|
|
|
|
const enrolledPercent = computed(() => {
|
|
if (!migrationTotal.value) return 0
|
|
return Math.round(100 * (Number(migrationCounts.value.enrolled) || 0) / migrationTotal.value)
|
|
})
|
|
|
|
const readyRows = computed(() => migrationRows.value.filter(row => row.state === 'ready'))
|
|
|
|
// The server cannot see a PC's trust store, so this blocker only clears when
|
|
// an admin queues the PC again after fixing trust. Next N never picks it.
|
|
const TRUST_BLOCKER = 'server certificate not trusted by this PC'
|
|
|
|
function isTrustRetryable(row) {
|
|
const blockers = row.blockers || []
|
|
return row.state === 'blocked' && blockers.length > 0
|
|
&& blockers.every(blocker => blocker === TRUST_BLOCKER)
|
|
}
|
|
|
|
function isSelectable(row) {
|
|
return row.state === 'ready' || isTrustRetryable(row)
|
|
}
|
|
const allReadySelected = computed(() =>
|
|
readyRows.value.length > 0 && readyRows.value.every(row => selected.value.includes(row.assetid)))
|
|
|
|
function toggleAllReady(event) {
|
|
selected.value = event.target.checked ? readyRows.value.map(row => row.assetid) : []
|
|
}
|
|
|
|
function toggleMigrationState(state) {
|
|
migrationState.value = migrationState.value === state ? '' : state
|
|
reloadMigration()
|
|
}
|
|
|
|
async function loadMigration({ quiet = false } = {}) {
|
|
if (!quiet) migrationLoading.value = true
|
|
try {
|
|
const params = { page: migrationPage.value, perpage: PERPAGE }
|
|
if (migrationState.value) params.state = migrationState.value
|
|
if (migrationSearch.value) params.search = migrationSearch.value
|
|
const response = await api.get('/endpoints/enrollment/migration', { params })
|
|
const data = response.data.data || {}
|
|
if (data.target) target.value = data.target
|
|
migrationCounts.value = data.counts || {}
|
|
migrationRows.value = data.computers || []
|
|
waves.value = data.waves || []
|
|
migrationTotalPages.value = response.data.meta?.pagination?.totalpages || 1
|
|
// Drop selections that are no longer selectable (queued by a wave, went offline).
|
|
const selectableIds = new Set(migrationRows.value.filter(isSelectable).map(row => row.assetid))
|
|
selected.value = selected.value.filter(id => selectableIds.has(id))
|
|
migrationError.value = ''
|
|
} catch (error) {
|
|
migrationError.value = apiError(error, 'Could not load migration status')
|
|
} finally {
|
|
migrationLoading.value = false
|
|
}
|
|
}
|
|
|
|
function reloadMigration() {
|
|
migrationPage.value = 1
|
|
loadMigration()
|
|
}
|
|
|
|
function goToMigrationPage(page) {
|
|
migrationPage.value = page
|
|
loadMigration()
|
|
}
|
|
|
|
let migrationSearchTimer = null
|
|
function debouncedMigrationSearch() {
|
|
clearTimeout(migrationSearchTimer)
|
|
migrationSearchTimer = setTimeout(reloadMigration, 300)
|
|
}
|
|
|
|
function waveConfirmText(count) {
|
|
return `Upgrade ${count} PC(s) to endpoint agent ${target.value}?\n\n`
|
|
+ 'Each PC downloads the new agent and restarts its service. Once the new '
|
|
+ 'agent is running it enrolls automatically with the shared key it already '
|
|
+ 'holds; no further action is needed unless it lands in Pending.\n\n'
|
|
+ 'PCs on agents older than 1.20 must trust the server certificate before '
|
|
+ 'they can download the upgrade (see runbook).'
|
|
}
|
|
|
|
async function startWave(body, count) {
|
|
if (!count) return
|
|
if (!confirm(waveConfirmText(count))) return
|
|
busy.value = true
|
|
try {
|
|
const response = await api.post('/endpoints/enrollment/migration/wave', body)
|
|
const data = response.data.data || {}
|
|
const queued = (data.queued || []).length
|
|
const skipped = data.skipped || []
|
|
if (queued) toast.success(`Wave ${data.waveid}: upgrade queued for ${queued} PC(s)`)
|
|
const retried = data.retried || []
|
|
if (retried.length) {
|
|
toast.info(`${retried.length} queued again despite the certificate trust blocker: `
|
|
+ retried.slice(0, 5).join(', '))
|
|
}
|
|
if (skipped.length) {
|
|
toast.info(`${skipped.length} skipped: `
|
|
+ skipped.slice(0, 5).map(item => `${item.assetid} (${item.reason})`).join(', '))
|
|
}
|
|
selected.value = []
|
|
await loadMigration({ quiet: true })
|
|
} catch (error) {
|
|
toast.error(apiError(error, 'Could not start the wave'))
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
function upgradeSelected() {
|
|
const assetids = [...selected.value]
|
|
return startWave({ assetids }, assetids.length)
|
|
}
|
|
|
|
function upgradeNext() {
|
|
const count = Math.min(waveSize, Number(migrationCounts.value.ready) || 0)
|
|
return startWave({ next: waveSize }, count)
|
|
}
|
|
|
|
function wavePercent(wave) {
|
|
if (!wave.size) return 0
|
|
return Math.round(100 * ((Number(wave.enrolled) || 0) + (Number(wave.failed) || 0)) / wave.size)
|
|
}
|
|
|
|
// ---- Credentials ------------------------------------------------------
|
|
|
|
const DEFAULT_PENDING_CAP = 500
|
|
|
|
const enrollmentSettings = ref({
|
|
sharedkeyallowed: true,
|
|
autoenroll: true,
|
|
// Matches the seeded default (plugin.endpoints.revokeddeniessharedkey is
|
|
// false), so the box renders unchecked before the first read lands.
|
|
revokeddeniessharedkey: false,
|
|
maxpendingenrollments: DEFAULT_PENDING_CAP
|
|
})
|
|
// Bound with v-model rather than :value so the field holds what the operator
|
|
// typed until it is accepted or rejected; a one-way binding fights the edit.
|
|
const pendingCapInput = ref(DEFAULT_PENDING_CAP)
|
|
const enrollmentCounts = ref({})
|
|
const credentialRows = ref([])
|
|
const requests = ref([])
|
|
const enrollmentPage = ref(1)
|
|
const enrollmentTotalPages = ref(1)
|
|
const enrollmentState = ref('')
|
|
const enrollmentSearch = ref('')
|
|
const enrollmentLoading = ref(false)
|
|
const enrollmentError = ref('')
|
|
const approving = ref(null)
|
|
|
|
async function loadEnrollment({ quiet = false } = {}) {
|
|
if (!quiet) enrollmentLoading.value = true
|
|
try {
|
|
const params = { page: enrollmentPage.value, perpage: PERPAGE }
|
|
if (enrollmentState.value) params.state = enrollmentState.value
|
|
if (enrollmentSearch.value) params.search = enrollmentSearch.value
|
|
const response = await api.get('/endpoints/enrollment', { params })
|
|
const data = response.data.data || {}
|
|
enrollmentSettings.value = {
|
|
sharedkeyallowed: data.settings?.sharedkeyallowed !== false,
|
|
autoenroll: data.settings?.autoenroll !== false,
|
|
// Opt-in, so anything but an explicit true reads as off.
|
|
revokeddeniessharedkey: data.settings?.revokeddeniessharedkey === true,
|
|
maxpendingenrollments: Number.isFinite(Number(data.settings?.maxpendingenrollments))
|
|
? Number(data.settings.maxpendingenrollments)
|
|
: DEFAULT_PENDING_CAP
|
|
}
|
|
pendingCapInput.value = enrollmentSettings.value.maxpendingenrollments
|
|
enrollmentCounts.value = data.counts || {}
|
|
credentialRows.value = data.computers || []
|
|
requests.value = data.requests || []
|
|
enrollmentTotalPages.value = response.data.meta?.pagination?.totalpages || 1
|
|
enrollmentError.value = ''
|
|
} catch (error) {
|
|
enrollmentError.value = apiError(error, 'Could not load enrollment')
|
|
} finally {
|
|
enrollmentLoading.value = false
|
|
}
|
|
}
|
|
|
|
function reloadEnrollment() {
|
|
enrollmentPage.value = 1
|
|
loadEnrollment()
|
|
}
|
|
|
|
function goToEnrollmentPage(page) {
|
|
enrollmentPage.value = page
|
|
loadEnrollment()
|
|
}
|
|
|
|
let enrollmentSearchTimer = null
|
|
function debouncedEnrollmentSearch() {
|
|
clearTimeout(enrollmentSearchTimer)
|
|
enrollmentSearchTimer = setTimeout(reloadEnrollment, 300)
|
|
}
|
|
|
|
async function saveSettings(next) {
|
|
busy.value = true
|
|
try {
|
|
await api.put('/endpoints/enrollment/settings', next)
|
|
toast.success('Enrollment settings saved')
|
|
} catch (error) {
|
|
toast.error(apiError(error, 'Could not save enrollment settings'))
|
|
} finally {
|
|
busy.value = false
|
|
await loadEnrollment({ quiet: true })
|
|
}
|
|
}
|
|
|
|
function onSharedKeyToggle(event) {
|
|
const turningOn = event.target.checked
|
|
if (!turningOn) {
|
|
const notenrolled = Number(enrollmentCounts.value.notenrolled) || 0
|
|
const answer = prompt(
|
|
'Turn OFF the shared key?\n\n'
|
|
+ `${notenrolled} PC(s) are not enrolled yet. Every one of them stops `
|
|
+ 'reporting, stops receiving commands, and cannot enroll on its own until '
|
|
+ 'the shared key is turned back on or an admin approves its request.\n\n'
|
|
+ 'Type OFF to confirm.')
|
|
if (!answer || answer.trim().toUpperCase() !== 'OFF') {
|
|
event.target.checked = true
|
|
return
|
|
}
|
|
}
|
|
return saveSettings({
|
|
sharedkeyallowed: turningOn,
|
|
autoenroll: enrollmentSettings.value.autoenroll
|
|
})
|
|
}
|
|
|
|
function onAutoEnrollToggle(event) {
|
|
return saveSettings({
|
|
sharedkeyallowed: enrollmentSettings.value.sharedkeyallowed,
|
|
autoenroll: event.target.checked
|
|
})
|
|
}
|
|
|
|
function onRevokedDeniesToggle(event) {
|
|
const turningOn = event.target.checked
|
|
if (turningOn) {
|
|
// Typed confirm, like the shared-key switch: this one silences PCs that
|
|
// are reporting right now, and the revoke-plus-rollback recipe in the
|
|
// runbook stops working the moment it is on.
|
|
const answer = prompt(
|
|
'Deny the shared key to revoked PCs?\n\n'
|
|
+ 'Any PC whose credential was revoked stops reporting and stops '
|
|
+ 'receiving commands until it enrolls again. The revoke + block '
|
|
+ 're-enrollment rollback onto an older agent no longer works.\n\n'
|
|
+ 'Type DENY to confirm.')
|
|
if (!answer || answer.trim().toUpperCase() !== 'DENY') {
|
|
event.target.checked = false
|
|
return
|
|
}
|
|
}
|
|
return saveSettings({ revokeddeniessharedkey: turningOn })
|
|
}
|
|
|
|
function onPendingCapChange() {
|
|
const wanted = Math.trunc(Number(pendingCapInput.value))
|
|
if (!Number.isFinite(wanted) || wanted < 0) {
|
|
pendingCapInput.value = enrollmentSettings.value.maxpendingenrollments
|
|
toast.error('The pending request limit must be 0 or a whole number')
|
|
return
|
|
}
|
|
return saveSettings({ maxpendingenrollments: wanted })
|
|
}
|
|
|
|
function openApprove(request) {
|
|
const options = []
|
|
if (request.matchedassetid) {
|
|
options.push({ assetid: request.matchedassetid, label: `${request.hostname} (matched)` })
|
|
}
|
|
approving.value = {
|
|
requestid: request.requestid,
|
|
hostname: request.hostname,
|
|
assetid: request.matchedassetid || null,
|
|
search: '',
|
|
options
|
|
}
|
|
}
|
|
|
|
let assetSearchTimer = null
|
|
function debouncedAssetSearch() {
|
|
clearTimeout(assetSearchTimer)
|
|
assetSearchTimer = setTimeout(searchAssets, 300)
|
|
}
|
|
|
|
async function searchAssets() {
|
|
const current = approving.value
|
|
if (!current || !current.search) return
|
|
try {
|
|
const response = await computersApi.list({ search: current.search, perpage: 20 })
|
|
const found = (response.data.data || []).map(item => ({
|
|
assetid: item.assetid,
|
|
label: `${item.computer?.hostname || item.name || item.assetid}`
|
|
+ (item.serialnumber ? ` (${item.serialnumber})` : '')
|
|
}))
|
|
const keep = current.options.filter(option => option.assetid === current.assetid)
|
|
const merged = [...keep]
|
|
found.forEach(option => {
|
|
if (!merged.some(existing => existing.assetid === option.assetid)) merged.push(option)
|
|
})
|
|
current.options = merged
|
|
} catch (error) {
|
|
toast.error(apiError(error, 'Could not search computers'))
|
|
}
|
|
}
|
|
|
|
// The approval also stamps the request's hostname onto the computer when that
|
|
// record has none - without it the PC collects its credential and then 403s on
|
|
// every call, because the server resolves the hostname it sends to nothing. A
|
|
// computer already carrying a DIFFERENT hostname comes back as a 409 naming
|
|
// both, and only an explicit second answer renames it.
|
|
async function postApprove(current, sethostname) {
|
|
const body = { assetid: current.assetid }
|
|
if (sethostname) body.sethostname = true
|
|
const response = await api.post(
|
|
`/endpoints/enrollment/requests/${current.requestid}/approve`, body)
|
|
const result = response?.data?.data?.hostnameresult
|
|
if (result === 'set') {
|
|
toast.success(`Approved ${current.hostname}; hostname recorded on that PC`)
|
|
} else if (result === 'overwritten') {
|
|
toast.success(`Approved ${current.hostname}; that PC was renamed to it`)
|
|
} else {
|
|
toast.success(`Approved ${current.hostname}`)
|
|
}
|
|
approving.value = null
|
|
await loadEnrollment({ quiet: true })
|
|
}
|
|
|
|
async function approve() {
|
|
const current = approving.value
|
|
if (!current || !current.assetid) return
|
|
const option = current.options.find(item => item.assetid === current.assetid)
|
|
if (!confirm(`Approve the request from ${current.hostname} and issue a credential for `
|
|
+ `${option ? option.label : 'asset ' + current.assetid}?`)) return
|
|
busy.value = true
|
|
try {
|
|
await postApprove(current, false)
|
|
} catch (error) {
|
|
const details = error?.response?.data?.data?.error?.details
|
|
const message = apiError(error, 'Could not approve the request')
|
|
if (details?.hostnameconflict) {
|
|
if (confirm(`${message}\n\nSet the hostname on this PC?`)) {
|
|
try {
|
|
await postApprove(current, true)
|
|
return
|
|
} catch (retryError) {
|
|
toast.error(apiError(retryError, 'Could not approve the request'))
|
|
return
|
|
}
|
|
}
|
|
toast.error(message)
|
|
return
|
|
}
|
|
toast.error(message)
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
async function reject(request) {
|
|
if (!confirm(`Reject the enrollment request from ${request.hostname}?`)) return
|
|
busy.value = true
|
|
try {
|
|
await api.post(`/endpoints/enrollment/requests/${request.requestid}/reject`)
|
|
toast.success(`Rejected ${request.hostname}`)
|
|
await loadEnrollment({ quiet: true })
|
|
} catch (error) {
|
|
toast.error(apiError(error, 'Could not reject the request'))
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
const revoking = ref(null)
|
|
|
|
const revokeFallback = computed(() => {
|
|
if (revoking.value?.block) {
|
|
return 'It stays un-enrolled and keeps reporting on the shared key while that is allowed.'
|
|
}
|
|
return enrollmentSettings.value.sharedkeyallowed
|
|
? 'It falls back to the shared key and enrolls again on its own.'
|
|
: 'The shared key is off, so it cannot report until an admin approves its new request.'
|
|
})
|
|
|
|
function openRevoke(row) {
|
|
revoking.value = { assetid: row.assetid, hostname: row.hostname, block: false, reason: '' }
|
|
}
|
|
|
|
async function confirmRevoke() {
|
|
const current = revoking.value
|
|
if (!current) return
|
|
busy.value = true
|
|
try {
|
|
const url = `/endpoints/enrollment/assets/${current.assetid}/revoke`
|
|
if (current.block) {
|
|
const body = { blockreenroll: true }
|
|
const reason = (current.reason || '').trim()
|
|
if (reason) body.reason = reason
|
|
await api.post(url, body)
|
|
} else {
|
|
await api.post(url)
|
|
}
|
|
toast.success(`Un-enrolled ${current.hostname}`)
|
|
revoking.value = null
|
|
await loadEnrollment({ quiet: true })
|
|
} catch (error) {
|
|
toast.error(apiError(error, 'Could not un-enroll'))
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
async function allowReenroll(row) {
|
|
if (!confirm(`Allow ${row.hostname} to enroll again?\n\n`
|
|
+ 'Its agent enrolls on its next attempt if it runs agent 1.20 or later.')) return
|
|
busy.value = true
|
|
try {
|
|
await api.post(`/endpoints/enrollment/assets/${row.assetid}/allow-reenroll`)
|
|
toast.success(`${row.hostname} may enroll again`)
|
|
await loadEnrollment({ quiet: true })
|
|
} catch (error) {
|
|
toast.error(apiError(error, 'Could not allow re-enrollment'))
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
// ---- Shared -----------------------------------------------------------
|
|
|
|
function stateLabel(state) {
|
|
return {
|
|
enrolled: 'Enrolled',
|
|
notenrolled: 'Not enrolled',
|
|
pending: 'Pending',
|
|
revoked: 'Revoked',
|
|
upgrading: 'Upgrading',
|
|
ready: 'Ready',
|
|
blocked: 'Blocked',
|
|
notreporting: 'Not reporting'
|
|
}[state] || state || '-'
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return '-'
|
|
return new Date(value).toLocaleString()
|
|
}
|
|
|
|
// Waves progress on their own; poll while this tab is open.
|
|
let pollTimer = null
|
|
function startPolling() {
|
|
stopPolling()
|
|
pollTimer = setInterval(() => {
|
|
if (tab.value === 'migration' && !busy.value) loadMigration({ quiet: true })
|
|
}, POLL_MS)
|
|
}
|
|
function stopPolling() {
|
|
if (pollTimer) clearInterval(pollTimer)
|
|
pollTimer = null
|
|
}
|
|
|
|
watch(tab, value => {
|
|
if (value === 'migration') loadMigration()
|
|
else loadEnrollment()
|
|
})
|
|
|
|
onMounted(() => {
|
|
loadMigration()
|
|
loadEnrollment({ quiet: true })
|
|
startPolling()
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
stopPolling()
|
|
clearTimeout(migrationSearchTimer)
|
|
clearTimeout(enrollmentSearchTimer)
|
|
clearTimeout(assetSearchTimer)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.enroll-tabs {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
border-bottom: 1px solid var(--border);
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.enroll-tab {
|
|
padding: 0.6rem 1.25rem;
|
|
background: none;
|
|
border: none;
|
|
border-bottom: 2px solid transparent;
|
|
font: inherit;
|
|
font-weight: 500;
|
|
color: var(--text-light);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.enroll-tab:hover { color: var(--text); }
|
|
|
|
.enroll-tab.active {
|
|
color: var(--primary);
|
|
border-bottom-color: var(--primary);
|
|
}
|
|
|
|
.load-error {
|
|
padding: 0.75rem 0;
|
|
color: var(--danger);
|
|
}
|
|
|
|
.enroll-tiles {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
gap: 0.75rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.enroll-tile {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.25rem;
|
|
padding: 0.8rem 0.95rem;
|
|
font: inherit;
|
|
color: var(--text);
|
|
text-align: left;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.enroll-tile.static { cursor: default; }
|
|
|
|
.enroll-tile:not(.static):hover,
|
|
.enroll-tile.on { border-color: var(--primary); }
|
|
|
|
.enroll-tile .n {
|
|
font-size: 1.6rem;
|
|
font-weight: 700;
|
|
line-height: 1.1;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.enroll-tile .l {
|
|
font-size: 0.8rem;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.enroll-tile.ok .n { color: var(--success-dark); }
|
|
.enroll-tile.warn .n { color: var(--warning-dark); }
|
|
.enroll-tile.bad .n { color: var(--danger); }
|
|
|
|
[data-theme="dark"] .enroll-tile.ok .n { color: var(--success); }
|
|
[data-theme="dark"] .enroll-tile.warn .n { color: var(--warning); }
|
|
|
|
.progress-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.progress-track {
|
|
flex: 1;
|
|
height: 10px;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 5px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.progress-track.small {
|
|
height: 6px;
|
|
min-width: 80px;
|
|
}
|
|
|
|
.progress-fill {
|
|
height: 100%;
|
|
background: var(--success);
|
|
}
|
|
|
|
.progress-text {
|
|
font-size: 0.9rem;
|
|
color: var(--text-light);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.enroll-filter { max-width: 200px; }
|
|
|
|
.bulk-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
padding: 0.6rem 0.9rem;
|
|
margin-bottom: 0.75rem;
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.25rem;
|
|
}
|
|
|
|
.bulk-count { font-size: 0.9rem; margin-right: 0.25rem; }
|
|
|
|
.select-cell { width: 1%; }
|
|
|
|
.empty-cell {
|
|
text-align: center;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.state-chip {
|
|
display: inline-block;
|
|
font-size: 0.78rem;
|
|
font-weight: 500;
|
|
padding: 0.1rem 0.5rem;
|
|
border-radius: 10px;
|
|
background: var(--bg);
|
|
color: var(--text-light);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.state-chip.enrolled,
|
|
.state-chip.ready { color: var(--success-dark); }
|
|
.state-chip.pending,
|
|
.state-chip.upgrading { color: var(--warning-dark); }
|
|
.state-chip.blocked,
|
|
.state-chip.revoked { color: var(--danger); }
|
|
|
|
[data-theme="dark"] .state-chip.enrolled,
|
|
[data-theme="dark"] .state-chip.ready { color: var(--success); }
|
|
[data-theme="dark"] .state-chip.pending,
|
|
[data-theme="dark"] .state-chip.upgrading { color: var(--warning); }
|
|
|
|
.retry-hint {
|
|
display: block;
|
|
font-size: 0.8em;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.blockers {
|
|
font-size: 0.85rem;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.section-title { margin: 1.5rem 0 0.5rem; }
|
|
|
|
.checkbox-row {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.5rem;
|
|
margin: 0.5rem 0;
|
|
}
|
|
|
|
.number-row {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.5rem;
|
|
margin: 0.5rem 0;
|
|
}
|
|
|
|
.number-row input {
|
|
width: 6rem;
|
|
flex: none;
|
|
}
|
|
|
|
.action-cell {
|
|
white-space: nowrap;
|
|
text-align: right;
|
|
}
|
|
|
|
.action-cell .btn + .btn { margin-left: 0.35rem; }
|
|
|
|
.approve-form {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.approve-form .form-control { max-width: 260px; }
|
|
|
|
.block-chip { margin-left: 0.35rem; }
|
|
|
|
.revoke-row .form-control { max-width: 260px; }
|
|
|
|
.mono {
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 13px;
|
|
}
|
|
</style>
|