Add multi-action policy holds and exempt collect_now from policy

Adds PolicyActionHold so an admin can suspend specific actions on a computer without editing the policy itself, covering the break-glass case where a machine must be quiesced immediately. Holds cover multiple actions in one atomic submission rather than one row per action, so a partial failure cannot leave a half-applied hold. Duration is free-form with 0 meaning indefinite (migration 0006 makes expiresat nullable); the prior fixed ceiling was removed because an admin quiescing a misbehaving machine does not know in advance how long it needs, and a hold silently expiring is worse than one that must be cleared deliberately. The What This Does explainer reflects indefinite holds.

collect_now exemption: refreshing status was denied on any endpoint with a non-empty effective policy even when the caller held the matching grant, because policy intersection silently removed the action. collect_now is a read-only status refresh with no side effects, so it now sits in POLICY_EXEMPT_ACTIONS alongside the other routine actions. Verified against a synthetic non-empty policy: operators can refresh status again while every write action stays gated.
This commit is contained in:
benjamin.baker
2026-09-07 12:38:55 -04:00
parent 627d3f5ff5
commit 52c9d2b009
10 changed files with 2174 additions and 19 deletions

View File

@@ -90,6 +90,46 @@
</button>
</div>
</div>
<!-- Endpoint lock. Rendered ABOVE the holds and in a heavier
treatment because it outranks them: a hold pauses one automatic
action, a lock stops every user but an admin from doing
anything here at all. Same-looking banners would train people
to read the two as synonyms. -->
<div v-if="endpointlock" class="endpoint-lock">
<span class="endpoint-lock-tag">LOCKED</span>
<span class="endpoint-lock-text">
by {{ endpointlock.lockedbyusername || 'a deleted user' }}
- {{ endpointlock.reason }}
</span>
<button v-if="canHoldPolicy" class="endpoint-lock-release"
:disabled="lockbusy"
@click="confirmReleaseLock">
{{ lockbusy ? 'Working...' : 'Unlock' }}
</button>
</div>
<!-- Active break-glass holds. Shown to every viewer, not just
admins: while a hold stands this PC is deliberately NOT being
reconciled to policy, and an operator seeing drift needs to
know it was authorized rather than chase a phantom fault. -->
<div v-if="policyholds.length" class="policy-holds">
<div v-for="hold in policyholds" :key="hold.action"
class="policy-hold">
<span class="policy-hold-action">{{ hold.action }}</span>
<span class="policy-hold-text">
policy enforcement paused by
{{ hold.createdbyusername || 'a deleted user' }}
until {{ holdExpiryLabel(hold) }} - {{ hold.reason }}
</span>
<!-- Release sits on the row, not in the header: holds are
per-action and several can stand at once, so a single
header button could only guess which one to end. -->
<button v-if="canHoldPolicy" class="policy-hold-release"
:disabled="holdbusy"
@click="confirmReleaseHold(hold)">Release</button>
</div>
</div>
</div>
<!-- Cloud identity. Laid out as a horizontal row in the same
@@ -178,8 +218,20 @@
:assetid="computer.assetid"
:accessmethods="computer.accessmethods || []"
>
<template #actions v-if="canRestart">
<button @click="confirmRestart" class="btn btn-warning btn-sm"
<template #actions v-if="canRestart || canHoldPolicy">
<!-- Admin only, and FIRST in the band. Placing a hold switches OFF
automatic policy enforcement, so it sits with the destructive
controls rather than with the read-only refresh. Red rather
than grey: grey undersold that this disables enforcement.
Leading the band because it is the control an admin reaches
for BEFORE restarting a machine they are about to work on. -->
<button v-if="canHoldPolicy" @click="openHoldDialog"
class="btn btn-danger btn-sm"
:disabled="holdbusy">
{{ holdbusy ? 'Working...' : 'Admin Hold' }}
</button>
<button v-if="canRestart" @click="confirmRestart"
class="btn btn-warning btn-sm"
:disabled="restarting">Restart Computer</button>
</template>
</AssetTriageBand>
@@ -470,6 +522,163 @@
<div v-else class="card">
<p style="text-align: center; color: var(--text-light);">Computer not found</p>
</div>
<Modal v-model="showholddialog" :title="holdDialogTitle" size="medium">
<!-- Collapsed by default: an admin who already knows what a hold does
should not have to scroll past an explanation to reach the form, but
the explanation has to be one click away because this switches off
automatic enforcement and that is not self-evident from the title. -->
<details class="hold-explain">
<summary>What this does</summary>
<div class="hold-explain-body">
<!-- Split into two labelled halves because the dialog now performs two
unrelated jobs. Running them together as prose invited the reader
to treat the lock as part of the hold, which is the confusion this
whole feature has to avoid: one expires by itself, one does not. -->
<template v-if="holdableactions.length">
<p class="hold-explain-head">Hold</p>
<p>
ShopDB normally keeps this computer matching its policy. If policy
says a service should be running and someone stops it, ShopDB
starts it again automatically.
</p>
<p>
A hold pauses that automatic correction for the actions you tick,
so you can work on the machine without ShopDB undoing your
changes. It does not change anyone's permissions, and on its own
it does not stop you or anyone else running those actions by hand.
Each one can be released separately afterwards.
</p>
<p>
Give it a time and automatic correction resumes on its own when
that runs out - you do not have to remember to turn it back on.
Set the time to <strong>0</strong> and it never resumes by itself:
the hold stands until someone releases it here, and nothing will
prompt anyone to do that.
</p>
</template>
<p class="hold-explain-head">Lock</p>
<p>
Locking is separate, and it is about PEOPLE rather than policy. A
hold stops ShopDB correcting the machine; a lock stops other users
touching it. While a lock stands, nobody but an admin can run any
action against this computer - they are refused and shown your
reason instead.
</p>
<p>
A lock never expires, so use it when the machine must be left alone
for reasons ShopDB cannot see - mid-repair, a validated part
running, an audit in progress. It stays until an admin unlocks it
from this page. Agent check-in and monitoring keep working
throughout; you do not go blind on the machine.
</p>
</div>
</details>
<!-- Only the HOLD half depends on policy enforcement existing. Locking is
a caller-authority action with no relationship to policy, so it must
stay reachable on an endpoint with nothing to reconcile - which is
most of them. -->
<div v-if="holdableactions.length === 0" class="hold-empty">
<p>
Nothing on this computer is under automatic policy enforcement right
now, so there is nothing for a hold to pause. You can still lock it
below.
</p>
</div>
<template v-if="holdableactions.length">
<div class="form-group">
<label>Actions to pause</label>
<div class="hold-action-list">
<label v-for="entry in holdableactions" :key="entry.action"
class="hold-action-item"
:class="{ 'is-held': entry.held }">
<input type="checkbox" :value="entry.action"
v-model="holdactions" :disabled="entry.held" />
<span class="hold-action-name">{{ entry.action }}</span>
<span v-if="entry.held" class="hold-action-note">
already held
</span>
</label>
</div>
<small class="form-hint">
Pick as many as you need. They all share the reason and duration
below, and each can be released on its own afterwards.
</small>
</div>
</template>
<!-- Outside the branch above: the reason is required by BOTH the hold and
the lock, and it is the text a blocked user reads. -->
<div class="form-group">
<label for="holdreason">Reason</label>
<input id="holdreason" v-model="holdreason" class="form-control"
placeholder="Why this needs to be paused or locked" />
<small class="form-hint">
Recorded against your account and shown to anyone viewing this
computer.
</small>
</div>
<template v-if="holdableactions.length">
<div class="form-group">
<label for="holdminutes">Pause for (minutes)</label>
<input id="holdminutes" v-model.number="holdminutes" type="number"
min="0" step="1" class="form-control" />
<small class="form-hint">
Any length you need. Policy resumes on its own when the time runs
out, and you can release it sooner from this page.
</small>
<!-- The only warning an indefinite hold ever gets: nothing else in
the UI reports a stale one, so it has to be said at the moment
the choice is made. -->
<p v-if="holdisindefinite" class="hold-indefinite-warn">
<strong>0 means indefinite.</strong>
Policy enforcement for the actions above stays switched off until
someone releases it by hand. Nothing will remind you: the computer
will keep reporting healthy while it drifts out of policy.
</p>
</div>
</template>
<!-- Separate concept, offered here because it is the same moment: an
admin about to work on a machine usually wants BOTH the reconciler
off their back and other people's hands off the box. Worded as
"lock" rather than "hold" on purpose - the hold above expires on
its own, this does not, and calling both a hold would teach people
to expect an expiry that never comes. -->
<div class="form-group hold-lock-option">
<label class="hold-lock-check">
<input type="checkbox" v-model="holdlockendpoint" />
<span>Also lock this endpoint</span>
</label>
<small class="form-hint">
Blocks other users from running any action on this computer until an
admin unlocks it. They are shown your reason above. This does not
expire on its own.
</small>
</div>
<template #footer>
<!-- A disabled submit button is indistinguishable from a broken one:
the click is swallowed with no error and no change, so the user
concludes the feature is broken rather than that they missed a
field. Say which field is missing, at the point of the click. -->
<span v-if="holdBlockedReason" class="hold-blocked-reason">
{{ holdBlockedReason }}
</span>
<button class="btn btn-secondary" @click="showholddialog = false">
Cancel
</button>
<button class="btn btn-primary"
:disabled="!canSubmitHold"
@click="submitHold">
{{ holdSubmitLabel }}
</button>
</template>
</Modal>
</div>
</template>
@@ -486,6 +695,7 @@ import AssetTriageBand from '@/components/AssetTriageBand.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetDetailTabs from '@/components/AssetDetailTabs.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'
import Modal from '@/components/Modal.vue'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { Copy } from 'lucide-vue-next'
import { getEntraDeviceUrls } from '@/utils/siteSettings'
@@ -611,12 +821,13 @@ const hasCloudIdentity = computed(() => portalTemplatesLoaded.value && Boolean(
// populatedGroups comes from PluginAssetPanels, which knows which panels
// actually returned data; Software is additionally true when core has an
// installed-applications list of its own.
// Gates the Corporate Tools tab. endpoints.deploy currently bundles
// scheduledtask_run with run_script/update_app - see the note beside the
// permission declaration in plugins/endpoints/plugin.py if that gets split.
// Gates the Corporate Tools tab, which runs scheduledtask_run. That action now
// has its own grant (endpoints.corptools) instead of riding the old
// endpoints.deploy bundle, so seeing this tab no longer implies the ability to
// push installers or run arbitrary scripts.
// Declared ahead of detailtabs because the watch below evaluates that computed
// during setup, which reads canDeploy before this line would otherwise run.
const canDeploy = computed(() => authStore.hasPermission('endpoints.deploy'))
const canDeploy = computed(() => authStore.hasPermission('endpoints.corptools'))
const detailtabs = computed(() => {
const filled = assetPanels.value?.populatedGroups ?? new Set()
@@ -640,7 +851,7 @@ const detailtabs = computed(() => {
if (has('activity')) tabs.push({ key: 'activity', label: 'Activity' })
// Corporate Tools is permission-gated rather than data-gated: it is a set of
// fixed actions, not a rendering of collected telemetry, so there is no
// populatedGroup to key off. Hidden without endpoints.deploy because every
// populatedGroup to key off. Hidden without endpoints.corptools because every
// button here is an elevated command; the server re-checks regardless.
if (canDeploy.value && liveness.value) {
tabs.push({ key: 'corptools', label: 'Corporate Tools' })
@@ -662,6 +873,214 @@ const refreshing = ref(false)
const canCommand = computed(() => authStore.hasPermission('endpoints.command'))
const canRestart = computed(() => authStore.hasPermission('endpoints.restart'))
// Break-glass is admin-only and re-checked server-side against the CALLER, so a
// scoped token cannot inherit it from the admin who minted it. This flag only
// decides whether we offer the prompt.
const canOverridePolicy = computed(() => authStore.isAdmin)
// Active holds are shown to everyone with endpoints.view: a held action means
// this PC is deliberately not being reconciled, and an operator who cannot see
// that is left debugging drift that someone already authorized.
const policyholds = ref([])
// Placing or releasing a hold is the same authority as break-glass, so it is
// admin-gated here and re-checked server-side against the CALLER.
const canHoldPolicy = computed(() => authStore.isAdmin)
const showholddialog = ref(false)
const holdableactions = ref([])
const holdactions = ref([])
const holdreason = ref('')
const holdminutes = ref(240)
const holdbusy = ref(false)
const holdlockendpoint = ref(false)
// Drives the warning panel. Guards against the empty input, which v-model.number
// yields as '' rather than 0 - treating that as indefinite would warn while the
// field looks blank, and worse, submit as indefinite.
const holdisindefinite = computed(() => holdminutes.value === 0)
// Null when unlocked. Loaded for EVERY viewer, not just admins, so a blocked
// operator can read who locked the box instead of guessing at a refusal.
const endpointlock = ref(null)
const lockbusy = ref(false)
// The dialog does two independent jobs, and on an endpoint with no policy
// enforcement only the lock half is available. Submitting needs a reason plus
// at least one job selected - otherwise the button would post nothing.
const canSubmitHold = computed(() => {
if (holdbusy.value || !holdreason.value.trim()) return false
// An empty duration is not a valid hold: it is neither a window nor an
// explicit indefinite choice, so block rather than guess which was meant.
if (holdactions.value.length && !Number.isInteger(holdminutes.value)) {
return false
}
return holdactions.value.length > 0 || holdlockendpoint.value
})
// Mirrors canSubmitHold in the same order, so the message always names the
// first thing actually standing in the way. Empty when the form is submittable.
const holdBlockedReason = computed(() => {
if (holdbusy.value) return ''
if (!holdreason.value.trim()) return 'Enter a reason to continue.'
if (!holdactions.value.length && !holdlockendpoint.value) {
return holdableactions.value.length
? 'Select an action to pause, or tick "Also lock this endpoint".'
: 'Tick "Also lock this endpoint" to continue.'
}
if (holdactions.value.length && !Number.isInteger(holdminutes.value)) {
return 'Enter a duration in minutes, or 0 for indefinite.'
}
return ''
})
const holdSubmitLabel = computed(() => {
if (holdbusy.value) return 'Working...'
const count = holdactions.value.length
if (count && holdlockendpoint.value) {
return count > 1 ? `Place ${count} holds and lock` : 'Place hold and lock'
}
if (holdlockendpoint.value) return 'Lock endpoint'
return count > 1 ? `Place ${count} holds` : 'Place hold'
})
// Named for what the dialog can actually do here. Calling it "Place admin hold"
// on an endpoint with nothing to hold reads as a dead end.
const holdDialogTitle = computed(() =>
holdableactions.value.length ? 'Place admin hold' : 'Lock endpoint')
async function openHoldDialog() {
holdactions.value = []
holdreason.value = ''
holdminutes.value = 240
holdableactions.value = []
// Default off: locking everyone else out is the bigger side effect of the two
// and should be a deliberate tick, not something inherited from last time.
holdlockendpoint.value = false
showholddialog.value = true
try {
const response = await endpointsApi.listHoldableActions(computer.value.assetid)
holdableactions.value = response.data?.data || []
} catch (error) {
// Leave the list empty: the dialog then explains there is nothing under
// enforcement, which is the same thing the user needs to know either way.
holdableactions.value = []
}
}
async function submitHold() {
if (!canSubmitHold.value) return
const reason = holdreason.value.trim()
const actions = [...holdactions.value]
const wantshold = actions.length > 0
const wantslock = holdlockendpoint.value
// Phrased once here so every branch below agrees on how the holds are named.
const summary = actions.length === 1
? actions[0]
: `${actions.length} actions`
holdbusy.value = true
try {
if (wantshold) {
// One call for every action: the server applies them atomically, so a
// rejected name leaves no partial set of holds behind.
await endpointsApi.placePolicyHold(
computer.value.assetid, actions, reason, holdminutes.value)
}
// Sequenced after the hold, not in parallel: if the lock call fails the
// hold still stands and the banner says so. Firing both at once could
// leave the page reporting a lock whose hold never landed.
if (wantslock) {
await endpointsApi.placeEndpointLock(computer.value.assetid, reason)
}
if (wantshold && wantslock) {
toast.success(
`Policy enforcement paused for ${summary} and endpoint locked`)
} else if (wantslock) {
toast.success('Endpoint locked')
} else {
toast.success(`Policy enforcement paused for ${summary}`)
}
showholddialog.value = false
await loadPolicyHolds()
await loadEndpointLock()
} catch (error) {
toast.error(apiError(error, wantshold
? 'Failed to place hold'
: 'Failed to lock endpoint'))
} finally {
holdbusy.value = false
}
}
async function confirmReleaseHold(hold) {
if (!hold) return
if (!confirm(`Resume automatic policy enforcement for ${hold.action}?\n\n`
+ 'ShopDB will go back to correcting this action automatically, '
+ 'which may undo changes made while the hold was in place.')) return
holdbusy.value = true
try {
await endpointsApi.releasePolicyHold(computer.value.assetid, hold.action)
toast.success(`Policy enforcement resumed for ${hold.action}`)
await loadPolicyHolds()
} catch (error) {
toast.error(apiError(error, 'Failed to release hold'))
} finally {
holdbusy.value = false
}
}
async function loadPolicyHolds() {
if (!computer.value?.assetid) return
try {
const response = await endpointsApi.listPolicyHolds(computer.value.assetid)
policyholds.value = response.data?.data || []
} catch {
// Non-fatal: the holds banner is context, not a gate. A failure here must
// not blank the page or block the actions below it.
policyholds.value = []
}
}
async function loadEndpointLock() {
if (!computer.value?.assetid) return
try {
const response = await endpointsApi.getEndpointLock(computer.value.assetid)
endpointlock.value = response.data?.data || null
} catch {
// Same reasoning as the holds banner: context, not a gate. The server is
// the real enforcement point, so a failed read here cannot let anyone past
// a lock - it only means this page does not explain one.
endpointlock.value = null
}
}
async function confirmReleaseLock() {
if (!endpointlock.value) return
if (!confirm('Unlock this endpoint?\n\n'
+ 'Other users will be able to run actions against it again. '
+ 'Any policy holds you placed stay in force until they expire '
+ 'or you release them separately.')) return
lockbusy.value = true
try {
await endpointsApi.releaseEndpointLock(computer.value.assetid)
toast.success('Endpoint unlocked')
await loadEndpointLock()
} catch (error) {
toast.error(apiError(error, 'Failed to unlock endpoint'))
} finally {
lockbusy.value = false
}
}
function holdExpiryLabel(hold) {
if (!hold.expiresat) return 'unknown'
// Server sends naive UTC isoformat; mark it as UTC so it renders in local time
// rather than being read as local and shifting by the offset.
const raw = hold.expiresat.endsWith('Z') ? hold.expiresat : `${hold.expiresat}Z`
const when = new Date(raw)
return Number.isNaN(when.getTime()) ? 'unknown' : when.toLocaleString()
}
// Warranty fetch + hero badge (shared across asset detail pages).
const { heroWarranty, warrantyDate: formatWarrantyDate } = useWarrantyBadge(() => computer.value?.assetid)
@@ -725,6 +1144,8 @@ onMounted(async () => {
} catch (appError) {
}
loadLiveness(computer.value?.assetid)
loadPolicyHolds()
loadEndpointLock()
// Warranties load via useWarrantyBadge (watches computer.assetid).
} catch (error) {
console.error('Error loading computer:', error)
@@ -836,6 +1257,33 @@ async function waitForCommand(commandId, { attempts = 15, delayMs = 1000 } = {})
return 'timeout'
}
// A policy denial is the server telling us this action is not exposed on this
// endpoint. For an admin that is recoverable, but only deliberately: we surface
// what was refused, demand a written reason, and send it as an explicit
// override. Returns true when the retry succeeded.
async function retryWithOverride(assetid, action, target, error, label) {
if (!canOverridePolicy.value) return false
const message = apiError(error, '')
if (!message.includes('not permitted by policy')) return false
const reason = prompt(
`Policy does not permit ${action} on ${label}.\n\n`
+ 'As an admin you may override this. The override is recorded against '
+ 'your account, and desired-state enforcement for this action is paused '
+ 'until the hold expires.\n\n'
+ 'Reason for the override:')
// Cancel returns null; an empty or whitespace reason is refused server-side
// anyway, so do not spend a round trip on it.
if (!reason || !reason.trim()) return false
await endpointsApi.enqueueCommand(assetid, action, target, {
policyoverride: true,
overridereason: reason.trim(),
})
await loadPolicyHolds()
return true
}
// Destructive: queues a system_restart that reboots the PC immediately
// (shutdown /r /t 0 on the agent). The confirm gate is the last stop before an
// unrecoverable action; the server re-checks endpoints.restart regardless.
@@ -849,7 +1297,17 @@ async function confirmRestart() {
await endpointsApi.enqueueCommand(computer.value.assetid, 'system_restart')
toast.success(`Restart queued for ${label}`)
} catch (error) {
toast.error(apiError(error, 'Failed to queue restart'))
try {
const overridden = await retryWithOverride(
computer.value.assetid, 'system_restart', null, error, label)
if (overridden) {
toast.success(`Restart queued for ${label} (policy overridden)`)
return
}
toast.error(apiError(error, 'Failed to queue restart'))
} catch (retryError) {
toast.error(apiError(retryError, 'Failed to queue restart'))
}
} finally {
restarting.value = false
}
@@ -909,7 +1367,7 @@ const firingTask = ref('')
// Elevated: queues a scheduledtask_run. The confirm gate mirrors
// confirmRestart - these start real work on a live machine and some of them
// change system state - and the server re-checks endpoints.deploy regardless.
// change system state - and the server re-checks endpoints.corptools regardless.
//
// Tools marked destructive get a TYPED confirmation rather than an OK button,
// for the same reason Restart has its own gate: applying firmware updates can
@@ -1110,6 +1568,214 @@ async function confirmRetire() {
justify-content: flex-end;
gap: 0.5rem;
}
/* Warning-toned, not error-toned: a hold is a deliberate, audited state, not a
fault. It should read as "someone did this on purpose" at a glance. */
.policy-holds {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-top: 0.6rem;
}
/* Heavier than .policy-hold on purpose. A lock outranks a hold - it stops every
non-admin from acting at all - so it must not read as one more amber note in
a stack of them. */
.endpoint-lock {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.6rem;
padding: 0.45rem 0.7rem;
font-size: 0.85rem;
border-left: 3px solid var(--danger);
background: color-mix(in srgb, var(--danger) 12%, transparent);
border-radius: 3px;
}
.endpoint-lock-tag {
font-family: monospace;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--danger);
}
.endpoint-lock-text {
color: var(--text);
}
.endpoint-lock-release {
margin-left: auto;
padding: 0.1rem 0.5rem;
font-size: 0.78rem;
color: var(--danger);
background: transparent;
border: 1px solid var(--danger);
border-radius: 3px;
cursor: pointer;
}
.endpoint-lock-release:hover:not(:disabled) {
background: color-mix(in srgb, var(--danger) 18%, transparent);
}
.endpoint-lock-release:disabled {
opacity: 0.55;
cursor: default;
}
/* Scrolls rather than growing the dialog: endpoints under broad policy can
carry a long action list, and the reason/duration fields below must stay
reachable without hunting for them. */
.hold-action-list {
display: flex;
flex-direction: column;
gap: 0.15rem;
max-height: 13rem;
overflow-y: auto;
padding: 0.4rem 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
}
.hold-action-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
font-weight: 400;
cursor: pointer;
}
.hold-action-item input {
width: auto;
margin: 0;
}
.hold-action-item.is-held {
opacity: 0.6;
cursor: default;
}
.hold-action-name {
font-family: var(--font-mono, monospace);
font-size: 0.9rem;
}
.hold-action-note {
font-size: 0.78rem;
color: var(--text-light);
font-style: italic;
}
/* Deliberately loud. An indefinite hold is the one setting here with no
self-correcting behaviour behind it.
Tinted from --warning via colour-mix rather than a fixed pale hex: a light
cream panel keeps its dark body text in dark mode and turns unreadable. */
.hold-indefinite-warn {
margin: 0.5rem 0 0;
padding: 0.5rem 0.65rem;
border-left: 3px solid var(--warning);
background: color-mix(in srgb, var(--warning) 12%, transparent);
font-size: 0.82rem;
line-height: 1.45;
color: var(--text);
}
/* Separated from the fields above it: this is a second, heavier decision than
the hold being configured, not another attribute of it. */
.hold-lock-option {
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
.hold-lock-check {
display: flex;
align-items: center;
gap: 0.45rem;
font-weight: 600;
cursor: pointer;
}
.hold-lock-check input {
width: auto;
margin: 0;
}
.policy-hold {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.4rem;
font-size: 0.85rem;
padding: 0.35rem 0.6rem;
border-left: 3px solid var(--warning);
background: color-mix(in srgb, var(--warning) 10%, transparent);
border-radius: 3px;
}
.policy-hold-action {
font-family: monospace;
font-weight: 600;
}
.policy-hold-text {
color: var(--text);
}
/* Pushed to the row end so the release affordance reads as belonging to that
specific hold rather than to the banner as a whole. */
.policy-hold-release {
margin-left: auto;
padding: 0.1rem 0.5rem;
font-size: 0.78rem;
color: var(--warning);
background: transparent;
border: 1px solid var(--warning);
border-radius: 3px;
cursor: pointer;
}
.policy-hold-release:hover:not(:disabled) {
background: color-mix(in srgb, var(--warning) 18%, transparent);
}
.policy-hold-release:disabled {
opacity: 0.55;
cursor: default;
}
.hold-explain {
margin-bottom: 1rem;
padding: 0.5rem 0.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
}
.hold-explain summary {
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
}
.hold-explain-body {
margin-top: 0.5rem;
font-size: 0.875rem;
line-height: 1.5;
}
.hold-explain-body p {
margin: 0 0 0.6rem;
}
.hold-explain-body p:last-child {
margin-bottom: 0;
}
/* Labels the two halves apart. Without them the lock paragraphs read as more
detail about the hold, which is the exact conflation to avoid. */
.hold-explain-head {
margin-bottom: 0.25rem !important;
font-weight: 700;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
color: var(--text-light);
}
.hold-explain-head:not(:first-child) {
margin-top: 0.9rem !important;
}
.hold-empty {
font-size: 0.9rem;
color: var(--text-light);
}
/* Sits to the left of the footer buttons, next to the control it explains. */
.hold-blocked-reason {
margin-right: auto;
align-self: center;
text-align: left;
font-size: 0.8rem;
color: var(--text-light);
}
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light);
}
/* The chip styles live in AssetTriageBand's scoped block, so they do not reach
this copy of the markup. Kept in step with that component deliberately. */
.triage-chip {