Initial import: shopdb-flask multi-site asset platform
Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin framework so sister GE Aerospace sites can adopt it. Product version 0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests. Key entry points: - docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook (own-site and subpath deployment methods) - docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify - docs/IMPORT-API.md - schema-agnostic legacy data import contract - scripts/site_imports/wjf/ - West Jefferson reference import loader - docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
551
frontend/src/views/settings/ApiTokensList.vue
Normal file
551
frontend/src/views/settings/ApiTokensList.vue
Normal file
@@ -0,0 +1,551 @@
|
||||
<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. A token may
|
||||
be restricted to a subset of your permissions; a restricted token cannot
|
||||
reach admin-only (role-gated) endpoints or import mode.
|
||||
</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>Access</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 :title="token.displayprefix + '...'">{{ token.tokenprefix }}...</code></td>
|
||||
<td><span class="badge" :class="scopeBadgeClass(token)">{{ scopeSummary(token) }}</span></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-secondary btn-sm"
|
||||
@click="openEdit(token)">Edit</button>
|
||||
<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="8" 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>Access</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 :title="token.displayprefix + '...'">{{ token.tokenprefix }}...</code></td>
|
||||
<td><span class="badge" :class="scopeBadgeClass(token)">{{ scopeSummary(token) }}</span></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="8" 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 modal-lg">
|
||||
<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 class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="form.restrict" />
|
||||
Restrict permissions
|
||||
</label>
|
||||
<small class="form-hint">
|
||||
Default is full access (the token acts as you). Restrict to grant
|
||||
only the checked permissions. A restricted token cannot use
|
||||
admin-only endpoints or import mode.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div v-if="form.restrict" class="form-group">
|
||||
<label>Allowed permissions</label>
|
||||
<div class="permissions-grid">
|
||||
<div v-for="(perms, category) in availableGrouped" :key="category"
|
||||
class="permission-category">
|
||||
<div class="category-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox"
|
||||
:checked="isCategoryFullySelected(category)"
|
||||
:indeterminate.prop="isCategoryPartiallySelected(category)"
|
||||
@change="toggleCategory(category, $event.target.checked)" />
|
||||
<strong>{{ formatCategory(category) }}</strong>
|
||||
</label>
|
||||
</div>
|
||||
<div class="category-perms">
|
||||
<label v-for="p in perms" :key="p.name" class="checkbox-label perm-item">
|
||||
<input type="checkbox" :value="p.name" v-model="form.scopes" />
|
||||
{{ p.description }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- Edit modal (scopes) -->
|
||||
<div v-if="editing" class="modal-overlay" @click.self="closeEdit">
|
||||
<div class="modal modal-lg">
|
||||
<div class="modal-header"><h3>Edit token access</h3></div>
|
||||
<form @submit.prevent="saveEdit">
|
||||
<div class="modal-body">
|
||||
<p class="tokens-intro">Editing <strong>{{ editing.name }}</strong>.</p>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="editForm.restrict" />
|
||||
Restrict permissions
|
||||
</label>
|
||||
<small class="form-hint">
|
||||
Uncheck for full access (acts as the owner).
|
||||
</small>
|
||||
</div>
|
||||
<div v-if="editForm.restrict" class="form-group">
|
||||
<label>Allowed permissions</label>
|
||||
<div class="permissions-grid">
|
||||
<div v-for="(perms, category) in availableGrouped" :key="category"
|
||||
class="permission-category">
|
||||
<div class="category-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox"
|
||||
:checked="isEditCategoryFullySelected(category)"
|
||||
:indeterminate.prop="isEditCategoryPartiallySelected(category)"
|
||||
@change="toggleEditCategory(category, $event.target.checked)" />
|
||||
<strong>{{ formatCategory(category) }}</strong>
|
||||
</label>
|
||||
</div>
|
||||
<div class="category-perms">
|
||||
<label v-for="p in perms" :key="p.name" class="checkbox-label perm-item">
|
||||
<input type="checkbox" :value="p.name" v-model="editForm.scopes" />
|
||||
{{ p.description }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="editError" class="error-message">{{ editError }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeEdit">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</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, usersApi, authApi } 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)
|
||||
|
||||
// Permission catalog grouped by category, plus the permissions the current
|
||||
// user actually holds (the create/edit grid never offers more than these).
|
||||
const permissionsGrouped = ref({})
|
||||
const myPermissions = ref([])
|
||||
|
||||
const showCreate = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({ name: '', expiresat: '', restrict: false, scopes: [] })
|
||||
|
||||
const editing = ref(null)
|
||||
const editError = ref('')
|
||||
const editForm = ref({ restrict: false, scopes: [] })
|
||||
|
||||
const newSecret = ref('')
|
||||
const copied = ref(false)
|
||||
const toRevoke = ref(null)
|
||||
|
||||
// Grid limited to permissions the current user holds. Admins see everything.
|
||||
const availableGrouped = computed(() => {
|
||||
const held = new Set(myPermissions.value)
|
||||
const result = {}
|
||||
for (const [category, perms] of Object.entries(permissionsGrouped.value)) {
|
||||
const usable = isAdmin.value ? perms : perms.filter(p => held.has(p.name))
|
||||
if (usable.length) result[category] = usable
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [tokensRes, permsRes, meRes] = await Promise.all([
|
||||
apitokensApi.list(),
|
||||
usersApi.permissions.list(),
|
||||
authApi.me()
|
||||
])
|
||||
myTokens.value = tokensRes.data.data || []
|
||||
permissionsGrouped.value = permsRes.data.data.grouped || {}
|
||||
myPermissions.value = meRes.data.data.permissions || []
|
||||
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 formatCategory(category) {
|
||||
return category.charAt(0).toUpperCase() + category.slice(1)
|
||||
}
|
||||
|
||||
function scopeSummary(token) {
|
||||
if (!token.scopes) return 'Full access'
|
||||
const count = token.scopes.length
|
||||
return count === 1 ? '1 permission' : `${count} permissions`
|
||||
}
|
||||
|
||||
function scopeBadgeClass(token) {
|
||||
return token.scopes ? 'badge-warning' : 'badge-success'
|
||||
}
|
||||
|
||||
// --- Create grid helpers ---
|
||||
function isCategoryFullySelected(category) {
|
||||
const perms = availableGrouped.value[category] || []
|
||||
return perms.length > 0 && perms.every(p => form.value.scopes.includes(p.name))
|
||||
}
|
||||
function isCategoryPartiallySelected(category) {
|
||||
const perms = availableGrouped.value[category] || []
|
||||
const selected = perms.filter(p => form.value.scopes.includes(p.name))
|
||||
return selected.length > 0 && selected.length < perms.length
|
||||
}
|
||||
function toggleCategory(category, checked) {
|
||||
const names = (availableGrouped.value[category] || []).map(p => p.name)
|
||||
if (checked) {
|
||||
for (const name of names) {
|
||||
if (!form.value.scopes.includes(name)) form.value.scopes.push(name)
|
||||
}
|
||||
} else {
|
||||
form.value.scopes = form.value.scopes.filter(n => !names.includes(n))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Edit grid helpers ---
|
||||
function isEditCategoryFullySelected(category) {
|
||||
const perms = availableGrouped.value[category] || []
|
||||
return perms.length > 0 && perms.every(p => editForm.value.scopes.includes(p.name))
|
||||
}
|
||||
function isEditCategoryPartiallySelected(category) {
|
||||
const perms = availableGrouped.value[category] || []
|
||||
const selected = perms.filter(p => editForm.value.scopes.includes(p.name))
|
||||
return selected.length > 0 && selected.length < perms.length
|
||||
}
|
||||
function toggleEditCategory(category, checked) {
|
||||
const names = (availableGrouped.value[category] || []).map(p => p.name)
|
||||
if (checked) {
|
||||
for (const name of names) {
|
||||
if (!editForm.value.scopes.includes(name)) editForm.value.scopes.push(name)
|
||||
}
|
||||
} else {
|
||||
editForm.value.scopes = editForm.value.scopes.filter(n => !names.includes(n))
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
form.value = { name: '', expiresat: '', restrict: false, scopes: [] }
|
||||
error.value = ''
|
||||
showCreate.value = true
|
||||
}
|
||||
|
||||
function closeCreate() { showCreate.value = false }
|
||||
|
||||
async function createToken() {
|
||||
error.value = ''
|
||||
if (form.value.restrict && form.value.scopes.length === 0) {
|
||||
error.value = 'Select at least one permission, or turn off Restrict permissions.'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { name: form.value.name }
|
||||
if (form.value.expiresat) payload.expiresat = form.value.expiresat
|
||||
if (form.value.restrict) payload.scopes = form.value.scopes
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(token) {
|
||||
editing.value = token
|
||||
editError.value = ''
|
||||
editForm.value = {
|
||||
restrict: !!token.scopes,
|
||||
scopes: token.scopes ? [...token.scopes] : []
|
||||
}
|
||||
}
|
||||
|
||||
function closeEdit() { editing.value = null }
|
||||
|
||||
async function saveEdit() {
|
||||
editError.value = ''
|
||||
if (editForm.value.restrict && editForm.value.scopes.length === 0) {
|
||||
editError.value = 'Select at least one permission, or turn off Restrict permissions.'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { scopes: editForm.value.restrict ? editForm.value.scopes : null }
|
||||
await apitokensApi.update(editing.value.tokenid, payload)
|
||||
editing.value = null
|
||||
loadData()
|
||||
} catch (err) {
|
||||
editError.value = apiError(err, 'Failed to update 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;
|
||||
}
|
||||
.permissions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.permission-category {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.category-header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.category-perms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.perm-item {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user