Dissolve System Settings into individual settings pages
Some checks failed
CI / backend (push) Successful in 1m13s
CI / naming (push) Successful in 1s
CI / frontend (push) Has been cancelled

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:
cproudlock
2026-07-12 07:52:21 -04:00
parent 5393846b8d
commit 1e93b3d570
25 changed files with 1924 additions and 1720 deletions

View File

@@ -92,6 +92,20 @@ ADR-007 and ADR-002.
no-target list with dispositions, a worked idempotent Python importer, and
row-count parity checks.
### Changed
- System Settings is no longer one tabbed page. The inner tab bar is gone and
each section is its own routed settings page reached through the settings
rail: ServiceNow, Zabbix Supplies, Dell Warranty, and Collector PC Types
(the former Integrations dumping ground, now split three-plus ways), plus
Branding, Floor Map, Printing & Labels, Email / SMTP, Authentication,
Audit & Logging, Asset Identifiers, and Global Search. The rail regroups
these under Site & Facility, Integrations, Communication, Search & Identity,
and Access & Security. Shared load/save/upload plumbing moved into a
`useSystemSettings` composable so the pages stay thin. Old bookmarks keep
working: `/settings/system` and every `/settings/system?tab=<key>` redirect
to the matching new page.
### Fixed
- Site & Facility settings page renders booleans as toggles and the

View File

@@ -142,7 +142,7 @@ If you change the payload, change it there and re-check this table.
### pc-type mapping (configurable per site)
`pctype` (e.g. `gea-shopfloor-cmm`) maps to a shopdb Computer Type through
`pctypemap_<pxetype>` settings (Settings > System > "Collector PC Type Mapping").
`pctypemap_<pxetype>` settings (Settings > Collector PC Types).
Defaults live in `plugins/computers/pctypemap.py` and are seeded on plugin
install; edit per site in the UI. Unmapped pc-types produce a warning, not a
failure.

View File

@@ -166,7 +166,7 @@ them under `instance/branding/`.
| Key | Default | Notes |
|-----|---------|-------|
| `map_blueprint_light` | `/static/images/floorplan-placeholder.svg` | Floor-map blueprint (light theme). Re-upload your own in Settings > Map. |
| `map_blueprint_light` | `/static/images/floorplan-placeholder.svg` | Floor-map blueprint (light theme). Re-upload your own in Settings > Floor Map. |
| `map_blueprint_dark` | `/static/images/floorplan-placeholder.svg` | Floor-map blueprint (dark theme). |
| `map_width` | `3300` | Blueprint native width in pixels. |
| `map_height` | `2550` | Blueprint native height in pixels. |

View File

@@ -97,7 +97,7 @@ removes those bundled PNGs and ships a generic placeholder SVG instead.
If your instance's `map_blueprint_light` / `map_blueprint_dark` Settings still
point at `/static/images/sitemap2025-*`, the map will 404 those images after the
upgrade. Re-upload your own floor plan in **Settings > Map**. Uploaded floor
upgrade. Re-upload your own floor plan in **Settings > Floor Map**. Uploaded floor
plans are stored under `instance/` and survive upgrades, so a site that already
uploaded its own plan is unaffected. Only instances still using the old bundled
default need to act.

View File

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

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

View File

@@ -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('/')

View File

@@ -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 }
},
{

View File

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

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

View File

@@ -0,0 +1,51 @@
<template>
<div>
<div class="page-header">
<h2>Audit &amp; 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 &rarr;
</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>

View 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 &rarr;
</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>

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

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

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

View File

@@ -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 &amp; Facility settings, then reload.
</p>
</div>

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

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

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

View File

@@ -0,0 +1,75 @@
<template>
<div>
<div class="page-header">
<h2>Printing &amp; 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>

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

View File

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

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

View File

@@ -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' },
],
},