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>
308 lines
9.5 KiB
Vue
308 lines
9.5 KiB
Vue
<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>
|