Add system settings, audit logging, user management, and dark mode fixes

System Settings:
- Add SystemSettings.vue with Zabbix integration, SMTP/email config, SAML SSO settings
- Add Setting model with key-value storage and typed values
- Add settings API with caching

Audit Logging:
- Add AuditLog model tracking user, IP, action, entity changes
- Add comprehensive audit logging to all CRUD operations:
  - Machines, Computers, Equipment, Network devices, VLANs, Subnets
  - Printers, USB devices (including checkout/checkin)
  - Applications, Settings, Users/Roles
- Track old/new values for all field changes
- Mask sensitive values (passwords, tokens) in logs

User Management:
- Add UsersList.vue with full user CRUD
- Add Role management with granular permissions
- Add 41 predefined permissions across 10 categories
- Add users API with roles and permissions endpoints

Reports:
- Add TonerReport.vue for printer supply monitoring

Dark Mode Fixes:
- Fix map position section in PCForm, PrinterForm
- Fix alert-warning in KnowledgeBaseDetail
- All components now use CSS variables for theming

CLI Commands:
- Add flask seed permissions
- Add flask seed settings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-02-04 22:16:56 -05:00
parent 83caaa7b7c
commit 2c36c0c873
40 changed files with 4221 additions and 39 deletions

View File

@@ -0,0 +1,742 @@
<template>
<div class="page-header">
<h1>System Settings</h1>
</div>
<div class="settings-sections">
<!-- Integrations Section -->
<div class="section-card">
<h2 class="section-title">Integrations</h2>
<div class="setting-group">
<h3>Zabbix Integration</h3>
<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>
<!-- Email Section -->
<div class="section-card">
<h2 class="section-title">Email / SMTP</h2>
<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>
<!-- Audit Section -->
<div class="section-card">
<h2 class="section-title">Audit & Logging</h2>
<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>
<!-- Authentication Section -->
<div class="section-card">
<h2 class="section-title">Authentication</h2>
<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>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="success-message">{{ success }}</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { settingsApi } from '../../api'
const settings = reactive({
// Zabbix
zabbix_enabled: false,
zabbix_url: '',
zabbix_token: '',
// Email
smtp_enabled: false,
smtp_host: '',
smtp_port: 587,
smtp_username: '',
smtp_password: '',
smtp_use_tls: true,
smtp_from_address: '',
smtp_from_name: 'ShopDB',
alert_recipients: '',
// Audit
audit_retention_days: 90,
// SAML
saml_enabled: false,
saml_idp_metadata_url: '',
saml_entity_id: '',
saml_acs_url: '',
saml_allow_local_login: true,
saml_auto_create_users: true,
saml_admin_group: ''
})
const loading = ref(true)
const saving = ref(false)
const testingEmail = ref(false)
const error = ref('')
const success = ref('')
// Zabbix status
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)'
})
// SMTP status
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
})
// SAML status
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'
})
async function loadSettings() {
try {
loading.value = true
const { data } = await settingsApi.list()
for (const setting of data.data) {
if (setting.key in settings) {
settings[setting.key] = setting.value
}
}
} catch (e) {
error.value = 'Failed to load settings'
console.error(e)
} finally {
loading.value = false
}
}
async function toggleSetting(key) {
const newValue = !settings[key]
await saveSetting(key, newValue)
}
async function saveSetting(key, value) {
try {
saving.value = true
error.value = ''
success.value = ''
await settingsApi.update(key, value)
settings[key] = value
success.value = 'Setting saved'
setTimeout(() => { success.value = '' }, 2000)
} catch (e) {
error.value = e.response?.data?.message || 'Failed to save setting'
console.error(e)
} finally {
saving.value = false
}
}
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 = e.response?.data?.message || 'Failed to send test email'
} finally {
testingEmail.value = false
setTimeout(() => { success.value = '' }, 3000)
}
}
onMounted(loadSettings)
</script>
<style scoped>
.settings-sections {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.section-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
}
.section-title {
margin: 0 0 1rem 0;
font-size: 1.1rem;
color: var(--text);
}
.setting-group {
border-top: 1px solid var(--border);
padding-top: 1rem;
}
.setting-group h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
color: var(--text);
}
.setting-description {
color: var(--text-light);
font-size: 0.9rem;
margin: 0 0 1rem 0;
line-height: 1.5;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.setting-row {
margin-bottom: 1rem;
}
.setting-row.full-width {
grid-column: 1 / -1;
}
.setting-row label {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.setting-row label span {
color: var(--text);
font-size: 0.9rem;
}
.setting-row input {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
font-size: 1rem;
max-width: 400px;
}
.setting-row input[type="number"] {
max-width: 120px;
}
.setting-row input:focus {
outline: none;
border-color: var(--primary);
}
.input-hint {
color: var(--text-light);
font-size: 0.8rem;
}
.toggle-label {
flex-direction: row !important;
align-items: center;
justify-content: space-between;
max-width: 400px;
}
.toggle-btn {
position: relative;
width: 50px;
height: 26px;
border-radius: 13px;
border: none;
background: var(--secondary);
cursor: pointer;
transition: background 0.2s;
}
.toggle-btn.active {
background: var(--success);
}
.toggle-slider {
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
border-radius: 50%;
background: white;
transition: transform 0.2s;
}
.toggle-btn.active .toggle-slider {
transform: translateX(24px);
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: var(--bg);
border-radius: 4px;
font-size: 0.9rem;
color: var(--text-light);
max-width: 400px;
margin-bottom: 1rem;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.inactive {
background: var(--secondary);
}
.status-dot.warning {
background: var(--warning);
}
.status-dot.pending {
background: var(--primary);
}
.status-dot.success {
background: var(--success);
}
.test-btn {
padding: 0.5rem 1rem;
background: var(--primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.test-btn:hover:not(:disabled) {
background: var(--primary-dark);
}
.test-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-message {
margin-top: 1rem;
padding: 0.75rem;
background: var(--danger);
color: white;
border-radius: 4px;
}
.success-message {
margin-top: 1rem;
padding: 0.75rem;
background: var(--success);
color: white;
border-radius: 4px;
}
.view-logs-link {
display: inline-block;
margin-top: 0.5rem;
color: var(--link);
text-decoration: none;
font-size: 0.9rem;
}
.view-logs-link:hover {
text-decoration: underline;
}
.toggle-hint {
display: block;
margin-top: -0.5rem;
margin-left: 0;
}
</style>