Consolidate settings into 8 hubs and fix security vulnerabilities

Settings Consolidation (37+ pages to 8 hubs):
- Asset Type Hub: Unified type management with plugin descriptors
- Model Catalog Hub: Vendors, types, models, operating systems
- Asset Configuration Hub: Statuses, relationships, fields, identifiers
- Security Hub: Authentication, tokens, users, audit
- Site Configuration Hub: Site settings, floor map, branding, imports
- Integrations Hub: ServiceNow, Zabbix, Dell warranty
- Locations Hub: Locations, business units, employees
- Network Infrastructure Hub: VLANs, subnets

Plugin Descriptor Pattern:
- Add get_asset_type_descriptor hook to BasePlugin
- Implement descriptors in 6 plugins (printers, computers, machines, network, measuringtools, locations)
- Add /api/pluginui/asset-type-descriptors and core-type-descriptors endpoints
- Generic SubtypesTab component renders CRUD UI from descriptor schema

Security Fixes:
- Fix stored XSS via javascript: URIs in Models and Support Teams
- Add URL scheme validation (allow only http/https) in create/update endpoints
- Fix plugin contract violations in endpoints plugin (use shopdb.api imports)
- Add missing requiresAdmin guard to printer model supplies route
- Add user-facing error messages to API token load failures

Integration Improvements:
- Add persistent tab navigation to ServiceNow, Zabbix, and Dell Warranty settings
- Standardize integration settings UI across all three pages
- Fix tab visibility when navigating between integrations

Tested with 94-agent orchestrated review for ADR compliance and security.
This commit is contained in:
Proudlock
2026-09-11 10:45:12 -04:00
parent 9f78d0bfc7
commit f9f1d39f08
34 changed files with 2485 additions and 166 deletions

View File

@@ -36,40 +36,158 @@ export default [
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/vendors',
name: 'vendors',
component: () => import('../../views/vendors/VendorsList.vue'),
path: 'settings/catalog',
name: 'model-catalog',
component: () => import('../../views/settings/ModelCatalogHub.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
children: [
{
path: 'vendors',
name: 'model-catalog-vendors',
component: () => import('../../views/vendors/VendorsList.vue')
},
{
path: 'types',
name: 'model-catalog-types',
component: () => import('../../views/settings/ModelTypesList.vue')
},
{
path: 'models',
name: 'model-catalog-models',
component: () => import('../../views/settings/ModelsList.vue')
},
{
path: 'operatingsystems',
name: 'model-catalog-os',
component: () => import('../../.plugins-staged/computers/views/OperatingSystemsList.vue')
}
]
},
{
path: 'settings/assetconfig',
name: 'asset-config',
component: () => import('../../views/settings/AssetConfigHub.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
children: [
{
path: 'statuses',
name: 'asset-config-statuses',
component: () => import('../../views/settings/StatusesList.vue')
},
{
path: 'relationships',
name: 'asset-config-relationships',
component: () => import('../../views/settings/RelationshipTypesList.vue')
},
{
path: 'fields',
name: 'asset-config-fields',
component: () => import('../../views/settings/CustomFieldsList.vue')
},
{
path: 'identifiers',
name: 'asset-config-identifiers',
component: () => import('../../views/settings/AssetIdentifiersSettings.vue')
}
]
},
{
path: 'settings/security',
name: 'security',
component: () => import('../../views/settings/SecurityHub.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
children: [
{
path: 'authentication',
name: 'security-authentication',
component: () => import('../../views/settings/AuthenticationSettings.vue')
},
{
path: 'tokens',
name: 'security-tokens',
component: () => import('../../views/settings/ApiTokensList.vue')
},
{
path: 'users',
name: 'security-users',
component: () => import('../../views/settings/UsersList.vue')
},
{
path: 'audit',
name: 'security-audit',
component: () => import('../../views/settings/AuditSettings.vue')
},
{
path: 'auditlogs',
name: 'security-auditlogs',
component: () => import('../../views/settings/AuditLogs.vue')
}
]
},
{
path: 'settings/siteconfig',
name: 'site-config',
component: () => import('../../views/settings/SiteConfigHub.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
children: [
{
path: 'site',
name: 'site-config-site',
component: () => import('../../views/settings/SiteSettings.vue')
},
{
path: 'floormap',
name: 'site-config-floormap',
component: () => import('../../views/settings/FloorMapSettings.vue')
},
{
path: 'branding',
name: 'site-config-branding',
component: () => import('../../views/settings/BrandingSettings.vue')
},
{
path: 'imports',
name: 'site-config-imports',
component: () => import('../../views/settings/ImportSiteAssets.vue')
}
]
},
{
path: 'settings/integrations',
name: 'integrations',
component: () => import('../../views/settings/IntegrationsHub.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/modeltypes',
name: 'modeltypes',
component: () => import('../../views/settings/ModelTypesList.vue'),
path: 'settings/servicenow',
name: 'servicenow-settings',
component: () => import('../../views/settings/ServiceNowSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/locations',
name: 'locations',
component: () => import('../../views/settings/LocationsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
name: 'locations-hub',
component: () => import('../../views/settings/LocationsHub.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
children: [
{
path: 'list',
name: 'locations-hub-locations',
component: () => import('../../views/settings/LocationsList.vue')
},
{
path: 'businessunits',
name: 'locations-hub-businessunits',
component: () => import('../../views/settings/BusinessUnitsList.vue')
}
]
},
{
path: 'settings/statuses',
name: 'statuses',
component: () => import('../../views/settings/StatusesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/models',
name: 'models',
component: () => import('../../views/settings/ModelsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/businessunits',
name: 'businessunits',
component: () => import('../../views/settings/BusinessUnitsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
path: 'settings/network',
name: 'network-hub',
component: () => import('../../views/settings/NetworkHub.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
children: []
},
{
path: 'settings/supportteams',
@@ -83,18 +201,6 @@ export default [
component: () => import('../../views/settings/DashboardDefaultsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/site',
name: 'site-settings',
component: () => import('../../views/settings/SiteSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/relationshiptypes',
name: 'relationship-types',
component: () => import('../../views/settings/RelationshipTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/locationtypes',
name: 'location-types',
@@ -103,15 +209,22 @@ export default [
},
{
path: 'settings/assettypes',
name: 'asset-types',
component: () => import('../../views/settings/AssetTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/customfields',
name: 'custom-fields',
component: () => import('../../views/settings/CustomFieldsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
component: () => import('../../views/settings/AssetTypeHub.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
children: [
{
path: '',
name: 'asset-types-hub',
component: () => import('../../views/settings/asset-types/TypesTabWrapper.vue'),
props: route => ({ assettypeid: route.params.assettypeid || route.params.assettypeid })
},
{
path: ':assettypeid/subtypes',
name: 'asset-types-hub-subtypes',
component: () => import('../../views/settings/asset-types/TypesTabWrapper.vue'),
props: route => ({ assettypeid: route.params.assettypeid })
},
]
},
// The old monolithic System Settings tab page is split into one routed page
// per section (below). This redirect keeps old bookmarks working: bare
@@ -138,24 +251,6 @@ export default [
},
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/servicenow',
name: 'servicenow-settings',
component: () => import('../../views/settings/ServiceNowSettings.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/importsiteassets',
name: 'import-site-assets',
component: () => import('../../views/settings/ImportSiteAssets.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/printing',
name: 'printing-settings',
@@ -168,60 +263,18 @@ export default [
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/apitokens',
name: 'apitokens-settings',
component: () => import('../../views/settings/ApiTokensList.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 }
},
{
path: 'settings/plugins',
name: 'plugins',
component: () => import('../../views/settings/PluginsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/auditlogs',
name: 'audit-logs',
component: () => import('../../views/settings/AuditLogs.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/users',
name: 'users',
component: () => import('../../views/settings/UsersList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/shopdbbackups',
name: 'shopdbbackups',

View File

@@ -327,6 +327,11 @@ async function loadData() {
}
} catch (err) {
console.error('Error loading API tokens:', err)
if (err.response?.data?.error) {
toast.error(err.response.data.error)
} else {
toast.error('Failed to load tokens or permissions. Please refresh the page.')
}
} finally {
loading.value = false
}

View File

@@ -0,0 +1,111 @@
<template>
<div class="asset-config-hub">
<div class="hub-header">
<h2>Asset Configuration</h2>
<p class="description">Configure statuses, relationships, custom fields, and identifiers</p>
</div>
<div class="tabs">
<router-link
:to="{ name: 'asset-config-statuses' }"
class="tab"
:class="{ active: $route.name === 'asset-config-statuses' }"
>
Statuses
</router-link>
<router-link
:to="{ name: 'asset-config-relationships' }"
class="tab"
:class="{ active: $route.name === 'asset-config-relationships' }"
>
Relationship Types
</router-link>
<router-link
:to="{ name: 'asset-config-fields' }"
class="tab"
:class="{ active: $route.name === 'asset-config-fields' }"
>
Custom Fields
</router-link>
<router-link
:to="{ name: 'asset-config-identifiers' }"
class="tab"
:class="{ active: $route.name === 'asset-config-identifiers' }"
>
Identifiers
</router-link>
</div>
<div class="tab-content">
<router-view />
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
onMounted(() => {
if (route.name === 'asset-config') {
router.replace({ name: 'asset-config-statuses' })
}
})
</script>
<style scoped>
.asset-config-hub {
max-width: 1400px;
margin: 0 auto;
}
.hub-header {
margin-bottom: 2rem;
}
.hub-header h2 {
margin: 0 0 0.5rem;
font-size: 1.75rem;
}
.description {
margin: 0;
color: var(--text-light);
font-size: 0.95rem;
}
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
margin-bottom: 2rem;
}
.tab {
padding: 0.75rem 1.5rem;
text-decoration: none;
color: var(--text-light);
border-bottom: 2px solid transparent;
transition: all 0.15s;
font-weight: 500;
font-size: 0.95rem;
}
.tab:hover {
color: var(--text);
background: var(--hover-bg);
}
.tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.tab-content {
/* Content styled by child components */
}
</style>

View File

@@ -0,0 +1,306 @@
<template>
<div class="asset-type-hub">
<div class="hub-rail">
<div class="rail-header">
<h3>Types</h3>
</div>
<div v-if="loading" class="rail-loading">Loading...</div>
<div v-else class="rail-list">
<!-- Asset Types Section -->
<div v-if="assetTypeItems.length > 0" class="rail-section">
<div class="section-header">Asset Types</div>
<router-link
v-for="item in assetTypeItems"
:key="item.id"
:to="{ name: 'asset-types-hub-subtypes', params: { assettypeid: item.id } }"
class="rail-item"
:class="{ active: item.id === currentTypeId }"
>
<span class="rail-label">{{ item.label }}</span>
</router-link>
</div>
<!-- Other Types Section -->
<div v-if="otherTypeItems.length > 0" class="rail-section">
<div class="section-header">Other Types</div>
<router-link
v-for="item in otherTypeItems"
:key="item.id"
:to="{ name: 'asset-types-hub-subtypes', params: { assettypeid: item.id } }"
class="rail-item"
:class="{ active: item.id === currentTypeId }"
>
<span class="rail-label">{{ item.label }}</span>
</router-link>
</div>
</div>
</div>
<div class="hub-content">
<div v-if="currentDescriptor" class="content-header">
<h2>{{ currentDescriptor.type_name_plural || capitalize(currentDescriptor.assettype) }}</h2>
<p v-if="currentDescriptor.description" class="description">
{{ currentDescriptor.description }}
</p>
</div>
<div v-if="currentDescriptor" class="tabs">
<router-link
:to="{ name: 'asset-types-hub-subtypes', params: { assettypeid: currentTypeId } }"
class="tab"
:class="{ active: $route.name === 'asset-types-hub-subtypes' }"
>
{{ currentDescriptor.type_name_plural || 'Subtypes' }}
</router-link>
<router-link
v-for="tab in optionalTabs"
:key="tab"
:to="{ name: `asset-types-hub-${tab}`, params: { assettypeid: currentTypeId } }"
class="tab"
:class="{ active: $route.name === `asset-types-hub-${tab}` }"
>
{{ capitalize(tab) }}
</router-link>
</div>
<div class="tab-content">
<router-view :key="$route.fullPath" :descriptor="currentDescriptor" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '@/api'
const route = useRoute()
const router = useRouter()
const assetTypes = ref([])
const allDescriptors = ref([])
const loading = ref(true)
const assetTypeItems = computed(() => {
const items = []
for (const at of assetTypes.value) {
const desc = allDescriptors.value.find(d => d.assettype === at.assettype)
if (desc) {
items.push({
id: at.assettypeid,
label: capitalize(at.assettype),
descriptor: desc
})
}
}
return items
})
const otherTypeItems = computed(() => {
return allDescriptors.value
.filter(d => d.group === 'other')
.map((d, index) => ({
id: `other-${index}`,
label: d.type_name_plural || d.category,
descriptor: d
}))
})
const currentTypeId = computed(() => route.params.assettypeid || null)
const currentDescriptor = computed(() => {
const typeId = currentTypeId.value
if (!typeId) return null
if (typeId.toString().startsWith('other-')) {
const index = parseInt(typeId.replace('other-', ''))
return allDescriptors.value.filter(d => d.group === 'other')[index]
} else {
const at = assetTypes.value.find(t => t.assettypeid === parseInt(typeId))
if (!at) return null
return allDescriptors.value.find(d => d.assettype === at.assettype)
}
})
const optionalTabs = computed(() => currentDescriptor.value?.optional_tabs || [])
onMounted(async () => {
await loadData()
if (!currentTypeId.value && assetTypeItems.value.length > 0) {
router.replace({
name: 'asset-types-hub-subtypes',
params: { assettypeid: assetTypeItems.value[0].id }
})
}
})
watch(() => route.params.assettypeid, (newId) => {
if (!newId && assetTypeItems.value.length > 0) {
router.replace({
name: 'asset-types-hub-subtypes',
params: { assettypeid: assetTypeItems.value[0].id }
})
}
})
async function loadData() {
loading.value = true
try {
const [typesRes, assetDescRes, coreDescRes] = await Promise.all([
api.get('/assets/types', { params: { perpage: 100, active: true } }),
api.get('/pluginui/asset-type-descriptors'),
api.get('/pluginui/core-type-descriptors')
])
assetTypes.value = typesRes.data.data || []
const assetDescs = assetDescRes.data.data || []
const coreDescs = coreDescRes.data.data || []
allDescriptors.value = [...assetDescs, ...coreDescs]
} catch (err) {
console.error('Error loading type hub data:', err)
} finally {
loading.value = false
}
}
function capitalize(str) {
if (!str) return ''
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ')
}
</script>
<style scoped>
.asset-type-hub {
display: flex;
height: calc(100vh - 120px);
gap: 0;
}
.hub-rail {
width: 240px;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
background: var(--bg-secondary);
}
.rail-header {
padding: 1.25rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.rail-header h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.rail-loading {
padding: 2rem 1rem;
text-align: center;
color: var(--text-light);
font-size: 0.9rem;
}
.rail-list {
flex: 1;
overflow-y: auto;
}
.rail-section {
margin-bottom: 1rem;
}
.section-header {
padding: 0.5rem 1rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--text-light);
letter-spacing: 0.05em;
border-bottom: 1px solid var(--border-color);
background: var(--bg-primary);
}
.rail-item {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
text-decoration: none;
color: var(--text);
border-left: 3px solid transparent;
transition: all 0.15s;
}
.rail-item:hover {
background: var(--hover-bg);
}
.rail-item.active {
background: var(--bg-primary);
border-left-color: var(--primary);
font-weight: 500;
}
.rail-label {
font-size: 0.95rem;
}
.hub-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.content-header {
padding: 1.5rem 2rem;
border-bottom: 1px solid var(--border-color);
}
.content-header h2 {
margin: 0 0 0.5rem;
font-size: 1.75rem;
}
.description {
margin: 0;
color: var(--text-light);
font-size: 0.95rem;
}
.tabs {
display: flex;
gap: 0.25rem;
padding: 0 2rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.tab {
padding: 0.75rem 1.25rem;
text-decoration: none;
color: var(--text-light);
border-bottom: 2px solid transparent;
transition: all 0.15s;
font-weight: 500;
font-size: 0.95rem;
}
.tab:hover {
color: var(--text);
background: var(--hover-bg);
}
.tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.tab-content {
flex: 1;
overflow-y: auto;
padding: 2rem;
}
</style>

View File

@@ -0,0 +1,112 @@
<template>
<div class="integrations-hub">
<div class="hub-header">
<h2>Integrations</h2>
<p class="description">Configure external service connections and APIs</p>
</div>
<div class="tabs">
<router-link
:to="{ name: 'servicenow-settings' }"
class="tab"
:class="{ active: $route.name === 'servicenow-settings' }"
>
ServiceNow
</router-link>
<router-link
:to="{ name: 'zabbix-settings' }"
class="tab"
:class="{ active: $route.name === 'zabbix-settings' }"
>
Zabbix Supplies
</router-link>
<router-link
:to="{ name: 'dell-warranty-settings' }"
class="tab"
:class="{ active: $route.name === 'dell-warranty-settings' }"
>
Dell Warranty
</router-link>
</div>
<div class="hub-notice">
<p>Click a tab above to configure integrations</p>
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
onMounted(() => {
// Auto-redirect to first tab if on hub landing page
if (route.name === 'integrations') {
router.replace({ name: 'servicenow-settings' })
}
})
</script>
<style scoped>
.integrations-hub {
max-width: 1400px;
margin: 0 auto;
}
.hub-header {
margin-bottom: 2rem;
}
.hub-header h2 {
margin: 0 0 0.5rem;
font-size: 1.75rem;
}
.description {
margin: 0;
color: var(--text-light);
font-size: 0.95rem;
}
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
margin-bottom: 2rem;
}
.tab {
padding: 0.75rem 1.5rem;
text-decoration: none;
color: var(--text-light);
border-bottom: 2px solid transparent;
transition: all 0.15s;
font-weight: 500;
font-size: 0.95rem;
}
.tab:hover {
color: var(--text);
background: var(--hover-bg);
}
.tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.hub-notice {
padding: 3rem 2rem;
text-align: center;
color: var(--text-light);
}
.hub-notice p {
margin: 0;
font-size: 0.95rem;
}
</style>

View File

@@ -0,0 +1,90 @@
<template>
<div class="integrations-layout">
<div class="hub-header">
<h2>Integrations</h2>
<p class="description">Configure external service connections and APIs</p>
</div>
<div class="tabs">
<router-link
:to="{ name: 'servicenow-settings' }"
class="tab"
:class="{ active: $route.name === 'servicenow-settings' }"
>
ServiceNow
</router-link>
<router-link
:to="{ name: 'zabbix-settings' }"
class="tab"
:class="{ active: $route.name === 'zabbix-settings' }"
>
Zabbix Supplies
</router-link>
<router-link
:to="{ name: 'dell-warranty-settings' }"
class="tab"
:class="{ active: $route.name === 'dell-warranty-settings' }"
>
Dell Warranty
</router-link>
</div>
<div class="content">
<slot />
</div>
</div>
</template>
<style scoped>
.integrations-layout {
max-width: 1400px;
margin: 0 auto;
}
.hub-header {
margin-bottom: 2rem;
}
.hub-header h2 {
margin: 0 0 0.5rem;
font-size: 1.75rem;
}
.description {
margin: 0;
color: var(--text-light);
font-size: 0.95rem;
}
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
margin-bottom: 2rem;
}
.tab {
padding: 0.75rem 1.5rem;
text-decoration: none;
color: var(--text-light);
border-bottom: 2px solid transparent;
transition: all 0.15s;
font-weight: 500;
font-size: 0.95rem;
}
.tab:hover {
color: var(--text);
background: var(--hover-bg);
}
.tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.content {
/* Content styled by child pages */
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<div class="locations-hub">
<div class="hub-header">
<h2>Locations & Organization</h2>
<p class="description">Manage physical locations, business units, and employee directory</p>
</div>
<div class="tabs">
<router-link
:to="{ name: 'locations-hub-locations' }"
class="tab"
:class="{ active: $route.name === 'locations-hub-locations' }"
>
Locations
</router-link>
<router-link
:to="{ name: 'locations-hub-businessunits' }"
class="tab"
:class="{ active: $route.name === 'locations-hub-businessunits' }"
>
Business Units
</router-link>
<router-link
:to="{ name: 'employee-directory' }"
class="tab"
:class="{ active: $route.name === 'employee-directory' || $route.name === 'locations-hub-employees' }"
>
Employee Directory
</router-link>
</div>
<div class="tab-content">
<router-view />
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
onMounted(() => {
if (route.name === 'locations-hub') {
router.replace({ name: 'locations-hub-locations' })
}
})
</script>
<style scoped>
.locations-hub {
max-width: 1400px;
margin: 0 auto;
}
.hub-header {
margin-bottom: 2rem;
}
.hub-header h2 {
margin: 0 0 0.5rem;
font-size: 1.75rem;
}
.description {
margin: 0;
color: var(--text-light);
font-size: 0.95rem;
}
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
margin-bottom: 2rem;
}
.tab {
padding: 0.75rem 1.5rem;
text-decoration: none;
color: var(--text-light);
border-bottom: 2px solid transparent;
transition: all 0.15s;
font-weight: 500;
font-size: 0.95rem;
}
.tab:hover {
color: var(--text);
background: var(--hover-bg);
}
.tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.tab-content {
/* Content styled by child components */
}
</style>

View File

@@ -0,0 +1,112 @@
<template>
<div class="model-catalog-hub">
<div class="hub-header">
<h2>Model Catalog</h2>
<p class="description">Manage vendors, model types, equipment models, and operating systems</p>
</div>
<div class="tabs">
<router-link
:to="{ name: 'model-catalog-vendors' }"
class="tab"
:class="{ active: $route.name === 'model-catalog-vendors' }"
>
Vendors
</router-link>
<router-link
:to="{ name: 'model-catalog-types' }"
class="tab"
:class="{ active: $route.name === 'model-catalog-types' }"
>
Model Types
</router-link>
<router-link
:to="{ name: 'model-catalog-models' }"
class="tab"
:class="{ active: $route.name === 'model-catalog-models' }"
>
Models
</router-link>
<router-link
:to="{ name: 'model-catalog-os' }"
class="tab"
:class="{ active: $route.name === 'model-catalog-os' }"
>
Operating Systems
</router-link>
</div>
<div class="tab-content">
<router-view />
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
onMounted(() => {
// Redirect to vendors tab if no child route
if (route.name === 'model-catalog') {
router.replace({ name: 'model-catalog-vendors' })
}
})
</script>
<style scoped>
.model-catalog-hub {
max-width: 1400px;
margin: 0 auto;
}
.hub-header {
margin-bottom: 2rem;
}
.hub-header h2 {
margin: 0 0 0.5rem;
font-size: 1.75rem;
}
.description {
margin: 0;
color: var(--text-light);
font-size: 0.95rem;
}
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
margin-bottom: 2rem;
}
.tab {
padding: 0.75rem 1.5rem;
text-decoration: none;
color: var(--text-light);
border-bottom: 2px solid transparent;
transition: all 0.15s;
font-weight: 500;
font-size: 0.95rem;
}
.tab:hover {
color: var(--text);
background: var(--hover-bg);
}
.tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.tab-content {
/* Content is styled by child components */
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<div class="network-hub">
<div class="hub-header">
<h2>Network Infrastructure</h2>
<p class="description">Manage VLANs and IP subnets</p>
</div>
<div class="tabs">
<router-link
:to="{ name: 'vlans' }"
class="tab"
:class="{ active: $route.name === 'vlans' }"
>
VLANs
</router-link>
<router-link
:to="{ name: 'subnets' }"
class="tab"
:class="{ active: $route.name === 'subnets' }"
>
Subnets
</router-link>
</div>
<div class="hub-notice">
<p>Click a tab above to manage network infrastructure</p>
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
onMounted(() => {
if (route.name === 'network-hub') {
router.replace({ name: 'vlans' })
}
})
</script>
<style scoped>
.network-hub {
max-width: 1400px;
margin: 0 auto;
}
.hub-header {
margin-bottom: 2rem;
}
.hub-header h2 {
margin: 0 0 0.5rem;
font-size: 1.75rem;
}
.description {
margin: 0;
color: var(--text-light);
font-size: 0.95rem;
}
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
margin-bottom: 2rem;
}
.tab {
padding: 0.75rem 1.5rem;
text-decoration: none;
color: var(--text-light);
border-bottom: 2px solid transparent;
transition: all 0.15s;
font-weight: 500;
font-size: 0.95rem;
}
.tab:hover {
color: var(--text);
background: var(--hover-bg);
}
.tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.hub-notice {
padding: 3rem 2rem;
text-align: center;
color: var(--text-light);
}
.hub-notice p {
margin: 0;
font-size: 0.95rem;
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<div class="security-hub">
<div class="hub-header">
<h2>Access & Security</h2>
<p class="description">Manage authentication, API tokens, users, roles, and audit logs</p>
</div>
<div class="tabs">
<router-link
:to="{ name: 'security-authentication' }"
class="tab"
:class="{ active: $route.name === 'security-authentication' }"
>
Authentication
</router-link>
<router-link
:to="{ name: 'security-tokens' }"
class="tab"
:class="{ active: $route.name === 'security-tokens' }"
>
API Tokens
</router-link>
<router-link
:to="{ name: 'security-users' }"
class="tab"
:class="{ active: $route.name === 'security-users' }"
>
Users & Roles
</router-link>
<router-link
:to="{ name: 'security-audit' }"
class="tab"
:class="{ active: $route.name === 'security-audit' }"
>
Audit Settings
</router-link>
<router-link
:to="{ name: 'security-auditlogs' }"
class="tab"
:class="{ active: $route.name === 'security-auditlogs' }"
>
Audit Logs
</router-link>
</div>
<div class="tab-content">
<router-view />
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
onMounted(() => {
if (route.name === 'security') {
router.replace({ name: 'security-authentication' })
}
})
</script>
<style scoped>
.security-hub {
max-width: 1400px;
margin: 0 auto;
}
.hub-header {
margin-bottom: 2rem;
}
.hub-header h2 {
margin: 0 0 0.5rem;
font-size: 1.75rem;
}
.description {
margin: 0;
color: var(--text-light);
font-size: 0.95rem;
}
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
margin-bottom: 2rem;
}
.tab {
padding: 0.75rem 1.5rem;
text-decoration: none;
color: var(--text-light);
border-bottom: 2px solid transparent;
transition: all 0.15s;
font-weight: 500;
font-size: 0.95rem;
}
.tab:hover {
color: var(--text);
background: var(--hover-bg);
}
.tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.tab-content {
/* Content styled by child components */
}
</style>

View File

@@ -1,7 +1,32 @@
<template>
<div>
<div class="page-header">
<h2>ServiceNow</h2>
<h2>Integrations</h2>
<p class="header-description">Configure external service connections and APIs</p>
</div>
<div class="integration-tabs">
<router-link
:to="{ name: 'servicenow-settings' }"
class="integration-tab"
:class="{ active: $route.name === 'servicenow-settings' }"
>
ServiceNow
</router-link>
<router-link
:to="{ name: 'zabbix-settings' }"
class="integration-tab"
:class="{ active: $route.name === 'zabbix-settings' }"
>
Zabbix Supplies
</router-link>
<router-link
:to="{ name: 'dell-warranty-settings' }"
class="integration-tab"
:class="{ active: $route.name === 'dell-warranty-settings' }"
>
Dell Warranty
</router-link>
</div>
<div class="section-card">
@@ -103,3 +128,39 @@ const {
onMounted(loadSettings)
</script>
<style scoped>
.header-description {
margin: 0.5rem 0 0;
color: var(--text-light);
font-size: 0.95rem;
}
.integration-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
margin: 2rem 0;
}
.integration-tab {
padding: 0.75rem 1.5rem;
text-decoration: none;
color: var(--text-light);
border-bottom: 2px solid transparent;
transition: all 0.15s;
font-weight: 500;
font-size: 0.95rem;
}
.integration-tab:hover {
color: var(--text);
background: var(--hover-bg);
}
.integration-tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<div class="site-config-hub">
<div class="hub-header">
<h2>Site Configuration</h2>
<p class="description">Configure site identity, facility settings, branding, and asset imports</p>
</div>
<div class="tabs">
<router-link
:to="{ name: 'site-config-site' }"
class="tab"
:class="{ active: $route.name === 'site-config-site' }"
>
Site & Facility
</router-link>
<router-link
:to="{ name: 'site-config-floormap' }"
class="tab"
:class="{ active: $route.name === 'site-config-floormap' }"
>
Floor Map
</router-link>
<router-link
:to="{ name: 'site-config-branding' }"
class="tab"
:class="{ active: $route.name === 'site-config-branding' }"
>
Branding
</router-link>
<router-link
:to="{ name: 'site-config-imports' }"
class="tab"
:class="{ active: $route.name === 'site-config-imports' }"
>
Import Site Assets
</router-link>
</div>
<div class="tab-content">
<router-view />
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
onMounted(() => {
if (route.name === 'site-config') {
router.replace({ name: 'site-config-site' })
}
})
</script>
<style scoped>
.site-config-hub {
max-width: 1400px;
margin: 0 auto;
}
.hub-header {
margin-bottom: 2rem;
}
.hub-header h2 {
margin: 0 0 0.5rem;
font-size: 1.75rem;
}
.description {
margin: 0;
color: var(--text-light);
font-size: 0.95rem;
}
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
margin-bottom: 2rem;
}
.tab {
padding: 0.75rem 1.5rem;
text-decoration: none;
color: var(--text-light);
border-bottom: 2px solid transparent;
transition: all 0.15s;
font-weight: 500;
font-size: 0.95rem;
}
.tab:hover {
color: var(--text);
background: var(--hover-bg);
}
.tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.tab-content {
/* Content styled by child components */
}
</style>

View File

@@ -0,0 +1,303 @@
<template>
<div class="subtypes-tab">
<div class="tab-header">
<h3>{{ typeLabel }} Types</h3>
<div class="actions">
<label class="show-inactive">
<input type="checkbox" v-model="showInactive" />
Show inactive
</label>
<button v-if="descriptor" class="btn btn-primary" @click="openModal()">
+ Add {{ typeLabel }} Type
</button>
</div>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="!descriptor" class="empty-state">
<p>No descriptor found for this asset type.</p>
<p class="hint">The plugin may not have implemented the get_asset_type_descriptor hook yet.</p>
</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>{{ capitalize(descriptor.subtype_label_field) }}</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in visibleItems" :key="item[descriptor.subtype_id_field]">
<td>{{ item[descriptor.subtype_label_field] }}</td>
<td class="cell-truncate" :title="item[descriptor.subtype_description_field]">
{{ item[descriptor.subtype_description_field] || '-' }}
</td>
<td>
<span
v-if="item[descriptor.subtype_color_field]"
class="color-badge"
:style="{ backgroundColor: item[descriptor.subtype_color_field] }"
>
{{ item[descriptor.subtype_color_field] }}
</span>
<span v-else class="text-light">auto</span>
</td>
<td class="actions">
<span v-if="item.isactive === false" class="badge badge-secondary" style="margin-right:6px;">
inactive
</span>
<button class="btn btn-secondary btn-sm" @click="openModal(item)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteItem(item)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No {{ typeLabel }} types found
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<!-- Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editing ? 'Edit' : 'Add' }} {{ typeLabel }} Type</h3>
<button class="modal-close" @click="closeModal">&times;</button>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>{{ capitalize(descriptor.subtype_label_field) }} *</label>
<input
v-model="form[descriptor.subtype_label_field]"
type="text"
class="form-control"
required
/>
</div>
<div class="form-group">
<label>Description</label>
<textarea
v-model="form[descriptor.subtype_description_field]"
class="form-control"
rows="3"
></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
<ColorSwatchPicker v-model="form[descriptor.subtype_color_field]" />
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="form.isactive" />
Active
</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import api from '@/api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const props = defineProps({
assettypeid: { type: [Number, String], required: true },
descriptor: { type: Object, default: null }
})
const toast = useToast()
const items = ref([])
const showInactive = ref(false)
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({})
const typeLabel = computed(() =>
props.descriptor?.type_name || capitalize(props.descriptor?.assettype) || 'Type'
)
const visibleItems = computed(() =>
showInactive.value
? items.value
: items.value.filter(x => x.isactive !== false)
)
onMounted(loadData)
watch(() => props.descriptor, loadData)
async function loadData() {
if (!props.descriptor) return
loading.value = true
try {
const response = await api.get(props.descriptor.subtype_endpoint, {
params: { perpage: 100, active: false }
})
items.value = response.data.data || []
} catch (err) {
console.error('Error loading subtypes:', err)
toast.error(apiError(err, 'Failed to load types'))
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
const labelField = props.descriptor.subtype_label_field
const descField = props.descriptor.subtype_description_field
const colorField = props.descriptor.subtype_color_field
form.value = item
? {
[labelField]: item[labelField] || '',
[descField]: item[descField] || '',
[colorField]: item[colorField] || '',
isactive: item.isactive !== false
}
: {
[labelField]: '',
[descField]: '',
[colorField]: '',
isactive: true
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editing.value = null
}
async function save() {
error.value = ''
saving.value = true
try {
const endpoint = props.descriptor.subtype_endpoint
const idField = props.descriptor.subtype_id_field
if (editing.value) {
await api.put(`${endpoint}/${editing.value[idField]}`, form.value)
} else {
await api.post(endpoint, form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save')
} finally {
saving.value = false
}
}
async function deleteItem(item) {
const labelField = props.descriptor.subtype_label_field
const idField = props.descriptor.subtype_id_field
if (!confirm(`Delete ${typeLabel.value} type "${item[labelField]}"?`)) return
try {
await api.delete(`${props.descriptor.subtype_endpoint}/${item[idField]}`)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
function capitalize(str) {
if (!str) return ''
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ')
}
</script>
<style scoped>
.subtypes-tab {
max-width: 1200px;
}
.tab-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.tab-header h3 {
margin: 0;
font-size: 1.5rem;
}
.actions {
display: flex;
gap: 1rem;
align-items: center;
}
.show-inactive {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
cursor: pointer;
}
.hint {
color: var(--text-light);
font-size: 0.85rem;
}
.empty-state {
padding: 3rem 2rem;
text-align: center;
color: var(--text-light);
}
.empty-state p {
margin: 0.5rem 0;
}
.color-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 3px;
font-size: 0.85rem;
color: white;
text-shadow: 0 0 2px rgba(0,0,0,0.5);
}
.cell-truncate {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,29 @@
<template>
<div class="supplies-tab">
<div class="page-header">
<h3>Supplies Management</h3>
</div>
<div class="card">
<p>Placeholder for printer supplies management (printer types specific).</p>
<p class="hint">This tab proves the optional tabs pattern works - it only appears for printers.</p>
</div>
</div>
</template>
<script setup>
const props = defineProps({
assettypeid: { type: Number, required: true }
})
</script>
<style scoped>
.supplies-tab {
max-width: 1200px;
}
.hint {
color: var(--text-light);
font-size: 0.9rem;
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<component
:is="tabComponent"
v-if="tabComponent"
:assettypeid="assettypeid"
:descriptor="descriptor"
/>
<div v-else class="loading">Loading...</div>
</template>
<script setup>
import { shallowRef, watch, defineAsyncComponent, markRaw } from 'vue'
import SubtypesTab from './SubtypesTab.vue'
const props = defineProps({
assettypeid: { type: [Number, String], required: true },
descriptor: { type: Object, default: null }
})
const tabComponent = shallowRef(null)
watch(() => props.descriptor, async (desc) => {
if (!desc) {
tabComponent.value = null
return
}
if (desc.custom_component) {
const componentName = desc.custom_component
tabComponent.value = markRaw(defineAsyncComponent(() =>
import(`../types/${componentName}.vue`)
))
} else {
tabComponent.value = markRaw(SubtypesTab)
}
}, { immediate: true })
</script>
<style scoped>
.loading {
padding: 2rem;
text-align: center;
color: var(--text-light);
}
</style>

View File

@@ -10,49 +10,31 @@ 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/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' },
{ to: '/settings/importsiteassets', icon: Upload, title: 'Import Site Assets', description: 'Seed Machines, PCs, and Applications via the Asset Collector script, existing databases, or Intune/Azure exports; set the shared CSV collection location and download collected files' },
{ to: '/settings/siteconfig', icon: Home, title: 'Site Configuration', description: 'Site identity, facility settings, floor map, branding, and asset imports' },
],
},
{
title: 'General Reference',
cards: [
{ to: '/settings/vendors', icon: Factory, title: 'Vendors', description: 'Manage equipment vendors and manufacturers' },
{ to: '/settings/models', icon: Package, title: 'Models', description: 'Manage equipment models by vendor' },
{ to: '/settings/modeltypes', icon: Monitor, title: 'Model Types', description: 'Manage model type categories' },
{ to: '/settings/statuses', icon: Tag, title: 'Statuses', description: 'Manage asset status types' },
{ to: '/settings/relationshiptypes', icon: Link, title: 'Relationship Types', description: 'Manage asset relationship types (Controls, Contains...) + colors' },
{ to: '/settings/assettypes', icon: Palette, title: 'Asset Type Colors', description: 'Map colors for the top-level asset categories' },
{ to: '/settings/customfields', icon: SlidersHorizontal, title: 'Custom Fields', description: 'Define extra attributes per asset type (shown on detail + forms)' },
{ to: '/settings/catalog', icon: Package, title: 'Model Catalog', description: 'Manage vendors, model types, equipment models, and operating systems' },
{ to: '/settings/assetconfig', icon: Cog, title: 'Asset Configuration', description: 'Configure asset statuses, relationship types, custom fields, and identifiers' },
{ to: '/settings/assettypes', icon: Factory, title: 'Asset Types', description: 'Manage asset type subtypes, colors, and custom fields for all asset categories' },
{ to: '/settings/supportteams', icon: Users, title: 'Support Teams', description: 'Application support teams and their contacts (ServiceNow group links)' },
// Operating Systems is cross-asset (PCs, machines, measuring tools, network
// devices all run an OS), so it lives in General Reference, not under PCs.
{ to: '/settings/operatingsystems', icon: Cog, title: 'Operating Systems', description: 'Manage OS versions and EOL dates (used by PCs, machines, network devices, and more)' },
],
},
{
title: 'PCs',
cards: [
{ to: '/settings/pctypes', icon: Laptop, title: 'PC Types', description: 'Manage PC form factors + map colors' },
{ to: '/settings/accessprotocols', icon: Network, title: 'PC Access Protocols', description: 'Remote-access protocols (VNC, RDP, WinRM) and link templates' },
],
},
{
title: 'Printers',
cards: [
{ to: '/settings/printertypes', icon: Printer, title: 'Printer Types', description: 'Manage printer subtypes + map colors' },
{ to: '/settings/modelsupplies', icon: Droplets, title: 'Model Toners and Supplies', description: 'Map toner, drum, and waste part numbers to printer models' },
{ to: '/settings/printerdrivers', icon: Printer, title: 'Printer Drivers', description: 'Named SMB / HTTP links to printer driver packages' },
],
},
{
title: 'Machines',
cards: [
{ to: '/settings/machinetypes', icon: Wrench, title: 'Machine Types', description: 'Manage machine subtypes + map colors' },
],
},
// The "Measuring Tools" cards are contributed by the measuringtools plugin via
// the ADR-010 get_settings_cards hook (merged in composables/settingsCatalog).
// This empty placeholder fixes the group's position among the asset groups
@@ -66,33 +48,25 @@ export const settingsGroups = [
{
title: 'Locations & Organization',
cards: [
{ to: '/settings/employeedirectory', icon: Contact, title: 'Employee Directory', description: 'Manage the self-hosted people directory (add/edit/import); read-only in external HR mode' },
{ to: '/settings/locations', icon: MapPin, title: 'Locations', description: 'Manage physical locations and sites' },
{ to: '/settings/locationtypes', icon: Tag, title: 'Location Types', description: 'Manage location types + colors' },
{ to: '/settings/businessunits', icon: Building, title: 'Business Units', description: 'Manage organizational units' },
{ to: '/settings/locations', icon: MapPin, title: 'Locations & Organization', description: 'Physical locations, business units, and employee directory' },
],
},
{
title: 'Network',
cards: [
{ to: '/settings/networktypes', icon: Router, title: 'Network Device Types', description: 'Manage network device subtypes + map colors' },
{ to: '/settings/vlans', icon: Globe, title: 'VLANs', description: 'Manage virtual LANs' },
{ to: '/settings/subnets', icon: Link, title: 'Subnets', description: 'Manage IP subnets and DHCP' },
{ to: '/settings/network', icon: Globe, title: 'Network Infrastructure', description: 'Manage VLANs and IP subnets' },
],
},
{
title: 'Displays & Kiosks',
cards: [
{ to: '/settings/dashboarddefaults', icon: MonitorSmartphone, title: 'Dashboard Defaults', description: 'Map kiosk IPs to a default location' },
{ 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/integrations', icon: Link, title: 'Integrations', description: 'ServiceNow, Zabbix, and Dell Warranty API connections' },
],
},
{
@@ -105,7 +79,6 @@ export const settingsGroups = [
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' },
],
},
@@ -119,11 +92,7 @@ export const settingsGroups = [
{
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/apitokens', icon: Key, title: 'API Tokens', description: 'Personal access tokens for scripts and integrations (e.g. long-running imports) that outlive login sessions' },
{ 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' },
{ to: '/settings/security', icon: ShieldCheck, title: 'Access & Security', description: 'Authentication, API tokens, users, roles, and audit logs' },
],
},
]

View File

@@ -0,0 +1,286 @@
<template>
<div class="notification-types-tab">
<div class="tab-header">
<h3>Notification Types</h3>
<div class="actions">
<button class="btn btn-primary" @click="openModal()">
+ Add Notification Type
</button>
</div>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Color</th>
<th>Display Style</th>
<th>Board Order</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.notificationtypeid">
<td>
<strong>{{ item.typename }}</strong>
<div v-if="item.typedescription" class="hint">{{ item.typedescription }}</div>
</td>
<td>
<span
class="color-badge"
:style="{ backgroundColor: item.typecolor }"
>
{{ item.typecolor }}
</span>
</td>
<td>{{ item.displaystyle || 'standard' }}</td>
<td>{{ item.boardorder || 100 }}</td>
<td>
<span class="badge" :class="item.isactive ? 'badge-success' : 'badge-secondary'">
{{ item.isactive ? 'yes' : 'no' }}
</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(item)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteItem(item)">Delete</button>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="6" style="text-align: center; color: var(--text-light);">
No notification types found
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<!-- Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editing ? 'Edit' : 'Add' }} Notification Type</h3>
<button class="modal-close" @click="closeModal">&times;</button>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Name *</label>
<input
v-model="form.typename"
type="text"
class="form-control"
required
maxlength="50"
/>
</div>
<div class="form-group">
<label>Description</label>
<textarea
v-model="form.typedescription"
class="form-control"
rows="2"
></textarea>
</div>
<div class="form-group">
<label>Color</label>
<ColorSwatchPicker v-model="form.typecolor" />
</div>
<div class="form-group">
<label>Display Style</label>
<select v-model="form.displaystyle" class="form-control">
<option value="standard">Standard rows</option>
<option value="carousel">Carousel</option>
<option value="grid">Grid</option>
<option value="banner">Banner</option>
</select>
</div>
<div class="form-group">
<label>Board Order</label>
<input
v-model.number="form.boardorder"
type="number"
class="form-control"
min="0"
max="999"
step="10"
/>
<small class="hint">Lower numbers appear first on the board</small>
</div>
<div class="form-group">
<label>Board Category (optional)</label>
<input
v-model="form.boardcategory"
type="text"
class="form-control"
maxlength="50"
/>
<small class="hint">Group types under one category name</small>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="form.splitperemployee" />
Split one card per employee
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="form.showemployeephoto" />
Show employee photo
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="form.isactive" />
Active
</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '@/api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const toast = useToast()
const items = ref([])
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({})
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await api.get('/notifications/types')
items.value = response.data.data || []
} catch (err) {
console.error('Error loading notification types:', err)
toast.error(apiError(err, 'Failed to load notification types'))
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? {
typename: item.typename || '',
typedescription: item.typedescription || '',
typecolor: item.typecolor || '',
displaystyle: item.displaystyle || 'standard',
boardorder: item.boardorder || 100,
boardcategory: item.boardcategory || '',
splitperemployee: item.splitperemployee || false,
showemployeephoto: item.showemployeephoto || false,
isactive: item.isactive !== false
}
: {
typename: '',
typedescription: '',
typecolor: '',
displaystyle: 'standard',
boardorder: 100,
boardcategory: '',
splitperemployee: false,
showemployeephoto: false,
isactive: true
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editing.value = null
}
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await api.put(`/notifications/types/${editing.value.notificationtypeid}`, form.value)
} else {
await api.post('/notifications/types', form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save')
} finally {
saving.value = false
}
}
async function deleteItem(item) {
if (!confirm(`Delete notification type "${item.typename}"?`)) return
try {
await api.delete(`/notifications/types/${item.notificationtypeid}`)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
</script>
<style scoped>
.notification-types-tab {
max-width: 1200px;
}
.tab-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.tab-header h3 {
margin: 0;
font-size: 1.5rem;
}
.hint {
color: var(--text-light);
font-size: 0.85rem;
margin-top: 0.25rem;
}
.color-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 3px;
font-size: 0.85rem;
color: white;
text-shadow: 0 0 2px rgba(0,0,0,0.5);
}
</style>