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

The monolithic tab page competed with the settings rail as a second
navigation system, and its Integrations tab was a dumping ground. Each
section is now its own routed rail page (ServiceNow, Zabbix Supplies,
Dell Warranty, Collector PC Types, Branding, Floor Map, Printing and
Labels, Email/SMTP, Audit, Authentication, Asset Identifiers, Global
Search), thin over a shared useSystemSettings composable, grouped
logically in the rail with system groups clustered last. Old
/settings/system?tab= URLs redirect to the right page.

Also fixes the post-login redirect: the auth guard now remembers the
intended destination and Login returns there (same-site paths only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 07:52:21 -04:00
parent 5393846b8d
commit 1e93b3d570
25 changed files with 1924 additions and 1720 deletions

View File

@@ -48,14 +48,25 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { setupApi } from '../api'
import { getSiteLogo } from '../utils/siteSettings'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
// Where to land after login: the guard-supplied ?redirect target, else the
// dashboard. Only accept same-site absolute paths to avoid open redirects.
function postLoginTarget() {
const redirect = route.query.redirect
if (typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')) {
return redirect
}
return '/'
}
const siteLogo = ref('/ge-aerospace-logo.svg')
const mode = ref('login')
const username = ref('')
@@ -79,7 +90,7 @@ async function handleLogin() {
loading.value = true
const result = await authStore.login(username.value, password.value)
loading.value = false
if (result.success) router.push('/')
if (result.success) router.push(postLoginTarget())
else error.value = result.message
}

View File

@@ -0,0 +1,58 @@
<template>
<div>
<div class="page-header">
<h2>Asset Identifiers</h2>
</div>
<div class="section-card">
<div class="setting-group">
<p class="setting-description">
Enable or disable optional asset identifiers per asset type. When disabled
for a type, the identifier is hidden from that type's forms and detail
pages across the system.
</p>
<div class="table-container">
<table class="identifier-matrix">
<thead>
<tr>
<th>Identifier</th>
<th v-for="col in assetTypeCols" :key="col.key">{{ col.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in identifierRows" :key="row.name">
<td class="identifier-name">{{ row.label }}</td>
<td v-for="col in assetTypeCols" :key="col.key">
<button
class="toggle-btn"
:class="{ active: matrixValue(row.name, col.key) }"
@click="toggleIdentifier(row.name, col.key)"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="settings-success">{{ success }}</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useSystemSettings, identifierRows, assetTypeCols } from '../../composables/systemSettings'
const {
saving, error, success,
loadSettings, matrixValue, toggleIdentifier,
} = useSystemSettings()
onMounted(loadSettings)
</script>

View File

@@ -0,0 +1,51 @@
<template>
<div>
<div class="page-header">
<h2>Audit &amp; Logging</h2>
</div>
<div class="section-card">
<div class="setting-group">
<h3>Audit Log Retention</h3>
<p class="setting-description">
Configure how long audit logs are retained. Older logs will be automatically purged.
Set to 0 to keep logs indefinitely.
</p>
<div class="setting-row">
<label>
<span>Retention Period (days)</span>
<input
type="number"
v-model="settings.audit_retention_days"
min="0"
placeholder="90"
@blur="saveSetting('audit_retention_days', settings.audit_retention_days)"
:disabled="saving"
>
<small class="input-hint">0 = keep forever, recommended: 90 days</small>
</label>
</div>
<router-link to="/settings/auditlogs" class="view-logs-link">
View Audit Logs &rarr;
</router-link>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="settings-success">{{ success }}</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useSystemSettings } from '../../composables/systemSettings'
const {
settings, saving, error, success,
loadSettings, saveSetting,
} = useSystemSettings()
onMounted(loadSettings)
</script>

View File

@@ -0,0 +1,163 @@
<template>
<div>
<div class="page-header">
<h2>Authentication</h2>
</div>
<div class="section-card">
<div class="setting-group">
<h3>SAML Single Sign-On</h3>
<p class="setting-description">
Enable SAML SSO for enterprise authentication. Users can sign in using your organization's
identity provider (Azure AD, Okta, etc.). Local login can remain enabled as a fallback.
</p>
<div class="setting-row">
<label class="toggle-label">
<span>Enable SAML SSO</span>
<button
class="toggle-btn"
:class="{ active: settings.saml_enabled }"
@click="toggleSetting('saml_enabled')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
</div>
<template v-if="settings.saml_enabled">
<div class="setting-row">
<label>
<span>Identity Provider Metadata URL</span>
<input
type="url"
v-model="settings.saml_idp_metadata_url"
placeholder="https://login.microsoftonline.com/.../federationmetadata.xml"
@blur="saveSetting('saml_idp_metadata_url', settings.saml_idp_metadata_url)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row">
<label>
<span>Service Provider Entity ID</span>
<input
type="text"
v-model="settings.saml_entity_id"
placeholder="https://shopdb.example.com"
@blur="saveSetting('saml_entity_id', settings.saml_entity_id)"
:disabled="saving"
>
<small class="input-hint">Unique identifier for this application</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Assertion Consumer Service URL</span>
<input
type="url"
v-model="settings.saml_acs_url"
placeholder="https://shopdb.example.com/api/auth/saml/acs"
@blur="saveSetting('saml_acs_url', settings.saml_acs_url)"
:disabled="saving"
>
<small class="input-hint">URL where SAML responses are sent</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Admin Group Name</span>
<input
type="text"
v-model="settings.saml_admin_group"
placeholder="ShopDB-Admins"
@blur="saveSetting('saml_admin_group', settings.saml_admin_group)"
:disabled="saving"
>
<small class="input-hint">SAML group that grants admin role (leave empty to manage manually)</small>
</label>
</div>
<div class="setting-row">
<label class="toggle-label">
<span>Allow Local Login</span>
<button
class="toggle-btn"
:class="{ active: settings.saml_allow_local_login }"
@click="toggleSetting('saml_allow_local_login')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
<small class="input-hint toggle-hint">Keep enabled for admin fallback access</small>
</div>
<div class="setting-row">
<label class="toggle-label">
<span>Auto-Create Users</span>
<button
class="toggle-btn"
:class="{ active: settings.saml_auto_create_users }"
@click="toggleSetting('saml_auto_create_users')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
<small class="input-hint toggle-hint">Automatically create user accounts on first SAML login</small>
</div>
<div class="status-indicator">
<span class="status-dot" :class="samlStatus"></span>
<span>{{ samlMessage }}</span>
</div>
</template>
</div>
<div class="setting-group">
<h3>User Management</h3>
<p class="setting-description">
Manage local user accounts and roles. Local accounts work alongside SAML when both are enabled.
</p>
<router-link to="/settings/users" class="view-logs-link">
Manage Users &rarr;
</router-link>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="settings-success">{{ success }}</div>
</div>
</template>
<script setup>
import { onMounted, computed } from 'vue'
import { useSystemSettings } from '../../composables/systemSettings'
const {
settings, saving, error, success,
loadSettings, saveSetting, toggleSetting,
} = useSystemSettings()
// Per-page connection status (stays with the page, not the composable).
const samlStatus = computed(() => {
if (!settings.saml_enabled) return 'inactive'
if (!settings.saml_idp_metadata_url || !settings.saml_entity_id) return 'warning'
return 'pending'
})
const samlMessage = computed(() => {
if (!settings.saml_enabled) return 'Disabled - using local authentication only'
if (!settings.saml_idp_metadata_url) return 'IdP metadata URL not configured'
if (!settings.saml_entity_id) return 'Entity ID not configured'
return 'Configured - SAML login available'
})
onMounted(loadSettings)
</script>

View File

@@ -0,0 +1,142 @@
<template>
<div>
<div class="page-header">
<h2>Branding</h2>
</div>
<div class="section-card">
<div class="setting-group">
<p class="setting-description">
Replace the shipped GE logos with your own site branding. Each logo can
be uploaded, or set to a path/URL directly. Leave blank to use the
bundled default.
</p>
<div class="setting-row" v-for="logo in brandingLogos" :key="logo.kind">
<label>
<span>{{ logo.label }}</span>
<input
type="text"
v-model="settings[logo.key]"
:placeholder="logo.placeholder"
@blur="saveSetting(logo.key, settings[logo.key])"
:disabled="saving"
>
<div class="map-upload-row">
<input type="file" :accept="logo.accept" @change="uploadLogo(logo.kind, logo.key, $event)" :disabled="brandingUploading" />
<img v-if="settings[logo.key]" :src="settings[logo.key]" class="map-thumb" :alt="logo.label" />
</div>
<small class="input-hint">{{ logo.hint }}</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Primary brand color</span>
<div class="color-input-row">
<input
type="color"
:value="settings.brand_primary_color || '#000000'"
@input="settings.brand_primary_color = $event.target.value"
@change="saveSetting('brand_primary_color', settings.brand_primary_color)"
:disabled="saving"
>
<input
type="text"
v-model="settings.brand_primary_color"
placeholder="(blank = built-in)"
@blur="saveSetting('brand_primary_color', settings.brand_primary_color)"
:disabled="saving"
>
</div>
<small class="input-hint">Hex color for the primary accent. Leave blank to use the built-in palette.</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Primary hover color</span>
<div class="color-input-row">
<input
type="color"
:value="settings.brand_primary_dark_color || '#000000'"
@input="settings.brand_primary_dark_color = $event.target.value"
@change="saveSetting('brand_primary_dark_color', settings.brand_primary_dark_color)"
:disabled="saving"
>
<input
type="text"
v-model="settings.brand_primary_dark_color"
placeholder="(blank = derived from primary)"
@blur="saveSetting('brand_primary_dark_color', settings.brand_primary_dark_color)"
:disabled="saving"
>
</div>
<small class="input-hint">Hover/active shade of the primary color. Leave blank to auto-darken the primary color ~15%.</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Accent color</span>
<div class="color-input-row">
<input
type="color"
:value="settings.brand_accent_color || '#000000'"
@input="settings.brand_accent_color = $event.target.value"
@change="saveSetting('brand_accent_color', settings.brand_accent_color)"
:disabled="saving"
>
<input
type="text"
v-model="settings.brand_accent_color"
placeholder="(blank = built-in)"
@blur="saveSetting('brand_accent_color', settings.brand_accent_color)"
:disabled="saving"
>
</div>
<small class="input-hint">Accent for secondary buttons and badges. Leave blank to use the built-in palette.</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Sidebar color</span>
<div class="color-input-row">
<input
type="color"
:value="settings.brand_sidebar_color || '#000000'"
@input="settings.brand_sidebar_color = $event.target.value"
@change="saveSetting('brand_sidebar_color', settings.brand_sidebar_color)"
:disabled="saving"
>
<input
type="text"
v-model="settings.brand_sidebar_color"
placeholder="(blank = built-in)"
@blur="saveSetting('brand_sidebar_color', settings.brand_sidebar_color)"
:disabled="saving"
>
</div>
<small class="input-hint">Sidebar background color. Leave blank to use the built-in palette.</small>
</label>
</div>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="settings-success">{{ success }}</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useSystemSettings, brandingLogos } from '../../composables/systemSettings'
const {
settings, saving, brandingUploading, error, success,
loadSettings, saveSetting, uploadLogo,
} = useSystemSettings()
onMounted(loadSettings)
</script>

View File

@@ -0,0 +1,117 @@
<template>
<div>
<div class="page-header">
<h2>Dell Warranty Lookup</h2>
</div>
<div class="section-card">
<div class="setting-group">
<p class="setting-description">
Look up Dell coverage by service tag via the Dell TechDirect Warranty API.
When enabled, the Refresh button on a Dell warranty pulls the current service
level and end date. Requires a Dell TechDirect API client id and secret.
</p>
<div class="setting-row">
<label class="toggle-label">
<span>Enable Dell Warranty Lookup</span>
<button
class="toggle-btn"
:class="{ active: settings.warranty_dell_enabled }"
@click="toggleSetting('warranty_dell_enabled')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
</div>
<div class="setting-row" v-if="settings.warranty_dell_enabled">
<label>
<span>Client ID</span>
<input
type="text"
v-model="settings.warranty_dell_clientid"
placeholder="Dell TechDirect client id"
@blur="saveSetting('warranty_dell_clientid', settings.warranty_dell_clientid)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row" v-if="settings.warranty_dell_enabled">
<label>
<span>Client Secret</span>
<input
type="password"
v-model="settings.warranty_dell_clientsecret"
placeholder="Enter client secret"
@blur="saveSetting('warranty_dell_clientsecret', settings.warranty_dell_clientsecret)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row" v-if="settings.warranty_dell_enabled">
<label>
<span>Token URL <small>(blank = Dell default)</small></span>
<input
type="url"
v-model="settings.warranty_dell_tokenurl"
placeholder="https://apigtwb2c.us.dell.com/auth/oauth/v2/token"
@blur="saveSetting('warranty_dell_tokenurl', settings.warranty_dell_tokenurl)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row" v-if="settings.warranty_dell_enabled">
<label>
<span>API URL <small>(blank = Dell default)</small></span>
<input
type="url"
v-model="settings.warranty_dell_apiurl"
placeholder="https://apigtwb2c.us.dell.com/PROD/sbil/eapi/v5/asset-entitlements"
@blur="saveSetting('warranty_dell_apiurl', settings.warranty_dell_apiurl)"
:disabled="saving"
>
</label>
</div>
<div class="status-indicator" v-if="settings.warranty_dell_enabled">
<span class="status-dot" :class="dellStatus"></span>
<span>{{ dellMessage }}</span>
</div>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="settings-success">{{ success }}</div>
</div>
</template>
<script setup>
import { onMounted, computed } from 'vue'
import { useSystemSettings } from '../../composables/systemSettings'
const {
settings, saving, error, success,
loadSettings, saveSetting, toggleSetting,
} = useSystemSettings()
// Per-page connection status (stays with the page, not the composable).
const dellStatus = computed(() => {
if (!settings.warranty_dell_enabled) return 'inactive'
if (!settings.warranty_dell_clientid || !settings.warranty_dell_clientsecret) return 'warning'
return 'pending'
})
const dellMessage = computed(() => {
if (!settings.warranty_dell_enabled) return 'Disabled'
if (!settings.warranty_dell_clientid) return 'Client id not configured'
if (!settings.warranty_dell_clientsecret) return 'Client secret not configured'
return 'Configured (used when you refresh a Dell warranty)'
})
onMounted(loadSettings)
</script>

View File

@@ -0,0 +1,208 @@
<template>
<div>
<div class="page-header">
<h2>Email / SMTP</h2>
</div>
<div class="section-card">
<div class="setting-group">
<h3>Email Notifications</h3>
<p class="setting-description">
Configure SMTP settings to enable email notifications for alerts, toner reports,
and other system notifications.
</p>
<div class="setting-row">
<label class="toggle-label">
<span>Enable Email Notifications</span>
<button
class="toggle-btn"
:class="{ active: settings.smtp_enabled }"
@click="toggleSetting('smtp_enabled')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
</div>
<template v-if="settings.smtp_enabled">
<div class="settings-grid">
<div class="setting-row">
<label>
<span>SMTP Host</span>
<input
type="text"
v-model="settings.smtp_host"
placeholder="smtp.example.com"
@blur="saveSetting('smtp_host', settings.smtp_host)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row">
<label>
<span>SMTP Port</span>
<input
type="number"
v-model="settings.smtp_port"
placeholder="587"
@blur="saveSetting('smtp_port', settings.smtp_port)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row">
<label>
<span>Username</span>
<input
type="text"
v-model="settings.smtp_username"
placeholder="username"
@blur="saveSetting('smtp_username', settings.smtp_username)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row">
<label>
<span>Password</span>
<input
type="password"
v-model="settings.smtp_password"
placeholder="password"
@blur="saveSetting('smtp_password', settings.smtp_password)"
:disabled="saving"
>
</label>
</div>
</div>
<div class="setting-row">
<label class="toggle-label">
<span>Use TLS Encryption</span>
<button
class="toggle-btn"
:class="{ active: settings.smtp_use_tls }"
@click="toggleSetting('smtp_use_tls')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
</div>
<div class="settings-grid">
<div class="setting-row">
<label>
<span>From Address</span>
<input
type="email"
v-model="settings.smtp_from_address"
placeholder="noreply@example.com"
@blur="saveSetting('smtp_from_address', settings.smtp_from_address)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row">
<label>
<span>From Name</span>
<input
type="text"
v-model="settings.smtp_from_name"
placeholder="ShopDB"
@blur="saveSetting('smtp_from_name', settings.smtp_from_name)"
:disabled="saving"
>
</label>
</div>
</div>
<div class="setting-row full-width">
<label>
<span>Default Alert Recipients</span>
<input
type="text"
v-model="settings.alert_recipients"
placeholder="user1@example.com, user2@example.com"
@blur="saveSetting('alert_recipients', settings.alert_recipients)"
:disabled="saving"
>
<small class="input-hint">Comma-separated list of email addresses</small>
</label>
</div>
<div class="status-indicator">
<span class="status-dot" :class="smtpStatus"></span>
<span>{{ smtpMessage }}</span>
</div>
<button
class="test-btn"
@click="testEmail"
:disabled="saving || testingEmail || !canTestEmail"
>
{{ testingEmail ? 'Sending...' : 'Send Test Email' }}
</button>
</template>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="settings-success">{{ success }}</div>
</div>
</template>
<script setup>
import { onMounted, ref, computed } from 'vue'
import { useSystemSettings } from '../../composables/systemSettings'
import { apiError } from '../../utils/apiError'
const {
settings, saving, error, success,
loadSettings, saveSetting, toggleSetting,
} = useSystemSettings()
const testingEmail = ref(false)
// Per-page connection status (stays with the page, not the composable).
const smtpStatus = computed(() => {
if (!settings.smtp_enabled) return 'inactive'
if (!settings.smtp_host || !settings.smtp_from_address) return 'warning'
return 'pending'
})
const smtpMessage = computed(() => {
if (!settings.smtp_enabled) return 'Disabled'
if (!settings.smtp_host) return 'SMTP host not configured'
if (!settings.smtp_from_address) return 'From address not configured'
return 'Configured - use test button to verify'
})
const canTestEmail = computed(() => {
return settings.smtp_host && settings.smtp_from_address && settings.alert_recipients
})
async function testEmail() {
// TODO: Implement test email endpoint
testingEmail.value = true
error.value = ''
success.value = ''
try {
// await settingsApi.testEmail()
success.value = 'Test email feature coming soon'
} catch (e) {
error.value = apiError(e, 'Failed to send test email')
} finally {
testingEmail.value = false
setTimeout(() => { success.value = '' }, 3000)
}
}
onMounted(loadSettings)
</script>

View File

@@ -13,7 +13,7 @@
The directory is in <strong>external</strong> mode - people come from a
separate HR database, so there is nothing to manage here. To manage
people in-app, set <code>employee_directory_mode</code> to
<code>selfhosted</code> under System Settings, then reload.
<code>selfhosted</code> under Site &amp; Facility settings, then reload.
</p>
</div>

View File

@@ -0,0 +1,100 @@
<template>
<div>
<div class="page-header">
<h2>Floor Map</h2>
</div>
<div class="section-card">
<div class="setting-group">
<h3>Facility Blueprint</h3>
<p class="setting-description">
The floor-plan image and its pixel dimensions for this facility. Map
markers are positioned against these dimensions, so the width and
height must match the native size of the blueprint image. Leave the
image paths at their defaults to use the bundled sitemap.
</p>
<div class="setting-row">
<label>
<span>Blueprint image (light theme)</span>
<input
type="text"
v-model="settings.map_blueprint_light"
placeholder="/static/images/sitemap2025-light.png"
@blur="saveSetting('map_blueprint_light', settings.map_blueprint_light)"
:disabled="saving"
>
<div class="map-upload-row">
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" />
<img v-if="settings.map_blueprint_light" :src="settings.map_blueprint_light" class="map-thumb" alt="light blueprint" />
</div>
<small class="input-hint">Upload an image, or type a path/URL to the light-theme floor plan</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Blueprint image (dark theme)</span>
<input
type="text"
v-model="settings.map_blueprint_dark"
placeholder="/static/images/sitemap2025-dark.png"
@blur="saveSetting('map_blueprint_dark', settings.map_blueprint_dark)"
:disabled="saving"
>
<div class="map-upload-row">
<input type="file" accept="image/*" @change="uploadBlueprint('dark', $event)" :disabled="mapUploading" />
<img v-if="settings.map_blueprint_dark" :src="settings.map_blueprint_dark" class="map-thumb map-thumb-dark" alt="dark blueprint" />
</div>
<small class="input-hint">Upload an image, or type a path/URL to the dark-theme floor plan</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Blueprint width (pixels)</span>
<input
type="number"
v-model="settings.map_width"
min="1"
placeholder="3300"
@blur="saveSetting('map_width', settings.map_width)"
:disabled="saving"
>
<small class="input-hint">Native pixel width of the blueprint image</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Blueprint height (pixels)</span>
<input
type="number"
v-model="settings.map_height"
min="1"
placeholder="2550"
@blur="saveSetting('map_height', settings.map_height)"
:disabled="saving"
>
<small class="input-hint">Native pixel height of the blueprint image</small>
</label>
</div>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="settings-success">{{ success }}</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useSystemSettings } from '../../composables/systemSettings'
const {
settings, saving, mapUploading, error, success,
loadSettings, saveSetting, uploadBlueprint,
} = useSystemSettings()
onMounted(loadSettings)
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div>
<div class="page-header">
<h2>Global Search</h2>
</div>
<div class="section-card">
<div class="setting-group">
<p class="setting-description">
Choose which content types appear in global search results. Disabling a
type here hides it from search without disabling the plugin elsewhere.
</p>
<div class="setting-row" v-for="domain in searchDomains" :key="domain.key">
<label class="toggle-label">
<span>{{ domain.label }}</span>
<button
class="toggle-btn"
:class="{ active: searchValue(domain.key) }"
@click="toggleSearchDomain(domain.key)"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
</div>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="settings-success">{{ success }}</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useSystemSettings, searchDomains } from '../../composables/systemSettings'
const {
saving, error, success,
loadSettings, searchValue, toggleSearchDomain,
} = useSystemSettings()
onMounted(loadSettings)
</script>

View File

@@ -0,0 +1,58 @@
<template>
<div>
<div class="page-header">
<h2>Collector PC Types</h2>
</div>
<div class="section-card">
<div class="setting-group">
<p class="setting-description">
When the collector ingests a PC, its imaging pc-type (from
C:\Enrollment\pc-type.txt) is mapped to one of your Computer Types.
Adjust the mapping per site.
</p>
<div class="table-container" v-if="pcTypeMappings.length">
<table class="identifier-matrix">
<thead>
<tr><th>Imaging pc-type</th><th>Computer Type</th></tr>
</thead>
<tbody>
<tr v-for="row in pcTypeMappings" :key="row.pxetype">
<td class="identifier-name">{{ row.pxetype }}</td>
<td>
<select
:value="row.computertype"
@change="changePcTypeMapping(row.pxetype, $event.target.value)"
:disabled="saving"
>
<option v-for="ct in computerTypes" :key="ct" :value="ct">{{ ct }}</option>
</select>
</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="setting-description">No collector pc-type mappings are configured yet.</p>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="settings-success">{{ success }}</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useSystemSettings } from '../../composables/systemSettings'
const {
saving, error, success, pcTypeMappings, computerTypes,
loadSettings, loadComputerTypes, changePcTypeMapping,
} = useSystemSettings()
onMounted(() => {
loadSettings()
loadComputerTypes()
})
</script>

View File

@@ -0,0 +1,75 @@
<template>
<div>
<div class="page-header">
<h2>Printing &amp; Labels</h2>
</div>
<div class="section-card">
<div class="setting-group">
<p class="setting-description">
Where printed QR codes point. Leave a target blank to link to the
asset's own page on this instance, or enter a custom URL template
with {placeholder} substitution.
</p>
<div class="setting-row">
<label>
<span>Printer QR target</span>
<input
type="text"
v-model="settings.qr_target_printer"
placeholder="(blank = printer page)"
@blur="saveSetting('qr_target_printer', settings.qr_target_printer)"
:disabled="saving"
>
<small class="input-hint">Placeholders: {printerid}, {assetid}, {assetnumber}, {serialnumber}, {ip}, {hostname}</small>
</label>
</div>
<div class="setting-row">
<label>
<span>USB label QR target</span>
<input
type="text"
v-model="settings.qr_target_usb"
placeholder="(blank = USB device page)"
@blur="saveSetting('qr_target_usb', settings.qr_target_usb)"
:disabled="saving"
>
<small class="input-hint">Placeholders: {id}, {serialnumber}, {alias}</small>
</label>
</div>
<div class="setting-row">
<label>
<span>USB label style</span>
<select
v-model="settings.usb_label_style"
@change="saveSetting('usb_label_style', settings.usb_label_style)"
:disabled="saving"
>
<option value="barcode">Barcode (CODE128 of the serial number)</option>
<option value="qr">QR code (links to the USB label QR target)</option>
</select>
<small class="input-hint">Applies to the batch USB mini-label print sheet.</small>
</label>
</div>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="settings-success">{{ success }}</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useSystemSettings } from '../../composables/systemSettings'
const {
settings, saving, error, success,
loadSettings, saveSetting,
} = useSystemSettings()
onMounted(loadSettings)
</script>

View File

@@ -0,0 +1,105 @@
<template>
<div>
<div class="page-header">
<h2>ServiceNow</h2>
</div>
<div class="section-card">
<div class="setting-group">
<p class="setting-description">
Wire global search and ticket links to your ServiceNow instance. When
enabled, matching ticket numbers become clickable links and can trigger
a smart-redirect from global search. Defaults ship for GE; change them
for your site.
</p>
<div class="setting-row">
<label class="toggle-label">
<span>Enable ServiceNow</span>
<button
class="toggle-btn"
:class="{ active: settings.servicenow_enabled }"
@click="toggleSetting('servicenow_enabled')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
</div>
<template v-if="settings.servicenow_enabled">
<div class="setting-row">
<label>
<span>Search URL</span>
<input
type="url"
v-model="settings.servicenow_search_url"
placeholder="https://geaerospaceqa.service-now.com/now/nav/ui/search/.../{ticket}/..."
@blur="saveSetting('servicenow_search_url', settings.servicenow_search_url)"
:disabled="saving"
>
<small class="input-hint">Global-search redirect target. Use {ticket} where the ticket number goes.</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Ticket Prefixes</span>
<input
type="text"
v-model="settings.servicenow_ticket_prefixes"
placeholder="GEINC,GECHG,GERIT,GESCT"
@blur="saveSetting('servicenow_ticket_prefixes', settings.servicenow_ticket_prefixes)"
:disabled="saving"
>
<small class="input-hint">Comma-separated ticket-number prefixes that this site recognizes.</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Incident URL</span>
<input
type="url"
v-model="settings.servicenow_incident_url"
placeholder="(blank = plain text; use {ticket} in a URL template)"
@blur="saveSetting('servicenow_incident_url', settings.servicenow_incident_url)"
:disabled="saving"
>
<small class="input-hint">Link template for incident tickets. Use {ticket} where the ticket number goes.</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Change URL</span>
<input
type="url"
v-model="settings.servicenow_change_url"
placeholder="(blank = plain text; use {ticket} in a URL template)"
@blur="saveSetting('servicenow_change_url', settings.servicenow_change_url)"
:disabled="saving"
>
<small class="input-hint">Link template for change tickets. Use {ticket} where the ticket number goes.</small>
</label>
</div>
</template>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="settings-success">{{ success }}</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useSystemSettings } from '../../composables/systemSettings'
const {
settings, saving, error, success,
loadSettings, saveSetting, toggleSetting,
} = useSystemSettings()
onMounted(loadSettings)
</script>

View File

@@ -59,20 +59,11 @@ const visibleGroups = computed(() => {
.filter(g => g.cards.length)
})
// A rail link is active when the current path matches its base path
// (ignoring query, so /settings/system?tab=map and /settings/system stay distinct
// only by their own comparison below).
// A rail link is active when the current path matches its base path.
// Every settings section is now its own page, so a plain path compare suffices.
function isActive(to) {
const base = to.split('?')[0]
const query = to.includes('?') ? to.split('?')[1] : ''
if (route.path !== base) return false
// Floor Map shares /settings/system with System Settings; disambiguate by tab.
if (base === '/settings/system') {
const wantMap = query.includes('tab=map')
const onMap = route.query.tab === 'map'
return wantMap === onMap
}
return true
return route.path === base
}
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
<template>
<div>
<div class="page-header">
<h2>Zabbix Supplies</h2>
</div>
<div class="section-card">
<div class="setting-group">
<p class="setting-description">
Connect to Zabbix for real-time printer supply monitoring. When enabled, supply levels
are fetched from Zabbix API and displayed on printer detail pages.
</p>
<div class="setting-row">
<label class="toggle-label">
<span>Enable Zabbix Integration</span>
<button
class="toggle-btn"
:class="{ active: settings.zabbix_enabled }"
@click="toggleSetting('zabbix_enabled')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
</div>
<div class="setting-row" v-if="settings.zabbix_enabled">
<label>
<span>Zabbix API URL</span>
<input
type="url"
v-model="settings.zabbix_url"
placeholder="http://zabbix.example.com:8080"
@blur="saveSetting('zabbix_url', settings.zabbix_url)"
:disabled="saving"
>
</label>
</div>
<div class="setting-row" v-if="settings.zabbix_enabled">
<label>
<span>Zabbix API Token</span>
<input
type="password"
v-model="settings.zabbix_token"
placeholder="Enter API token"
@blur="saveSetting('zabbix_token', settings.zabbix_token)"
:disabled="saving"
>
</label>
</div>
<div class="status-indicator" v-if="settings.zabbix_enabled">
<span class="status-dot" :class="zabbixStatus"></span>
<span>{{ zabbixMessage }}</span>
</div>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="settings-success">{{ success }}</div>
</div>
</template>
<script setup>
import { onMounted, computed } from 'vue'
import { useSystemSettings } from '../../composables/systemSettings'
const {
settings, saving, error, success,
loadSettings, saveSetting, toggleSetting,
} = useSystemSettings()
// Per-page connection status (stays with the page, not the composable).
const zabbixStatus = computed(() => {
if (!settings.zabbix_enabled) return 'inactive'
if (!settings.zabbix_url || !settings.zabbix_token) return 'warning'
return 'pending'
})
const zabbixMessage = computed(() => {
if (!settings.zabbix_enabled) return 'Disabled'
if (!settings.zabbix_url) return 'URL not configured'
if (!settings.zabbix_token) return 'Token not configured'
return 'Configured (connectivity checked on first use)'
})
onMounted(loadSettings)
</script>

View File

@@ -1,15 +1,18 @@
// Shared settings navigation catalog.
// Used by SettingsLayout (left rail) and SettingsIndex (landing overview) so the
// grouping lives in one place.
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact } from 'lucide-vue-next'
// Order: site identity + the reference-data catalogs users touch daily come
// first, then the platform/system groups (integrations, communication, search,
// plugins, access) cluster together at the end.
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search } from 'lucide-vue-next'
export const settingsGroups = [
{
title: 'Site & Facility',
cards: [
{ to: '/settings/site', icon: Home, title: 'Site & Facility', description: 'Site URL/FQDN, facility name, PC access domain, employee-ID pattern, printer hostname template' },
{ to: '/settings/system?tab=map', icon: MapPin, title: 'Floor Map', description: 'Facility floor-plan blueprint and dimensions' },
{ to: '/settings/system?tab=branding', icon: Palette, title: 'Branding', description: 'Site, QR, and badge logos, favicon, and primary brand color' },
{ to: '/settings/floormap', icon: MapPin, title: 'Floor Map', description: 'Facility floor-plan blueprint image and pixel dimensions (width, height) for the site map' },
{ to: '/settings/branding', icon: Palette, title: 'Branding', description: 'Site, QR, and badge logos, favicon, and brand primary, accent, sidebar, and theme colors' },
],
},
{
@@ -74,17 +77,41 @@ export const settingsGroups = [
{ to: '/settings/notificationtypes', icon: Bell, title: 'Notification Types', description: 'Manage notification types, display styles, colors, and auto-expiry' },
],
},
{
title: 'Integrations',
cards: [
{ to: '/settings/servicenow', icon: Link, title: 'ServiceNow', description: 'ServiceNow ticket links (incident, change) prefixes and global-search redirect' },
{ to: '/settings/zabbix', icon: Droplets, title: 'Zabbix Supplies', description: 'Zabbix API for real-time printer toner and supply monitoring' },
{ to: '/settings/dellwarranty', icon: ShieldCheck, title: 'Dell Warranty', description: 'Dell TechDirect warranty API lookup by service tag' },
{ to: '/settings/pctypemapping', icon: Laptop, title: 'Collector PC Types', description: 'Map collector enrollment imaging pc-type (shopfloor) to a Computer Type' },
],
},
{
title: 'Communication',
cards: [
{ to: '/settings/email', icon: Mail, title: 'Email / SMTP', description: 'SMTP mail host for email notifications, alerts, TLS, from address, and recipients' },
],
},
{
title: 'Search & Identity',
cards: [
{ to: '/settings/globalsearch', icon: Search, title: 'Global Search', description: 'Choose which content types (domains) appear in global search results' },
{ to: '/settings/assetidentifiers', icon: Fingerprint, title: 'Asset Identifiers', description: 'Enable optional asset identifiers per type: gauge lab, maintenance, FQDN hostname' },
{ to: '/settings/printing', icon: Printer, title: 'Printing & Labels', description: 'Printed QR code and USB label targets, barcode style, and URL templates' },
],
},
{
title: 'System',
cards: [
{ to: '/settings/system', icon: Settings, title: 'System Settings', description: 'Integrations (ServiceNow, Zabbix), branding, identifiers, search, and PC-type mapping' },
{ to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' },
],
},
{
title: 'Access & Audit',
title: 'Access & Security',
cards: [
{ to: '/settings/authentication', icon: KeyRound, title: 'Authentication', description: 'SAML single sign-on (SSO) with your IdP, local login, and auto-create users' },
{ to: '/settings/users', icon: Users, title: 'Users & Roles', description: 'Manage user accounts and permissions' },
{ to: '/settings/audit', icon: History, title: 'Audit & Logging', description: 'Audit log retention period and history purge policy' },
{ to: '/settings/auditlogs', icon: FileText, title: 'Audit Logs', description: 'View system activity and change history' },
],
},