Files
shopdb-flask/frontend/src/composables/systemSettings.js
cproudlock a846587f39
All checks were successful
CI / backend (push) Successful in 1m23s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Add email sending (service + 3 flows) and a general asset label generator
Email: a stdlib SMTP mailer (settings-first config, graceful no-op when
unconfigured), a test-email endpoint wired to the Email settings page,
forced first-login password change (users.mustchangepassword, migration
7d23, /change-password flow), new-user welcome mail, and on-demand
report/alert delivery (POST /api/reports/email + Email Report buttons)
with an external-cron-with-a-scoped-PAT path documented for automation.
All tests patch smtplib - no network.

Labels: a shared /print/asset-label/<type>/<id> view any asset detail
page opens - card or plain style, QR or barcode, configurable encoding.
Per-type qr_target_* templates plus label_default_style/codetype/encodes
settings on the Printing page. Measuring-tool labels default to encoding
their inspection-operation code (derived from the location name, e.g.
0615), so every tool in an area shares the area code - verified by
decoding the rendered QR. Machine labels default to the machine number;
blank-serial handled gracefully.

808 tests pass; both features verified live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 11:58:30 -04:00

360 lines
12 KiB
JavaScript

// Shared plumbing for the individual system-settings pages.
// Each settings page calls useSystemSettings() in its own setup, so state is
// per-page (no cross-page leak). The composable owns the load-all/save/toggle/
// upload machinery plus the static display catalogs, keeping the pages thin.
// Per-page connection-status computeds (zabbix/dell/smtp/saml) stay in the pages.
import { ref, reactive } from 'vue'
import { settingsApi, computersApi } from '../api'
import { setIdentifierFlag } from './identifierSettings'
import { apiError } from '../utils/apiError'
// Branding logo widgets. kind maps to the backend endpoint; key is the setting
// the resulting URL is stored under.
export const brandingLogos = [
{ kind: 'site', key: 'site_logo', label: 'Site logo', accept: 'image/*',
placeholder: '/ge-aerospace-logo.svg',
hint: 'Shown in the app header and login. Upload an image or type a path/URL.' },
{ kind: 'qr', key: 'qr_logo', label: 'QR overlay logo', accept: 'image/*',
placeholder: '/ge-monogram.svg',
hint: 'Logo overlaid on printed QR codes. Leave blank for no overlay.' },
{ kind: 'badge', key: 'badge_logo', label: 'Machine badge logo', accept: 'image/*',
placeholder: '/ge-aerospace-logo.svg',
hint: 'Logo printed on machine badges. Upload an image or type a path/URL.' },
{ kind: 'favicon', key: 'site_favicon', label: 'Favicon', accept: 'image/*,.ico',
placeholder: '(blank = shipped /favicon.svg)',
hint: 'Browser-tab icon. Leave blank to use the shipped favicon.' },
]
// Asset identifier matrix: identifier x asset type. Keys follow
// identifier_<name>_<assettype>_enabled. Missing = enabled (default on).
export const identifierRows = [
{ name: 'gaugelabreference', label: 'Gauge Lab Reference' },
{ name: 'maintenancereference', label: 'Maintenance Reference' },
{ name: 'fqdn', label: 'FQDN / Hostname' }
]
export const assetTypeCols = [
{ key: 'machine', label: 'Machine' },
{ key: 'computer', label: 'PC' },
{ key: 'printer', label: 'Printer' },
{ key: 'network_device', label: 'Network' },
{ key: 'measuring_tool', label: 'Measuring Tool' }
]
// Global-search domain toggles: keys follow search_<type>_enabled.
// Missing = enabled (default on).
export const searchDomains = [
{ key: 'application', label: 'Applications' },
{ key: 'knowledgebase', label: 'Knowledge Base' },
{ key: 'employee', label: 'Employees' },
{ key: 'machine', label: 'Machines' },
{ key: 'computer', label: 'PCs' },
{ key: 'printer', label: 'Printers' },
{ key: 'network_device', label: 'Network Devices' },
{ key: 'measuring_tool', label: 'Measuring Tools' },
{ key: 'notification', label: 'Notifications' },
{ key: 'subnet', label: 'Subnets' }
]
export function useSystemSettings() {
// Every settings key across all pages. A page binds only its own subset.
const settings = reactive({
// Zabbix
zabbix_enabled: false,
zabbix_url: '',
zabbix_token: '',
// Dell warranty
warranty_dell_enabled: false,
warranty_dell_clientid: '',
warranty_dell_clientsecret: '',
warranty_dell_tokenurl: '',
warranty_dell_apiurl: '',
// ServiceNow
servicenow_enabled: true,
servicenow_search_url: '',
servicenow_ticket_prefixes: '',
servicenow_incident_url: '',
servicenow_change_url: '',
// Branding
site_logo: '',
qr_logo: '',
badge_logo: '',
site_favicon: '',
brand_primary_color: '',
brand_primary_dark_color: '',
brand_accent_color: '',
brand_sidebar_color: '',
// Printing and labels
qr_target_printer: '',
qr_target_usb: '',
usb_label_style: 'barcode',
qr_target_machine: '',
qr_target_computer: '',
qr_target_network_device: '',
qr_target_measuring_tool: '',
label_default_style: 'card',
label_default_codetype: 'qr',
label_default_encodes_machine: 'assetnumber',
label_default_encodes_computer: 'assetpage',
label_default_encodes_printer: 'assetpage',
label_default_encodes_network_device: 'assetpage',
label_default_encodes_measuring_tool: 'location',
// Email
smtp_enabled: false,
smtp_host: '',
smtp_port: 587,
smtp_username: '',
smtp_password: '',
smtp_use_tls: true,
smtp_from_address: '',
smtp_from_name: 'ShopDB',
alert_recipients: '',
// Audit
audit_retention_days: 90,
// Floor map blueprint (per-facility)
map_blueprint_light: '',
map_blueprint_dark: '',
map_width: 3300,
map_height: 2550,
// SAML
saml_enabled: false,
saml_idp_metadata_url: '',
saml_entity_id: '',
saml_acs_url: '',
saml_allow_local_login: true,
saml_auto_create_users: true,
saml_admin_group: ''
})
const identifierMatrix = reactive({})
const searchMatrix = reactive({})
const pcTypeMappings = ref([]) // [{ pxetype, computertype }]
const computerTypes = ref([]) // ComputerType names for the dropdown
const loading = ref(true)
const saving = ref(false)
const mapUploading = ref(false)
const brandingUploading = ref(false)
const error = ref('')
const success = ref('')
function flashSaved() {
success.value = 'Setting saved'
setTimeout(() => { success.value = '' }, 2000)
}
function identifierKey(name, assettype) {
return `identifier_${name}_${assettype}_enabled`
}
function matrixValue(name, assettype) {
const key = identifierKey(name, assettype)
return key in identifierMatrix ? identifierMatrix[key] : true
}
function searchValue(domainKey) {
const key = `search_${domainKey}_enabled`
return key in searchMatrix ? searchMatrix[key] : true
}
// Load all settings and fan them into settings / matrices / pctype rows.
async function loadSettings() {
try {
loading.value = true
const { data } = await settingsApi.list()
const pctypeRows = []
for (const setting of data.data) {
if (setting.key in settings) {
settings[setting.key] = setting.value
} else if (/^identifier_.+_(machine|computer|printer|network_device|measuring_tool)_enabled$/.test(setting.key)) {
identifierMatrix[setting.key] = setting.value !== false
} else if (/^search_.+_enabled$/.test(setting.key)) {
searchMatrix[setting.key] = setting.value !== false
} else if (setting.key.startsWith('pctypemap_')) {
pctypeRows.push({
pxetype: setting.key.slice('pctypemap_'.length),
computertype: setting.value
})
}
}
pcTypeMappings.value = pctypeRows.sort((a, b) => a.pxetype.localeCompare(b.pxetype))
} catch (e) {
error.value = 'Failed to load settings'
console.error(e)
} finally {
loading.value = false
}
}
// Computer type options for the pctype mapping dropdown (own request).
async function loadComputerTypes() {
try {
const typesResponse = await computersApi.types.list({ perpage: 100 })
computerTypes.value = (typesResponse.data.data || []).map(t => t.computertype)
} catch (typesError) {
console.error('Failed to load computer types', typesError)
}
}
async function saveSetting(key, value) {
try {
saving.value = true
error.value = ''
success.value = ''
await settingsApi.update(key, value)
settings[key] = value
flashSaved()
} catch (e) {
error.value = apiError(e, 'Failed to save setting')
console.error(e)
} finally {
saving.value = false
}
}
async function toggleSetting(key) {
await saveSetting(key, !settings[key])
}
// Toggle a per-type identifier flag. The key may not be seeded on older
// installs, so create it when the update returns 404.
async function toggleIdentifier(name, assettype) {
const key = identifierKey(name, assettype)
const newValue = !matrixValue(name, assettype)
try {
saving.value = true
error.value = ''
success.value = ''
try {
await settingsApi.update(key, newValue)
} catch (e) {
if (e.response?.status === 404) {
await settingsApi.create({
key,
value: newValue,
valuetype: 'boolean',
category: 'identifiers',
description: `Show the ${name} identifier on ${assettype} assets`
})
} else {
throw e
}
}
identifierMatrix[key] = newValue
// Push into the shared composable so open asset views react without a
// full page reload (the composable otherwise fetches only once).
setIdentifierFlag(name, assettype, newValue)
flashSaved()
} catch (e) {
error.value = apiError(e, 'Failed to save setting')
console.error(e)
} finally {
saving.value = false
}
}
async function toggleSearchDomain(domainKey) {
const key = `search_${domainKey}_enabled`
const newValue = !searchValue(domainKey)
const label = searchDomains.find(d => d.key === domainKey)?.label || domainKey
try {
saving.value = true
error.value = ''
success.value = ''
try {
await settingsApi.update(key, newValue)
} catch (e) {
if (e.response?.status === 404) {
await settingsApi.create({
key,
value: newValue,
valuetype: 'boolean',
category: 'search',
description: `Include ${label} in global search results`
})
} else {
throw e
}
}
searchMatrix[key] = newValue
flashSaved()
} catch (e) {
error.value = apiError(e, 'Failed to save setting')
console.error(e)
} finally {
saving.value = false
}
}
async function changePcTypeMapping(pxetype, computertype) {
const key = `pctypemap_${pxetype}`
try {
saving.value = true
error.value = ''
success.value = ''
await settingsApi.update(key, computertype)
const row = pcTypeMappings.value.find(r => r.pxetype === pxetype)
if (row) row.computertype = computertype
flashSaved()
} catch (e) {
error.value = apiError(e, 'Failed to save setting')
console.error(e)
} finally {
saving.value = false
}
}
async function uploadBlueprint(theme, event) {
const file = event.target.files[0]
if (!file) return
mapUploading.value = true
error.value = ''
success.value = ''
try {
const { data } = await settingsApi.uploadMapBlueprint(theme, file)
const url = data?.data?.value
if (theme === 'light') settings.map_blueprint_light = url
else settings.map_blueprint_dark = url
success.value = 'Blueprint uploaded'
setTimeout(() => { success.value = '' }, 2000)
} catch (e) {
error.value = apiError(e, 'Upload failed')
} finally {
mapUploading.value = false
event.target.value = ''
}
}
async function uploadLogo(kind, key, event) {
const file = event.target.files[0]
if (!file) return
brandingUploading.value = true
error.value = ''
success.value = ''
try {
const { data } = await settingsApi.uploadBrandingLogo(kind, file)
const url = data?.data?.value
if (url) settings[key] = url
success.value = 'Logo uploaded'
setTimeout(() => { success.value = '' }, 2000)
} catch (e) {
error.value = apiError(e, 'Upload failed')
} finally {
brandingUploading.value = false
event.target.value = ''
}
}
return {
settings,
identifierMatrix, searchMatrix, pcTypeMappings, computerTypes,
loading, saving, mapUploading, brandingUploading, error, success,
loadSettings, loadComputerTypes,
saveSetting, toggleSetting,
identifierKey, matrixValue, toggleIdentifier,
searchValue, toggleSearchDomain,
changePcTypeMapping,
uploadBlueprint, uploadLogo,
}
}