Remediate the 2026-09-03 feature-test findings
This commit is contained in:
@@ -405,6 +405,12 @@ export const vendorsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/vendors', { params })
|
||||
},
|
||||
// Every vendor, paged. perpage is capped at 100 server-side, so a picker
|
||||
// asking for 500 silently showed the first 100 and dropped the rest - with a
|
||||
// 200, so nothing looked wrong.
|
||||
listAll(params = {}) {
|
||||
return fetchAllPages('/vendors', params)
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/vendors/${id}`)
|
||||
},
|
||||
@@ -667,6 +673,10 @@ export const businessunitsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/businessunits', { params })
|
||||
},
|
||||
// See vendorsApi.listAll: the cap is 100 whatever the caller asks for.
|
||||
listAll(params = {}) {
|
||||
return fetchAllPages('/businessunits', params)
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/businessunits/${id}`)
|
||||
},
|
||||
@@ -1306,6 +1316,11 @@ export const slidesApi = {
|
||||
},
|
||||
remove(surface, files) {
|
||||
return api.post(`/slides/${surface}/delete`, { files })
|
||||
},
|
||||
// Per-slide hold time. The route has always existed; nothing called it, so
|
||||
// the documented per-slide seconds could only be set with curl.
|
||||
update(surface, slideid, data) {
|
||||
return api.patch(`/slides/${surface}/${slideid}`, data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1153,6 +1153,18 @@ td.actions .btn + .btn {
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
/* A settings block whose feature is not built yet. Reads as a warning rather
|
||||
than body text, because the failure it prevents is an administrator
|
||||
believing a control does something. */
|
||||
.setting-notice {
|
||||
border-left: 3px solid var(--warning);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 1rem 0;
|
||||
padding: 0.6rem 0.8rem;
|
||||
}
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
|
||||
@@ -1,251 +1,272 @@
|
||||
<template>
|
||||
<div class="employee-search">
|
||||
<div class="employee-search-container">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="form-control"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@input="onSearch"
|
||||
@keydown.enter.prevent="addCustom"
|
||||
/>
|
||||
<div v-if="results.length" class="employee-dropdown">
|
||||
<div
|
||||
v-for="emp in results"
|
||||
:key="emp.SSO"
|
||||
class="employee-option"
|
||||
@click="selectEmployee(emp)"
|
||||
>
|
||||
<span class="emp-name">{{ emp.First_Name }} {{ emp.Last_Name }}</span>
|
||||
<span class="emp-sso">{{ emp.SSO }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small v-if="allowCustom" class="form-hint">Search for employees or press Enter to add a custom name</small>
|
||||
|
||||
<!-- Selected employee(s) display -->
|
||||
<div v-if="multiple && selectedList.length" class="selected-employees">
|
||||
<div
|
||||
v-for="(emp, idx) in selectedList"
|
||||
:key="idx"
|
||||
class="selected-employee"
|
||||
>
|
||||
<span>{{ emp.name }}</span>
|
||||
<span v-if="emp.sso && !emp.sso.startsWith('NAME:')" class="emp-sso-tag">{{ emp.sso }}</span>
|
||||
<button v-if="!disabled" type="button" class="btn-remove" @click="removeEmployee(idx)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!multiple && selected" class="selected-employee-display">
|
||||
<span>{{ selected.name }}</span>
|
||||
<span v-if="selected.sso" class="emp-sso-tag">{{ selected.sso }}</span>
|
||||
<button v-if="!disabled" type="button" class="btn-remove" @click="clearSelection">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { employeesApi } from '../api'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Object, Array], default: null },
|
||||
multiple: { type: Boolean, default: false },
|
||||
allowCustom: { type: Boolean, default: false },
|
||||
placeholder: { type: String, default: 'Search by name...' },
|
||||
disabled: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const searchQuery = ref('')
|
||||
const results = ref([])
|
||||
const selected = ref(null)
|
||||
const selectedList = ref([])
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
// Initialize from modelValue
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (props.multiple) {
|
||||
selectedList.value = val || []
|
||||
} else {
|
||||
selected.value = val
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function onSearch() {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
|
||||
const query = searchQuery.value.trim()
|
||||
if (query.length < 2) {
|
||||
results.value = []
|
||||
return
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await employeesApi.search(query)
|
||||
results.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Employee search error:', err)
|
||||
results.value = []
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function selectEmployee(emp) {
|
||||
const employee = {
|
||||
sso: String(emp.SSO),
|
||||
name: `${emp.First_Name} ${emp.Last_Name}`.trim()
|
||||
}
|
||||
|
||||
if (props.multiple) {
|
||||
// Check if already selected
|
||||
if (selectedList.value.some(e => e.sso === employee.sso)) {
|
||||
return
|
||||
}
|
||||
selectedList.value.push(employee)
|
||||
emit('update:modelValue', selectedList.value)
|
||||
} else {
|
||||
selected.value = employee
|
||||
emit('update:modelValue', employee)
|
||||
}
|
||||
|
||||
searchQuery.value = ''
|
||||
results.value = []
|
||||
}
|
||||
|
||||
function addCustom() {
|
||||
if (!props.allowCustom) return
|
||||
|
||||
const name = searchQuery.value.trim()
|
||||
if (!name) return
|
||||
|
||||
const employee = {
|
||||
sso: `NAME:${name}`,
|
||||
name: name
|
||||
}
|
||||
|
||||
if (props.multiple) {
|
||||
selectedList.value.push(employee)
|
||||
emit('update:modelValue', selectedList.value)
|
||||
} else {
|
||||
selected.value = employee
|
||||
emit('update:modelValue', employee)
|
||||
}
|
||||
|
||||
searchQuery.value = ''
|
||||
results.value = []
|
||||
}
|
||||
|
||||
function removeEmployee(idx) {
|
||||
selectedList.value.splice(idx, 1)
|
||||
emit('update:modelValue', selectedList.value)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selected.value = null
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.employee-search-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.employee-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-card-solid, #1a1a1a);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.employee-option {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.employee-option:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.emp-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.emp-sso {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Multiple selection */
|
||||
.selected-employees {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.selected-employee {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Single selection */
|
||||
.selected-employee-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.emp-sso-tag {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="employee-search">
|
||||
<div class="employee-search-container">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="form-control"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@input="onSearch"
|
||||
@keydown.enter.prevent="addCustom"
|
||||
/>
|
||||
<div v-if="results.length" class="employee-dropdown">
|
||||
<div
|
||||
v-for="emp in results"
|
||||
:key="emp.SSO"
|
||||
class="employee-option"
|
||||
@click="selectEmployee(emp)"
|
||||
>
|
||||
<span class="emp-name">{{ emp.First_Name }} {{ emp.Last_Name }}</span>
|
||||
<span class="emp-sso">{{ emp.SSO }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small v-if="allowCustom" class="form-hint">Search for employees or press Enter to add a custom name</small>
|
||||
|
||||
<!-- Selected employee(s) display -->
|
||||
<div v-if="multiple && selectedList.length" class="selected-employees">
|
||||
<div
|
||||
v-for="(emp, idx) in selectedList"
|
||||
:key="idx"
|
||||
class="selected-employee"
|
||||
>
|
||||
<span>{{ emp.name }}</span>
|
||||
<span v-if="emp.sso && !emp.sso.startsWith('NAME:')" class="emp-sso-tag">{{ emp.sso }}</span>
|
||||
<button v-if="!disabled" type="button" class="btn-remove" @click="removeEmployee(idx)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!multiple && selected" class="selected-employee-display">
|
||||
<span>{{ selected.name }}</span>
|
||||
<span v-if="selected.sso" class="emp-sso-tag">{{ selected.sso }}</span>
|
||||
<button v-if="!disabled" type="button" class="btn-remove" @click="clearSelection">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { employeesApi } from '../api'
|
||||
import { resolveEmployeeName } from '../composables/employeeResolve'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Object, Array], default: null },
|
||||
multiple: { type: Boolean, default: false },
|
||||
allowCustom: { type: Boolean, default: false },
|
||||
placeholder: { type: String, default: 'Search by name...' },
|
||||
disabled: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const searchQuery = ref('')
|
||||
const results = ref([])
|
||||
const selected = ref(null)
|
||||
const selectedList = ref([])
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
// Initialize from modelValue
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (props.multiple) {
|
||||
selectedList.value = val || []
|
||||
} else {
|
||||
selected.value = val
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function onSearch() {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
|
||||
const query = searchQuery.value.trim()
|
||||
if (query.length < 2) {
|
||||
results.value = []
|
||||
return
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await employeesApi.search(query)
|
||||
results.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Employee search error:', err)
|
||||
results.value = []
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function selectEmployee(emp) {
|
||||
const employee = {
|
||||
sso: String(emp.SSO),
|
||||
name: `${emp.First_Name} ${emp.Last_Name}`.trim()
|
||||
}
|
||||
|
||||
if (props.multiple) {
|
||||
// Check if already selected
|
||||
if (selectedList.value.some(e => e.sso === employee.sso)) {
|
||||
return
|
||||
}
|
||||
selectedList.value.push(employee)
|
||||
emit('update:modelValue', selectedList.value)
|
||||
} else {
|
||||
selected.value = employee
|
||||
emit('update:modelValue', employee)
|
||||
}
|
||||
|
||||
searchQuery.value = ''
|
||||
results.value = []
|
||||
}
|
||||
|
||||
async function addCustom() {
|
||||
const name = searchQuery.value.trim()
|
||||
if (!name) return
|
||||
|
||||
// Look it up before deciding it is free text. Picking a suggestion gave a
|
||||
// real SSO; typing the same name in full and pressing Enter gave
|
||||
// `NAME:<text>`, linked to nothing - and the chip looked the same either
|
||||
// way. Nobody types a name in full expecting a different record.
|
||||
const { match, matches, failed } = await resolveEmployeeName(name)
|
||||
if (match) {
|
||||
selectEmployee(match)
|
||||
return
|
||||
}
|
||||
if (matches && matches.length) {
|
||||
// Ambiguous: show the candidates rather than guessing, which is exactly
|
||||
// when guessing wrong matters.
|
||||
results.value = matches
|
||||
return
|
||||
}
|
||||
if (failed) {
|
||||
// The directory is unreachable. Falling through to a custom label here
|
||||
// would quietly turn a real employee into free text.
|
||||
return
|
||||
}
|
||||
if (!props.allowCustom) return
|
||||
|
||||
const employee = {
|
||||
sso: `NAME:${name}`,
|
||||
name: name
|
||||
}
|
||||
|
||||
if (props.multiple) {
|
||||
selectedList.value.push(employee)
|
||||
emit('update:modelValue', selectedList.value)
|
||||
} else {
|
||||
selected.value = employee
|
||||
emit('update:modelValue', employee)
|
||||
}
|
||||
|
||||
searchQuery.value = ''
|
||||
results.value = []
|
||||
}
|
||||
|
||||
function removeEmployee(idx) {
|
||||
selectedList.value.splice(idx, 1)
|
||||
emit('update:modelValue', selectedList.value)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selected.value = null
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.employee-search-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.employee-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-card-solid, #1a1a1a);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.employee-option {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.employee-option:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.emp-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.emp-sso {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Multiple selection */
|
||||
.selected-employees {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.selected-employee {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Single selection */
|
||||
.selected-employee-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.emp-sso-tag {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,83 +1,96 @@
|
||||
<template>
|
||||
<div class="pagination-bar" v-if="totalPages > 1 || showPerPage">
|
||||
<div class="pagination-info">
|
||||
<select
|
||||
v-if="showPerPage"
|
||||
:value="perPage"
|
||||
class="perpage-select"
|
||||
@change="$emit('update:perPage', Number($event.target.value))"
|
||||
>
|
||||
<option v-for="opt in perPageOptions" :key="opt" :value="opt">
|
||||
{{ opt }} per page
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button :disabled="page === 1" @click="$emit('update:page', page - 1)">
|
||||
Prev
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-for="p in visiblePages"
|
||||
:key="p"
|
||||
:class="{ active: p === page, ellipsis: p === '...' }"
|
||||
:disabled="p === '...'"
|
||||
@click="p !== '...' && $emit('update:page', p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
|
||||
<button :disabled="page === totalPages" @click="$emit('update:page', page + 1)">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
page: { type: Number, required: true },
|
||||
totalPages: { type: Number, required: true },
|
||||
perPage: { type: Number, default: 20 },
|
||||
showPerPage: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
defineEmits(['update:page', 'update:perPage'])
|
||||
|
||||
const perPageOptions = [10, 20, 50, 100]
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const total = props.totalPages
|
||||
const current = props.page
|
||||
const pages = []
|
||||
|
||||
if (total <= 7) {
|
||||
for (let i = 1; i <= total; i++) pages.push(i)
|
||||
return pages
|
||||
}
|
||||
|
||||
pages.push(1)
|
||||
|
||||
if (current > 3) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
const start = Math.max(2, current - 1)
|
||||
const end = Math.min(total - 1, current + 1)
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
if (current < total - 2) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
pages.push(total)
|
||||
|
||||
return pages
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="pagination-bar" v-if="totalPages > 1 || showPerPage">
|
||||
<div class="pagination-info">
|
||||
<select
|
||||
v-if="showPerPage"
|
||||
:value="perPage"
|
||||
class="perpage-select"
|
||||
@change="$emit('update:perPage', Number($event.target.value))"
|
||||
>
|
||||
<option v-for="opt in perPageOptions" :key="opt" :value="opt">
|
||||
{{ opt }} per page
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button :disabled="page === 1" @click="$emit('update:page', page - 1)">
|
||||
Prev
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-for="p in visiblePages"
|
||||
:key="p"
|
||||
:class="{ active: p === page, ellipsis: p === '...' }"
|
||||
:disabled="p === '...'"
|
||||
@click="p !== '...' && $emit('update:page', p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
|
||||
<button :disabled="page === totalPages" @click="$emit('update:page', page + 1)">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
page: { type: Number, required: true },
|
||||
totalPages: { type: Number, required: true },
|
||||
perPage: { type: Number, default: 20 },
|
||||
showPerPage: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
defineEmits(['update:page', 'update:perPage'])
|
||||
|
||||
// A page size the caller actually uses must be selectable, or the control renders
|
||||
// blank: NetworkDevicesList defaults to 25, which was in no option, so
|
||||
// :value="25" matched nothing and the box showed empty - the operator could not
|
||||
// see the current page size at all. Any size the component is handed is folded
|
||||
// in rather than silently dropped.
|
||||
const BASE_PER_PAGE_OPTIONS = [10, 20, 50, 100]
|
||||
|
||||
const perPageOptions = computed(() => {
|
||||
const current = Number(props.perPage)
|
||||
if (!current || BASE_PER_PAGE_OPTIONS.includes(current)) {
|
||||
return BASE_PER_PAGE_OPTIONS
|
||||
}
|
||||
return [...BASE_PER_PAGE_OPTIONS, current].sort((a, b) => a - b)
|
||||
})
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const total = props.totalPages
|
||||
const current = props.page
|
||||
const pages = []
|
||||
|
||||
if (total <= 7) {
|
||||
for (let i = 1; i <= total; i++) pages.push(i)
|
||||
return pages
|
||||
}
|
||||
|
||||
pages.push(1)
|
||||
|
||||
if (current > 3) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
const start = Math.max(2, current - 1)
|
||||
const end = Math.min(total - 1, current + 1)
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
if (current < total - 2) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
pages.push(total)
|
||||
|
||||
return pages
|
||||
})
|
||||
</script>
|
||||
|
||||
56
frontend/src/composables/assetTypeIds.js
Normal file
56
frontend/src/composables/assetTypeIds.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// Asset-type ids resolved by NAME, once per page load.
|
||||
//
|
||||
// Four asset forms carried a hardcoded id - `const COMPUTER_ASSETTYPEID = 2`,
|
||||
// and the same for machines, printers and network devices - to tell
|
||||
// CustomFieldsInputs which type's fields to render. The ids come from seed
|
||||
// order, not from anything stable: on the instance those constants were written
|
||||
// against, computer was 2; on a freshly seeded one it is 1 and 2 is
|
||||
// network_device. So the PC form asked for another type's custom fields,
|
||||
// rendered them, and dropped whatever the operator typed - with a success
|
||||
// toast, because the values were keyed to a type the record does not have.
|
||||
//
|
||||
// A name is the stable thing. `assettype` is a fixed vocabulary (ADR-001) and
|
||||
// the API already publishes the mapping; nothing else should hardcode a number.
|
||||
import { reactive } from 'vue'
|
||||
import { assetsApi } from '../api'
|
||||
|
||||
const state = reactive({
|
||||
byname: {},
|
||||
loaded: false
|
||||
})
|
||||
|
||||
let inflight = null
|
||||
|
||||
export async function loadAssetTypeIds() {
|
||||
if (state.loaded) return state
|
||||
if (inflight) return inflight
|
||||
inflight = assetsApi.types.list()
|
||||
.then((response) => {
|
||||
const rows = response?.data?.data || response?.data || []
|
||||
const byname = {}
|
||||
rows.forEach((row) => {
|
||||
if (row && row.assettype) byname[row.assettype] = row.assettypeid
|
||||
})
|
||||
state.byname = byname
|
||||
state.loaded = true
|
||||
return state
|
||||
})
|
||||
.catch(() => {
|
||||
// Leave it unloaded rather than caching a failure: a form that could not
|
||||
// resolve its type renders no custom fields, which is honest. Guessing an
|
||||
// id is what caused the defect this replaces.
|
||||
state.loaded = false
|
||||
return state
|
||||
})
|
||||
.finally(() => { inflight = null })
|
||||
return inflight
|
||||
}
|
||||
|
||||
// Undefined until loaded, and undefined for a name the server does not know.
|
||||
// CustomFieldsInputs treats an undefined assettypeid as "nothing to render",
|
||||
// which is the safe reading: better a missing panel than the wrong type's.
|
||||
export function assetTypeId(name) {
|
||||
return state.byname[name]
|
||||
}
|
||||
|
||||
export { state }
|
||||
94
frontend/src/composables/assetTypeIds.spec.js
Normal file
94
frontend/src/composables/assetTypeIds.spec.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// Asset-type ids must come from the server by name.
|
||||
//
|
||||
// Four forms hardcoded the id. Those constants were written against one
|
||||
// instance's seed order and were wrong on another: PCForm said 2, and on a
|
||||
// freshly seeded database 2 is network_device. The form asked
|
||||
// CustomFieldsInputs for that type's fields, rendered them, and dropped what
|
||||
// the operator typed - with a success toast, because the values were keyed to
|
||||
// a type the record does not have.
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
assetsApi: {
|
||||
types: {
|
||||
list: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import { assetsApi } from '../api'
|
||||
import { loadAssetTypeIds, assetTypeId, state } from './assetTypeIds'
|
||||
|
||||
function reset() {
|
||||
state.byname = {}
|
||||
state.loaded = false
|
||||
assetsApi.types.list.mockReset()
|
||||
}
|
||||
|
||||
describe('assetTypeIds', () => {
|
||||
beforeEach(reset)
|
||||
|
||||
it('resolves an id by name from what the server actually has', async () => {
|
||||
assetsApi.types.list.mockResolvedValue({
|
||||
data: { data: [
|
||||
{ assettypeid: 1, assettype: 'computer' },
|
||||
{ assettypeid: 2, assettype: 'network_device' },
|
||||
{ assettypeid: 4, assettype: 'machine' }
|
||||
] }
|
||||
})
|
||||
|
||||
await loadAssetTypeIds()
|
||||
|
||||
expect(assetTypeId('computer')).toBe(1)
|
||||
expect(assetTypeId('network_device')).toBe(2)
|
||||
expect(assetTypeId('machine')).toBe(4)
|
||||
})
|
||||
|
||||
it('survives a differently seeded site, which is the whole point', async () => {
|
||||
// The same names, different ids. A hardcoded 2 would be network_device
|
||||
// here and computer in the other fixture.
|
||||
assetsApi.types.list.mockResolvedValue({
|
||||
data: { data: [
|
||||
{ assettypeid: 2, assettype: 'computer' },
|
||||
{ assettypeid: 3, assettype: 'network_device' }
|
||||
] }
|
||||
})
|
||||
|
||||
await loadAssetTypeIds()
|
||||
|
||||
expect(assetTypeId('computer')).toBe(2)
|
||||
expect(assetTypeId('network_device')).toBe(3)
|
||||
})
|
||||
|
||||
it('fetches once even when several forms ask at the same time', async () => {
|
||||
assetsApi.types.list.mockResolvedValue({
|
||||
data: { data: [{ assettypeid: 1, assettype: 'computer' }] }
|
||||
})
|
||||
|
||||
await Promise.all([loadAssetTypeIds(), loadAssetTypeIds(), loadAssetTypeIds()])
|
||||
await loadAssetTypeIds()
|
||||
|
||||
expect(assetsApi.types.list).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns undefined rather than a guess when the load fails', async () => {
|
||||
assetsApi.types.list.mockRejectedValue(new Error('network down'))
|
||||
|
||||
await loadAssetTypeIds()
|
||||
|
||||
// Undefined means CustomFieldsInputs renders nothing. A missing panel is
|
||||
// honest; the wrong type's panel is what this replaces.
|
||||
expect(assetTypeId('computer')).toBeUndefined()
|
||||
expect(state.loaded).toBe(false)
|
||||
})
|
||||
|
||||
it('returns undefined for a type the server does not know', async () => {
|
||||
assetsApi.types.list.mockResolvedValue({
|
||||
data: { data: [{ assettypeid: 1, assettype: 'computer' }] }
|
||||
})
|
||||
|
||||
await loadAssetTypeIds()
|
||||
|
||||
expect(assetTypeId('forklift')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
55
frontend/src/composables/employeeResolve.js
Normal file
55
frontend/src/composables/employeeResolve.js
Normal file
@@ -0,0 +1,55 @@
|
||||
// Turning what somebody typed into an employee, before giving up on it.
|
||||
//
|
||||
// Both employee pickers had the same shape: click a suggestion and you get the
|
||||
// person, with their SSO; type the whole name and press Enter and you get a
|
||||
// free-text label (`NAME:Jane Smith`) that is linked to nothing. It looks
|
||||
// identical on screen - the chip says "Jane Smith" either way - and the
|
||||
// consequences only show up later: no photo, no directory link, and a
|
||||
// recognition that cannot be found by the person it is about.
|
||||
//
|
||||
// Nobody types a name in full expecting a different outcome from picking it
|
||||
// off the list. So a typed name is looked up first, and only becomes a custom
|
||||
// label when the directory genuinely does not have it.
|
||||
//
|
||||
// An ambiguous name is NOT guessed at. Two people called J Smith is exactly
|
||||
// when picking the wrong one matters most, so the caller is told to choose.
|
||||
import { employeesApi } from '../api'
|
||||
|
||||
function fullname(employee) {
|
||||
return `${employee.First_Name || ''} ${employee.Last_Name || ''}`.trim()
|
||||
}
|
||||
|
||||
// { match } when one employee is clearly meant, { matches } when several are
|
||||
// possible, {} when the directory has nobody by that name.
|
||||
export async function resolveEmployeeName(name) {
|
||||
const query = (name || '').trim()
|
||||
if (!query) return {}
|
||||
|
||||
let rows = []
|
||||
try {
|
||||
const response = await employeesApi.search(query)
|
||||
rows = response?.data?.data || []
|
||||
} catch (err) {
|
||||
// A directory that cannot be reached must not silently turn a real
|
||||
// employee into a custom label - the caller decides what to do about it.
|
||||
// The MESSAGE, not the error object: a rejected mock handed to
|
||||
// console.error is re-surfaced by the test runner as an unhandled error,
|
||||
// which fails the very test that proves this branch works.
|
||||
console.warn('Employee lookup failed:', err && err.message)
|
||||
return { failed: true }
|
||||
}
|
||||
|
||||
if (!rows.length) return {}
|
||||
|
||||
// An exact full-name match wins over a partial one: searching "Dana Reyes"
|
||||
// can also return "Dana Reyes-Smith", and the person typing the shorter name
|
||||
// meant the shorter name.
|
||||
const exact = rows.filter(
|
||||
(row) => fullname(row).toLowerCase() === query.toLowerCase())
|
||||
if (exact.length === 1) return { match: exact[0] }
|
||||
if (exact.length > 1) return { matches: exact }
|
||||
if (rows.length === 1) return { match: rows[0] }
|
||||
return { matches: rows }
|
||||
}
|
||||
|
||||
export { fullname }
|
||||
81
frontend/src/composables/employeeResolve.spec.js
Normal file
81
frontend/src/composables/employeeResolve.spec.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// Typing a name in full must reach the same employee as picking it off the list.
|
||||
//
|
||||
// Both pickers turned a typed name straight into `NAME:<text>` - a free-text
|
||||
// label linked to nothing. On screen the chip is identical to a real
|
||||
// selection, so the difference only shows up later: no photo, no directory
|
||||
// record, and a recognition that cannot be found by the person it is about.
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
employeesApi: { search: vi.fn() }
|
||||
}))
|
||||
|
||||
import { employeesApi } from '../api'
|
||||
import { resolveEmployeeName } from './employeeResolve'
|
||||
|
||||
const DANA = { SSO: 270015376, First_Name: 'Dana', Last_Name: 'Reyes' }
|
||||
const DANA_HYPHEN = { SSO: 270099999, First_Name: 'Dana', Last_Name: 'Reyes-Smith' }
|
||||
const SAM = { SSO: 270000001, First_Name: 'Sam', Last_Name: 'Reyes' }
|
||||
|
||||
function found(rows) {
|
||||
employeesApi.search.mockResolvedValue({ data: { data: rows } })
|
||||
}
|
||||
|
||||
describe('resolveEmployeeName', () => {
|
||||
beforeEach(() => employeesApi.search.mockReset())
|
||||
|
||||
it('resolves a full name to the employee', async () => {
|
||||
found([DANA])
|
||||
const { match } = await resolveEmployeeName('Dana Reyes')
|
||||
expect(match.SSO).toBe(270015376)
|
||||
})
|
||||
|
||||
it('prefers an exact full-name match over a longer partial one', async () => {
|
||||
// Searching "Dana Reyes" also returns "Dana Reyes-Smith"; the person who
|
||||
// typed the shorter name meant the shorter name.
|
||||
found([DANA, DANA_HYPHEN])
|
||||
const { match, matches } = await resolveEmployeeName('Dana Reyes')
|
||||
expect(matches).toBeUndefined()
|
||||
expect(match.SSO).toBe(270015376)
|
||||
})
|
||||
|
||||
it('refuses to guess between genuinely ambiguous matches', async () => {
|
||||
found([DANA, SAM])
|
||||
const { match, matches } = await resolveEmployeeName('Reyes')
|
||||
expect(match).toBeUndefined()
|
||||
expect(matches).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('reports nothing found, so the caller may fall back to a label', async () => {
|
||||
found([])
|
||||
const result = await resolveEmployeeName('Somebody External')
|
||||
expect(result.match).toBeUndefined()
|
||||
expect(result.matches).toBeUndefined()
|
||||
expect(result.failed).toBeUndefined()
|
||||
})
|
||||
|
||||
it('says the lookup FAILED rather than reporting nobody', async () => {
|
||||
// The difference matters: "not in the directory" may become a custom
|
||||
// label, "could not ask" must not.
|
||||
// A THENABLE that calls its reject callback, rather than a mock returning
|
||||
// a rejected promise. The await sees the same failure, but no rejected
|
||||
// promise object exists for the runner to collect as an unhandled error -
|
||||
// which it does even when the code under test catches it, failing the very
|
||||
// test that proves the catch works.
|
||||
employeesApi.search.mockImplementationOnce(() => {
|
||||
const failure = new Error('network down')
|
||||
return { then: (_ok, fail) => fail(failure) }
|
||||
})
|
||||
// resolves.toEqual, so the runner sees the rejection consumed by an
|
||||
// assertion rather than as an unhandled error of its own.
|
||||
await expect(resolveEmployeeName('Dana Reyes')).resolves.toEqual({
|
||||
failed: true
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call the directory for an empty box', async () => {
|
||||
const result = await resolveEmployeeName(' ')
|
||||
expect(employeesApi.search).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -83,6 +83,12 @@
|
||||
<span v-if="result.location" class="result-location">{{ result.location }}</span>
|
||||
<span v-if="result.ticketnumber" class="result-ticket">{{ result.ticketnumber }}</span>
|
||||
<span v-if="result.iscurrent" class="badge badge-success">Active</span>
|
||||
<!-- Retired assets are searchable on purpose (the retire
|
||||
dialog promises they can be restored, and the search box
|
||||
is where somebody looks for one). They rank below live
|
||||
assets and say so, or a retired record looks like a
|
||||
current one. -->
|
||||
<span v-if="result.retired" class="badge badge-secondary">retired</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
<div class="setting-group">
|
||||
<h3>Audit Log Retention</h3>
|
||||
<p class="setting-description">
|
||||
Configure how long audit logs are retained. Older logs will be automatically purged.
|
||||
Set to 0 to keep logs indefinitely.
|
||||
How long audit entries are kept. 0 keeps everything forever, which is
|
||||
the default. Saving a period does NOT delete anything by itself:
|
||||
purge from Settings > Site & Facility, or run
|
||||
<code>flask audit purge --apply</code> on the server.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
@@ -17,13 +19,13 @@
|
||||
<span>Retention Period (days)</span>
|
||||
<input
|
||||
type="number"
|
||||
v-model="settings.audit_retention_days"
|
||||
v-model="settings.audit_retentiondays"
|
||||
min="0"
|
||||
placeholder="90"
|
||||
@blur="saveSetting('audit_retention_days', settings.audit_retention_days)"
|
||||
placeholder="0"
|
||||
@blur="saveSetting('audit_retentiondays', settings.audit_retentiondays)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">0 = keep forever, recommended: 90 days</small>
|
||||
<small class="input-hint">0 = keep forever</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,9 +7,16 @@
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<h3>SAML Single Sign-On</h3>
|
||||
<p class="setting-notice">
|
||||
<strong>Not implemented yet.</strong> These settings are stored so a
|
||||
site can be configured ahead of the feature landing, but nothing
|
||||
consumes them today: there is no SAML login route, and no assertion
|
||||
is accepted. Sign-in is local accounts only, whatever is set here.
|
||||
</p>
|
||||
<p class="setting-description">
|
||||
Enable SAML SSO for enterprise authentication. Users can sign in using your organization's
|
||||
identity provider (Azure AD, Okta, etc.). Local login can remain enabled as a fallback.
|
||||
When it ships, users will sign in through your organization's identity
|
||||
provider (Azure AD, Okta, etc.), with local login available as a
|
||||
fallback.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
@@ -82,19 +89,26 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Locked on until SAML exists. A toggle that reads "Allow Local
|
||||
Login: off" while password login still works is the dangerous
|
||||
one on this page: an administrator can believe they have closed
|
||||
the local door, and nothing tells them otherwise. It is not a
|
||||
setting anyone can honour yet, so it does not accept a click. -->
|
||||
<div class="setting-row">
|
||||
<label class="toggle-label">
|
||||
<span>Allow Local Login</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: settings.saml_allow_local_login }"
|
||||
@click="toggleSetting('saml_allow_local_login')"
|
||||
:disabled="saving"
|
||||
class="toggle-btn active"
|
||||
disabled
|
||||
title="Local login cannot be turned off until SAML sign-in exists"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
<small class="input-hint toggle-hint">Keep enabled for admin fallback access</small>
|
||||
<small class="input-hint toggle-hint">
|
||||
Always on for now. Local accounts are the only way in until SAML
|
||||
sign-in ships, so this cannot be turned off yet.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
@@ -148,15 +162,20 @@ const {
|
||||
// Per-page connection status (stays with the page, not the composable).
|
||||
const samlStatus = computed(() => {
|
||||
if (!settings.saml_enabled) return 'inactive'
|
||||
if (!settings.saml_idp_metadata_url || !settings.saml_entity_id) return 'warning'
|
||||
// 'pending' rather than a healthy state even when every field is filled in:
|
||||
// nothing is connected until the feature exists.
|
||||
return 'pending'
|
||||
})
|
||||
|
||||
// Never says "available". It used to report "Configured - SAML login
|
||||
// available" as soon as two fields held text, which is a claim about a feature
|
||||
// that does not exist: an administrator could reasonably read that as SSO
|
||||
// being live and stop issuing local accounts.
|
||||
const samlMessage = computed(() => {
|
||||
if (!settings.saml_enabled) return 'Disabled - using local authentication only'
|
||||
if (!settings.saml_idp_metadata_url) return 'IdP metadata URL not configured'
|
||||
if (!settings.saml_entity_id) return 'Entity ID not configured'
|
||||
return 'Configured - SAML login available'
|
||||
if (!settings.saml_enabled) return 'Off - local accounts only'
|
||||
if (!settings.saml_idp_metadata_url) return 'IdP metadata URL not set'
|
||||
if (!settings.saml_entity_id) return 'Entity ID not set'
|
||||
return 'Settings saved - waiting on SAML support, local accounts still in use'
|
||||
})
|
||||
|
||||
onMounted(loadSettings)
|
||||
|
||||
Reference in New Issue
Block a user