Guides image + draft gating, audit retention, ADR-015 gate, backup credential redaction
This commit is contained in:
@@ -27,8 +27,10 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
columns = {c['name'] for c in
|
||||
sa.inspect(op.get_bind()).get_columns('backuprevisions')}
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if 'backuprevisions' not in inspector.get_table_names():
|
||||
return
|
||||
columns = {c['name'] for c in inspector.get_columns('backuprevisions')}
|
||||
if 'lastseenat' not in columns:
|
||||
op.add_column('backuprevisions',
|
||||
sa.Column('lastseenat', sa.DateTime(), nullable=True))
|
||||
@@ -37,7 +39,9 @@ def upgrade():
|
||||
|
||||
|
||||
def downgrade():
|
||||
columns = {c['name'] for c in
|
||||
sa.inspect(op.get_bind()).get_columns('backuprevisions')}
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if 'backuprevisions' not in inspector.get_table_names():
|
||||
return
|
||||
columns = {c['name'] for c in inspector.get_columns('backuprevisions')}
|
||||
if 'lastseenat' in columns:
|
||||
op.drop_column('backuprevisions', 'lastseenat')
|
||||
|
||||
@@ -410,7 +410,13 @@ class BackupsPlugin(BasePlugin):
|
||||
# with_for_update serializes concurrent posts for the same chain: the
|
||||
# fleet collects on one GE-Enforce cycle, so a retry could otherwise
|
||||
# pass the hash check twice and insert the same revision.
|
||||
sourcehostname = payload.get('sourcehostname')
|
||||
# Normalize ONCE, and use the same value to query and to store.
|
||||
# samesource() maps '' to IS NULL, but the insert below used to store
|
||||
# the raw '' - so a client posting an empty hostname wrote rows that its
|
||||
# own next post could never match. Every post then deduped against
|
||||
# nothing, inserting forever: unbounded growth, and a history no
|
||||
# technician can read. Whitespace does the same thing more quietly.
|
||||
sourcehostname = (payload.get('sourcehostname') or '').strip() or None
|
||||
latest = (db.session.query(BackupRevision)
|
||||
.filter(BackupRevision.assetid == assetid,
|
||||
BackupRevision.backupkind == kindkey,
|
||||
@@ -476,7 +482,7 @@ class BackupsPlugin(BasePlugin):
|
||||
sharepath=sharepath,
|
||||
sourcefilename=sourcefilename,
|
||||
bytesize=bytesize,
|
||||
sourcehostname=payload.get('sourcehostname'),
|
||||
sourcehostname=sourcehostname,
|
||||
collectedat=collectedat or datetime.utcnow(),
|
||||
lastseenat=datetime.utcnow(),
|
||||
)
|
||||
|
||||
@@ -32,6 +32,31 @@ import re
|
||||
|
||||
SCHEMA = 'ntlars/1'
|
||||
|
||||
# SECRETS ARE NOT BACKED UP.
|
||||
#
|
||||
# The DNC registry carries an FTP password in cleartext. Stored verbatim it
|
||||
# reached three places it must not: the backuprevisions table, the revision
|
||||
# panel (which renders values), and the download route (which hands back a
|
||||
# ready-to-import .reg). Anyone with backups.view could read the fleet's FTP
|
||||
# password off a web page.
|
||||
#
|
||||
# Redaction happens at PARSE, so the secret never enters the database at all.
|
||||
# Redacting only on display would leave it at rest and one query away.
|
||||
#
|
||||
# What is deliberately NOT done: dropping the value silently. This module's
|
||||
# parser refuses to discard an unparseable value precisely because a backup that
|
||||
# looks complete and restores a machine with a setting missing is the worst
|
||||
# outcome. A redacted value keeps its NAME and records itself in
|
||||
# `redacted`, so both the panel and a restore can say plainly that this one
|
||||
# field must be set by hand.
|
||||
REDACTEDMARKER = '***REDACTED***'
|
||||
SECRETNAMEPATTERN = re.compile(r'passw|pwd|secret|apikey|token', re.IGNORECASE)
|
||||
|
||||
|
||||
def issecretname(name):
|
||||
"""Whether a registry value name holds a credential."""
|
||||
return bool(SECRETNAMEPATTERN.search(name or ''))
|
||||
|
||||
REGROOT = 'HKEY_LOCAL_MACHINE'
|
||||
DNCPATH = r'SOFTWARE\GE Aircraft Engines\DNC'
|
||||
DNCPATHWOW = r'SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC'
|
||||
@@ -245,6 +270,20 @@ def parse(raw):
|
||||
|
||||
dialect = 'wow6432node' if sawwow else ('ntlars' if sawplain else 'unknown')
|
||||
|
||||
# Strip credentials before the projection is returned - i.e. before anything
|
||||
# persists it. Recorded by path\name so the omission is visible rather than
|
||||
# discovered during a restore.
|
||||
redacted = []
|
||||
for path in keys:
|
||||
for name, value in keys[path].items():
|
||||
if not issecretname(name):
|
||||
continue
|
||||
if value.get('data') in ('', None):
|
||||
continue # nothing to hide
|
||||
keys[path][name] = {'type': value.get('type', 'REG_SZ'),
|
||||
'data': REDACTEDMARKER}
|
||||
redacted.append('{}\\{}'.format(path, name) if path else name)
|
||||
|
||||
return {
|
||||
'schema': SCHEMA,
|
||||
'sourcedialect': dialect,
|
||||
@@ -252,6 +291,7 @@ def parse(raw):
|
||||
{'path': path, 'values': dict(sorted(keys[path].items()))}
|
||||
for path in sorted(keys)
|
||||
],
|
||||
'redacted': sorted(redacted),
|
||||
}
|
||||
|
||||
|
||||
@@ -291,6 +331,12 @@ def render(projection, dialect='ntlars', comments=None):
|
||||
|
||||
dialect='ntlars' omits WOW6432Node - use with the NTLARS Load... button
|
||||
dialect='wow6432node' includes it - use with `reg import` on 64-bit
|
||||
|
||||
Redacted credentials are rendered as a comment naming the value, NOT as the
|
||||
marker string. Importing '***REDACTED***' as the FTP password would leave a
|
||||
machine that looks restored and cannot connect, and the technician would be
|
||||
debugging the network. A commented-out value fails loudly at the one moment
|
||||
somebody is looking at the file.
|
||||
"""
|
||||
if dialect not in ('ntlars', 'wow6432node'):
|
||||
raise ValueError('unknown dialect: {!r}'.format(dialect))
|
||||
@@ -302,10 +348,28 @@ def render(projection, dialect='ntlars', comments=None):
|
||||
if comments:
|
||||
out.append('')
|
||||
|
||||
redacted = projection.get('redacted') or []
|
||||
if redacted:
|
||||
out.append('; ---------------------------------------------------------------')
|
||||
out.append('; {} credential value(s) were NOT backed up and are NOT in this'
|
||||
.format(len(redacted)))
|
||||
out.append('; file. Set them by hand after importing:')
|
||||
for name in redacted:
|
||||
out.append('; {}'.format(name))
|
||||
out.append('; ---------------------------------------------------------------')
|
||||
out.append('')
|
||||
|
||||
for key in projection.get('keys', []):
|
||||
path = key.get('path', '')
|
||||
out.append('[{}]'.format(root + ('\\' + path if path else '')))
|
||||
for name, entry in (key.get('values') or {}).items():
|
||||
# A redacted credential is commented out, never emitted as the
|
||||
# marker. Importing the marker as a password produces a machine that
|
||||
# looks restored and cannot log in.
|
||||
if (entry or {}).get('data') == REDACTEDMARKER:
|
||||
out.append('; "{}" was not backed up - set it by hand'.format(
|
||||
_escape(name)))
|
||||
continue
|
||||
out.append(_rendervalue(name, entry))
|
||||
out.append('')
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
|
||||
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
|
||||
<router-link to="/pcs" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,7 +255,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { colorStyle } from "@/utils/colorStyle"
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computersApi, applicationsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||
@@ -264,8 +265,12 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
||||
import AssetRelationships from '@/components/AssetRelationships.vue'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -310,6 +315,23 @@ function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
// Retire, not delete: the server deactivates the asset, so the PC
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
async function confirmRetire() {
|
||||
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
||||
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 computersApi.delete(route.params.id)
|
||||
toast.success(`Retired ${label}`)
|
||||
router.push('/pcs')
|
||||
} catch (error) {
|
||||
console.error('Error retiring PC:', error)
|
||||
toast.error(apiError(error, 'Failed to retire PC'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -11,6 +11,7 @@ serving the working copy from the reader's route would quietly undo it.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
import re
|
||||
|
||||
from flask import Blueprint, request, Response
|
||||
@@ -19,7 +20,7 @@ from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
|
||||
from shopdb.api import (
|
||||
db,
|
||||
success_response, error_response, ErrorCodes,
|
||||
require_permission,
|
||||
require_permission, harden_upload_response,
|
||||
)
|
||||
|
||||
from ..models import Guide, GuideRevision, GuideImage
|
||||
@@ -109,27 +110,51 @@ def list_guides():
|
||||
drafts are invisible to readers - but an author must be able to find their
|
||||
own, or drafting is a trap door.
|
||||
"""
|
||||
query = Guide.query
|
||||
caneditall = _has('guides.edit')
|
||||
mine = _userid()
|
||||
|
||||
# Match against the PUBLISHED text for anything the caller is only allowed
|
||||
# to read, and against the working copy only for guides they may edit.
|
||||
# Searching the working copy for everyone leaks draft wording to readers:
|
||||
# they get a hit on a word that is not in the page when they open it.
|
||||
published = db.aliased(GuideRevision)
|
||||
query = Guide.query.outerjoin(
|
||||
published, Guide.publishedrevisionid == published.guiderevisionid)
|
||||
|
||||
search = (request.args.get('q') or '').strip()
|
||||
if search:
|
||||
like = '%{}%'.format(search)
|
||||
query = query.filter(db.or_(
|
||||
Guide.title.ilike(like),
|
||||
Guide.summary.ilike(like),
|
||||
Guide.tags.ilike(like),
|
||||
|
||||
def matches(title, summary, tags, blocks):
|
||||
# The body too: people look for "the one about the drive letter",
|
||||
# not for a title they never read.
|
||||
Guide.blocksjson.ilike(like),
|
||||
))
|
||||
return db.or_(title.ilike(like), summary.ilike(like),
|
||||
tags.ilike(like), blocks.ilike(like))
|
||||
|
||||
working = matches(Guide.title, Guide.summary, Guide.tags, Guide.blocksjson)
|
||||
readable = matches(published.title, published.summary,
|
||||
published.tags, published.blocksjson)
|
||||
if caneditall:
|
||||
query = query.filter(db.or_(working, readable))
|
||||
else:
|
||||
query = query.filter(db.or_(
|
||||
readable,
|
||||
db.and_(Guide.createdby == mine, working),
|
||||
))
|
||||
|
||||
tag = (request.args.get('tag') or '').strip()
|
||||
if tag:
|
||||
query = query.filter(Guide.tags.ilike('%{}%'.format(tag)))
|
||||
like = '%{}%'.format(tag)
|
||||
if caneditall:
|
||||
query = query.filter(db.or_(Guide.tags.ilike(like),
|
||||
published.tags.ilike(like)))
|
||||
else:
|
||||
query = query.filter(db.or_(
|
||||
published.tags.ilike(like),
|
||||
db.and_(Guide.createdby == mine, Guide.tags.ilike(like)),
|
||||
))
|
||||
|
||||
caneditall = _has('guides.edit')
|
||||
if not caneditall:
|
||||
mine = _userid()
|
||||
query = query.filter(db.or_(
|
||||
Guide.publishedrevisionid.isnot(None),
|
||||
Guide.createdby == mine,
|
||||
@@ -143,6 +168,18 @@ def list_guides():
|
||||
data['ispublished'] = bool(guide.publishedrevisionid)
|
||||
data['checklistitems'] = checklistitemcount(guide.blocks)
|
||||
data['canedit'] = _mayedit(guide)
|
||||
|
||||
# A reader's card must describe the guide they will actually open. The
|
||||
# Guide row carries the working copy, so a retitled draft would list
|
||||
# under its new name and open under its old one.
|
||||
if not data['canedit'] and guide.publishedrevisionid:
|
||||
revision = db.session.get(GuideRevision, guide.publishedrevisionid)
|
||||
if revision is not None:
|
||||
data['title'] = revision.title
|
||||
data['summary'] = revision.summary
|
||||
data['tags'] = [t.strip() for t in (revision.tags or '').split(',')
|
||||
if t.strip()]
|
||||
data['checklistitems'] = checklistitemcount(revision.blocks)
|
||||
out.append(data)
|
||||
return success_response(out)
|
||||
|
||||
@@ -151,9 +188,24 @@ def list_guides():
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def list_tags():
|
||||
"""Every tag in use, with a count, for the filter chips."""
|
||||
"""Every tag in use, with a count, for the filter chips.
|
||||
|
||||
Readers count published tags only. A chip carrying a tag that exists solely
|
||||
on somebody's draft is both a leak and a dead end - filtering by it returns
|
||||
nothing a reader is allowed to see.
|
||||
"""
|
||||
counts = {}
|
||||
for (tags,) in db.session.query(Guide.tags).filter(Guide.tags.isnot(None)).all():
|
||||
if _has('guides.edit'):
|
||||
pairs = db.session.query(Guide.tags).filter(Guide.tags.isnot(None)).all()
|
||||
else:
|
||||
published = db.aliased(GuideRevision)
|
||||
pairs = db.session.query(published.tags).join(
|
||||
Guide, Guide.publishedrevisionid == published.guiderevisionid
|
||||
).filter(published.tags.isnot(None)).all()
|
||||
mine = _userid()
|
||||
pairs += db.session.query(Guide.tags).filter(
|
||||
Guide.createdby == mine, Guide.tags.isnot(None)).all()
|
||||
for (tags,) in pairs:
|
||||
for tag in [t.strip() for t in tags.split(',') if t.strip()]:
|
||||
counts[tag] = counts.get(tag, 0) + 1
|
||||
return success_response([
|
||||
@@ -229,6 +281,13 @@ def list_revisions(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
# History is editors' business. guides.view alone is permission to read what
|
||||
# was PUBLISHED; the revision list is every draft this guide has ever been,
|
||||
# including the ones deliberately not published yet.
|
||||
if not _mayedit(guide):
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'You do not have permission to see this history',
|
||||
http_code=403)
|
||||
rows = (GuideRevision.query.filter_by(guideid=guideid)
|
||||
.order_by(GuideRevision.revision.desc()).all())
|
||||
published = guide.publishedrevisionid
|
||||
@@ -242,10 +301,27 @@ def list_revisions(guideid):
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def read_revision(revisionid):
|
||||
"""One revision by id.
|
||||
|
||||
Gated on its PARENT, not on guides.view alone. Revision ids are sequential
|
||||
integers, so an ungated route here is an invitation to walk 1..n and read
|
||||
every unpublished draft on the site - which is the opposite of what
|
||||
the GUIDES wiki page promises about drafts.
|
||||
|
||||
A reader may fetch the published revision (it is what the slug route would
|
||||
serve anyway); everything else takes edit rights on that guide.
|
||||
"""
|
||||
revision = db.session.get(GuideRevision, revisionid)
|
||||
if revision is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Revision not found',
|
||||
http_code=404)
|
||||
guide = db.session.get(Guide, revision.guideid)
|
||||
ispublished = bool(guide is not None
|
||||
and guide.publishedrevisionid == revision.guiderevisionid)
|
||||
if not ispublished and not (guide is not None and _mayedit(guide)):
|
||||
# 404, not 403: a draft's existence is itself the thing being withheld.
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Revision not found',
|
||||
http_code=404)
|
||||
return success_response(revision.to_dict(includeblocks=True))
|
||||
|
||||
|
||||
@@ -311,7 +387,16 @@ def update_guide(guideid):
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
||||
|
||||
if 'title' in data:
|
||||
# The slug is set once, at create, and does NOT follow the title.
|
||||
#
|
||||
# _slugify's own docstring says the slug is stored so that renaming cannot
|
||||
# break a link somebody pasted into a work order months ago - and then this
|
||||
# re-slugged on every save that carried a title, which the editor's 3-second
|
||||
# autosave does on every keystroke. Fixing a typo in a heading silently
|
||||
# moved the guide's URL and broke exactly the links the stored slug exists
|
||||
# to protect. A guide with no slug (older row, or a failed create) still
|
||||
# gets one here.
|
||||
if not guide.slug:
|
||||
guide.slug = _slugify(guide.title, guide.guideid)
|
||||
|
||||
db.session.commit()
|
||||
@@ -426,6 +511,47 @@ def upload_image():
|
||||
return success_response(image.to_dict(), message='Image uploaded')
|
||||
|
||||
|
||||
def _mayseeimage(image):
|
||||
"""Whether the caller may fetch this image.
|
||||
|
||||
An image is content. One pasted into a published guide must be readable by
|
||||
anyone who can read that guide; one pasted into a draft must not be, or the
|
||||
draft leaks a screenshot at a time to anyone willing to count upwards
|
||||
through image ids.
|
||||
|
||||
So: editors of the parent see it, and everyone else sees it only if the
|
||||
PUBLISHED revision actually references it. An unattached image - pasted
|
||||
before the guide was first saved - belongs to whoever uploaded it.
|
||||
"""
|
||||
if image.guideid is None:
|
||||
return _has('guides.edit') or image.uploadedby == _userid()
|
||||
|
||||
guide = db.session.get(Guide, image.guideid)
|
||||
if guide is None:
|
||||
return _has('guides.edit')
|
||||
if _mayedit(guide):
|
||||
return True
|
||||
if not guide.publishedrevisionid:
|
||||
return False
|
||||
|
||||
revision = db.session.get(GuideRevision, guide.publishedrevisionid)
|
||||
if revision is None:
|
||||
return False
|
||||
try:
|
||||
blocks = json.loads(revision.blocksjson or '[]')
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
needle = '/guides/images/{}'.format(image.guideimageid)
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict) or block.get('type') != 'image':
|
||||
continue
|
||||
if str(block.get('imageid') or '') == str(image.guideimageid):
|
||||
return True
|
||||
if needle in str(block.get('url') or ''):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@guides_bp.route('/images/<int:imageid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
@@ -433,10 +559,15 @@ def serve_image(imageid):
|
||||
image = db.session.get(GuideImage, imageid)
|
||||
if image is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Image not found', http_code=404)
|
||||
return Response(image.data, mimetype=image.mimetype, headers={
|
||||
if not _mayseeimage(image):
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Image not found', http_code=404)
|
||||
# Same inert-document headers every other upload route serves under: an
|
||||
# uploaded file is attacker-influenced content and must not be able to run
|
||||
# as a document in this origin.
|
||||
return harden_upload_response(Response(image.data, mimetype=image.mimetype, headers={
|
||||
# Immutable: an image is never edited in place, only replaced by a new
|
||||
# upload with a new id, so it can be cached hard.
|
||||
'Cache-Control': 'private, max-age=31536000, immutable',
|
||||
'Content-Disposition': 'inline; filename="{}"'.format(
|
||||
image.filename or 'image{}'.format(imageid)),
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -72,8 +72,27 @@
|
||||
<pre :ref="el => registerCode(block.id, el)"><code>{{ block.text }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The image is fetched by script, not by the browser.
|
||||
|
||||
An <img src> is a plain browser request: it carries no Authorization
|
||||
header, and this app keeps its JWT in one. So a direct src against the
|
||||
authenticated image route is a 401 and a broken-image icon for every
|
||||
reader - which is exactly how this shipped. Fetching through the api
|
||||
client sends the token, and it resolves against the client's baseURL, so
|
||||
it also survives a subpath mount that a stored '/api/...' string does
|
||||
not.
|
||||
|
||||
A failure says so in words. A guide whose screenshot is missing must not
|
||||
look like a guide that never had one.
|
||||
-->
|
||||
<figure v-else-if="block.type === 'image'" class="gb-figure">
|
||||
<img :src="block.url" :alt="block.alt || ''" loading="lazy">
|
||||
<img v-if="imageState(block).src" :src="imageState(block).src"
|
||||
:alt="block.alt || ''">
|
||||
<p v-else-if="imageState(block).failed" class="gb-imagefailed">
|
||||
Image unavailable{{ block.alt ? ': ' + block.alt : '' }}
|
||||
</p>
|
||||
<p v-else class="gb-imageloading">Loading image...</p>
|
||||
<figcaption v-if="block.caption">{{ block.caption }}</figcaption>
|
||||
</figure>
|
||||
|
||||
@@ -97,8 +116,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, toRef } from 'vue'
|
||||
import { renderInline } from '../utils/inline.js'
|
||||
import { useGuideImages } from '../utils/images.js'
|
||||
|
||||
const props = defineProps({
|
||||
blocks: { type: Array, default: () => [] },
|
||||
@@ -112,6 +132,9 @@ const selected = ref('')
|
||||
const codeNodes = {}
|
||||
function registerCode (id, el) { if (el) codeNodes[id] = el }
|
||||
|
||||
// Images are fetched by script, not by <img src>; see utils/images.js.
|
||||
const { state: imageState } = useGuideImages(toRef(props, 'blocks'))
|
||||
|
||||
function inline (text) {
|
||||
return renderInline(text)
|
||||
}
|
||||
@@ -226,6 +249,12 @@ function select (block) {
|
||||
|
||||
.gb-figure { margin: 0 0 1.2rem; }
|
||||
.gb-figure img { max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: 4px; }
|
||||
.gb-imageloading, .gb-imagefailed {
|
||||
margin: 0; padding: 0.75rem 1rem; border: 1px dashed var(--border);
|
||||
border-radius: 4px; font-size: 0.9rem;
|
||||
}
|
||||
.gb-imageloading { color: var(--textmuted); }
|
||||
.gb-imagefailed { color: var(--danger, #b3261e); }
|
||||
.gb-figure figcaption { font-size: 0.8rem; color: var(--text-light); margin-top: 0.35rem; }
|
||||
|
||||
.gb-tablewrap { overflow-x: auto; margin: 0 0 1.2rem; }
|
||||
|
||||
77
plugins/guides/frontend/utils/images.js
Normal file
77
plugins/guides/frontend/utils/images.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Resolving guide images.
|
||||
*
|
||||
* A guide image lives behind an authenticated route, and an <img src> is a
|
||||
* plain browser request that carries no Authorization header - this app keeps
|
||||
* its JWT in one. Pointing src at the image route therefore yields 401 and a
|
||||
* broken-image icon for every reader, which is how guides first shipped.
|
||||
*
|
||||
* So the bytes are fetched through the api client, which attaches the token and
|
||||
* resolves against its own baseURL - the latter also fixing a stored
|
||||
* '/api/guides/images/N' string under a subpath mount, where the API is not at
|
||||
* /api at all.
|
||||
*
|
||||
* Used by the renderer AND the editor: an author needs to see the screenshot
|
||||
* they just pasted as much as a reader does.
|
||||
*/
|
||||
import { reactive, watch, onBeforeUnmount } from 'vue'
|
||||
import api from '@/api'
|
||||
|
||||
/** The image id a block refers to, whichever way it was written.
|
||||
*
|
||||
* Blocks store imageid (written since this was fixed) or a url string (written
|
||||
* before it). The url is never used as a src, only read for its id.
|
||||
*/
|
||||
export function imageIdOf (block) {
|
||||
if (!block) return ''
|
||||
if (block.imageid) return String(block.imageid)
|
||||
const match = /\/guides\/images\/(\d+)/.exec(block.url || '')
|
||||
return match ? match[1] : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps object URLs for the image blocks in `blocksRef`, fetching each id once
|
||||
* however many blocks use it, and revoking them as blocks go away.
|
||||
*
|
||||
* Returns `state(block)` -> { src, failed }.
|
||||
*/
|
||||
export function useGuideImages (blocksRef) {
|
||||
const images = reactive({})
|
||||
|
||||
async function load (id) {
|
||||
if (images[id]) return
|
||||
images[id] = { src: '', failed: false }
|
||||
try {
|
||||
const response = await api.get(`/guides/images/${id}`, { responseType: 'blob' })
|
||||
images[id].src = URL.createObjectURL(response.data)
|
||||
} catch (err) {
|
||||
images[id].failed = true
|
||||
}
|
||||
}
|
||||
|
||||
function release (id) {
|
||||
if (images[id] && images[id].src) URL.revokeObjectURL(images[id].src)
|
||||
delete images[id]
|
||||
}
|
||||
|
||||
function state (block) {
|
||||
const id = imageIdOf(block)
|
||||
if (!id) return { src: '', failed: true }
|
||||
return images[id] || { src: '', failed: false }
|
||||
}
|
||||
|
||||
// Editing replaces the block list on every keystroke, so resolve only ids
|
||||
// that are new and keep the ones already held. Without that, every keystroke
|
||||
// would refetch every image in the guide.
|
||||
watch(blocksRef, blocks => {
|
||||
const wanted = new Set(
|
||||
(blocks || []).filter(b => b && b.type === 'image')
|
||||
.map(imageIdOf).filter(Boolean))
|
||||
wanted.forEach(load)
|
||||
Object.keys(images).forEach(id => { if (!wanted.has(id)) release(id) })
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
onBeforeUnmount(() => { Object.keys(images).forEach(release) })
|
||||
|
||||
return { state }
|
||||
}
|
||||
@@ -27,12 +27,29 @@ function escapeHtml (text) {
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
// The text was HTML-escaped before any pattern ran, so a captured URL arrives
|
||||
// carrying entities: a real query string like ?a=1&b=2 reaches here as
|
||||
// ?a=1&b=2. Escaping that again for the attribute produced &amp; and a
|
||||
// link that opened the wrong record - the ServiceNow and SharePoint links that
|
||||
// carry several parameters were all broken this way. Decode first, then let the
|
||||
// single escape on output put back exactly one level.
|
||||
function decodeEntities (text) {
|
||||
return String(text)
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&') // last, so &lt; does not become <
|
||||
}
|
||||
|
||||
// Only http(s) and mailto. A javascript: or data: URL in a link is the one way
|
||||
// an author could otherwise smuggle script past the escaping above.
|
||||
function safeHref (url) {
|
||||
const trimmed = String(url || '').trim()
|
||||
if (/^(https?:|mailto:)/i.test(trimmed)) return trimmed
|
||||
if (trimmed.startsWith('/')) return trimmed // in-app link
|
||||
// In-app link. NOT '//host', which looks like a path and is read by the
|
||||
// browser as protocol-relative - an off-site link wearing a local costume.
|
||||
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) return trimmed
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -50,13 +67,18 @@ export function renderInline (text) {
|
||||
// [label](url). The URL pattern allows ONE level of balanced parentheses so
|
||||
// real links survive - Wikipedia-style /wiki/Foo_(bar) is the common case,
|
||||
// and a naive [^)]+ silently truncates it into a broken link.
|
||||
// Finished links are stashed like code spans, so the emphasis passes below
|
||||
// cannot reach inside an href. Without that, a URL containing *characters*
|
||||
// has <em> injected into the middle of the attribute and the link breaks.
|
||||
const links = []
|
||||
html = html.replace(/\[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g, (match, label, url) => {
|
||||
const href = safeHref(url)
|
||||
const href = safeHref(decodeEntities(url))
|
||||
if (!href) return label
|
||||
const external = /^https?:/i.test(href)
|
||||
return '<a href="' + escapeHtml(href) + '"' +
|
||||
links.push('<a href="' + escapeHtml(href) + '"' +
|
||||
(external ? ' target="_blank" rel="noopener noreferrer"' : '') +
|
||||
'>' + label + '</a>'
|
||||
'>' + label + '</a>')
|
||||
return '\u0000LINK' + (links.length - 1) + '\u0000'
|
||||
})
|
||||
|
||||
// **bold** then *italic*, longest marker first so **x** is not read as
|
||||
@@ -66,6 +88,8 @@ export function renderInline (text) {
|
||||
|
||||
html = html.replace(/\n/g, '<br>')
|
||||
|
||||
html = html.replace(/\u0000LINK(\d+)\u0000/g, (match, index) => links[Number(index)])
|
||||
|
||||
html = html.replace(/\u0000CODE(\d+)\u0000/g, (match, index) =>
|
||||
'<code>' + codeSpans[Number(index)] + '</code>')
|
||||
|
||||
|
||||
50
plugins/guides/frontend/utils/inline.spec.js
Normal file
50
plugins/guides/frontend/utils/inline.spec.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Inline rendering: the security model and the link bugs it hid.
|
||||
*
|
||||
* Escape-then-format is the whole defence, and it has one awkward consequence
|
||||
* that produced real broken links: by the time a URL is captured it is already
|
||||
* escaped, so it has to be decoded before it can be escaped again exactly once.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderInline } from './inline.js'
|
||||
|
||||
describe('renderInline', () => {
|
||||
it('escapes markup an author pasted', () => {
|
||||
expect(renderInline('<img src=x onerror=alert(1)>'))
|
||||
.toBe('<img src=x onerror=alert(1)>')
|
||||
})
|
||||
|
||||
it('keeps a multi-parameter URL intact', () => {
|
||||
// Double-escaped, this became &amp; and the link opened the wrong
|
||||
// record. Every ServiceNow and SharePoint link carries several parameters.
|
||||
const html = renderInline('[ticket](https://x.example/nav?a=1&b=2&c=3)')
|
||||
expect(html).toContain('href="https://x.example/nav?a=1&b=2&c=3"')
|
||||
expect(html).not.toContain('&amp;')
|
||||
})
|
||||
|
||||
it('does not let emphasis reach inside an href', () => {
|
||||
const html = renderInline('[t](https://x.example/a*b*c)')
|
||||
expect(html).toContain('href="https://x.example/a*b*c"')
|
||||
expect(html).not.toContain('<em>')
|
||||
})
|
||||
|
||||
it('refuses a protocol-relative URL dressed as an in-app path', () => {
|
||||
// '//evil.example' starts with '/' but the browser reads it as off-site.
|
||||
expect(renderInline('[evil](//evil.example/x)')).toBe('evil')
|
||||
})
|
||||
|
||||
it('refuses javascript: and keeps the label', () => {
|
||||
expect(renderInline('[bad](javascript:alert(1))')).toBe('bad')
|
||||
})
|
||||
|
||||
it('allows an in-app link', () => {
|
||||
expect(renderInline('[map](/map)')).toBe('<a href="/map">map</a>')
|
||||
})
|
||||
|
||||
it('still renders bold, italic and code', () => {
|
||||
expect(renderInline('**b**')).toBe('<strong>b</strong>')
|
||||
expect(renderInline('*i*')).toBe('<em>i</em>')
|
||||
expect(renderInline('`C:\\**temp**`'))
|
||||
.toBe('<code>C:\\**temp**</code>')
|
||||
})
|
||||
})
|
||||
@@ -142,7 +142,12 @@
|
||||
</div>
|
||||
|
||||
<div v-if="block.type === 'image'" class="ge-image">
|
||||
<img v-if="block.url" :src="block.url" alt="">
|
||||
<!-- Same authenticated fetch as the reader's view: an author must
|
||||
see the screenshot they just pasted. -->
|
||||
<img v-if="imageState(block).src" :src="imageState(block).src" alt="">
|
||||
<p v-else-if="imageState(block).failed" class="ge-imagefailed">
|
||||
Image unavailable - it may have been removed
|
||||
</p>
|
||||
<input v-model="block.caption" placeholder="Caption (optional)">
|
||||
<input v-model="block.alt" placeholder="Alt text, for anyone who cannot see it">
|
||||
</div>
|
||||
@@ -203,6 +208,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
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'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -382,7 +388,10 @@ async function insertImage (file) {
|
||||
status.value = 'Uploading image...'
|
||||
try {
|
||||
const image = await uploadImage(file)
|
||||
const block = { id: newid(), type: 'image', url: image.url, caption: '', alt: '' }
|
||||
// imageid is what the renderer resolves; url stays for older readers and
|
||||
// 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)
|
||||
focused.value = block.id
|
||||
status.value = ''
|
||||
@@ -413,15 +422,21 @@ function onFilePick (event) {
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
// Editor thumbnails resolve the same way the reader's do; see utils/images.js.
|
||||
const { state: imageState } = useGuideImages(blocks)
|
||||
|
||||
function payload () {
|
||||
return { title: guide.title, summary: guide.summary || '',
|
||||
tags: guide.tags || '', blocks: blocks.value }
|
||||
}
|
||||
|
||||
// Returns whether the save actually reached the server. publish() depends on
|
||||
// that answer: a publish that runs after a failed save promotes the LAST
|
||||
// revision the server accepted, which is not what is on the author's screen.
|
||||
async function save (quiet) {
|
||||
if (!guide.title.trim()) {
|
||||
if (!quiet) error.value = 'A guide needs a title before it can be saved'
|
||||
return
|
||||
return false
|
||||
}
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
@@ -439,9 +454,11 @@ async function save (quiet) {
|
||||
dirty.value = false
|
||||
status.value = 'Saved ' + new Date().toLocaleTimeString()
|
||||
await loadRevisions()
|
||||
return true
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not save this guide')
|
||||
status.value = 'Not saved'
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -476,7 +493,14 @@ async function publish () {
|
||||
+ 'anything flagged in a command. Publish anyway?')
|
||||
if (!ok) return
|
||||
}
|
||||
await save(true)
|
||||
// Publishing promotes the newest SAVED revision, so an unsaved edit is not
|
||||
// in what readers get. Reporting "Published version 4" over a failed save
|
||||
// tells the author their words went out when the old ones did.
|
||||
if (!await save(true)) {
|
||||
error.value = error.value
|
||||
|| 'Could not save this guide, so it was not published'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await api.post('/guides/' + guide.guideid + '/publish')
|
||||
guide.ispublished = true
|
||||
@@ -637,6 +661,8 @@ onMounted(async () => {
|
||||
background: transparent; color: var(--text-light); border-radius: 3px;
|
||||
cursor: pointer; justify-self: start;
|
||||
}
|
||||
.ge-imagefailed { margin: 0 0 0.35rem; padding: 0.5rem 0.7rem; font-size: 0.85rem;
|
||||
color: var(--danger, #b3261e); border: 1px dashed var(--border); border-radius: 4px; }
|
||||
.ge-image img { max-width: 18rem; display: block; margin-bottom: 0.35rem;
|
||||
border: 1px solid var(--border); border-radius: 4px; }
|
||||
.ge-image input { width: 100%; margin-bottom: 0.25rem; padding: 0.3rem 0.45rem;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<router-link :to="`/machines/${machine?.machine?.machineid}/edit`" class="btn btn-primary" v-if="machine">
|
||||
Edit
|
||||
</router-link>
|
||||
<button @click="confirmRetire" class="btn btn-danger" v-if="machine">Retire</button>
|
||||
<router-link to="/machines" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -248,7 +249,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { machinesApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||
@@ -258,8 +259,12 @@ import AssetRelationships from '@/components/AssetRelationships.vue'
|
||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -293,6 +298,23 @@ function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
// Retire, not delete: the server deactivates the asset, so the machine
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
async function confirmRetire() {
|
||||
const label = machine.value?.assetnumber || machine.value?.name || 'this machine'
|
||||
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 machinesApi.delete(machine.value?.machine?.machineid)
|
||||
toast.success(`Retired ${label}`)
|
||||
router.push('/machines')
|
||||
} catch (error) {
|
||||
console.error('Error retiring machine:', error)
|
||||
toast.error(apiError(error, 'Failed to retire machine'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<router-link :to="`/measuringtools/${tool?.measuringtool?.measuringtoolid}/edit`" class="btn btn-primary" v-if="tool">
|
||||
Edit
|
||||
</router-link>
|
||||
<button @click="confirmRetire" class="btn btn-danger" v-if="tool">Retire</button>
|
||||
<router-link to="/measuringtools" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,7 +166,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { measuringtoolsApi } from '@/api'
|
||||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
|
||||
@@ -173,8 +174,12 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
||||
import AssetRelationships from '@/components/AssetRelationships.vue'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
@@ -201,6 +206,23 @@ onMounted(async () => {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// Retire, not delete: the server deactivates the asset, so the measuring tool
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
async function confirmRetire() {
|
||||
const label = tool.value?.assetnumber || tool.value?.name || 'this measuring tool'
|
||||
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 measuringtoolsApi.remove(tool.value?.measuringtool?.measuringtoolid)
|
||||
toast.success(`Retired ${label}`)
|
||||
router.push('/measuringtools')
|
||||
} catch (error) {
|
||||
console.error('Error retiring measuring tool:', error)
|
||||
toast.error(apiError(error, 'Failed to retire measuring tool'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
The gage lab issues WJRP asset numbers for printed parts; the internal
|
||||
itemcode stays auto-generated, and this optional unique tag carries the
|
||||
lab's number. The kiosk resolves scans/typed digits against both.
|
||||
|
||||
Guarded/idempotent: skips when the table is absent (plugin disabled) or the
|
||||
column already exists (e.g. a test DB built by db.create_all() from the model).
|
||||
Unguarded, this failed `flask plugin upgrade-all` on any create-all-built
|
||||
database and took 0004 down with it, since a chain stops at its first error.
|
||||
|
||||
The index is matched by COLUMN SET, not by name: create_all names indexes by
|
||||
SQLAlchemy's convention and this migration by the repo's, so a name-keyed guard
|
||||
would add a second index over the same column rather than skip.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
@@ -15,13 +24,31 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _indexed(inspector):
|
||||
return any(tuple(ix.get('column_names') or ()) == ('gagelabtag',)
|
||||
for ix in inspector.get_indexes('printeditems'))
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('printeditems',
|
||||
sa.Column('gagelabtag', sa.String(length=50), nullable=True))
|
||||
op.create_index('ix_printeditems_gagelabtag', 'printeditems',
|
||||
['gagelabtag'], unique=True)
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if 'printeditems' not in inspector.get_table_names():
|
||||
return
|
||||
columns = {column['name'] for column in inspector.get_columns('printeditems')}
|
||||
if 'gagelabtag' not in columns:
|
||||
op.add_column('printeditems',
|
||||
sa.Column('gagelabtag', sa.String(length=50), nullable=True))
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not _indexed(inspector):
|
||||
op.create_index('ix_printeditems_gagelabtag', 'printeditems',
|
||||
['gagelabtag'], unique=True)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('ix_printeditems_gagelabtag', table_name='printeditems')
|
||||
op.drop_column('printeditems', 'gagelabtag')
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if 'printeditems' not in inspector.get_table_names():
|
||||
return
|
||||
if _indexed(inspector):
|
||||
op.drop_index('ix_printeditems_gagelabtag', table_name='printeditems')
|
||||
columns = {column['name'] for column in inspector.get_columns('printeditems')}
|
||||
if 'gagelabtag' in columns:
|
||||
op.drop_column('printeditems', 'gagelabtag')
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
Print Label
|
||||
</router-link>
|
||||
<router-link :to="`/printers/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
|
||||
<router-link to="/printers" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -296,7 +297,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { printersApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||
@@ -306,8 +307,12 @@ import AssetRelationships from '@/components/AssetRelationships.vue'
|
||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -386,6 +391,23 @@ function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
// Retire, not delete: the server deactivates the asset, so the printer
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
async function confirmRetire() {
|
||||
const label = displayTitle.value || 'this printer'
|
||||
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 printersApi.remove(route.params.id)
|
||||
toast.success(`Retired ${label}`)
|
||||
router.push('/printers')
|
||||
} catch (error) {
|
||||
console.error('Error retiring printer:', error)
|
||||
toast.error(apiError(error, 'Failed to retire printer'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user