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 } meta: { requiresAuth: true, requiresAdmin: true }
}, },
{ {
path: 'settings/vendors', path: 'settings/catalog',
name: 'vendors', name: 'model-catalog',
component: () => import('../../views/vendors/VendorsList.vue'), 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 } meta: { requiresAuth: true, requiresAdmin: true }
}, },
{ {
path: 'settings/modeltypes', path: 'settings/servicenow',
name: 'modeltypes', name: 'servicenow-settings',
component: () => import('../../views/settings/ModelTypesList.vue'), component: () => import('../../views/settings/ServiceNowSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true } meta: { requiresAuth: true, requiresAdmin: true }
}, },
{ {
path: 'settings/locations', path: 'settings/locations',
name: 'locations', name: 'locations-hub',
component: () => import('../../views/settings/LocationsList.vue'), component: () => import('../../views/settings/LocationsHub.vue'),
meta: { requiresAuth: true, requiresAdmin: true } 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', path: 'settings/network',
name: 'statuses', name: 'network-hub',
component: () => import('../../views/settings/StatusesList.vue'), component: () => import('../../views/settings/NetworkHub.vue'),
meta: { requiresAuth: true, requiresAdmin: true } meta: { requiresAuth: true, requiresAdmin: true },
}, children: []
{
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/supportteams', path: 'settings/supportteams',
@@ -83,18 +201,6 @@ export default [
component: () => import('../../views/settings/DashboardDefaultsList.vue'), component: () => import('../../views/settings/DashboardDefaultsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true } 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', path: 'settings/locationtypes',
name: 'location-types', name: 'location-types',
@@ -103,15 +209,22 @@ export default [
}, },
{ {
path: 'settings/assettypes', path: 'settings/assettypes',
name: 'asset-types', component: () => import('../../views/settings/AssetTypeHub.vue'),
component: () => import('../../views/settings/AssetTypesList.vue'), meta: { requiresAuth: true, requiresAdmin: true },
meta: { requiresAuth: true, requiresAdmin: true } children: [
}, {
{ path: '',
path: 'settings/customfields', name: 'asset-types-hub',
name: 'custom-fields', component: () => import('../../views/settings/asset-types/TypesTabWrapper.vue'),
component: () => import('../../views/settings/CustomFieldsList.vue'), props: route => ({ assettypeid: route.params.assettypeid || route.params.assettypeid })
meta: { requiresAuth: true, requiresAdmin: true } },
{
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 // The old monolithic System Settings tab page is split into one routed page
// per section (below). This redirect keeps old bookmarks working: bare // per section (below). This redirect keeps old bookmarks working: bare
@@ -138,24 +251,6 @@ export default [
}, },
meta: { requiresAuth: true, requiresAdmin: true } 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', path: 'settings/printing',
name: 'printing-settings', name: 'printing-settings',
@@ -168,60 +263,18 @@ export default [
component: () => import('../../views/settings/EmailSettings.vue'), component: () => import('../../views/settings/EmailSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true } 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', path: 'settings/globalsearch',
name: 'global-search-settings', name: 'global-search-settings',
component: () => import('../../views/settings/GlobalSearchSettings.vue'), component: () => import('../../views/settings/GlobalSearchSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true } 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', path: 'settings/plugins',
name: 'plugins', name: 'plugins',
component: () => import('../../views/settings/PluginsList.vue'), component: () => import('../../views/settings/PluginsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true } 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', path: 'settings/shopdbbackups',
name: 'shopdbbackups', name: 'shopdbbackups',

View File

@@ -327,6 +327,11 @@ async function loadData() {
} }
} catch (err) { } catch (err) {
console.error('Error loading API tokens:', 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 { } finally {
loading.value = false 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> <template>
<div> <div>
<div class="page-header"> <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>
<div class="section-card"> <div class="section-card">
@@ -103,3 +128,39 @@ const {
onMounted(loadSettings) onMounted(loadSettings)
</script> </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', title: 'Site & Facility',
cards: [ 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/siteconfig', icon: Home, title: 'Site Configuration', description: 'Site identity, facility settings, floor map, branding, and asset imports' },
{ 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' },
], ],
}, },
{ {
title: 'General Reference', title: 'General Reference',
cards: [ cards: [
{ to: '/settings/vendors', icon: Factory, title: 'Vendors', description: 'Manage equipment vendors and manufacturers' }, { to: '/settings/catalog', icon: Package, title: 'Model Catalog', description: 'Manage vendors, model types, equipment models, and operating systems' },
{ to: '/settings/models', icon: Package, title: 'Models', description: 'Manage equipment models by vendor' }, { to: '/settings/assetconfig', icon: Cog, title: 'Asset Configuration', description: 'Configure asset statuses, relationship types, custom fields, and identifiers' },
{ to: '/settings/modeltypes', icon: Monitor, title: 'Model Types', description: 'Manage model type categories' }, { to: '/settings/assettypes', icon: Factory, title: 'Asset Types', description: 'Manage asset type subtypes, colors, and custom fields for all asset 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/supportteams', icon: Users, title: 'Support Teams', description: 'Application support teams and their contacts (ServiceNow group links)' }, { 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', title: 'PCs',
cards: [ 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' }, { to: '/settings/accessprotocols', icon: Network, title: 'PC Access Protocols', description: 'Remote-access protocols (VNC, RDP, WinRM) and link templates' },
], ],
}, },
{ {
title: 'Printers', title: 'Printers',
cards: [ 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/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' }, { 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 "Measuring Tools" cards are contributed by the measuringtools plugin via
// the ADR-010 get_settings_cards hook (merged in composables/settingsCatalog). // the ADR-010 get_settings_cards hook (merged in composables/settingsCatalog).
// This empty placeholder fixes the group's position among the asset groups // This empty placeholder fixes the group's position among the asset groups
@@ -66,33 +48,25 @@ export const settingsGroups = [
{ {
title: 'Locations & Organization', title: 'Locations & Organization',
cards: [ 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 & Organization', description: 'Physical locations, business units, and employee directory' },
{ 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' },
], ],
}, },
{ {
title: 'Network', title: 'Network',
cards: [ cards: [
{ to: '/settings/networktypes', icon: Router, title: 'Network Device Types', description: 'Manage network device subtypes + map colors' }, { to: '/settings/network', icon: Globe, title: 'Network Infrastructure', description: 'Manage VLANs and IP subnets' },
{ to: '/settings/vlans', icon: Globe, title: 'VLANs', description: 'Manage virtual LANs' },
{ to: '/settings/subnets', icon: Link, title: 'Subnets', description: 'Manage IP subnets and DHCP' },
], ],
}, },
{ {
title: 'Displays & Kiosks', title: 'Displays & Kiosks',
cards: [ cards: [
{ to: '/settings/dashboarddefaults', icon: MonitorSmartphone, title: 'Dashboard Defaults', description: 'Map kiosk IPs to a default location' }, { 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', title: 'Integrations',
cards: [ cards: [
{ to: '/settings/servicenow', icon: Link, title: 'ServiceNow', description: 'ServiceNow ticket links (incident, change) prefixes and global-search redirect' }, { to: '/settings/integrations', icon: Link, title: 'Integrations', description: 'ServiceNow, Zabbix, and Dell Warranty API connections' },
{ 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' },
], ],
}, },
{ {
@@ -105,7 +79,6 @@ export const settingsGroups = [
title: 'Search & Identity', title: 'Search & Identity',
cards: [ cards: [
{ to: '/settings/globalsearch', icon: Search, title: 'Global Search', description: 'Choose which content types (domains) appear in global search results' }, { 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' }, { 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', title: 'Access & Security',
cards: [ 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/security', icon: ShieldCheck, title: 'Access & Security', description: 'Authentication, API tokens, users, roles, and audit logs' },
{ 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' },
], ],
}, },
] ]

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>

View File

@@ -2307,3 +2307,16 @@ class ComputersPlugin(BasePlugin):
today should weigh it. today should weigh it.
""" """
return ['computers.view'] return ['computers.view']
def get_asset_type_descriptor(self):
"""Return descriptor for computer types management in Asset Type Hub."""
return {
'assettype': 'computer',
'subtype_endpoint': '/computers/types',
'subtype_id_field': 'computertypeid',
'subtype_label_field': 'computertype',
'subtype_description_field': 'description',
'subtype_color_field': 'color',
'optional_tabs': [],
'edit_permission': 'computers.manage',
}

View File

@@ -14,6 +14,12 @@ export default [
name: 'employee-directory', name: 'employee-directory',
component: () => import('./views/EmployeeDirectory.vue'), component: () => import('./views/EmployeeDirectory.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'employees' } meta: { requiresAuth: true, requiresAdmin: true, plugin: 'employees' }
},
{
path: 'settings/locations/employees',
name: 'locations-hub-employees',
component: () => import('./views/EmployeeDirectory.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'employees' }
} }
] ]

View File

@@ -18,13 +18,13 @@ from flask import Blueprint, request, current_app
from flask_jwt_extended import current_user from flask_jwt_extended import current_user
from shopdb.api import ( from shopdb.api import (
db, Asset, Setting, success_response, error_response, paginated_response, db, Asset, AssetRelationship, Setting, success_response, error_response, paginated_response,
ErrorCodes, get_pagination_params, paginate_query, require_permission, ErrorCodes, get_pagination_params, paginate_query, require_permission,
require_role, caller_holds, caller_is_role, mint_service_token, require_role, caller_holds, caller_is_role, mint_service_token,
list_service_tokens, list_service_tokens,
revoke_service_tokens, revoke_service_tokens,
AuditLog, audit_log, collector_authorized, User, AuditLog, audit_log, collector_authorized, User,
Application, AppVersion, Script, Application, AppVersion, Script, send_alert,
) )
from ..models import ( from ..models import (
@@ -34,7 +34,6 @@ from ..models import (
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, minimum_agent_version_for, action_minimum_versions, parse_agent_version, agent_meets_minimum, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, minimum_agent_version_for, action_minimum_versions, parse_agent_version, agent_meets_minimum, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK
from . import agentstore from . import agentstore
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
from shopdb.api import send_alert
from markupsafe import escape from markupsafe import escape
endpoints_bp = Blueprint('endpoints', __name__) endpoints_bp = Blueprint('endpoints', __name__)
@@ -3451,22 +3450,10 @@ def _purge_expired_operator_reports():
def _send_operator_report_alert(report, asset_id, hostname): def _send_operator_report_alert(report, asset_id, hostname):
"""Send email alert for new operator issue report to default alert recipients.""" """Send email alert for new operator issue report to default alert recipients."""
from flask import url_for, request from flask import url_for, request
from shopdb.utils.mailer import get_smtp_config, _normalize_recipients
from shopdb.core.models.relationship import AssetRelationship
current_app.logger.info('_send_operator_report_alert called for report %d, asset %d, hostname %s', current_app.logger.info('_send_operator_report_alert called for report %d, asset %d, hostname %s',
report.operatorreportid, asset_id, hostname) report.operatorreportid, asset_id, hostname)
# Check if we have recipients before doing expensive work
config = get_smtp_config()
current_app.logger.info('SMTP config: enabled=%s, host=%s, alert_recipients=%s',
config.get('enabled'), config.get('host'), config.get('alert_recipients'))
recipients = _normalize_recipients(config['alert_recipients'])
if not recipients:
current_app.logger.warning('Skipping operator report alert: no alert recipients configured')
return
current_app.logger.info('Will send alert to %d recipient(s): %s', len(recipients), recipients)
asset = Asset.query.get(asset_id) asset = Asset.query.get(asset_id)
if not asset: if not asset:
current_app.logger.warning('Cannot send operator report alert: asset %d not found', asset_id) current_app.logger.warning('Cannot send operator report alert: asset %d not found', asset_id)

View File

@@ -246,3 +246,16 @@ class MachinesPlugin(BasePlugin):
('machines.edit', 'Edit machines', 'machines'), ('machines.edit', 'Edit machines', 'machines'),
('machines.delete', 'Delete machines', 'machines'), ('machines.delete', 'Delete machines', 'machines'),
] ]
def get_asset_type_descriptor(self):
"""Return descriptor for machine types management in Asset Type Hub."""
return {
'assettype': 'machine',
'subtype_endpoint': '/machines/types',
'subtype_id_field': 'machinetypeid',
'subtype_label_field': 'machinetype',
'subtype_description_field': 'description',
'subtype_color_field': 'color',
'optional_tabs': [],
'edit_permission': 'machines.manage',
}

View File

@@ -7,7 +7,7 @@
"dependencies": [], "dependencies": [],
"core_version": ">=0.20.0,<1.0.0", "core_version": ">=0.20.0,<1.0.0",
"api_prefix": "/api/measuringtools", "api_prefix": "/api/measuringtools",
"default_enabled": false, "default_enabled": true,
"provides": { "provides": {
"features": ["measuring-tools", "calibration-tracking"] "features": ["measuring-tools", "calibration-tracking"]
} }

View File

@@ -180,3 +180,16 @@ class MeasuringToolsPlugin(BasePlugin):
('measuringtools.edit', 'Edit measuring tools', 'measuringtools'), ('measuringtools.edit', 'Edit measuring tools', 'measuringtools'),
('measuringtools.delete', 'Delete measuring tools', 'measuringtools'), ('measuringtools.delete', 'Delete measuring tools', 'measuringtools'),
] ]
def get_asset_type_descriptor(self):
"""Return descriptor for measuring tool types management in Asset Type Hub."""
return {
'assettype': 'measuring_tool',
'subtype_endpoint': '/measuringtools/types',
'subtype_id_field': 'measuringtooltypeid',
'subtype_label_field': 'name',
'subtype_description_field': 'description',
'subtype_color_field': 'color',
'optional_tabs': [],
'edit_permission': 'measuringtools.manage',
}

View File

@@ -55,6 +55,18 @@ export default [
name: 'subnets', name: 'subnets',
component: () => import('./views/SubnetsList.vue'), component: () => import('./views/SubnetsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' } meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
},
{
path: 'settings/network/vlans',
name: 'network-hub-vlans',
component: () => import('./views/VLANsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
},
{
path: 'settings/network/subnets',
name: 'network-hub-subnets',
component: () => import('./views/SubnetsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
} }
] ]

View File

@@ -221,3 +221,16 @@ class NetworkPlugin(BasePlugin):
('network.edit', 'Edit network devices', 'network'), ('network.edit', 'Edit network devices', 'network'),
('network.delete', 'Delete network devices', 'network'), ('network.delete', 'Delete network devices', 'network'),
] ]
def get_asset_type_descriptor(self):
"""Return descriptor for network device types management in Asset Type Hub."""
return {
'assettype': 'network_device',
'subtype_endpoint': '/network/types',
'subtype_id_field': 'networkdevicetypeid',
'subtype_label_field': 'networkdevicetype',
'subtype_description_field': 'description',
'subtype_color_field': 'color',
'optional_tabs': [],
'edit_permission': 'network.manage',
}

View File

@@ -26,6 +26,12 @@ export default [
component: () => import('./views/ZabbixSettings.vue'), component: () => import('./views/ZabbixSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' } meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
}, },
{
path: 'settings/integrations/zabbix',
name: 'integrations-zabbix',
component: () => import('./views/ZabbixSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
},
{ {
path: 'settings/printer-alerts', path: 'settings/printer-alerts',
name: 'printer-alerts', name: 'printer-alerts',
@@ -61,7 +67,7 @@ export default [
path: 'settings/modelsupplies', path: 'settings/modelsupplies',
name: 'model-supplies', name: 'model-supplies',
component: () => import('./views/ModelSuppliesList.vue'), component: () => import('./views/ModelSuppliesList.vue'),
meta: { requiresAuth: true, plugin: 'printers' } meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
}, },
{ {
path: 'settings/printerdrivers', path: 'settings/printerdrivers',

View File

@@ -1,7 +1,32 @@
<template> <template>
<div> <div>
<div class="page-header"> <div class="page-header">
<h2>Zabbix Supplies</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>
<div class="section-card"> <div class="section-card">
@@ -88,3 +113,39 @@ const zabbixMessage = computed(() => {
onMounted(loadSettings) onMounted(loadSettings)
</script> </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

@@ -678,3 +678,16 @@ class PrintersPlugin(BasePlugin):
return None return None
# _computer_by_hostname returns the (Computer, Asset) pair. # _computer_by_hostname returns the (Computer, Asset) pair.
return row[1].assetid return row[1].assetid
def get_asset_type_descriptor(self):
"""Return descriptor for printer types management in Asset Type Hub."""
return {
'assettype': 'printer',
'subtype_endpoint': '/printers/types',
'subtype_id_field': 'printertypeid',
'subtype_label_field': 'printertype',
'subtype_description_field': 'description',
'subtype_color_field': 'color',
'optional_tabs': [],
'edit_permission': 'printers.manage',
}

View File

@@ -8,6 +8,12 @@ export default [
component: () => import('./views/DellWarrantySettings.vue'), component: () => import('./views/DellWarrantySettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'warranty' } meta: { requiresAuth: true, requiresAdmin: true, plugin: 'warranty' }
}, },
{
path: 'settings/integrations/dell',
name: 'integrations-dell',
component: () => import('./views/DellWarrantySettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'warranty' }
},
{ {
path: 'warranties', path: 'warranties',
name: 'warranties', name: 'warranties',

View File

@@ -1,7 +1,32 @@
<template> <template>
<div> <div>
<div class="page-header"> <div class="page-header">
<h2>Dell Warranty Lookup</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>
<div class="section-card"> <div class="section-card">
@@ -132,3 +157,39 @@ const dellMessage = computed(() => {
onMounted(loadSettings) onMounted(loadSettings)
</script> </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

@@ -2,6 +2,7 @@
import glob import glob
import os import os
from urllib.parse import urlparse
from flask import Blueprint, request, current_app from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
@@ -39,6 +40,16 @@ def _modelimage_dir():
return os.path.join(current_app.instance_path, 'modelimages') return os.path.join(current_app.instance_path, 'modelimages')
def _validate_url_scheme(url):
"""Validate that URL uses safe scheme (prevents XSS via javascript: URIs)."""
if not url:
return True
parsed = urlparse(url)
if parsed.scheme and parsed.scheme not in ['http', 'https']:
raise ValueError(f"Invalid URL scheme '{parsed.scheme}'. Only http:// and https:// are allowed.")
return True
@models_bp.route('', methods=['GET']) @models_bp.route('', methods=['GET'])
@require_permission('assets.view') @require_permission('assets.view')
def list_models(): def list_models():
@@ -127,6 +138,13 @@ def create_model():
http_code=409 http_code=409
) )
# Validate URL scheme to prevent XSS
try:
_validate_url_scheme(data.get('documentationurl'))
_validate_url_scheme(data.get('imageurl'))
except ValueError as e:
return error_response(ErrorCodes.VALIDATION_ERROR, str(e))
m = Model( m = Model(
modelnumber=data['modelnumber'], modelnumber=data['modelnumber'],
vendorid=data.get('vendorid'), vendorid=data.get('vendorid'),
@@ -162,6 +180,15 @@ def update_model(model_id: int):
if not data: if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# Validate URL schemes to prevent XSS
try:
if 'documentationurl' in data:
_validate_url_scheme(data['documentationurl'])
if 'imageurl' in data:
_validate_url_scheme(data['imageurl'])
except ValueError as e:
return error_response(ErrorCodes.VALIDATION_ERROR, str(e))
for key in ['modelnumber', 'vendorid', 'modeltypeid', 'description', 'imageurl', 'documentationurl', 'notes', 'isactive']: for key in ['modelnumber', 'vendorid', 'modeltypeid', 'description', 'imageurl', 'documentationurl', 'notes', 'isactive']:
if key in data: if key in data:
setattr(m, key, data[key]) setattr(m, key, data[key])

View File

@@ -131,3 +131,104 @@ def asset_presentation():
""" """
entries = _collect('get_asset_presentation') entries = _collect('get_asset_presentation')
return success_response(entries) return success_response(entries)
@pluginui_bp.route('/asset-type-descriptors', methods=['GET'])
@require_permission('assets.view')
def asset_type_descriptors():
"""Merge enabled plugins' asset-type subtype descriptors.
The Asset Type Hub uses these to render generic CRUD interfaces for
each plugin's subtype table (printertypes, machinetypes, etc.) without
importing plugin models or views.
Each descriptor maps to ONE asset type (from assettypes table) and
tells core:
- Which API endpoint handles the subtype CRUD
- Field names for ID, label, description, color
- Required permission
- Optional plugin-specific tabs (e.g., 'supplies' for printers)
Returns: List[Dict] sorted by assettype name
"""
pm = current_app.extensions.get('plugin_manager')
if not pm:
return success_response([])
descriptors = []
for name, plugin in pm.get_all_plugins().items():
if not pm.registry.is_enabled(name):
continue
try:
desc = plugin.get_asset_type_descriptor()
if desc is None:
continue
desc['plugin'] = name
descriptors.append(desc)
except Exception:
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
raise
current_app.logger.exception('Plugin %s get_asset_type_descriptor failed', name)
# Validation: each descriptor must have required keys
required_keys = {'assettype', 'subtype_endpoint', 'subtype_id_field',
'subtype_label_field', 'subtype_description_field',
'subtype_color_field', 'edit_permission'}
validated = []
for desc in descriptors:
missing = required_keys - set(desc.keys())
if missing:
current_app.logger.error(
'Plugin %s descriptor missing keys: %s',
desc.get('plugin', 'unknown'), missing
)
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
raise ValueError(f"Descriptor missing keys: {missing}")
continue
validated.append(desc)
validated.sort(key=lambda d: d.get('assettype', ''))
return success_response(validated)
@pluginui_bp.route('/core-type-descriptors', methods=['GET'])
@require_permission('assets.view')
def core_type_descriptors():
"""Return type descriptors for core-owned non-asset types.
These are types managed by core (not plugins) that fit the same
management pattern as asset subtypes: location types, notification types, etc.
Returns same descriptor format as asset-type-descriptors, with optional
'custom_component' field for types that need specialized UI.
"""
descriptors = [
{
'category': 'location',
'type_name': 'Location Type',
'type_name_plural': 'Location Types',
'subtype_endpoint': '/locations/types',
'subtype_id_field': 'locationtypeid',
'subtype_label_field': 'locationtype',
'subtype_description_field': 'description',
'subtype_color_field': 'color',
'optional_tabs': [],
'edit_permission': 'admin',
'group': 'other',
},
{
'category': 'notification',
'type_name': 'Notification Type',
'type_name_plural': 'Notification Types',
'subtype_endpoint': '/notifications/types',
'subtype_id_field': 'notificationtypeid',
'subtype_label_field': 'typename',
'subtype_description_field': 'typedescription',
'subtype_color_field': 'typecolor',
'optional_tabs': [],
'edit_permission': 'admin',
'group': 'other',
'custom_component': 'NotificationTypesTab',
},
]
return success_response(descriptors)

View File

@@ -1,5 +1,7 @@
"""Support Teams API endpoints - teams and their nested contacts.""" """Support Teams API endpoints - teams and their nested contacts."""
from urllib.parse import urlparse
from flask import Blueprint, request from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
@@ -19,6 +21,16 @@ from shopdb.utils.authz import require_permission
supportteams_bp = Blueprint('supportteams', __name__) supportteams_bp = Blueprint('supportteams', __name__)
def _validate_url_scheme(url):
"""Validate that URL uses safe scheme (prevents XSS via javascript: URIs)."""
if not url:
return True
parsed = urlparse(url)
if parsed.scheme and parsed.scheme not in ['http', 'https']:
raise ValueError(f"Invalid URL scheme '{parsed.scheme}'. Only http:// and https:// are allowed.")
return True
@supportteams_bp.route('', methods=['GET']) @supportteams_bp.route('', methods=['GET'])
@require_permission('assets.view') @require_permission('assets.view')
def list_support_teams(): def list_support_teams():
@@ -62,6 +74,13 @@ def create_support_team():
f"Support team '{data['teamname']}' already exists", f"Support team '{data['teamname']}' already exists",
http_code=409) http_code=409)
# Validate URL schemes to prevent XSS
try:
_validate_url_scheme(data.get('teamurl'))
_validate_url_scheme(data.get('webhookurl'))
except ValueError as e:
return error_response(ErrorCodes.VALIDATION_ERROR, str(e))
team = SupportTeam( team = SupportTeam(
teamname=data['teamname'], teamname=data['teamname'],
teamurl=data.get('teamurl'), teamurl=data.get('teamurl'),
@@ -101,6 +120,15 @@ def update_support_team(team_id: int):
f"Support team '{data['teamname']}' already exists", f"Support team '{data['teamname']}' already exists",
http_code=409) http_code=409)
# Validate URL schemes to prevent XSS
try:
if 'teamurl' in data:
_validate_url_scheme(data['teamurl'])
if 'webhookurl' in data:
_validate_url_scheme(data['webhookurl'])
except ValueError as e:
return error_response(ErrorCodes.VALIDATION_ERROR, str(e))
for key in ['teamname', 'teamurl', 'webhookurl', 'isactive']: for key in ['teamname', 'teamurl', 'webhookurl', 'isactive']:
if key in data: if key in data:
setattr(team, key, data[key]) setattr(team, key, data[key])

View File

@@ -16,7 +16,8 @@ class PluginMeta:
author: str = "" author: str = ""
dependencies: List[str] = field(default_factory=list) dependencies: List[str] = field(default_factory=list)
# Default to the current pre-1.0 contract range; tighten when 1.0 lands # Default to the current pre-1.0 contract range; tighten when 1.0 lands
core_version: str = ">=0.2.0,<1.0.0" # 0.20.0: added get_asset_type_descriptor hook (additive, backward-compatible)
core_version: str = ">=0.20.0,<1.0.0"
api_prefix: str = None api_prefix: str = None
def __post_init__(self): def __post_init__(self):
@@ -440,3 +441,42 @@ class BasePlugin(ABC):
skipped; a broken plugin is isolated in prod, re-raised in dev/test. skipped; a broken plugin is isolated in prod, re-raised in dev/test.
""" """
return [] return []
def get_asset_type_descriptor(self) -> Optional[Dict]:
"""Return subtype management descriptor for this plugin's asset type.
Declares how to CRUD the plugin's subtype table (printertypes,
machinetypes, etc.) so core can render a generic CRUD UI without
importing the plugin's models or views. Return None if the plugin
has no subtype table.
Contract (all keys required if returning non-None):
{
'assettype': str, # matches assettypes.assettype
'subtype_endpoint': str, # API path WITHOUT /api prefix (e.g., '/printers/types')
'subtype_id_field': str, # PK field name (e.g., 'printertypeid')
'subtype_label_field': str, # Display name field (e.g., 'printertype')
'subtype_description_field': str, # Description field
'subtype_color_field': str, # Color field for map markers
'optional_tabs': List[str], # Plugin-specific tabs (e.g., ['supplies'])
'edit_permission': str, # Required permission (e.g., 'printers.manage')
}
The endpoint must support:
- GET (list) ?active=true/false for pagination
- GET /{id} for single record
- POST (create)
- PUT /{id} (update)
- DELETE /{id}
Payload fields:
- {subtype_label_field}: required string
- {subtype_description_field}: optional text
- {subtype_color_field}: optional string (CSS color)
- isactive: optional boolean (default True)
Consumed by GET /api/pluginui/asset-type-descriptors. Disabled
plugins are skipped; a broken plugin is isolated in prod, re-raised
in dev/test.
"""
return None