Navigation sections declared, guide editor drag + references, USB settings, permission refresh

This commit is contained in:
2026-08-27 09:30:08 -04:00
parent 0b8c455b99
commit 376298dc02
17 changed files with 538 additions and 82 deletions

View File

@@ -75,13 +75,25 @@ export const useAuthStore = defineStore('auth', {
}
},
// Re-read the signed-in user from the server, permissions included.
//
// The login response is the ONLY thing that used to populate this, and it
// is cached in localStorage - so granting somebody a permission did nothing
// visible until they happened to log out and back in. They were told they
// had rights, the button stayed hidden, and nothing explained why. Called
// on app boot so a permission change lands on the next page load.
//
// Only a 401 logs out. A transient failure - API restarting, network blip -
// must not throw somebody out of a session they still hold; the cached user
// is stale, not invalid.
async fetchUser() {
try {
const response = await authApi.me()
this.user = response.data.data
await this.enrichFromDirectory()
localStorage.setItem('user', JSON.stringify(this.user))
} catch (error) {
this.logout()
if (error.response?.status === 401) this.logout()
}
},

View File

@@ -189,14 +189,33 @@ const defaultNav = [
{ name: 'Notifications', icon: 'bell', route: '/notifications', position: 5 },
{ name: 'Calendar', icon: 'calendar', route: '/calendar', position: 6 },
{ name: 'Map', icon: 'map', route: '/map', position: 4 },
{ name: 'Machines', icon: 'cog', route: '/machines', position: 10 },
{ name: 'PCs', icon: 'desktop', route: '/pcs', position: 15 },
{ name: 'Network', icon: 'network-wired', route: '/network', position: 18 },
{ name: 'Printers', icon: 'printer', route: '/printers', position: 20 },
{ name: 'USB Devices', icon: 'usb', route: '/usb', position: 45 },
{ name: 'Machines', icon: 'cog', route: '/machines', position: 10, section: 'assets' },
{ name: 'PCs', icon: 'desktop', route: '/pcs', position: 15, section: 'assets' },
{ name: 'Network', icon: 'network-wired', route: '/network', position: 18, section: 'assets' },
{ name: 'Printers', icon: 'printer', route: '/printers', position: 20, section: 'assets' },
{ name: 'USB Devices', icon: 'usb', route: '/usb', position: 25, section: 'assets' },
{ name: 'Applications', icon: 'app-window', route: '/applications', position: 30, section: 'information' },
{ name: 'Knowledge Base', icon: 'book-open', route: '/knowledgebase', position: 35 },
{ name: 'Reports', icon: 'bar-chart-3', route: '/reports', position: 40 },
{ name: 'Knowledge Base', icon: 'book-open', route: '/knowledgebase', position: 35, section: 'information' },
{ name: 'Reports', icon: 'bar-chart-3', route: '/reports', position: 40, section: 'information' },
]
// The canonical sidebar sections, in the order they render. A nav item DECLARES
// which one it belongs to; nothing is inferred.
//
// Sections used to be derived from the position number - 10-29 became Assets,
// 30-49 became Information - so a plugin landed in whichever bucket its number
// happened to fall in. That is how USB Devices, an asset, came to sit under
// Information next to GE-Enforce and Tech Tools, and why two plugins picking
// the same number silently reordered each other. A number is for ORDER; it was
// never a statement about meaning.
//
// An item with no section (or an unknown one) stays in the unlabelled group at
// the top, which is the safe default: it appears, it is just not filed.
const NAV_SECTIONS = [
{ key: null, label: null },
{ key: 'assets', label: 'Assets' },
{ key: 'information', label: 'Information' },
{ key: 'fleet', label: 'Fleet' },
]
function buildNavItems(items) {
@@ -206,29 +225,26 @@ function buildNavItems(items) {
const visible = items.filter(item =>
!item.permission || authStore.hasPermission(item.permission))
// Sort by position
const sorted = [...visible].sort((a, b) => (a.position || 99) - (b.position || 99))
const known = new Set(NAV_SECTIONS.map(s => s.key).filter(Boolean))
const bucket = item => {
const declared = (item.section || '').toLowerCase()
return known.has(declared) ? declared : null
}
// Assign section headers based on position ranges
const result = []
let currentSection = null
for (const item of sorted) {
let section = null
if (item.position >= 10 && item.position < 30 && currentSection !== 'Assets') {
section = 'Assets'
currentSection = 'Assets'
} else if (item.section === 'information' || (item.position >= 30 && item.position < 50 && currentSection !== 'Information')) {
if (currentSection !== 'Information') {
section = 'Information'
currentSection = 'Information'
}
}
result.push({
...item,
section,
iconComponent: iconMap[item.icon] || null
for (const { key, label } of NAV_SECTIONS) {
const inSection = visible
.filter(item => bucket(item) === key)
// ?? not ||: position 0 is a real position, and `0 || 99` is 99, which
// sent Dashboard - the only item that uses 0 - to the end of its group.
.sort((a, b) => (a.position ?? 99) - (b.position ?? 99))
inSection.forEach((item, index) => {
result.push({
...item,
// Only the first item in a section carries its heading.
section: index === 0 ? label : null,
iconComponent: iconMap[item.icon] || null,
})
})
}
@@ -236,6 +252,11 @@ function buildNavItems(items) {
}
onMounted(async () => {
// Refresh the signed-in user (and their permissions) on every app load. The
// login payload is cached in localStorage, so without this a permission
// granted mid-session stays invisible until the person logs out and back in.
if (authStore.isAuthenticated) authStore.fetchUser()
// Half-hourly: cheap, and it crosses midnight into the new week without a
// reload on a screen nobody touches.
fiscalWeekTimer = setInterval(() => { currentFiscalWeek.value = fiscalWeek() }, 30 * 60 * 1000)

View File

@@ -1653,6 +1653,7 @@ class ComputersPlugin(BasePlugin):
'icon': 'desktop',
'route': '/pcs',
'position': 15,
'section': 'assets',
},
]

View File

@@ -125,6 +125,7 @@ class GeEnforcePlugin(BasePlugin):
'icon': 'shield',
'route': '/geenforce/manifests',
'position': 46,
'section': 'fleet',
},
]

View File

@@ -68,12 +68,71 @@
</ul>
</div>
<!-- Reference picker -->
<div v-if="refOpen" class="ge-refmask" @click.self="refOpen = false">
<div class="ge-ref">
<div class="ge-refhead">
<strong>Link to a guide or article</strong>
<button type="button" @click="refOpen = false"><X :size="16" /></button>
</div>
<input ref="refInput" v-model="refQuery" class="ge-refsearch"
placeholder="Search by title..." @input="searchReferences">
<p v-if="refLoading" class="ge-hint">Searching...</p>
<p v-else-if="!refResults.length" class="ge-hint">
{{ refQuery ? 'Nothing matches that.' : 'Type to search.' }}
</p>
<ul v-else class="ge-reflist">
<li v-for="item in refResults" :key="item.kind + item.id">
<button type="button" @click="insertReference(item)">
<span class="ge-refkind">{{ item.kind }}</span>
<span class="ge-reftitle">{{ item.title }}</span>
<small v-if="item.summary">{{ item.summary }}</small>
</button>
</li>
</ul>
</div>
</div>
<div class="ge-split" :class="{ 'ge-haspreview': showPreview }">
<div class="ge-editor">
<div v-for="(block, index) in blocks" :key="block.id"
class="ge-block" :class="{ 'ge-focused': focused === block.id }"
@focusin="focused = block.id">
<!-- Insert point ABOVE each section.
The type palette used to live only at the bottom of the whole
guide, so adding a step in the middle meant adding it at the end
and walking it up one press at a time. The insert bar puts the
palette where the section is actually going. -->
<div v-for="(block, index) in blocks" :key="block.id">
<div class="ge-insert" :class="{ 'ge-insertopen': insertAt === index }">
<button type="button" class="ge-insertbtn"
:title="'Add a section here'"
@click="insertAt = insertAt === index ? null : index">
<Plus :size="14" /> Add section
</button>
</div>
<div v-if="insertAt === index" class="ge-palette">
<button v-for="type in blocktypes" :key="type" type="button"
@click="add(type, index)">{{ type }}</button>
<label class="ge-upload">
image
<input type="file" accept="image/*" hidden
@change="event => onFilePick(event, index)">
</label>
</div>
<div class="ge-block"
:class="{ 'ge-focused': focused === block.id,
'ge-dragging': dragIndex === index,
'ge-dragover': dragOver === index }"
@focusin="focused = block.id"
@dragover.prevent="dragOver = index"
@dragleave="dragOver === index && (dragOver = null)"
@drop.prevent="dropOn(index)">
<div class="ge-blockbar">
<!-- Dragging is on the HANDLE, not the whole block: a draggable
container swallows text selection inside its own textarea. -->
<span class="ge-grip" draggable="true" title="Drag to move"
@dragstart="startDrag(index, $event)" @dragend="endDrag">
<GripVertical :size="14" />
</span>
<span class="ge-type">{{ block.type }}</span>
<select v-if="block.type === 'callout'" v-model="block.variant" class="ge-mini">
<option value="info">info</option>
@@ -92,16 +151,23 @@
<button type="button" title="Italic" @click="wrap(block, '*')"><i>I</i></button>
<button type="button" title="Code" @click="wrap(block, '`')">&lt;&gt;</button>
<button type="button" title="Link" @click="makeLink(block)">link</button>
<!-- Linking to another guide or a KB article by hand means
knowing its slug or id. This finds it by name and writes the
link, so a procedure can point at the one before it. -->
<button type="button" title="Reference a guide or KB article"
@click="openReference(block)"><Link2 :size="14" /></button>
</div>
<div class="ge-blockbtns">
<button type="button" title="Move up" @click="move(index, -1)"
:disabled="index === 0">^</button>
:disabled="index === 0"><ArrowUp :size="14" /></button>
<button type="button" title="Move down" @click="move(index, 1)"
:disabled="index === blocks.length - 1">v</button>
<button type="button" title="Duplicate" @click="duplicate(index)">copy</button>
:disabled="index === blocks.length - 1"><ArrowDown :size="14" /></button>
<button type="button" title="Duplicate" @click="duplicate(index)">
<Copy :size="14" />
</button>
<button type="button" class="ge-del" title="Remove"
@click="remove(index)">x</button>
@click="remove(index)"><X :size="14" /></button>
</div>
</div>
@@ -158,14 +224,24 @@
placeholder="One row per line, cells separated by |&#10;Header | Header&#10;Cell | Cell"></textarea>
</div>
</div>
</div>
<div class="ge-add">
<span>Add:</span>
<!-- The end of the guide gets the same control as every gap between
sections, so there is one way to add a section, not two. -->
<div class="ge-insert ge-insertend"
:class="{ 'ge-insertopen': insertAt === blocks.length }">
<button type="button" class="ge-insertbtn"
@click="insertAt = insertAt === blocks.length ? null : blocks.length">
<Plus :size="14" /> Add section
</button>
</div>
<div v-if="insertAt === blocks.length" class="ge-palette">
<button v-for="type in blocktypes" :key="type" type="button"
@click="add(type)">{{ type }}</button>
@click="add(type, blocks.length)">{{ type }}</button>
<label class="ge-upload">
image
<input type="file" accept="image/*" hidden @change="onFilePick">
<input type="file" accept="image/*" hidden
@change="event => onFilePick(event, blocks.length)">
</label>
</div>
@@ -209,6 +285,8 @@ import GuideBlocks from '../components/GuideBlocks.vue'
import { templates } from '../utils/templates.js'
import { proof, applyFix } from '../utils/proof.js'
import { useGuideImages } from '../utils/images.js'
import { ArrowUp, ArrowDown, GripVertical, Copy, X, Plus, Link2 } from 'lucide-vue-next'
import { knowledgebaseApi } from '@/api'
const route = useRoute()
const router = useRouter()
@@ -220,6 +298,17 @@ const blocktypes = ['heading', 'paragraph', 'step', 'checklist', 'bullets',
const guide = reactive({ guideid: null, title: '', summary: '', tags: '',
slug: '', ispublished: false })
const blocks = ref([])
// Which gap the type palette is open in. null = closed; blocks.length = the end.
const insertAt = ref(null)
const dragIndex = ref(null)
const dragOver = ref(null)
const refOpen = ref(false)
const refQuery = ref('')
const refResults = ref([])
const refLoading = ref(false)
const refInput = ref(null)
let refBlock = null
let refTimer = null
const revisions = ref([])
const saving = ref(false)
const message = ref('')
@@ -288,17 +377,111 @@ function insertIndex () {
return index === -1 ? blocks.value.length : index + 1
}
function add (type) {
// `at` is an explicit gap from an insert bar. Without one, fall back to
// "after whatever is focused", which is what the old bottom palette did.
function add (type, at = null) {
const block = { id: newid(), type }
if (hasItems(block)) block.items = [{ id: newid(), text: '' }]
if (type === 'callout') block.variant = 'info'
if (type === 'table') { block.raw = ''; block.header = []; block.rows = [] }
const at = insertIndex()
blocks.value.splice(at, 0, block)
const index = at === null ? insertIndex() : at
blocks.value.splice(index, 0, block)
insertAt.value = null
focused.value = block.id
nextTick(() => textareas[block.id]?.focus())
}
// --------------------------------------------------------------- dragging ---
function startDrag (index, event) {
dragIndex.value = index
// Firefox will not start a drag without payload, and 'move' is what the
// cursor should say.
event.dataTransfer.effectAllowed = 'move'
try { event.dataTransfer.setData('text/plain', String(index)) } catch (err) { /* older browsers */ }
}
function endDrag () {
dragIndex.value = null
dragOver.value = null
}
function dropOn (index) {
const from = dragIndex.value
endDrag()
if (from === null || from === index) return
const [moved] = blocks.value.splice(from, 1)
blocks.value.splice(index, 0, moved)
focused.value = moved.id
}
// ------------------------------------------------------------- references ---
function openReference (block) {
refBlock = block
refOpen.value = true
refQuery.value = ''
refResults.value = []
nextTick(() => refInput.value?.focus())
}
function searchReferences () {
clearTimeout(refTimer)
const term = refQuery.value.trim()
if (!term) { refResults.value = []; return }
refTimer = setTimeout(async () => {
refLoading.value = true
try {
const results = []
// Guides first: a procedure most often points at another procedure.
try {
const response = await api.get('/guides', { params: { q: term } })
for (const guide of (response.data?.data || [])) {
if (!guide.ispublished) continue // never link readers at a draft
results.push({ kind: 'guide', id: guide.guideid, title: guide.title,
summary: guide.summary, url: `/guides/${guide.slug}` })
}
} catch (err) { /* guides always exist here, but never break the picker */ }
// The knowledge base is a separate plugin and may not be installed at
// this site (ADR-009). Its absence must cost nothing but its results.
try {
const response = await knowledgebaseApi.list({ search: term, perpage: 20 })
const rows = response.data?.data || []
for (const article of (Array.isArray(rows) ? rows : rows.items || [])) {
results.push({ kind: 'article', id: article.articleid || article.id,
title: article.title,
summary: article.summary || article.description,
url: `/knowledgebase/${article.articleid || article.id}` })
}
} catch (err) { /* KB plugin absent or disabled */ }
refResults.value = results.slice(0, 25)
} finally {
refLoading.value = false
}
}, 250)
}
function insertReference (item) {
// An in-app path, not an absolute URL: the link has to keep working when the
// site is reached by another hostname or mounted under a subpath.
const markdown = `[${item.title}](${item.url})`
const area = refBlock ? textareas[refBlock.id] : null
if (refBlock && area) {
const start = area.selectionStart ?? refBlock.text?.length ?? 0
const end = area.selectionEnd ?? start
const text = refBlock.text || ''
refBlock.text = text.slice(0, start) + markdown + text.slice(end)
nextTick(() => {
area.focus()
const caret = start + markdown.length
area.setSelectionRange(caret, caret)
})
} else if (refBlock) {
refBlock.text = (refBlock.text || '') + markdown
}
refOpen.value = false
}
function duplicate (index) {
const source = blocks.value[index]
const copy = JSON.parse(JSON.stringify(source))
@@ -383,7 +566,7 @@ async function uploadImage (file) {
return response.data?.data || response.data
}
async function insertImage (file) {
async function insertImage (file, at = null) {
error.value = ''
status.value = 'Uploading image...'
try {
@@ -392,7 +575,8 @@ async function insertImage (file) {
// for anything that still treats a block as a plain document.
const block = { id: newid(), type: 'image', imageid: image.guideimageid,
url: image.url, caption: '', alt: '' }
blocks.value.splice(insertIndex(), 0, block)
blocks.value.splice(at === null ? insertIndex() : at, 0, block)
insertAt.value = null
focused.value = block.id
status.value = ''
} catch (err) {
@@ -416,9 +600,9 @@ function onDrop (event) {
if (file) insertImage(file)
}
function onFilePick (event) {
function onFilePick (event, at = null) {
const file = event.target.files?.[0]
if (file) insertImage(file)
if (file) insertImage(file, at)
event.target.value = ''
}
@@ -714,4 +898,77 @@ onMounted(async () => {
.ge-rev button { margin-left: auto; font-size: 0.72rem; padding: 0.12rem 0.45rem;
border: 1px solid var(--border); background: transparent;
color: var(--text-light); border-radius: 3px; cursor: pointer; }
/* --- insert bars, drag handle, reference picker -------------------------- */
/* A quiet line that becomes a button on hover: present when wanted, not
competing with the guide's own content the rest of the time. */
.ge-insert {
display: flex; align-items: center; justify-content: center;
height: 1.5rem; opacity: 0; transition: opacity 0.12s;
}
.ge-insert:hover, .ge-insertopen, .ge-insertend { opacity: 1; }
.ge-insert::before, .ge-insert::after {
content: ''; flex: 1; height: 1px; background: var(--border);
}
.ge-insertbtn {
display: inline-flex; align-items: center; gap: 0.3rem;
margin: 0 0.5rem; padding: 0.15rem 0.6rem;
font-size: 0.75rem; color: var(--text-light);
border: 1px solid var(--border); border-radius: 999px;
background: var(--bg); cursor: pointer; white-space: nowrap;
}
.ge-insertbtn:hover { color: var(--primary); border-color: var(--primary); }
.ge-palette {
display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center;
margin: 0 0 0.6rem; padding: 0.5rem;
border: 1px solid var(--border); border-radius: 5px;
}
.ge-palette button, .ge-palette .ge-upload {
font-size: 0.75rem; padding: 0.2rem 0.5rem; border: 1px solid var(--border);
background: transparent; color: var(--text); border-radius: 3px; cursor: pointer;
}
.ge-palette button:hover, .ge-palette .ge-upload:hover {
color: var(--primary); border-color: var(--primary);
}
.ge-grip { display: inline-flex; align-items: center; color: var(--text-light);
cursor: grab; padding-right: 0.2rem; }
.ge-grip:active { cursor: grabbing; }
.ge-dragging { opacity: 0.45; }
.ge-dragover { border-top: 2px solid var(--primary); }
.ge-refmask {
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45);
display: flex; align-items: flex-start; justify-content: center;
padding-top: 8vh; z-index: 50;
}
.ge-ref {
width: min(34rem, 92vw); max-height: 70vh; overflow: auto;
background: var(--bg); color: var(--text);
border: 1px solid var(--border); border-radius: 6px; padding: 1rem;
}
.ge-refhead { display: flex; align-items: center; justify-content: space-between;
margin-bottom: 0.6rem; }
.ge-refhead button { background: transparent; border: none; color: var(--text-light);
cursor: pointer; }
.ge-refsearch {
width: 100%; padding: 0.5rem 0.7rem; font-size: 1rem;
border: 1px solid var(--border); border-radius: 4px;
background: transparent; color: var(--text); margin-bottom: 0.6rem;
}
.ge-reflist { list-style: none; margin: 0; padding: 0; }
.ge-reflist button {
width: 100%; display: flex; flex-direction: column; gap: 0.15rem;
padding: 0.5rem 0.6rem; margin-bottom: 0.3rem; text-align: left;
border: 1px solid var(--border); border-radius: 4px;
background: transparent; color: var(--text); cursor: pointer;
}
.ge-reflist button:hover { border-color: var(--primary); }
.ge-refkind {
font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--text-light);
}
.ge-reftitle { font-weight: 600; }
.ge-reflist small { color: var(--text-light); }
</style>

View File

@@ -75,10 +75,15 @@ class GuidesPlugin(BasePlugin):
]
def get_navigation_items(self) -> List[Dict]:
# Position 8, beside Slides (7), rather than down in the Information
# section with the reference lists. A guide is something you go and DO
# something with, like a slide deck you put on a screen - not a
# catalogue you look things up in.
# Position 8, directly under Slides (7), rather than down in the
# Information section with the reference lists. A guide is something you
# go and DO something with, like a slide deck you put on a screen - not
# a catalogue you look things up in.
#
# Warranties held 8 as well, and a TIE is broken by plugin load order,
# so Guides landed underneath it and read as "not under Slides at all".
# Warranties moved to 9. Two nav items sharing a position is not a
# preference, it is a coin toss.
return [
{
'name': 'Guides',

View File

@@ -232,6 +232,7 @@ class MachinesPlugin(BasePlugin):
'icon': 'cog',
'route': '/machines',
'position': 10,
'section': 'assets',
},
]

View File

@@ -74,6 +74,7 @@ class MeasuringToolsPlugin(BasePlugin):
'icon': 'ruler',
'route': '/measuringtools',
'position': 22,
'section': 'assets',
},
]

View File

@@ -1,5 +1,25 @@
<template>
<div class="detail-page" v-if="device">
<!--
The same page-header + header-actions block every other asset detail page
uses. This page had no header at all: Edit was tucked inside the hero
title row and there was no Print Label, no Retire and no way back to the
list, so managing a network device meant looking somewhere different from
every other asset type.
-->
<div class="page-header">
<h2>Network Device Details</h2>
<div class="header-actions">
<router-link :to="`/print/asset-label/network_device/${deviceId}`"
class="btn btn-secondary" target="_blank">Print Label</router-link>
<router-link v-if="authStore.isAuthenticated" :to="`/network/${deviceId}/edit`"
class="btn btn-primary">Edit</router-link>
<button v-if="authStore.isAuthenticated" @click="confirmRetire"
class="btn btn-danger">Retire</button>
<router-link to="/network" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div class="hero-card">
<div class="hero-image">
<img v-if="device.networkdevice?.imageurl" :src="withBase(device.networkdevice.imageurl)" alt="Model photo" />
@@ -10,13 +30,6 @@
<div class="hero-content">
<div class="hero-title-row">
<h1 class="hero-title">{{ device.networkdevice?.hostname || device.name || device.assetnumber }}</h1>
<router-link
v-if="authStore.isAuthenticated"
:to="`/network/${deviceId}/edit`"
class="btn btn-secondary"
>
Edit
</router-link>
</div>
<div class="hero-meta">
<span class="badge" :style="colorStyle(device.statuscolor)">
@@ -184,18 +197,9 @@
</div>
</div>
<!-- Actions -->
<div class="action-bar" v-if="authStore.isAuthenticated">
<router-link :to="`/print/asset-label/network_device/${deviceId}`" class="btn btn-secondary" target="_blank">
Print Label
</router-link>
<router-link :to="`/network/${deviceId}/edit`" class="btn btn-primary">
Edit Device
</router-link>
<button @click="confirmDelete" class="btn btn-danger">
Delete Device
</button>
</div>
<!-- The manage actions live in the page header, where every other asset
detail page puts them. They used to sit down here instead, which is
why finding them on a network device meant looking somewhere else. -->
<!-- Audit Footer -->
<div class="audit-footer">
@@ -228,6 +232,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import { useWarrantyBadge } from '@/composables/warrantyBadge'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
import { setRecordTitle } from '@/composables/pageTitle'
const toast = useToast()
@@ -293,17 +298,24 @@ function formatDate(dateStr) {
})
}
async function confirmDelete() {
if (confirm(`Are you sure you want to delete ${device.value.networkdevice?.hostname || device.value.assetnumber}?`)) {
try {
await networkApi.delete(deviceId)
router.push('/network')
} catch (error) {
console.error('Error deleting device:', error)
toast.error('Failed to delete device')
}
// Soft delete, like every other asset type: the record leaves the lists while
// its history and relationships stay put, and it can be restored by setting
// isactive back. Called Retire rather than Delete for exactly that reason.
async function confirmRetire () {
const label = device.value?.networkdevice?.hostname
|| device.value?.name || device.value?.assetnumber || 'this device'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
+ `History and relationships are kept, and it can be restored.`)) return
try {
await networkApi.delete(deviceId)
toast.success(`Retired ${label}`)
router.push('/network')
} catch (error) {
console.error('Error retiring network device:', error)
toast.error(apiError(error, 'Failed to retire network device'))
}
}
</script>
<style scoped>

View File

@@ -208,6 +208,7 @@ class NetworkPlugin(BasePlugin):
'icon': 'network-wired',
'route': '/network',
'position': 18,
'section': 'assets',
},
]

View File

@@ -106,7 +106,8 @@ class PrintedpartsPlugin(BasePlugin):
'name': '3D Parts',
'icon': 'box',
'route': '/printedparts',
'position': 46,
'position': 27,
'section': 'assets',
},
]

View File

@@ -386,6 +386,7 @@ class PrintersPlugin(BasePlugin):
'icon': 'printer',
'route': '/printers',
'position': 20,
'section': 'assets',
},
]

View File

@@ -121,6 +121,7 @@ class ToolsPlugin(BasePlugin):
'icon': 'wrench',
'route': '/tools',
'position': 47,
'section': 'fleet',
},
]

View File

@@ -25,6 +25,12 @@ export default [
name: 'usb-edit',
component: () => import('./views/USBForm.vue'),
meta: { requiresAuth: true, plugin: 'usb' }
},
{
path: 'settings/usb',
name: 'settings-usb',
component: () => import('./views/USBSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'usb' }
}
]

View File

@@ -0,0 +1,113 @@
<template>
<div>
<div class="page-header">
<h2>USB Devices</h2>
</div>
<div class="card form-card">
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="form-group">
<label class="check-row">
<input type="checkbox" v-model="values.usb_kiosk_enabled" />
<span>Allow the logged-out USB kiosk</span>
</label>
<p class="field-hint">
Turns on <code>/usb-kiosk</code>, a full-screen touch page where somebody
at the drive cabinet takes and returns a drive without signing in.
It is off by default: this is a write nobody has logged in for, so it is
a decision a site makes deliberately. While it is off, every kiosk
request is refused and the page says so.
</p>
<p class="field-hint">
The kiosk can only move a drive between checked out and checked in. It
cannot add, edit or retire one. Each action is attributed to the SSO
entered on the screen, and an SSO that matches no employee is refused -
a checkout nobody can trace to a person defeats the point of tracking
it.
</p>
<p v-if="values.usb_kiosk_enabled" class="field-hint">
<a :href="withBase('/usb-kiosk')" target="_blank">Open the kiosk</a>
- also in the sidebar under Displays.
</p>
</div>
<div class="form-group">
<label class="check-row">
<input type="checkbox" v-model="values.usb_kiosk_allow_return_for_others"
:disabled="!values.usb_kiosk_enabled" />
<span>Let somebody return a drive checked out to another person</span>
</label>
<p class="field-hint">
The kiosk asks first and names the holder, then records the SSO that
actually handed the drive back. Turn this off to refuse a mismatch
outright - but then a drive left behind by somebody on leave cannot be
returned at the kiosk at all, and needs a signed-in user to sort out.
</p>
</div>
<div class="form-actions">
<button class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { settingsApi } from '@/api'
import { withBase } from '@/utils/basePath'
const KEYS = ['usb_kiosk_enabled', 'usb_kiosk_allow_return_for_others']
const values = ref({
usb_kiosk_enabled: false,
usb_kiosk_allow_return_for_others: true,
})
const saving = ref(false)
const message = ref('')
const error = ref('')
function asBool (value) {
return String(value).toLowerCase() === 'true' || value === true || value === 1
}
async function load () {
try {
const response = await settingsApi.list({ category: 'usb' })
for (const row of (response.data.data || [])) {
if (KEYS.includes(row.key)) values.value[row.key] = asBool(row.value)
}
} catch (loadError) {
error.value = 'Could not load USB settings.'
}
}
async function save () {
saving.value = true
message.value = ''
error.value = ''
try {
for (const key of KEYS) {
await settingsApi.update(key, values.value[key] ? 'true' : 'false')
}
message.value = 'Settings saved'
} catch (saveError) {
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
.check-row { display: flex; align-items: center; gap: 0.5rem; font-weight: 600; }
.check-row input { width: auto; }
.form-actions { margin-top: 1rem; }
</style>

View File

@@ -70,6 +70,27 @@ class USBPlugin(BasePlugin):
'docs': 'plugins/usb/README.md',
}
def get_settings_cards(self) -> List[Dict]:
"""Somewhere to actually turn the kiosk on.
The kiosk page tells an administrator to enable it in Settings under
USB. Without this card that place does not exist, and the instruction
is a dead end - the same trap the backups plugin shipped with, where
settings existed but only a shell could reach them.
"""
return [
{
'group': 'USB Devices',
'to': '/settings/usb',
'icon': 'usb',
'title': 'USB Settings',
'description': 'Logged-out kiosk for taking and returning '
'drives, and whether somebody may return a drive '
'checked out to another person',
'position': 45,
},
]
def get_settings_defaults(self) -> List[Dict]:
"""Kiosk settings.
@@ -144,7 +165,8 @@ class USBPlugin(BasePlugin):
'name': 'USB Devices',
'icon': 'usb',
'route': '/usb',
'position': 45,
'position': 25,
'section': 'assets',
},
]

View File

@@ -57,7 +57,7 @@ class WarrantyPlugin(BasePlugin):
'name': 'Warranties',
'icon': 'shield',
'route': '/warranties',
'position': 8,
'position': 9,
},
]