Add optional permission scopes to API tokens
A token may carry a scopes list: it then grants only those permissions, intersected with what the owner holds at use time, with the admin role bypass suspended and role-gated routes denied - a scoped token from an admin account is genuinely limited. Scope ceiling enforced at create/update too (only permissions the owner holds; 400 lists violations) and the picker only offers what you hold. Token management itself now requires the new apitokens.create permission (admin by default, grantable via roles). Unscoped tokens keep the exact prior act-as-owner behavior; imports need an unscoped admin token. Migration 7d22. 756 tests pass; live-verified scoped 201/403 matrix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,9 @@
|
||||
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.
|
||||
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>
|
||||
@@ -22,6 +24,7 @@
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Token</th>
|
||||
<th>Access</th>
|
||||
<th>Created</th>
|
||||
<th>Expires</th>
|
||||
<th>Last Used</th>
|
||||
@@ -33,6 +36,7 @@
|
||||
<tr v-for="token in myTokens" :key="token.tokenid">
|
||||
<td>{{ token.name }}</td>
|
||||
<td><code>{{ token.displayprefix }}...</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>
|
||||
@@ -42,12 +46,14 @@
|
||||
<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="7" style="text-align: center; color: var(--text-light);">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
||||
No tokens yet
|
||||
</td>
|
||||
</tr>
|
||||
@@ -67,6 +73,7 @@
|
||||
<th>Owner</th>
|
||||
<th>Name</th>
|
||||
<th>Token</th>
|
||||
<th>Access</th>
|
||||
<th>Expires</th>
|
||||
<th>Last Used</th>
|
||||
<th>Status</th>
|
||||
@@ -78,6 +85,7 @@
|
||||
<td>{{ token.username || '-' }}</td>
|
||||
<td>{{ token.name }}</td>
|
||||
<td><code>{{ token.displayprefix }}...</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>
|
||||
@@ -91,7 +99,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="allTokens.length === 0">
|
||||
<td colspan="7" style="text-align: center; color: var(--text-light);">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
||||
No tokens
|
||||
</td>
|
||||
</tr>
|
||||
@@ -102,7 +110,7 @@
|
||||
|
||||
<!-- Create modal -->
|
||||
<div v-if="showCreate" class="modal-overlay" @click.self="closeCreate">
|
||||
<div class="modal">
|
||||
<div class="modal modal-lg">
|
||||
<div class="modal-header"><h3>New API Token</h3></div>
|
||||
<form @submit.prevent="createToken">
|
||||
<div class="modal-body">
|
||||
@@ -116,6 +124,43 @@
|
||||
<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">
|
||||
@@ -128,6 +173,57 @@
|
||||
</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">
|
||||
@@ -169,7 +265,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { apitokensApi } from '../../api'
|
||||
import { apitokensApi, usersApi, authApi } from '../../api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
@@ -183,22 +279,48 @@ 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: '' })
|
||||
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 response = await apitokensApi.list()
|
||||
myTokens.value = response.data.data || []
|
||||
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 || []
|
||||
@@ -215,8 +337,64 @@ function formatDate(value) {
|
||||
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: '' }
|
||||
form.value = { name: '', expiresat: '', restrict: false, scopes: [] }
|
||||
error.value = ''
|
||||
showCreate.value = true
|
||||
}
|
||||
@@ -225,10 +403,15 @@ 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
|
||||
@@ -241,6 +424,36 @@ async function createToken() {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -304,4 +517,35 @@ async function revokeToken() {
|
||||
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