Files
shopdb-flask/plugins/computers/frontend/views/BusinessPoliciesList.vue
cproudlock f4d3827214 Record how a PC is reached, and stop pages implying they can do things
Three findings, all about a screen or a log saying something that is not so.

Editing a PC wrote an audit entry for its named fields and nothing for the two
changes that matter most when somebody investigates later: the primary IP
address, which is how the machine is found, and the remote-access protocols and
ports, which is how somebody connects to it. Both are on the same form, in the
same request, and both were invisible afterwards - a PC that moved address had
no history of moving, and a PC that gained a VNC listener had no record of
gaining one. Both are recorded now, and re-sending the same value still records
nothing, because an audit log full of non-events is one nobody reads.

While proving that, a silent discard turned up in the same lines: both the
create and the update path did `if ip_comtype:` around the IP write and did
nothing at all when the 'IP' communication type row was missing. Somebody typed
an address, saved, got a 200, and the address was gone. A missing reference row
is a seeding gap, not a reason to drop what was typed, so it is created on
demand.

The PCs list rendered a 403 as "No computers found", which tells a person
without the permission that the fleet is empty and sends them looking for the
data rather than for the permission. It now says which permission is missing.

The Site Policies and Configuration Policies pages rendered their empty table
and an Add button while the policy feature flag was off - every route answers
404 "Policy feature is not enabled", so the button could never work. Both pages
now say the feature is off and that the empty list is a consequence of that,
not of nothing being configured.

Nine tests. The audit entries verified by planting: remove either and two fail.

Scope: product
2026-09-03 16:56:22 -04:00

243 lines
8.3 KiB
Vue

<template>
<div>
<div class="page-header">
<h2>Site Policies</h2>
<button v-if="!featureOff" class="btn btn-primary" @click="openModal()">+ Add Site Policy</button>
</div>
<div v-if="featureOff" class="card feature-off-notice">
<strong>Site policies are not enabled on this site.</strong>
<p>Nothing can be created here until the feature is turned on.
The list below is empty because the feature is off, not
because nothing has been configured.</p>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Priority</th>
<th>Name</th>
<th>Description</th>
<th>Configuration policies</th>
<th>Source</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="p in policies" :key="p.businesspolicyid">
<td>{{ p.priority }}</td>
<td>{{ p.name }}</td>
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
<td>
<span v-for="it in p.items" :key="it.businesspolicyitemid" class="badge" style="margin-right:4px;">
{{ it.name }}
</span>
<span v-if="!p.items || p.items.length === 0" style="color: var(--text-light);">none</span>
</td>
<td>
<span class="badge" :class="p.source === 'local' ? 'badge-secondary' : 'badge-warning'">
{{ p.source }}
</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" :disabled="p.source !== 'local'" @click="openModal(p)">Edit</button>
<button class="btn btn-danger btn-sm" :disabled="p.source !== 'local'" @click="remove(p)">Delete</button>
</td>
</tr>
<tr v-if="policies.length === 0">
<td colspan="6" style="text-align:center; color: var(--text-light);">No site policies</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 Site Policy' : 'Add Site Policy' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Name *</label>
<input v-model="form.name" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Priority <span class="hint">(lower runs first; first match wins per action)</span></label>
<input v-model.number="form.priority" type="number" class="form-control" />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="2"></textarea>
</div>
<div class="form-group">
<label>Configuration policies <span class="hint">(evaluated top to bottom)</span></label>
<div v-for="(it, index) in form.items" :key="index" class="item-row">
<span class="order-badge">{{ index + 1 }}</span>
<select v-model.number="it.configurationpolicyid" class="form-control">
<option :value="undefined" disabled>Select configuration policy...</option>
<option v-for="cp in configPolicies" :key="cp.configurationpolicyid" :value="cp.configurationpolicyid">
{{ cp.name }}
</option>
</select>
<button type="button" class="btn btn-secondary btn-sm" :disabled="index === 0" @click="moveUp(index)">Up</button>
<button type="button" class="btn btn-secondary btn-sm" :disabled="index === form.items.length - 1" @click="moveDown(index)">Down</button>
<button type="button" class="btn btn-danger btn-sm" @click="form.items.splice(index, 1)">Remove</button>
</div>
<button type="button" class="btn btn-secondary btn-sm" @click="addItem">+ Add configuration policy</button>
</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 { computersApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const toast = useToast()
const policies = ref([])
const configPolicies = ref([])
const loading = ref(true)
const featureOff = ref(false)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref(emptyForm())
function emptyForm() {
return { name: '', description: '', priority: 100, items: [] }
}
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const [biz, config] = await Promise.all([
computersApi.policies.business.list(),
computersApi.policies.configuration.list()
])
policies.value = biz.data.data || []
configPolicies.value = config.data.data || []
} catch (err) {
console.error('Error loading business policies:', err)
// The API answers 404 'Policy feature is not enabled' when the
// flag is off. Without this the page rendered its empty table
// and an Add button for a feature that cannot accept one.
featureOff.value = err?.response?.status === 404
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
error.value = ''
if (item) {
form.value = {
name: item.name || '',
description: item.description || '',
priority: item.priority ?? 100,
items: (item.items || [])
.slice()
.sort((a, b) => a.sortorder - b.sortorder)
.map(it => ({ configurationpolicyid: it.configurationpolicyid }))
}
} else {
form.value = emptyForm()
}
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
function addItem() {
form.value.items.push({ configurationpolicyid: undefined })
}
function moveUp(index) {
const items = form.value.items
;[items[index - 1], items[index]] = [items[index], items[index - 1]]
}
function moveDown(index) {
const items = form.value.items
;[items[index + 1], items[index]] = [items[index], items[index + 1]]
}
async function save() {
saving.value = true
error.value = ''
try {
const payload = {
name: form.value.name,
description: form.value.description,
priority: form.value.priority,
items: form.value.items.map((it, order) => ({
configurationpolicyid: it.configurationpolicyid,
sortorder: order
}))
}
if (editing.value) {
await computersApi.policies.business.update(editing.value.businesspolicyid, payload)
} else {
await computersApi.policies.business.create(payload)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save site policy')
} finally {
saving.value = false
}
}
async function remove(p) {
if (!confirm(`Delete site policy "${p.name}"?`)) return
try {
await computersApi.policies.business.remove(p.businesspolicyid)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
</script>
<style scoped>
.feature-off-notice {
border-left: 3px solid var(--warning);
margin-bottom: 1rem;
}
.feature-off-notice p {
margin: 0.4rem 0 0 0;
}
.item-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.order-badge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 24px; height: 24px; border-radius: 12px;
background: var(--border-color, #444);
}
</style>