Dissolve System Settings into individual settings pages
The monolithic tab page competed with the settings rail as a second navigation system, and its Integrations tab was a dumping ground. Each section is now its own routed rail page (ServiceNow, Zabbix Supplies, Dell Warranty, Collector PC Types, Branding, Floor Map, Printing and Labels, Email/SMTP, Audit, Authentication, Asset Identifiers, Global Search), thin over a shared useSystemSettings composable, grouped logically in the rail with system groups clustered last. Old /settings/system?tab= URLs redirect to the right page. Also fixes the post-login redirect: the auth guard now remembers the intended destination and Login returns there (same-site paths only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1083,6 +1083,212 @@ td.actions {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Settings form controls - shared by the individual settings pages
|
||||
(branding, printing, email, integrations, identifiers, search, map...). */
|
||||
.setting-group {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.setting-group:first-of-type {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
.setting-group h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.setting-description {
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.setting-row {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.setting-row.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.setting-row label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.setting-row label span {
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.setting-row input,
|
||||
.setting-row select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
max-width: 400px;
|
||||
}
|
||||
.setting-row input[type="number"] {
|
||||
max-width: 120px;
|
||||
}
|
||||
.setting-row input:focus,
|
||||
.setting-row select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.input-hint {
|
||||
color: var(--text-light);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.toggle-label {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 400px;
|
||||
}
|
||||
.toggle-hint {
|
||||
display: block;
|
||||
margin-top: -0.5rem;
|
||||
margin-left: 0;
|
||||
}
|
||||
.toggle-btn {
|
||||
position: relative;
|
||||
width: 50px;
|
||||
height: 26px;
|
||||
border-radius: 13px;
|
||||
border: none;
|
||||
background: var(--secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.toggle-btn.active {
|
||||
background: var(--success);
|
||||
}
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.toggle-btn.active .toggle-slider {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-light);
|
||||
max-width: 400px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-dot.inactive { background: var(--secondary); }
|
||||
.status-dot.warning { background: var(--warning); }
|
||||
.status-dot.pending { background: var(--primary); }
|
||||
.status-dot.success { background: var(--success); }
|
||||
.test-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.test-btn:hover:not(:disabled) {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
.test-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.view-logs-link {
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.view-logs-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.identifier-matrix {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.identifier-matrix th,
|
||||
.identifier-matrix td {
|
||||
padding: 0.6rem 0.75rem;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.identifier-matrix th:first-child,
|
||||
.identifier-matrix td.identifier-name {
|
||||
text-align: left;
|
||||
}
|
||||
.identifier-matrix th {
|
||||
color: var(--text-light);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.identifier-matrix .identifier-name {
|
||||
color: var(--text);
|
||||
}
|
||||
.color-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.color-input-row input[type="color"] {
|
||||
width: 48px;
|
||||
height: 34px;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.color-input-row input[type="text"] {
|
||||
max-width: 160px;
|
||||
}
|
||||
.map-upload-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.map-thumb {
|
||||
height: 40px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
.map-thumb-dark { background: #222; }
|
||||
.settings-success {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Info List */
|
||||
.info-list {
|
||||
display: flex;
|
||||
|
||||
346
frontend/src/composables/systemSettings.js
Normal file
346
frontend/src/composables/systemSettings.js
Normal file
@@ -0,0 +1,346 @@
|
||||
// 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' }
|
||||
]
|
||||
|
||||
// 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: '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',
|
||||
// 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)_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,
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,8 @@ router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
return next('/login')
|
||||
// Remember where they were headed so login can send them back there.
|
||||
return next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
}
|
||||
if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||
return next('/')
|
||||
|
||||
@@ -161,10 +161,101 @@ export default [
|
||||
component: () => import('../../views/settings/EmployeeDirectory.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
// The old monolithic System Settings tab page is split into one routed page
|
||||
// per section (below). This redirect keeps old bookmarks working: bare
|
||||
// /settings/system and every /settings/system?tab=<key> land on the right page.
|
||||
{
|
||||
path: 'settings/system',
|
||||
name: 'system-settings',
|
||||
component: () => import('../../views/settings/SystemSettings.vue'),
|
||||
redirect: to => {
|
||||
const tabRedirects = {
|
||||
integrations: '/settings/servicenow',
|
||||
branding: '/settings/branding',
|
||||
printing: '/settings/printing',
|
||||
email: '/settings/email',
|
||||
audit: '/settings/audit',
|
||||
auth: '/settings/authentication',
|
||||
identifiers: '/settings/assetidentifiers',
|
||||
search: '/settings/globalsearch',
|
||||
map: '/settings/floormap',
|
||||
pctype: '/settings/pctypemapping'
|
||||
}
|
||||
// vue-router carries the original ?tab= query onto the target; harmless
|
||||
// since the new pages ignore it, and the path is what routes the view.
|
||||
return tabRedirects[to.query.tab] || '/settings/servicenow'
|
||||
},
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/servicenow',
|
||||
name: 'servicenow-settings',
|
||||
component: () => import('../../views/settings/ServiceNowSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/zabbix',
|
||||
name: 'zabbix-settings',
|
||||
component: () => import('../../views/settings/ZabbixSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/dellwarranty',
|
||||
name: 'dell-warranty-settings',
|
||||
component: () => import('../../views/settings/DellWarrantySettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/pctypemapping',
|
||||
name: 'pctype-mapping-settings',
|
||||
component: () => import('../../views/settings/PCTypeMappingSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/branding',
|
||||
name: 'branding-settings',
|
||||
component: () => import('../../views/settings/BrandingSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/printing',
|
||||
name: 'printing-settings',
|
||||
component: () => import('../../views/settings/PrintingSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/email',
|
||||
name: 'email-settings',
|
||||
component: () => import('../../views/settings/EmailSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/audit',
|
||||
name: 'audit-settings',
|
||||
component: () => import('../../views/settings/AuditSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/authentication',
|
||||
name: 'authentication-settings',
|
||||
component: () => import('../../views/settings/AuthenticationSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/assetidentifiers',
|
||||
name: 'asset-identifiers-settings',
|
||||
component: () => import('../../views/settings/AssetIdentifiersSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/globalsearch',
|
||||
name: 'global-search-settings',
|
||||
component: () => import('../../views/settings/GlobalSearchSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/floormap',
|
||||
name: 'floor-map-settings',
|
||||
component: () => import('../../views/settings/FloorMapSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
|
||||
@@ -48,14 +48,25 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { setupApi } from '../api'
|
||||
import { getSiteLogo } from '../utils/siteSettings'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Where to land after login: the guard-supplied ?redirect target, else the
|
||||
// dashboard. Only accept same-site absolute paths to avoid open redirects.
|
||||
function postLoginTarget() {
|
||||
const redirect = route.query.redirect
|
||||
if (typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')) {
|
||||
return redirect
|
||||
}
|
||||
return '/'
|
||||
}
|
||||
|
||||
const siteLogo = ref('/ge-aerospace-logo.svg')
|
||||
const mode = ref('login')
|
||||
const username = ref('')
|
||||
@@ -79,7 +90,7 @@ async function handleLogin() {
|
||||
loading.value = true
|
||||
const result = await authStore.login(username.value, password.value)
|
||||
loading.value = false
|
||||
if (result.success) router.push('/')
|
||||
if (result.success) router.push(postLoginTarget())
|
||||
else error.value = result.message
|
||||
}
|
||||
|
||||
|
||||
58
frontend/src/views/settings/AssetIdentifiersSettings.vue
Normal file
58
frontend/src/views/settings/AssetIdentifiersSettings.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Asset Identifiers</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
Enable or disable optional asset identifiers per asset type. When disabled
|
||||
for a type, the identifier is hidden from that type's forms and detail
|
||||
pages across the system.
|
||||
</p>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="identifier-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Identifier</th>
|
||||
<th v-for="col in assetTypeCols" :key="col.key">{{ col.label }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in identifierRows" :key="row.name">
|
||||
<td class="identifier-name">{{ row.label }}</td>
|
||||
<td v-for="col in assetTypeCols" :key="col.key">
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: matrixValue(row.name, col.key) }"
|
||||
@click="toggleIdentifier(row.name, col.key)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useSystemSettings, identifierRows, assetTypeCols } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
saving, error, success,
|
||||
loadSettings, matrixValue, toggleIdentifier,
|
||||
} = useSystemSettings()
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
51
frontend/src/views/settings/AuditSettings.vue
Normal file
51
frontend/src/views/settings/AuditSettings.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Audit & Logging</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Retention Period (days)</span>
|
||||
<input
|
||||
type="number"
|
||||
v-model="settings.audit_retention_days"
|
||||
min="0"
|
||||
placeholder="90"
|
||||
@blur="saveSetting('audit_retention_days', settings.audit_retention_days)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">0 = keep forever, recommended: 90 days</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<router-link to="/settings/auditlogs" class="view-logs-link">
|
||||
View Audit Logs →
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useSystemSettings } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
settings, saving, error, success,
|
||||
loadSettings, saveSetting,
|
||||
} = useSystemSettings()
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
163
frontend/src/views/settings/AuthenticationSettings.vue
Normal file
163
frontend/src/views/settings/AuthenticationSettings.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Authentication</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<h3>SAML Single Sign-On</h3>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="toggle-label">
|
||||
<span>Enable SAML SSO</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: settings.saml_enabled }"
|
||||
@click="toggleSetting('saml_enabled')"
|
||||
:disabled="saving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="settings.saml_enabled">
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Identity Provider Metadata URL</span>
|
||||
<input
|
||||
type="url"
|
||||
v-model="settings.saml_idp_metadata_url"
|
||||
placeholder="https://login.microsoftonline.com/.../federationmetadata.xml"
|
||||
@blur="saveSetting('saml_idp_metadata_url', settings.saml_idp_metadata_url)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Service Provider Entity ID</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.saml_entity_id"
|
||||
placeholder="https://shopdb.example.com"
|
||||
@blur="saveSetting('saml_entity_id', settings.saml_entity_id)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Unique identifier for this application</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Assertion Consumer Service URL</span>
|
||||
<input
|
||||
type="url"
|
||||
v-model="settings.saml_acs_url"
|
||||
placeholder="https://shopdb.example.com/api/auth/saml/acs"
|
||||
@blur="saveSetting('saml_acs_url', settings.saml_acs_url)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">URL where SAML responses are sent</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Admin Group Name</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.saml_admin_group"
|
||||
placeholder="ShopDB-Admins"
|
||||
@blur="saveSetting('saml_admin_group', settings.saml_admin_group)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">SAML group that grants admin role (leave empty to manage manually)</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
<small class="input-hint toggle-hint">Keep enabled for admin fallback access</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="toggle-label">
|
||||
<span>Auto-Create Users</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: settings.saml_auto_create_users }"
|
||||
@click="toggleSetting('saml_auto_create_users')"
|
||||
:disabled="saving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
<small class="input-hint toggle-hint">Automatically create user accounts on first SAML login</small>
|
||||
</div>
|
||||
|
||||
<div class="status-indicator">
|
||||
<span class="status-dot" :class="samlStatus"></span>
|
||||
<span>{{ samlMessage }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<h3>User Management</h3>
|
||||
<p class="setting-description">
|
||||
Manage local user accounts and roles. Local accounts work alongside SAML when both are enabled.
|
||||
</p>
|
||||
|
||||
<router-link to="/settings/users" class="view-logs-link">
|
||||
Manage Users →
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { useSystemSettings } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
settings, saving, error, success,
|
||||
loadSettings, saveSetting, toggleSetting,
|
||||
} = useSystemSettings()
|
||||
|
||||
// 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'
|
||||
return 'pending'
|
||||
})
|
||||
|
||||
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'
|
||||
})
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
142
frontend/src/views/settings/BrandingSettings.vue
Normal file
142
frontend/src/views/settings/BrandingSettings.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Branding</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
Replace the shipped GE logos with your own site branding. Each logo can
|
||||
be uploaded, or set to a path/URL directly. Leave blank to use the
|
||||
bundled default.
|
||||
</p>
|
||||
|
||||
<div class="setting-row" v-for="logo in brandingLogos" :key="logo.kind">
|
||||
<label>
|
||||
<span>{{ logo.label }}</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings[logo.key]"
|
||||
:placeholder="logo.placeholder"
|
||||
@blur="saveSetting(logo.key, settings[logo.key])"
|
||||
:disabled="saving"
|
||||
>
|
||||
<div class="map-upload-row">
|
||||
<input type="file" :accept="logo.accept" @change="uploadLogo(logo.kind, logo.key, $event)" :disabled="brandingUploading" />
|
||||
<img v-if="settings[logo.key]" :src="settings[logo.key]" class="map-thumb" :alt="logo.label" />
|
||||
</div>
|
||||
<small class="input-hint">{{ logo.hint }}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Primary brand color</span>
|
||||
<div class="color-input-row">
|
||||
<input
|
||||
type="color"
|
||||
:value="settings.brand_primary_color || '#000000'"
|
||||
@input="settings.brand_primary_color = $event.target.value"
|
||||
@change="saveSetting('brand_primary_color', settings.brand_primary_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.brand_primary_color"
|
||||
placeholder="(blank = built-in)"
|
||||
@blur="saveSetting('brand_primary_color', settings.brand_primary_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</div>
|
||||
<small class="input-hint">Hex color for the primary accent. Leave blank to use the built-in palette.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Primary hover color</span>
|
||||
<div class="color-input-row">
|
||||
<input
|
||||
type="color"
|
||||
:value="settings.brand_primary_dark_color || '#000000'"
|
||||
@input="settings.brand_primary_dark_color = $event.target.value"
|
||||
@change="saveSetting('brand_primary_dark_color', settings.brand_primary_dark_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.brand_primary_dark_color"
|
||||
placeholder="(blank = derived from primary)"
|
||||
@blur="saveSetting('brand_primary_dark_color', settings.brand_primary_dark_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</div>
|
||||
<small class="input-hint">Hover/active shade of the primary color. Leave blank to auto-darken the primary color ~15%.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Accent color</span>
|
||||
<div class="color-input-row">
|
||||
<input
|
||||
type="color"
|
||||
:value="settings.brand_accent_color || '#000000'"
|
||||
@input="settings.brand_accent_color = $event.target.value"
|
||||
@change="saveSetting('brand_accent_color', settings.brand_accent_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.brand_accent_color"
|
||||
placeholder="(blank = built-in)"
|
||||
@blur="saveSetting('brand_accent_color', settings.brand_accent_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</div>
|
||||
<small class="input-hint">Accent for secondary buttons and badges. Leave blank to use the built-in palette.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Sidebar color</span>
|
||||
<div class="color-input-row">
|
||||
<input
|
||||
type="color"
|
||||
:value="settings.brand_sidebar_color || '#000000'"
|
||||
@input="settings.brand_sidebar_color = $event.target.value"
|
||||
@change="saveSetting('brand_sidebar_color', settings.brand_sidebar_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.brand_sidebar_color"
|
||||
placeholder="(blank = built-in)"
|
||||
@blur="saveSetting('brand_sidebar_color', settings.brand_sidebar_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</div>
|
||||
<small class="input-hint">Sidebar background color. Leave blank to use the built-in palette.</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useSystemSettings, brandingLogos } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
settings, saving, brandingUploading, error, success,
|
||||
loadSettings, saveSetting, uploadLogo,
|
||||
} = useSystemSettings()
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
117
frontend/src/views/settings/DellWarrantySettings.vue
Normal file
117
frontend/src/views/settings/DellWarrantySettings.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Dell Warranty Lookup</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
Look up Dell coverage by service tag via the Dell TechDirect Warranty API.
|
||||
When enabled, the Refresh button on a Dell warranty pulls the current service
|
||||
level and end date. Requires a Dell TechDirect API client id and secret.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="toggle-label">
|
||||
<span>Enable Dell Warranty Lookup</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: settings.warranty_dell_enabled }"
|
||||
@click="toggleSetting('warranty_dell_enabled')"
|
||||
:disabled="saving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" v-if="settings.warranty_dell_enabled">
|
||||
<label>
|
||||
<span>Client ID</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.warranty_dell_clientid"
|
||||
placeholder="Dell TechDirect client id"
|
||||
@blur="saveSetting('warranty_dell_clientid', settings.warranty_dell_clientid)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" v-if="settings.warranty_dell_enabled">
|
||||
<label>
|
||||
<span>Client Secret</span>
|
||||
<input
|
||||
type="password"
|
||||
v-model="settings.warranty_dell_clientsecret"
|
||||
placeholder="Enter client secret"
|
||||
@blur="saveSetting('warranty_dell_clientsecret', settings.warranty_dell_clientsecret)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" v-if="settings.warranty_dell_enabled">
|
||||
<label>
|
||||
<span>Token URL <small>(blank = Dell default)</small></span>
|
||||
<input
|
||||
type="url"
|
||||
v-model="settings.warranty_dell_tokenurl"
|
||||
placeholder="https://apigtwb2c.us.dell.com/auth/oauth/v2/token"
|
||||
@blur="saveSetting('warranty_dell_tokenurl', settings.warranty_dell_tokenurl)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" v-if="settings.warranty_dell_enabled">
|
||||
<label>
|
||||
<span>API URL <small>(blank = Dell default)</small></span>
|
||||
<input
|
||||
type="url"
|
||||
v-model="settings.warranty_dell_apiurl"
|
||||
placeholder="https://apigtwb2c.us.dell.com/PROD/sbil/eapi/v5/asset-entitlements"
|
||||
@blur="saveSetting('warranty_dell_apiurl', settings.warranty_dell_apiurl)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="status-indicator" v-if="settings.warranty_dell_enabled">
|
||||
<span class="status-dot" :class="dellStatus"></span>
|
||||
<span>{{ dellMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { useSystemSettings } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
settings, saving, error, success,
|
||||
loadSettings, saveSetting, toggleSetting,
|
||||
} = useSystemSettings()
|
||||
|
||||
// Per-page connection status (stays with the page, not the composable).
|
||||
const dellStatus = computed(() => {
|
||||
if (!settings.warranty_dell_enabled) return 'inactive'
|
||||
if (!settings.warranty_dell_clientid || !settings.warranty_dell_clientsecret) return 'warning'
|
||||
return 'pending'
|
||||
})
|
||||
|
||||
const dellMessage = computed(() => {
|
||||
if (!settings.warranty_dell_enabled) return 'Disabled'
|
||||
if (!settings.warranty_dell_clientid) return 'Client id not configured'
|
||||
if (!settings.warranty_dell_clientsecret) return 'Client secret not configured'
|
||||
return 'Configured (used when you refresh a Dell warranty)'
|
||||
})
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
208
frontend/src/views/settings/EmailSettings.vue
Normal file
208
frontend/src/views/settings/EmailSettings.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Email / SMTP</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<h3>Email Notifications</h3>
|
||||
<p class="setting-description">
|
||||
Configure SMTP settings to enable email notifications for alerts, toner reports,
|
||||
and other system notifications.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="toggle-label">
|
||||
<span>Enable Email Notifications</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: settings.smtp_enabled }"
|
||||
@click="toggleSetting('smtp_enabled')"
|
||||
:disabled="saving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="settings.smtp_enabled">
|
||||
<div class="settings-grid">
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>SMTP Host</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.smtp_host"
|
||||
placeholder="smtp.example.com"
|
||||
@blur="saveSetting('smtp_host', settings.smtp_host)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>SMTP Port</span>
|
||||
<input
|
||||
type="number"
|
||||
v-model="settings.smtp_port"
|
||||
placeholder="587"
|
||||
@blur="saveSetting('smtp_port', settings.smtp_port)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.smtp_username"
|
||||
placeholder="username"
|
||||
@blur="saveSetting('smtp_username', settings.smtp_username)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
v-model="settings.smtp_password"
|
||||
placeholder="password"
|
||||
@blur="saveSetting('smtp_password', settings.smtp_password)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="toggle-label">
|
||||
<span>Use TLS Encryption</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: settings.smtp_use_tls }"
|
||||
@click="toggleSetting('smtp_use_tls')"
|
||||
:disabled="saving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>From Address</span>
|
||||
<input
|
||||
type="email"
|
||||
v-model="settings.smtp_from_address"
|
||||
placeholder="noreply@example.com"
|
||||
@blur="saveSetting('smtp_from_address', settings.smtp_from_address)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>From Name</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.smtp_from_name"
|
||||
placeholder="ShopDB"
|
||||
@blur="saveSetting('smtp_from_name', settings.smtp_from_name)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row full-width">
|
||||
<label>
|
||||
<span>Default Alert Recipients</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.alert_recipients"
|
||||
placeholder="user1@example.com, user2@example.com"
|
||||
@blur="saveSetting('alert_recipients', settings.alert_recipients)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Comma-separated list of email addresses</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="status-indicator">
|
||||
<span class="status-dot" :class="smtpStatus"></span>
|
||||
<span>{{ smtpMessage }}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="test-btn"
|
||||
@click="testEmail"
|
||||
:disabled="saving || testingEmail || !canTestEmail"
|
||||
>
|
||||
{{ testingEmail ? 'Sending...' : 'Send Test Email' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { useSystemSettings } from '../../composables/systemSettings'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const {
|
||||
settings, saving, error, success,
|
||||
loadSettings, saveSetting, toggleSetting,
|
||||
} = useSystemSettings()
|
||||
|
||||
const testingEmail = ref(false)
|
||||
|
||||
// Per-page connection status (stays with the page, not the composable).
|
||||
const smtpStatus = computed(() => {
|
||||
if (!settings.smtp_enabled) return 'inactive'
|
||||
if (!settings.smtp_host || !settings.smtp_from_address) return 'warning'
|
||||
return 'pending'
|
||||
})
|
||||
|
||||
const smtpMessage = computed(() => {
|
||||
if (!settings.smtp_enabled) return 'Disabled'
|
||||
if (!settings.smtp_host) return 'SMTP host not configured'
|
||||
if (!settings.smtp_from_address) return 'From address not configured'
|
||||
return 'Configured - use test button to verify'
|
||||
})
|
||||
|
||||
const canTestEmail = computed(() => {
|
||||
return settings.smtp_host && settings.smtp_from_address && settings.alert_recipients
|
||||
})
|
||||
|
||||
async function testEmail() {
|
||||
// TODO: Implement test email endpoint
|
||||
testingEmail.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
// await settingsApi.testEmail()
|
||||
success.value = 'Test email feature coming soon'
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'Failed to send test email')
|
||||
} finally {
|
||||
testingEmail.value = false
|
||||
setTimeout(() => { success.value = '' }, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
@@ -13,7 +13,7 @@
|
||||
The directory is in <strong>external</strong> mode - people come from a
|
||||
separate HR database, so there is nothing to manage here. To manage
|
||||
people in-app, set <code>employee_directory_mode</code> to
|
||||
<code>selfhosted</code> under System Settings, then reload.
|
||||
<code>selfhosted</code> under Site & Facility settings, then reload.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
100
frontend/src/views/settings/FloorMapSettings.vue
Normal file
100
frontend/src/views/settings/FloorMapSettings.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Floor Map</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<h3>Facility Blueprint</h3>
|
||||
<p class="setting-description">
|
||||
The floor-plan image and its pixel dimensions for this facility. Map
|
||||
markers are positioned against these dimensions, so the width and
|
||||
height must match the native size of the blueprint image. Leave the
|
||||
image paths at their defaults to use the bundled sitemap.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Blueprint image (light theme)</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.map_blueprint_light"
|
||||
placeholder="/static/images/sitemap2025-light.png"
|
||||
@blur="saveSetting('map_blueprint_light', settings.map_blueprint_light)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<div class="map-upload-row">
|
||||
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" />
|
||||
<img v-if="settings.map_blueprint_light" :src="settings.map_blueprint_light" class="map-thumb" alt="light blueprint" />
|
||||
</div>
|
||||
<small class="input-hint">Upload an image, or type a path/URL to the light-theme floor plan</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Blueprint image (dark theme)</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.map_blueprint_dark"
|
||||
placeholder="/static/images/sitemap2025-dark.png"
|
||||
@blur="saveSetting('map_blueprint_dark', settings.map_blueprint_dark)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<div class="map-upload-row">
|
||||
<input type="file" accept="image/*" @change="uploadBlueprint('dark', $event)" :disabled="mapUploading" />
|
||||
<img v-if="settings.map_blueprint_dark" :src="settings.map_blueprint_dark" class="map-thumb map-thumb-dark" alt="dark blueprint" />
|
||||
</div>
|
||||
<small class="input-hint">Upload an image, or type a path/URL to the dark-theme floor plan</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Blueprint width (pixels)</span>
|
||||
<input
|
||||
type="number"
|
||||
v-model="settings.map_width"
|
||||
min="1"
|
||||
placeholder="3300"
|
||||
@blur="saveSetting('map_width', settings.map_width)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Native pixel width of the blueprint image</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Blueprint height (pixels)</span>
|
||||
<input
|
||||
type="number"
|
||||
v-model="settings.map_height"
|
||||
min="1"
|
||||
placeholder="2550"
|
||||
@blur="saveSetting('map_height', settings.map_height)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Native pixel height of the blueprint image</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useSystemSettings } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
settings, saving, mapUploading, error, success,
|
||||
loadSettings, saveSetting, uploadBlueprint,
|
||||
} = useSystemSettings()
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
45
frontend/src/views/settings/GlobalSearchSettings.vue
Normal file
45
frontend/src/views/settings/GlobalSearchSettings.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Global Search</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
Choose which content types appear in global search results. Disabling a
|
||||
type here hides it from search without disabling the plugin elsewhere.
|
||||
</p>
|
||||
|
||||
<div class="setting-row" v-for="domain in searchDomains" :key="domain.key">
|
||||
<label class="toggle-label">
|
||||
<span>{{ domain.label }}</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: searchValue(domain.key) }"
|
||||
@click="toggleSearchDomain(domain.key)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useSystemSettings, searchDomains } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
saving, error, success,
|
||||
loadSettings, searchValue, toggleSearchDomain,
|
||||
} = useSystemSettings()
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
58
frontend/src/views/settings/PCTypeMappingSettings.vue
Normal file
58
frontend/src/views/settings/PCTypeMappingSettings.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Collector PC Types</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
When the collector ingests a PC, its imaging pc-type (from
|
||||
C:\Enrollment\pc-type.txt) is mapped to one of your Computer Types.
|
||||
Adjust the mapping per site.
|
||||
</p>
|
||||
|
||||
<div class="table-container" v-if="pcTypeMappings.length">
|
||||
<table class="identifier-matrix">
|
||||
<thead>
|
||||
<tr><th>Imaging pc-type</th><th>Computer Type</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in pcTypeMappings" :key="row.pxetype">
|
||||
<td class="identifier-name">{{ row.pxetype }}</td>
|
||||
<td>
|
||||
<select
|
||||
:value="row.computertype"
|
||||
@change="changePcTypeMapping(row.pxetype, $event.target.value)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<option v-for="ct in computerTypes" :key="ct" :value="ct">{{ ct }}</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-else class="setting-description">No collector pc-type mappings are configured yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useSystemSettings } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
saving, error, success, pcTypeMappings, computerTypes,
|
||||
loadSettings, loadComputerTypes, changePcTypeMapping,
|
||||
} = useSystemSettings()
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
loadComputerTypes()
|
||||
})
|
||||
</script>
|
||||
75
frontend/src/views/settings/PrintingSettings.vue
Normal file
75
frontend/src/views/settings/PrintingSettings.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Printing & Labels</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
Where printed QR codes point. Leave a target blank to link to the
|
||||
asset's own page on this instance, or enter a custom URL template
|
||||
with {placeholder} substitution.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Printer QR target</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.qr_target_printer"
|
||||
placeholder="(blank = printer page)"
|
||||
@blur="saveSetting('qr_target_printer', settings.qr_target_printer)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Placeholders: {printerid}, {assetid}, {assetnumber}, {serialnumber}, {ip}, {hostname}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>USB label QR target</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.qr_target_usb"
|
||||
placeholder="(blank = USB device page)"
|
||||
@blur="saveSetting('qr_target_usb', settings.qr_target_usb)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Placeholders: {id}, {serialnumber}, {alias}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>USB label style</span>
|
||||
<select
|
||||
v-model="settings.usb_label_style"
|
||||
@change="saveSetting('usb_label_style', settings.usb_label_style)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<option value="barcode">Barcode (CODE128 of the serial number)</option>
|
||||
<option value="qr">QR code (links to the USB label QR target)</option>
|
||||
</select>
|
||||
<small class="input-hint">Applies to the batch USB mini-label print sheet.</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useSystemSettings } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
settings, saving, error, success,
|
||||
loadSettings, saveSetting,
|
||||
} = useSystemSettings()
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
105
frontend/src/views/settings/ServiceNowSettings.vue
Normal file
105
frontend/src/views/settings/ServiceNowSettings.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>ServiceNow</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
Wire global search and ticket links to your ServiceNow instance. When
|
||||
enabled, matching ticket numbers become clickable links and can trigger
|
||||
a smart-redirect from global search. Defaults ship for GE; change them
|
||||
for your site.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="toggle-label">
|
||||
<span>Enable ServiceNow</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: settings.servicenow_enabled }"
|
||||
@click="toggleSetting('servicenow_enabled')"
|
||||
:disabled="saving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="settings.servicenow_enabled">
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Search URL</span>
|
||||
<input
|
||||
type="url"
|
||||
v-model="settings.servicenow_search_url"
|
||||
placeholder="https://geaerospaceqa.service-now.com/now/nav/ui/search/.../{ticket}/..."
|
||||
@blur="saveSetting('servicenow_search_url', settings.servicenow_search_url)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Global-search redirect target. Use {ticket} where the ticket number goes.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Ticket Prefixes</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.servicenow_ticket_prefixes"
|
||||
placeholder="GEINC,GECHG,GERIT,GESCT"
|
||||
@blur="saveSetting('servicenow_ticket_prefixes', settings.servicenow_ticket_prefixes)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Comma-separated ticket-number prefixes that this site recognizes.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Incident URL</span>
|
||||
<input
|
||||
type="url"
|
||||
v-model="settings.servicenow_incident_url"
|
||||
placeholder="(blank = plain text; use {ticket} in a URL template)"
|
||||
@blur="saveSetting('servicenow_incident_url', settings.servicenow_incident_url)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Link template for incident tickets. Use {ticket} where the ticket number goes.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Change URL</span>
|
||||
<input
|
||||
type="url"
|
||||
v-model="settings.servicenow_change_url"
|
||||
placeholder="(blank = plain text; use {ticket} in a URL template)"
|
||||
@blur="saveSetting('servicenow_change_url', settings.servicenow_change_url)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Link template for change tickets. Use {ticket} where the ticket number goes.</small>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useSystemSettings } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
settings, saving, error, success,
|
||||
loadSettings, saveSetting, toggleSetting,
|
||||
} = useSystemSettings()
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
@@ -59,20 +59,11 @@ const visibleGroups = computed(() => {
|
||||
.filter(g => g.cards.length)
|
||||
})
|
||||
|
||||
// A rail link is active when the current path matches its base path
|
||||
// (ignoring query, so /settings/system?tab=map and /settings/system stay distinct
|
||||
// only by their own comparison below).
|
||||
// A rail link is active when the current path matches its base path.
|
||||
// Every settings section is now its own page, so a plain path compare suffices.
|
||||
function isActive(to) {
|
||||
const base = to.split('?')[0]
|
||||
const query = to.includes('?') ? to.split('?')[1] : ''
|
||||
if (route.path !== base) return false
|
||||
// Floor Map shares /settings/system with System Settings; disambiguate by tab.
|
||||
if (base === '/settings/system') {
|
||||
const wantMap = query.includes('tab=map')
|
||||
const onMap = route.query.tab === 'map'
|
||||
return wantMap === onMap
|
||||
}
|
||||
return true
|
||||
return route.path === base
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
90
frontend/src/views/settings/ZabbixSettings.vue
Normal file
90
frontend/src/views/settings/ZabbixSettings.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Zabbix Supplies</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
Connect to Zabbix for real-time printer supply monitoring. When enabled, supply levels
|
||||
are fetched from Zabbix API and displayed on printer detail pages.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="toggle-label">
|
||||
<span>Enable Zabbix Integration</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: settings.zabbix_enabled }"
|
||||
@click="toggleSetting('zabbix_enabled')"
|
||||
:disabled="saving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" v-if="settings.zabbix_enabled">
|
||||
<label>
|
||||
<span>Zabbix API URL</span>
|
||||
<input
|
||||
type="url"
|
||||
v-model="settings.zabbix_url"
|
||||
placeholder="http://zabbix.example.com:8080"
|
||||
@blur="saveSetting('zabbix_url', settings.zabbix_url)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" v-if="settings.zabbix_enabled">
|
||||
<label>
|
||||
<span>Zabbix API Token</span>
|
||||
<input
|
||||
type="password"
|
||||
v-model="settings.zabbix_token"
|
||||
placeholder="Enter API token"
|
||||
@blur="saveSetting('zabbix_token', settings.zabbix_token)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="status-indicator" v-if="settings.zabbix_enabled">
|
||||
<span class="status-dot" :class="zabbixStatus"></span>
|
||||
<span>{{ zabbixMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { useSystemSettings } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
settings, saving, error, success,
|
||||
loadSettings, saveSetting, toggleSetting,
|
||||
} = useSystemSettings()
|
||||
|
||||
// Per-page connection status (stays with the page, not the composable).
|
||||
const zabbixStatus = computed(() => {
|
||||
if (!settings.zabbix_enabled) return 'inactive'
|
||||
if (!settings.zabbix_url || !settings.zabbix_token) return 'warning'
|
||||
return 'pending'
|
||||
})
|
||||
|
||||
const zabbixMessage = computed(() => {
|
||||
if (!settings.zabbix_enabled) return 'Disabled'
|
||||
if (!settings.zabbix_url) return 'URL not configured'
|
||||
if (!settings.zabbix_token) return 'Token not configured'
|
||||
return 'Configured (connectivity checked on first use)'
|
||||
})
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
@@ -1,15 +1,18 @@
|
||||
// Shared settings navigation catalog.
|
||||
// Used by SettingsLayout (left rail) and SettingsIndex (landing overview) so the
|
||||
// grouping lives in one place.
|
||||
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact } from 'lucide-vue-next'
|
||||
// Order: site identity + the reference-data catalogs users touch daily come
|
||||
// first, then the platform/system groups (integrations, communication, search,
|
||||
// plugins, access) cluster together at the end.
|
||||
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search } from 'lucide-vue-next'
|
||||
|
||||
export const settingsGroups = [
|
||||
{
|
||||
title: 'Site & Facility',
|
||||
cards: [
|
||||
{ to: '/settings/site', icon: Home, title: 'Site & Facility', description: 'Site URL/FQDN, facility name, PC access domain, employee-ID pattern, printer hostname template' },
|
||||
{ to: '/settings/system?tab=map', icon: MapPin, title: 'Floor Map', description: 'Facility floor-plan blueprint and dimensions' },
|
||||
{ to: '/settings/system?tab=branding', icon: Palette, title: 'Branding', description: 'Site, QR, and badge logos, favicon, and primary brand color' },
|
||||
{ to: '/settings/floormap', icon: MapPin, title: 'Floor Map', description: 'Facility floor-plan blueprint image and pixel dimensions (width, height) for the site map' },
|
||||
{ to: '/settings/branding', icon: Palette, title: 'Branding', description: 'Site, QR, and badge logos, favicon, and brand primary, accent, sidebar, and theme colors' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -74,17 +77,41 @@ export const settingsGroups = [
|
||||
{ to: '/settings/notificationtypes', icon: Bell, title: 'Notification Types', description: 'Manage notification types, display styles, colors, and auto-expiry' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Integrations',
|
||||
cards: [
|
||||
{ to: '/settings/servicenow', icon: Link, title: 'ServiceNow', description: 'ServiceNow ticket links (incident, change) prefixes and global-search redirect' },
|
||||
{ to: '/settings/zabbix', icon: Droplets, title: 'Zabbix Supplies', description: 'Zabbix API for real-time printer toner and supply monitoring' },
|
||||
{ to: '/settings/dellwarranty', icon: ShieldCheck, title: 'Dell Warranty', description: 'Dell TechDirect warranty API lookup by service tag' },
|
||||
{ to: '/settings/pctypemapping', icon: Laptop, title: 'Collector PC Types', description: 'Map collector enrollment imaging pc-type (shopfloor) to a Computer Type' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Communication',
|
||||
cards: [
|
||||
{ to: '/settings/email', icon: Mail, title: 'Email / SMTP', description: 'SMTP mail host for email notifications, alerts, TLS, from address, and recipients' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Search & Identity',
|
||||
cards: [
|
||||
{ to: '/settings/globalsearch', icon: Search, title: 'Global Search', description: 'Choose which content types (domains) appear in global search results' },
|
||||
{ to: '/settings/assetidentifiers', icon: Fingerprint, title: 'Asset Identifiers', description: 'Enable optional asset identifiers per type: gauge lab, maintenance, FQDN hostname' },
|
||||
{ to: '/settings/printing', icon: Printer, title: 'Printing & Labels', description: 'Printed QR code and USB label targets, barcode style, and URL templates' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
cards: [
|
||||
{ to: '/settings/system', icon: Settings, title: 'System Settings', description: 'Integrations (ServiceNow, Zabbix), branding, identifiers, search, and PC-type mapping' },
|
||||
{ to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Access & Audit',
|
||||
title: 'Access & Security',
|
||||
cards: [
|
||||
{ to: '/settings/authentication', icon: KeyRound, title: 'Authentication', description: 'SAML single sign-on (SSO) with your IdP, local login, and auto-create users' },
|
||||
{ to: '/settings/users', icon: Users, title: 'Users & Roles', description: 'Manage user accounts and permissions' },
|
||||
{ to: '/settings/audit', icon: History, title: 'Audit & Logging', description: 'Audit log retention period and history purge policy' },
|
||||
{ to: '/settings/auditlogs', icon: FileText, title: 'Audit Logs', description: 'View system activity and change history' },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user