feat: add management token permissions

This commit is contained in:
Entropy.Xu
2026-05-07 23:48:29 +08:00
parent 6f620d92be
commit cc5cb3475e
34 changed files with 2212 additions and 100 deletions

View File

@@ -13,6 +13,9 @@ export interface ManagementToken {
description?: string
token_display: string
allowed_ips?: string[] | null
permissions?: string[] | null
permission_mode?: 'legacy_full' | 'full' | 'read_only' | 'custom'
permission_summary?: string
expires_at?: string | null
last_used_at?: string | null
last_used_ip?: string | null
@@ -32,6 +35,7 @@ export interface CreateManagementTokenRequest {
name: string
description?: string
allowed_ips?: string[]
permissions?: string[] | null
expires_at?: string | null
}
@@ -45,10 +49,25 @@ export interface UpdateManagementTokenRequest {
name?: string
description?: string | null
allowed_ips?: string[] | null
permissions?: string[] | null
expires_at?: string | null
is_active?: boolean
}
export interface ManagementTokenPermissionCatalogItem {
key: string
scope: string
scope_label: string
access: 'read' | 'write' | 'admin'
access_label: string
}
export interface ManagementTokenPermissionCatalogResponse {
items: ManagementTokenPermissionCatalogItem[]
all_permissions: string[]
read_only_permissions: string[]
}
export interface ManagementTokenListResponse {
items: ManagementToken[]
total: number
@@ -179,6 +198,43 @@ export const adminManagementTokenApi = {
return response.data
},
/**
* 获取可分配权限目录(管理员)
*/
async getPermissionCatalog(): Promise<ManagementTokenPermissionCatalogResponse> {
const response = await apiClient.get<ManagementTokenPermissionCatalogResponse>(
'/api/admin/management-tokens/permissions/catalog'
)
return response.data
},
/**
* 创建 Management Token管理员
*/
async createToken(
data: CreateManagementTokenRequest
): Promise<CreateManagementTokenResponse> {
const response = await apiClient.post<CreateManagementTokenResponse>(
'/api/admin/management-tokens',
data
)
return response.data
},
/**
* 更新 Management Token管理员
*/
async updateToken(
tokenId: string,
data: UpdateManagementTokenRequest
): Promise<{ message: string; data: ManagementToken }> {
const response = await apiClient.put<{ message: string; data: ManagementToken }>(
`/api/admin/management-tokens/${tokenId}`,
data
)
return response.data
},
/**
* 删除任意 Token管理员
*/
@@ -199,5 +255,17 @@ export const adminManagementTokenApi = {
`/api/admin/management-tokens/${tokenId}/status`
)
return response.data
},
/**
* 重新生成任意 Token管理员
*/
async regenerateToken(
tokenId: string
): Promise<{ token: string; data: ManagementToken }> {
const response = await apiClient.post<{ token: string; data: ManagementToken }>(
`/api/admin/management-tokens/${tokenId}/regenerate`
)
return response.data
}
}

View File

@@ -10,7 +10,7 @@
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div>
<h3 class="text-sm sm:text-base font-semibold">
访问令牌
{{ isAdminPage ? '管理令牌' : '访问令牌' }}
</h3>
<p class="text-xs text-muted-foreground mt-0.5">
<template v-if="quota">
@@ -20,9 +20,12 @@
class="text-destructive font-medium"
>已达上限</span>
</template>
<template v-else>
<template v-else-if="canManageTokens">
用于程序化访问管理 API 的令牌
</template>
<template v-else>
仅管理员可以创建和管理此类令牌
</template>
</p>
</div>
@@ -30,12 +33,13 @@
<div class="flex items-center gap-2">
<!-- 新增按钮 -->
<Button
v-if="canManageTokens"
variant="ghost"
size="icon"
class="h-8 w-8"
title="创建新令牌"
:disabled="quota ? quota.used >= quota.max : false"
@click="showCreateDialog = true"
@click="openCreateDialog"
>
<Plus class="w-3.5 h-3.5" />
</Button>
@@ -69,9 +73,10 @@
>
<template #actions>
<Button
v-if="canManageTokens"
size="lg"
class="shadow-lg shadow-primary/20"
@click="showCreateDialog = true"
@click="openCreateDialog"
>
<Plus class="mr-2 h-4 w-4" />
创建访问令牌
@@ -94,6 +99,18 @@
<TableHead class="min-w-[160px] h-12 font-semibold">
令牌
</TableHead>
<TableHead
v-if="isAdminPage"
class="min-w-[160px] h-12 font-semibold"
>
所属用户
</TableHead>
<TableHead class="min-w-[150px] h-12 font-semibold">
权限
</TableHead>
<TableHead class="min-w-[150px] h-12 font-semibold">
IP 限制
</TableHead>
<TableHead class="min-w-[80px] h-12 font-semibold text-center">
使用次数
</TableHead>
@@ -103,7 +120,10 @@
<TableHead class="min-w-[100px] h-12 font-semibold">
时间
</TableHead>
<TableHead class="min-w-[100px] h-12 font-semibold text-center">
<TableHead
v-if="canManageTokens"
class="min-w-[100px] h-12 font-semibold text-center"
>
操作
</TableHead>
</TableRow>
@@ -140,6 +160,7 @@
{{ token.token_display }}
</code>
<Button
v-if="canManageTokens"
variant="ghost"
size="icon"
class="h-6 w-6"
@@ -151,6 +172,39 @@
</div>
</TableCell>
<TableCell
v-if="isAdminPage"
class="py-4"
>
<div class="text-sm font-medium truncate">
{{ token.user?.username || token.user?.email || token.user_id }}
</div>
<div
v-if="token.user?.email"
class="text-xs text-muted-foreground truncate"
>
{{ token.user.email }}
</div>
</TableCell>
<TableCell class="py-4">
<Badge
variant="secondary"
class="font-medium px-2 py-1"
>
{{ token.permission_summary || permissionModeText(token.permission_mode) }}
</Badge>
</TableCell>
<TableCell class="py-4 text-xs text-muted-foreground">
<div class="truncate">
{{ token.allowed_ips?.length ? token.allowed_ips.join(', ') : '不限制' }}
</div>
<div class="mt-1">
{{ token.last_used_ip ? `最后 IP ${token.last_used_ip}` : '暂无最后 IP' }}
</div>
</TableCell>
<!-- 使用次数 -->
<TableCell class="py-4 text-center">
<span class="text-sm font-medium">
@@ -179,7 +233,10 @@
</TableCell>
<!-- 操作按钮 -->
<TableCell class="py-4">
<TableCell
v-if="canManageTokens"
class="py-4"
>
<div class="flex justify-center gap-1">
<Button
variant="ghost"
@@ -240,7 +297,10 @@
{{ getStatusText(token) }}
</Badge>
</div>
<div class="flex items-center gap-0.5 flex-shrink-0">
<div
v-if="canManageTokens"
class="flex items-center gap-0.5 flex-shrink-0"
>
<Button
variant="ghost"
size="icon"
@@ -275,6 +335,7 @@
<div class="flex items-center gap-2 text-xs mb-2">
<code class="font-mono text-muted-foreground">{{ token.token_display }}</code>
<Button
v-if="canManageTokens"
variant="ghost"
size="icon"
class="h-5 w-5"
@@ -289,6 +350,8 @@
<div class="flex items-center gap-3 text-xs text-muted-foreground">
<span>{{ formatNumber(token.usage_count || 0) }} 次使用</span>
<span>·</span>
<span>{{ token.permission_summary || permissionModeText(token.permission_mode) }}</span>
<span>·</span>
<span>{{ token.last_used_at ? formatRelativeTime(token.last_used_at) : '从未使用' }}</span>
</div>
</div>
@@ -397,6 +460,104 @@
留空表示永不过期
</p>
</div>
<!-- 权限 -->
<div class="space-y-3">
<Label class="text-sm font-semibold">权限</Label>
<div class="grid grid-cols-3 gap-2">
<Button
type="button"
:variant="permissionMode === 'full' ? 'default' : 'outline'"
class="h-9"
@click="setPermissionMode('full')"
>
全权
</Button>
<Button
type="button"
:variant="permissionMode === 'read_only' ? 'default' : 'outline'"
class="h-9"
@click="setPermissionMode('read_only')"
>
只读
</Button>
<Button
type="button"
:variant="permissionMode === 'custom' ? 'default' : 'outline'"
class="h-9"
@click="setPermissionMode('custom')"
>
自定义
</Button>
</div>
<div
v-if="permissionMode === 'custom'"
class="flex flex-wrap items-center gap-2"
>
<Button
type="button"
variant="outline"
class="h-8 px-3 text-xs"
@click="setCustomPermissions('none')"
>
全部禁用
</Button>
<Button
type="button"
variant="outline"
class="h-8 px-3 text-xs"
@click="setCustomPermissions('read_only')"
>
全部只读
</Button>
<Button
type="button"
variant="outline"
class="h-8 px-3 text-xs"
@click="setCustomPermissions('full')"
>
全部全权
</Button>
</div>
<div
v-if="permissionMode === 'custom'"
class="max-h-72 overflow-y-auto rounded-md border border-border/60"
>
<div
v-for="group in permissionGroups"
:key="group.scope"
class="border-b border-border/50 last:border-b-0 px-3 py-2"
>
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-medium">
{{ group.label }}
</div>
<div class="flex items-center gap-3">
<label class="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Checkbox
:checked="isPermissionGroupDenied(group)"
@update:checked="togglePermissionGroupDenied(group, $event)"
/>
<span>禁止</span>
</label>
<label
v-for="item in group.items"
:key="item.key"
class="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
>
<Checkbox
:checked="selectedPermissions.includes(item.key)"
@update:checked="togglePermission(item.key, $event)"
/>
<span>{{ item.access_label }}</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
<template #footer>
@@ -512,15 +673,20 @@
<script setup lang="ts">
import { ref, computed, onMounted, reactive, watch } from 'vue'
import { useRoute } from 'vue-router'
import {
adminManagementTokenApi,
managementTokenApi,
type ManagementToken
type ManagementToken,
type ManagementTokenPermissionCatalogItem
} from '@/api/management-tokens'
import { useAuthStore } from '@/stores/auth'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Badge from '@/components/ui/badge.vue'
import Checkbox from '@/components/ui/checkbox.vue'
import { Dialog, Pagination } from '@/components/ui'
import { LoadingState, AlertDialog, EmptyState } from '@/components/common'
import {
@@ -548,6 +714,11 @@ import { log } from '@/utils/logger'
import { parseApiError } from '@/utils/errorParser'
const { success, error: showError } = useToast()
const route = useRoute()
const authStore = useAuthStore()
const isAdminPage = computed(() => route.path.startsWith('/admin'))
const canManageTokens = computed(() => authStore.isAdmin)
// 数据
const tokens = ref<ManagementToken[]>([])
@@ -566,6 +737,29 @@ const pageSize = ref(10)
const paginatedTokens = computed(() => tokens.value)
const permissionCatalog = ref<ManagementTokenPermissionCatalogItem[]>([])
const allPermissionKeys = ref<string[]>([])
const readOnlyPermissionKeys = ref<string[]>([])
const permissionMode = ref<'full' | 'read_only' | 'custom'>('full')
const selectedPermissions = ref<string[]>([])
const permissionGroups = computed(() => {
const groups = new Map<string, { scope: string; label: string; items: ManagementTokenPermissionCatalogItem[] }>()
for (const item of permissionCatalog.value) {
const group = groups.get(item.scope)
if (group) {
group.items.push(item)
} else {
groups.set(item.scope, {
scope: item.scope,
label: item.scope_label,
items: [item]
})
}
}
return Array.from(groups.values())
})
// 监听分页变化
watch([currentPage, pageSize], () => {
loadTokens()
@@ -598,7 +792,11 @@ const tokenToRegenerate = ref<ManagementToken | null>(null)
// 表单验证
const isFormValid = computed(() => {
return formData.name.trim().length > 0
if (formData.name.trim().length === 0) return false
if (permissionMode.value === 'custom' && selectedPermissions.value.length === 0) {
return false
}
return true
})
function getStatusVariant(token: ManagementToken): 'success' | 'secondary' | 'destructive' {
@@ -624,18 +822,28 @@ onMounted(() => {
loadTokens()
})
watch(
canManageTokens,
(allowed) => {
if (allowed) {
void loadPermissionCatalog()
}
},
{ immediate: true }
)
async function loadTokens() {
loading.value = true
try {
const skip = (currentPage.value - 1) * pageSize.value
const response = await managementTokenApi.listTokens({ skip, limit: pageSize.value })
const response = isAdminPage.value
? await adminManagementTokenApi.listAllTokens({ skip, limit: pageSize.value })
: await managementTokenApi.listTokens({ skip, limit: pageSize.value })
tokens.value = response.items
totalTokens.value = response.total
if (response.quota) {
quota.value = response.quota
}
quota.value = response.quota ?? null
// 如果当前页超出范围,重置到第一页
if (tokens.value.length === 0 && currentPage.value > 1) {
@@ -649,8 +857,34 @@ async function loadTokens() {
}
}
async function loadPermissionCatalog() {
if (permissionCatalog.value.length > 0) return
try {
const response = await adminManagementTokenApi.getPermissionCatalog()
permissionCatalog.value = response.items
allPermissionKeys.value = response.all_permissions
readOnlyPermissionKeys.value = response.read_only_permissions
if (selectedPermissions.value.length === 0) {
selectedPermissions.value = [...response.all_permissions]
}
} catch (err: unknown) {
log.error('加载 Management Token 权限目录失败:', err)
showError(parseApiError(err, '权限目录加载失败'))
}
}
function openCreateDialog() {
if (!canManageTokens.value) return
resetForm()
permissionMode.value = 'full'
selectedPermissions.value = [...allPermissionKeys.value]
void loadPermissionCatalog()
showCreateDialog.value = true
}
// 打开编辑对话框
function openEditDialog(token: ManagementToken) {
if (!canManageTokens.value) return
editingToken.value = token
formData.name = token.name
formData.description = token.description || ''
@@ -660,17 +894,114 @@ function openEditDialog(token: ManagementToken) {
formData.expiresAt = token.expires_at
? toLocalDatetimeString(new Date(token.expires_at))
: ''
const mode = token.permission_mode === 'read_only' || token.permission_mode === 'custom'
? token.permission_mode
: 'full'
permissionMode.value = mode
selectedPermissions.value = token.permissions?.length
? [...token.permissions]
: (mode === 'read_only' ? [...readOnlyPermissionKeys.value] : [...allPermissionKeys.value])
void loadPermissionCatalog()
showCreateDialog.value = true
}
// 关闭对话框
function closeDialog() {
showCreateDialog.value = false
resetForm()
}
function resetForm() {
editingToken.value = null
formData.name = ''
formData.description = ''
formData.allowedIpsText = ''
formData.expiresAt = ''
permissionMode.value = 'full'
selectedPermissions.value = [...allPermissionKeys.value]
}
function setPermissionMode(mode: 'full' | 'read_only' | 'custom') {
permissionMode.value = mode
if (mode === 'full') {
selectedPermissions.value = [...allPermissionKeys.value]
} else if (mode === 'read_only') {
selectedPermissions.value = [...readOnlyPermissionKeys.value]
} else if (selectedPermissions.value.length === 0) {
selectedPermissions.value = [...readOnlyPermissionKeys.value]
}
}
function setCustomPermissions(mode: 'none' | 'read_only' | 'full') {
if (mode === 'none') {
selectedPermissions.value = []
} else if (mode === 'read_only') {
selectedPermissions.value = [...readOnlyPermissionKeys.value]
} else {
selectedPermissions.value = [...allPermissionKeys.value]
}
}
function togglePermission(key: string, checked: boolean) {
const next = new Set(selectedPermissions.value)
if (checked) {
next.add(key)
} else {
next.delete(key)
}
selectedPermissions.value = Array.from(next).sort()
}
function isPermissionGroupDenied(group: { items: ManagementTokenPermissionCatalogItem[] }): boolean {
return group.items.every(item => !selectedPermissions.value.includes(item.key))
}
function togglePermissionGroupDenied(
group: { items: ManagementTokenPermissionCatalogItem[] },
checked: boolean
) {
const next = new Set(selectedPermissions.value)
for (const item of group.items) {
next.delete(item.key)
}
if (!checked) {
const readPermission = group.items.find(item => item.access === 'read')
if (readPermission) {
next.add(readPermission.key)
}
}
selectedPermissions.value = Array.from(next).sort()
}
async function resolveFormPermissions(): Promise<string[]> {
if (permissionCatalog.value.length === 0) {
await loadPermissionCatalog()
}
if (allPermissionKeys.value.length === 0) {
throw new Error('权限目录不可用')
}
if (permissionMode.value === 'full') {
return [...allPermissionKeys.value]
}
if (permissionMode.value === 'read_only') {
return [...readOnlyPermissionKeys.value]
}
return [...selectedPermissions.value]
}
function permissionModeText(mode?: ManagementToken['permission_mode']): string {
switch (mode) {
case 'legacy_full':
return '旧版全权限'
case 'full':
return '全权限'
case 'read_only':
return '只读'
case 'custom':
return '自定义'
default:
return '未配置'
}
}
// 保存 Token
@@ -688,16 +1019,26 @@ async function saveToken() {
const expiresAtUtc = formData.expiresAt
? new Date(formData.expiresAt).toISOString()
: null
const permissions = await resolveFormPermissions()
if (editingToken.value) {
// 更新
const tokenId = editingToken.value.id
const result = await managementTokenApi.updateToken(tokenId, {
name: formData.name,
description: formData.description.trim() || null,
allowed_ips: allowedIps.length > 0 ? allowedIps : null,
expires_at: expiresAtUtc
})
const result = isAdminPage.value
? await adminManagementTokenApi.updateToken(tokenId, {
name: formData.name,
description: formData.description.trim() || null,
allowed_ips: allowedIps.length > 0 ? allowedIps : null,
permissions,
expires_at: expiresAtUtc
})
: await managementTokenApi.updateToken(tokenId, {
name: formData.name,
description: formData.description.trim() || null,
allowed_ips: allowedIps.length > 0 ? allowedIps : null,
permissions,
expires_at: expiresAtUtc
})
// 局部更新:直接替换列表中对应的记录
const index = tokens.value.findIndex(t => t.id === tokenId)
if (index !== -1) {
@@ -706,12 +1047,16 @@ async function saveToken() {
success('令牌更新成功')
} else {
// 创建
const result = await managementTokenApi.createToken({
const payload = {
name: formData.name,
description: formData.description || undefined,
allowed_ips: allowedIps.length > 0 ? allowedIps : undefined,
permissions,
expires_at: expiresAtUtc
})
}
const result = isAdminPage.value
? await adminManagementTokenApi.createToken(payload)
: await managementTokenApi.createToken(payload)
newTokenValue.value = result.token
isRegenerating.value = false
showTokenDialog.value = true
@@ -730,8 +1075,11 @@ async function saveToken() {
// 切换状态
async function toggleToken(token: ManagementToken) {
if (!canManageTokens.value) return
try {
const result = await managementTokenApi.toggleToken(token.id)
const result = isAdminPage.value
? await adminManagementTokenApi.toggleToken(token.id)
: await managementTokenApi.toggleToken(token.id)
const index = tokens.value.findIndex(t => t.id === token.id)
if (index !== -1) {
@@ -746,6 +1094,7 @@ async function toggleToken(token: ManagementToken) {
// 删除
function confirmDelete(token: ManagementToken) {
if (!canManageTokens.value) return
tokenToDelete.value = token
showDeleteDialog.value = true
}
@@ -755,7 +1104,11 @@ async function deleteToken() {
deleting.value = true
try {
await managementTokenApi.deleteToken(tokenToDelete.value.id)
if (isAdminPage.value) {
await adminManagementTokenApi.deleteToken(tokenToDelete.value.id)
} else {
await managementTokenApi.deleteToken(tokenToDelete.value.id)
}
showDeleteDialog.value = false
success('令牌已删除')
@@ -771,6 +1124,7 @@ async function deleteToken() {
// 重新生成
function confirmRegenerate(token: ManagementToken) {
if (!canManageTokens.value) return
tokenToRegenerate.value = token
showRegenerateDialog.value = true
}
@@ -780,7 +1134,9 @@ async function regenerateToken() {
regenerating.value = true
try {
const result = await managementTokenApi.regenerateToken(tokenToRegenerate.value.id)
const result = isAdminPage.value
? await adminManagementTokenApi.regenerateToken(tokenToRegenerate.value.id)
: await managementTokenApi.regenerateToken(tokenToRegenerate.value.id)
newTokenValue.value = result.token
isRegenerating.value = true
showRegenerateDialog.value = false