Remediate the 2026-09-03 feature-test findings

This commit is contained in:
2026-09-03 17:14:53 -04:00
parent ddf41c3e65
commit ff9ae693a6
112 changed files with 5182 additions and 857 deletions

View File

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

View File

@@ -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));

View File

@@ -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)">&times;</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">&times;</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)">&times;</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">&times;</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>

View File

@@ -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>

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

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

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

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

View File

@@ -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

View File

@@ -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 &gt; Site &amp; 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>

View File

@@ -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)