Add personal API tokens; wire measuring tools into remaining surfaces
API tokens: any user mints named, optionally-expiring tokens (shopdb_pat_..., sha256-stored, secret shown once) at Settings > API Tokens; a before-request shim swaps a valid PAT for a request-scoped JWT of its owner, so the entire existing auth/authz/import-mode stack works unchanged and revoked/expired tokens 401 cleanly. Built for long-running scripts - the legacy import no longer dies when a login JWT expires. Migration 7d21_apitokens; create/revoke audit-logged. Audited integration gaps fixed: Asset.to_dict serializes measuring tools (typedata + pluginid - relationship links to tools resolve); map subtype filter/colors and MapEditor include them; dashboard totals count them; warranty links use a new by-asset route; the measuringtools ADR-010 hooks are real (corrected presentation token, implemented map-overlay endpoint); the login avatar resolves through the employee-photo helper. 737 tests pass; naming green; frontend builds; both features verified live end-to-end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -906,6 +906,24 @@ export const usersApi = {
|
||||
}
|
||||
}
|
||||
|
||||
// Personal API tokens: authenticate scripts/integrations as a user without
|
||||
// the hourly-expiring login JWT. The secret is returned ONCE, on create.
|
||||
export const apitokensApi = {
|
||||
// all=true (admin) lists everyone's tokens; otherwise just the caller's.
|
||||
list(params = {}) {
|
||||
return api.get('/apitokens', { params })
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/apitokens', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/apitokens/${id}`, data)
|
||||
},
|
||||
remove(id) {
|
||||
return api.delete(`/apitokens/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Network API (devices, subnets, and VLANs)
|
||||
export const networkApi = {
|
||||
// Network devices
|
||||
|
||||
@@ -132,7 +132,9 @@ const assetTypeColorsMap = {
|
||||
'computer': '#2196F3', // Blue
|
||||
'printer': '#4CAF50', // Green
|
||||
'network device': '#FF9800', // Orange
|
||||
'network_device': '#FF9800' // Orange (alternate key)
|
||||
'network_device': '#FF9800', // Orange (alternate key)
|
||||
'measuring_tool': '#9C27B0', // Purple
|
||||
'measuring tool': '#9C27B0' // Purple (normalized key)
|
||||
}
|
||||
|
||||
// Get asset type color with case-insensitive lookup
|
||||
@@ -229,6 +231,7 @@ function getSubtypeId(asset) {
|
||||
if (typeLower === 'computer') return asset.typedata.computertypeid
|
||||
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
|
||||
if (typeLower === 'printer') return asset.typedata.printertypeid
|
||||
if (typeLower === 'measuring tool') return asset.typedata.measuringtooltypeid
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -240,6 +240,12 @@ export default [
|
||||
component: () => import('../../views/settings/AuthenticationSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/apitokens',
|
||||
name: 'apitokens-settings',
|
||||
component: () => import('../../views/settings/ApiTokensList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/assetidentifiers',
|
||||
name: 'asset-identifiers-settings',
|
||||
|
||||
@@ -19,6 +19,14 @@ export default [
|
||||
component: () => import('../../views/measuringtools/MeasuringToolForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'measuringtools' }
|
||||
},
|
||||
{
|
||||
// Resolve a tool from its core asset id (search rows / cross-links carry
|
||||
// assetid, not the extension id). Shares the detail component.
|
||||
path: 'measuringtools/by-asset/:assetid',
|
||||
name: 'measuringtool-by-asset',
|
||||
component: () => import('../../views/measuringtools/MeasuringToolDetail.vue'),
|
||||
meta: { plugin: 'measuringtools' }
|
||||
},
|
||||
{
|
||||
path: 'measuringtools/:id',
|
||||
name: 'measuringtool-detail',
|
||||
|
||||
@@ -15,9 +15,10 @@ export const useAuthStore = defineStore('auth', {
|
||||
isAdmin: (state) => state.user?.roles?.includes('admin') || false,
|
||||
// Full name from the employee directory (falls back to username/SSO).
|
||||
displayName: (state) => state.user?.directoryname || state.user?.username || '',
|
||||
// Employee photo URL if the directory has one for this SSO.
|
||||
avatarUrl: (state) => state.user?.directorypicture
|
||||
? `/static/employees/${state.user.directorypicture}` : null
|
||||
// Employee photo URL if the directory has one for this SSO. The directory
|
||||
// resolver already returns a usable URL (self-hosted upload or external HR
|
||||
// path), so it is used as-is.
|
||||
avatarUrl: (state) => state.user?.directoryphotourl || null
|
||||
},
|
||||
|
||||
actions: {
|
||||
@@ -77,7 +78,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
const emp = response.data?.data
|
||||
if (emp) {
|
||||
this.user.directoryname = `${emp.First_Name || ''} ${emp.Last_Name || ''}`.trim() || null
|
||||
this.user.directorypicture = emp.Picture || null
|
||||
this.user.directoryphotourl = emp.photourl || null
|
||||
}
|
||||
} catch (err) { /* directory unavailable - fall back to username */ }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ export const assetTypeColorsMap = {
|
||||
computer: '#2196F3', // Blue
|
||||
printer: '#4CAF50', // Green
|
||||
'network device': '#FF9800', // Orange
|
||||
network_device: '#FF9800' // Orange (alternate key)
|
||||
network_device: '#FF9800', // Orange (alternate key)
|
||||
measuring_tool: '#9C27B0', // Purple
|
||||
'measuring tool': '#9C27B0' // Purple (normalized key)
|
||||
}
|
||||
|
||||
const DEFAULT_COLOR = '#BDBDBD'
|
||||
@@ -37,6 +39,7 @@ export function getSubtypeId(asset) {
|
||||
if (typeLower === 'computer') return asset.typedata.computertypeid
|
||||
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
|
||||
if (typeLower === 'printer') return asset.typedata.printertypeid
|
||||
if (typeLower === 'measuring tool') return asset.typedata.measuringtooltypeid
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<option value="computer">Computers</option>
|
||||
<option value="printer">Printers</option>
|
||||
<option value="network_device">Network Devices</option>
|
||||
<option value="measuring_tool">Measuring Tools</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -105,7 +106,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Cog, Monitor, Printer, Globe, Package, MapPin } from 'lucide-vue-next'
|
||||
import { Cog, Monitor, Printer, Globe, Ruler, Package, MapPin } from 'lucide-vue-next'
|
||||
import ShopFloorMap from '../components/ShopFloorMap.vue'
|
||||
import { assetsApi } from '../api'
|
||||
import { currentTheme } from '../stores/theme'
|
||||
@@ -158,7 +159,8 @@ function getTypeIcon(assettype) {
|
||||
'machine': Cog,
|
||||
'computer': Monitor,
|
||||
'printer': Printer,
|
||||
'network_device': Globe
|
||||
'network_device': Globe,
|
||||
'measuring_tool': Ruler
|
||||
}
|
||||
return icons[assettype] || Package
|
||||
}
|
||||
|
||||
@@ -15,9 +15,6 @@
|
||||
<template v-else-if="tool">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="tool.measuringtool?.imageurl">
|
||||
<img :src="tool.measuringtool.imageurl" alt="Model photo" />
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ tool.assetnumber }}</h1>
|
||||
@@ -188,7 +185,11 @@ function formatDateTime(d) { if (!d) return '-'; return new Date(d).toLocaleStri
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await measuringtoolsApi.get(route.params.id)
|
||||
// by-asset route resolves the tool from a core asset id (search/cross-link
|
||||
// rows carry assetid, not the extension id); detail route keys on the id.
|
||||
const response = route.params.assetid
|
||||
? await measuringtoolsApi.getByAsset(route.params.assetid)
|
||||
: await measuringtoolsApi.get(route.params.id)
|
||||
tool.value = response.data.data
|
||||
} catch (err) {
|
||||
console.error('Error loading measuring tool:', err)
|
||||
|
||||
@@ -70,7 +70,7 @@ const bucketOrder = [
|
||||
function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
|
||||
function cardStyle(color) { return { borderTop: `3px solid ${color}` } }
|
||||
function assetLink(a) {
|
||||
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', machine: '/machines/' }
|
||||
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', machine: '/machines/', measuring_tool: '/measuringtools/by-asset/' }
|
||||
return (map[a.assettypename] || '/assets/') + a.assetid
|
||||
}
|
||||
|
||||
|
||||
307
frontend/src/views/settings/ApiTokensList.vue
Normal file
307
frontend/src/views/settings/ApiTokensList.vue
Normal file
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>API Tokens</h2>
|
||||
<button class="btn btn-primary" @click="openCreate()">+ New Token</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="tokens-intro">
|
||||
Personal access tokens let scripts and integrations authenticate as you
|
||||
without an hourly-expiring login session. Send the token as
|
||||
<code>Authorization: Bearer shopdb_pat_...</code>. Ideal for long-running
|
||||
imports that would otherwise die when the login JWT expires.
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Token</th>
|
||||
<th>Created</th>
|
||||
<th>Expires</th>
|
||||
<th>Last Used</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="token in myTokens" :key="token.tokenid">
|
||||
<td>{{ token.name }}</td>
|
||||
<td><code>{{ token.displayprefix }}...</code></td>
|
||||
<td>{{ formatDate(token.createddate) }}</td>
|
||||
<td>{{ token.expiresat ? formatDate(token.expiresat) : 'Never' }}</td>
|
||||
<td>{{ token.lastusedat ? formatDate(token.lastusedat) : 'Never' }}</td>
|
||||
<td>
|
||||
<span v-if="!token.isactive" class="badge badge-danger">Revoked</span>
|
||||
<span v-else-if="token.isexpired" class="badge badge-warning">Expired</span>
|
||||
<span v-else class="badge badge-success">Active</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button v-if="token.isactive" class="btn btn-danger btn-sm"
|
||||
@click="confirmRevoke(token)">Revoke</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="myTokens.length === 0">
|
||||
<td colspan="7" style="text-align: center; color: var(--text-light);">
|
||||
No tokens yet
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Admin: all tokens across every user -->
|
||||
<div v-if="isAdmin" class="card admin-tokens">
|
||||
<h3 class="section-subtitle">All Tokens (admin)</h3>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Owner</th>
|
||||
<th>Name</th>
|
||||
<th>Token</th>
|
||||
<th>Expires</th>
|
||||
<th>Last Used</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="token in allTokens" :key="token.tokenid">
|
||||
<td>{{ token.username || '-' }}</td>
|
||||
<td>{{ token.name }}</td>
|
||||
<td><code>{{ token.displayprefix }}...</code></td>
|
||||
<td>{{ token.expiresat ? formatDate(token.expiresat) : 'Never' }}</td>
|
||||
<td>{{ token.lastusedat ? formatDate(token.lastusedat) : 'Never' }}</td>
|
||||
<td>
|
||||
<span v-if="!token.isactive" class="badge badge-danger">Revoked</span>
|
||||
<span v-else-if="token.isexpired" class="badge badge-warning">Expired</span>
|
||||
<span v-else class="badge badge-success">Active</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button v-if="token.isactive" class="btn btn-danger btn-sm"
|
||||
@click="confirmRevoke(token)">Revoke</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="allTokens.length === 0">
|
||||
<td colspan="7" style="text-align: center; color: var(--text-light);">
|
||||
No tokens
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create modal -->
|
||||
<div v-if="showCreate" class="modal-overlay" @click.self="closeCreate">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>New API Token</h3></div>
|
||||
<form @submit.prevent="createToken">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="tokenname">Name *</label>
|
||||
<input id="tokenname" v-model="form.name" type="text" class="form-control"
|
||||
placeholder="e.g. legacy import runner" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tokenexpiry">Expiry (optional)</label>
|
||||
<input id="tokenexpiry" v-model="form.expiresat" type="date" class="form-control" />
|
||||
<small class="form-hint">Leave blank for a token that never expires.</small>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeCreate">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Creating...' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Secret reveal modal (shown once) -->
|
||||
<div v-if="newSecret" class="modal-overlay" @click.self="dismissSecret">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>Copy your new token</h3></div>
|
||||
<div class="modal-body">
|
||||
<p class="secret-warning">
|
||||
This is the only time the token is shown. Copy it now and store it
|
||||
somewhere safe. You will not be able to see it again.
|
||||
</p>
|
||||
<div class="secret-box">
|
||||
<code class="secret-value">{{ newSecret }}</code>
|
||||
<button class="btn btn-secondary btn-sm" @click="copySecret">
|
||||
{{ copied ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary" @click="dismissSecret">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Revoke confirm -->
|
||||
<div v-if="toRevoke" class="modal-overlay" @click.self="toRevoke = null">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>Revoke Token</h3></div>
|
||||
<div class="modal-body">
|
||||
<p>Revoke <strong>{{ toRevoke.name }}</strong>? Any script using it will
|
||||
immediately lose access.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="toRevoke = null">Cancel</button>
|
||||
<button class="btn btn-danger" @click="revokeToken">Revoke</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { apitokensApi } from '../../api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const isAdmin = computed(() => auth.isAdmin)
|
||||
|
||||
const myTokens = ref([])
|
||||
const allTokens = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const showCreate = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({ name: '', expiresat: '' })
|
||||
|
||||
const newSecret = ref('')
|
||||
const copied = ref(false)
|
||||
const toRevoke = ref(null)
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await apitokensApi.list()
|
||||
myTokens.value = response.data.data || []
|
||||
if (isAdmin.value) {
|
||||
const all = await apitokensApi.list({ all: true })
|
||||
allTokens.value = all.data.data || []
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading API tokens:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleDateString()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
form.value = { name: '', expiresat: '' }
|
||||
error.value = ''
|
||||
showCreate.value = true
|
||||
}
|
||||
|
||||
function closeCreate() { showCreate.value = false }
|
||||
|
||||
async function createToken() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { name: form.value.name }
|
||||
if (form.value.expiresat) payload.expiresat = form.value.expiresat
|
||||
const response = await apitokensApi.create(payload)
|
||||
showCreate.value = false
|
||||
newSecret.value = response.data.data.secret
|
||||
copied.value = false
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to create token')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copySecret() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(newSecret.value)
|
||||
copied.value = true
|
||||
} catch {
|
||||
toast.error('Copy failed. Select the token and copy manually.')
|
||||
}
|
||||
}
|
||||
|
||||
function dismissSecret() { newSecret.value = ''; copied.value = false }
|
||||
|
||||
function confirmRevoke(token) { toRevoke.value = token }
|
||||
|
||||
async function revokeToken() {
|
||||
try {
|
||||
await apitokensApi.remove(toRevoke.value.tokenid)
|
||||
toRevoke.value = null
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error('Failed to revoke token')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tokens-intro {
|
||||
color: var(--text-light);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.tokens-intro code {
|
||||
background: var(--bg);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.admin-tokens {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.section-subtitle {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.form-hint {
|
||||
display: block;
|
||||
color: var(--text-light);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.secret-warning {
|
||||
color: var(--danger);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.secret-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.secret-value {
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@
|
||||
// Order: site identity + the reference-data catalogs users touch daily come
|
||||
// first, then the platform/system groups (integrations, communication, search,
|
||||
// plugins, access) cluster together at the end.
|
||||
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search } from 'lucide-vue-next'
|
||||
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key } from 'lucide-vue-next'
|
||||
|
||||
export const settingsGroups = [
|
||||
{
|
||||
@@ -110,6 +110,7 @@ export const settingsGroups = [
|
||||
title: 'Access & Security',
|
||||
cards: [
|
||||
{ to: '/settings/authentication', icon: KeyRound, title: 'Authentication', description: 'SAML single sign-on (SSO) with your IdP, local login, and auto-create users' },
|
||||
{ to: '/settings/apitokens', icon: Key, title: 'API Tokens', description: 'Personal access tokens for scripts and integrations (e.g. long-running imports) that outlive login sessions' },
|
||||
{ to: '/settings/users', icon: Users, title: 'Users & Roles', description: 'Manage user accounts and permissions' },
|
||||
{ to: '/settings/audit', icon: History, title: 'Audit & Logging', description: 'Audit log retention period and history purge policy' },
|
||||
{ to: '/settings/auditlogs', icon: FileText, title: 'Audit Logs', description: 'View system activity and change history' },
|
||||
|
||||
@@ -208,7 +208,7 @@ function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
|
||||
|
||||
// Route to the right detail page by asset type.
|
||||
function assetLink(a) {
|
||||
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', machine: '/machines/' }
|
||||
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', machine: '/machines/', measuring_tool: '/measuringtools/by-asset/' }
|
||||
const base = map[a.assettypename] || '/assets/'
|
||||
return base + a.assetid
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user