mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
Merge branch 'dev'
This commit is contained in:
@@ -96,4 +96,5 @@ LDAP_USER_ATTR_LAST_NAME=sn
|
|||||||
USER_MANAGEMENT_ENABLED=false
|
USER_MANAGEMENT_ENABLED=false
|
||||||
DEFAULT_USER_ROLE=user
|
DEFAULT_USER_ROLE=user
|
||||||
USER_SESSION_TIMEOUT=86400000
|
USER_SESSION_TIMEOUT=86400000
|
||||||
MAX_API_KEYS_PER_USER=5
|
MAX_API_KEYS_PER_USER=1
|
||||||
|
ALLOW_USER_DELETE_API_KEYS=false
|
||||||
|
|||||||
@@ -175,7 +175,8 @@ const config = {
|
|||||||
enabled: process.env.USER_MANAGEMENT_ENABLED === 'true',
|
enabled: process.env.USER_MANAGEMENT_ENABLED === 'true',
|
||||||
defaultUserRole: process.env.DEFAULT_USER_ROLE || 'user',
|
defaultUserRole: process.env.DEFAULT_USER_ROLE || 'user',
|
||||||
userSessionTimeout: parseInt(process.env.USER_SESSION_TIMEOUT) || 86400000, // 24小时
|
userSessionTimeout: parseInt(process.env.USER_SESSION_TIMEOUT) || 86400000, // 24小时
|
||||||
maxApiKeysPerUser: parseInt(process.env.MAX_API_KEYS_PER_USER) || 5
|
maxApiKeysPerUser: parseInt(process.env.MAX_API_KEYS_PER_USER) || 1,
|
||||||
|
allowUserDeleteApiKeys: process.env.ALLOW_USER_DELETE_API_KEYS === 'true' // 默认不允许用户删除自己的API Keys
|
||||||
},
|
},
|
||||||
|
|
||||||
// 📢 Webhook通知配置
|
// 📢 Webhook通知配置
|
||||||
|
|||||||
@@ -24,6 +24,51 @@ const ProxyHelper = require('../utils/proxyHelper')
|
|||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
|
// 👥 用户管理
|
||||||
|
|
||||||
|
// 获取所有用户列表(用于API Key分配)
|
||||||
|
router.get('/users', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userService = require('../services/userService')
|
||||||
|
const result = await userService.getAllUsers({ isActive: true, limit: 1000 }) // Get all active users
|
||||||
|
|
||||||
|
// Extract users array from the paginated result
|
||||||
|
const allUsers = result.users || []
|
||||||
|
|
||||||
|
// Map to the format needed for the dropdown
|
||||||
|
const activeUsers = allUsers.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName || user.username,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 添加Admin选项作为第一个
|
||||||
|
const usersWithAdmin = [
|
||||||
|
{
|
||||||
|
id: 'admin',
|
||||||
|
username: 'admin',
|
||||||
|
displayName: 'Admin',
|
||||||
|
email: '',
|
||||||
|
role: 'admin'
|
||||||
|
},
|
||||||
|
...activeUsers
|
||||||
|
]
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: usersWithAdmin
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get users list:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to get users list',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 🔑 API Keys 管理
|
// 🔑 API Keys 管理
|
||||||
|
|
||||||
// 调试:获取API Key费用详情
|
// 调试:获取API Key费用详情
|
||||||
@@ -63,6 +108,9 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
const { timeRange = 'all' } = req.query // all, 7days, monthly
|
const { timeRange = 'all' } = req.query // all, 7days, monthly
|
||||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
const apiKeys = await apiKeyService.getAllApiKeys()
|
||||||
|
|
||||||
|
// 获取用户服务来补充owner信息
|
||||||
|
const userService = require('../services/userService')
|
||||||
|
|
||||||
// 根据时间范围计算查询模式
|
// 根据时间范围计算查询模式
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const searchPatterns = []
|
const searchPatterns = []
|
||||||
@@ -313,6 +361,28 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 为每个API Key添加owner的displayName
|
||||||
|
for (const apiKey of apiKeys) {
|
||||||
|
// 如果API Key有关联的用户ID,获取用户信息
|
||||||
|
if (apiKey.userId) {
|
||||||
|
try {
|
||||||
|
const user = await userService.getUserById(apiKey.userId, false)
|
||||||
|
if (user) {
|
||||||
|
apiKey.ownerDisplayName = user.displayName || user.username || 'Unknown User'
|
||||||
|
} else {
|
||||||
|
apiKey.ownerDisplayName = 'Unknown User'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`无法获取用户 ${apiKey.userId} 的信息:`, error)
|
||||||
|
apiKey.ownerDisplayName = 'Unknown User'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果没有userId,使用createdBy字段或默认为Admin
|
||||||
|
apiKey.ownerDisplayName =
|
||||||
|
apiKey.createdBy === 'admin' ? 'Admin' : apiKey.createdBy || 'Admin'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return res.json({ success: true, data: apiKeys })
|
return res.json({ success: true, data: apiKeys })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to get API keys:', error)
|
logger.error('❌ Failed to get API keys:', error)
|
||||||
@@ -803,6 +873,7 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { keyId } = req.params
|
const { keyId } = req.params
|
||||||
const {
|
const {
|
||||||
|
name, // 添加名称字段
|
||||||
tokenLimit,
|
tokenLimit,
|
||||||
concurrencyLimit,
|
concurrencyLimit,
|
||||||
rateLimitWindow,
|
rateLimitWindow,
|
||||||
@@ -822,12 +893,25 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
|||||||
expiresAt,
|
expiresAt,
|
||||||
dailyCostLimit,
|
dailyCostLimit,
|
||||||
weeklyOpusCostLimit,
|
weeklyOpusCostLimit,
|
||||||
tags
|
tags,
|
||||||
|
ownerId // 新增:所有者ID字段
|
||||||
} = req.body
|
} = req.body
|
||||||
|
|
||||||
// 只允许更新指定字段
|
// 只允许更新指定字段
|
||||||
const updates = {}
|
const updates = {}
|
||||||
|
|
||||||
|
// 处理名称字段
|
||||||
|
if (name !== undefined && name !== null && name !== '') {
|
||||||
|
const trimmedName = name.toString().trim()
|
||||||
|
if (trimmedName.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'API Key name cannot be empty' })
|
||||||
|
}
|
||||||
|
if (trimmedName.length > 100) {
|
||||||
|
return res.status(400).json({ error: 'API Key name must be less than 100 characters' })
|
||||||
|
}
|
||||||
|
updates.name = trimmedName
|
||||||
|
}
|
||||||
|
|
||||||
if (tokenLimit !== undefined && tokenLimit !== null && tokenLimit !== '') {
|
if (tokenLimit !== undefined && tokenLimit !== null && tokenLimit !== '') {
|
||||||
if (!Number.isInteger(Number(tokenLimit)) || Number(tokenLimit) < 0) {
|
if (!Number.isInteger(Number(tokenLimit)) || Number(tokenLimit) < 0) {
|
||||||
return res.status(400).json({ error: 'Token limit must be a non-negative integer' })
|
return res.status(400).json({ error: 'Token limit must be a non-negative integer' })
|
||||||
@@ -992,6 +1076,45 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
|||||||
updates.isActive = isActive
|
updates.isActive = isActive
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理所有者变更
|
||||||
|
if (ownerId !== undefined) {
|
||||||
|
const userService = require('../services/userService')
|
||||||
|
|
||||||
|
if (ownerId === 'admin') {
|
||||||
|
// 分配给Admin
|
||||||
|
updates.userId = ''
|
||||||
|
updates.userUsername = ''
|
||||||
|
updates.createdBy = 'admin'
|
||||||
|
} else if (ownerId) {
|
||||||
|
// 分配给用户
|
||||||
|
try {
|
||||||
|
const user = await userService.getUserById(ownerId, false)
|
||||||
|
if (!user) {
|
||||||
|
return res.status(400).json({ error: 'Invalid owner: User not found' })
|
||||||
|
}
|
||||||
|
if (!user.isActive) {
|
||||||
|
return res.status(400).json({ error: 'Cannot assign to inactive user' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置新的所有者信息
|
||||||
|
updates.userId = ownerId
|
||||||
|
updates.userUsername = user.username
|
||||||
|
updates.createdBy = user.username
|
||||||
|
|
||||||
|
// 管理员重新分配时,不检查用户的API Key数量限制
|
||||||
|
logger.info(`🔄 Admin reassigning API key ${keyId} to user ${user.username}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching user for owner reassignment:', error)
|
||||||
|
return res.status(400).json({ error: 'Invalid owner ID' })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 清空所有者(分配给Admin)
|
||||||
|
updates.userId = ''
|
||||||
|
updates.userUsername = ''
|
||||||
|
updates.createdBy = 'admin'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await apiKeyService.updateApiKey(keyId, updates)
|
await apiKeyService.updateApiKey(keyId, updates)
|
||||||
|
|
||||||
logger.success(`📝 Admin updated API key: ${keyId}`)
|
logger.success(`📝 Admin updated API key: ${keyId}`)
|
||||||
|
|||||||
@@ -208,7 +208,8 @@ router.get('/profile', authenticateUser, async (req, res) => {
|
|||||||
totalUsage: user.totalUsage
|
totalUsage: user.totalUsage
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
maxApiKeysPerUser: config.userManagement.maxApiKeysPerUser
|
maxApiKeysPerUser: config.userManagement.maxApiKeysPerUser,
|
||||||
|
allowUserDeleteApiKeys: config.userManagement.allowUserDeleteApiKeys
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -352,6 +353,15 @@ router.delete('/api-keys/:keyId', authenticateUser, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { keyId } = req.params
|
const { keyId } = req.params
|
||||||
|
|
||||||
|
// 检查是否允许用户删除自己的API Keys
|
||||||
|
if (!config.userManagement.allowUserDeleteApiKeys) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Operation not allowed',
|
||||||
|
message:
|
||||||
|
'Users are not allowed to delete their own API keys. Please contact an administrator.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 检查API Key是否属于当前用户
|
// 检查API Key是否属于当前用户
|
||||||
const existingKey = await apiKeyService.getApiKeyById(keyId)
|
const existingKey = await apiKeyService.getApiKeyById(keyId)
|
||||||
if (!existingKey || existingKey.userId !== req.user.id) {
|
if (!existingKey || existingKey.userId !== req.user.id) {
|
||||||
|
|||||||
@@ -368,7 +368,10 @@ class ApiKeyService {
|
|||||||
'allowedClients',
|
'allowedClients',
|
||||||
'dailyCostLimit',
|
'dailyCostLimit',
|
||||||
'weeklyOpusCostLimit',
|
'weeklyOpusCostLimit',
|
||||||
'tags'
|
'tags',
|
||||||
|
'userId', // 新增:用户ID(所有者变更)
|
||||||
|
'userUsername', // 新增:用户名(所有者变更)
|
||||||
|
'createdBy' // 新增:创建者(所有者变更)
|
||||||
]
|
]
|
||||||
const updatedData = { ...keyData }
|
const updatedData = { ...keyData }
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,38 @@ class LdapService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🌐 从DN中提取域名,用于Windows AD UPN格式认证
|
||||||
|
extractDomainFromDN(dnString) {
|
||||||
|
try {
|
||||||
|
if (!dnString || typeof dnString !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取所有DC组件:DC=test,DC=demo,DC=com
|
||||||
|
const dcMatches = dnString.match(/DC=([^,]+)/gi)
|
||||||
|
if (!dcMatches || dcMatches.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取DC值并连接成域名
|
||||||
|
const domainParts = dcMatches.map((match) => {
|
||||||
|
const value = match.replace(/DC=/i, '').trim()
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
|
||||||
|
if (domainParts.length > 0) {
|
||||||
|
const domain = domainParts.join('.')
|
||||||
|
logger.debug(`🌐 从DN提取域名: ${domain}`)
|
||||||
|
return domain
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('⚠️ 域名提取失败:', error.message)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🔗 创建LDAP客户端连接
|
// 🔗 创建LDAP客户端连接
|
||||||
createClient() {
|
createClient() {
|
||||||
try {
|
try {
|
||||||
@@ -336,6 +368,79 @@ class LdapService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔐 Windows AD兼容认证 - 在DN认证失败时尝试多种格式
|
||||||
|
async tryWindowsADAuthentication(username, password) {
|
||||||
|
if (!username || !password) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从searchBase提取域名
|
||||||
|
const domain = this.extractDomainFromDN(this.config.server.searchBase)
|
||||||
|
|
||||||
|
const adFormats = []
|
||||||
|
|
||||||
|
if (domain) {
|
||||||
|
// UPN格式(Windows AD标准)
|
||||||
|
adFormats.push(`${username}@${domain}`)
|
||||||
|
|
||||||
|
// 如果域名有多个部分,也尝试简化版本
|
||||||
|
const domainParts = domain.split('.')
|
||||||
|
if (domainParts.length > 1) {
|
||||||
|
adFormats.push(`${username}@${domainParts.slice(-2).join('.')}`) // 只取后两部分
|
||||||
|
}
|
||||||
|
|
||||||
|
// 域\用户名格式
|
||||||
|
const firstDomainPart = domainParts[0]
|
||||||
|
if (firstDomainPart) {
|
||||||
|
adFormats.push(`${firstDomainPart}\\${username}`)
|
||||||
|
adFormats.push(`${firstDomainPart.toUpperCase()}\\${username}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 纯用户名(最后尝试)
|
||||||
|
adFormats.push(username)
|
||||||
|
|
||||||
|
logger.info(`🔄 尝试 ${adFormats.length} 种Windows AD认证格式...`)
|
||||||
|
|
||||||
|
for (const format of adFormats) {
|
||||||
|
try {
|
||||||
|
logger.info(`🔍 尝试格式: ${format}`)
|
||||||
|
const result = await this.tryDirectBind(format, password)
|
||||||
|
if (result) {
|
||||||
|
logger.info(`✅ Windows AD认证成功: ${format}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
logger.debug(`❌ 认证失败: ${format}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`认证异常 ${format}:`, error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`🚫 所有Windows AD格式认证都失败了`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔐 直接尝试绑定认证的辅助方法
|
||||||
|
async tryDirectBind(identifier, password) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const authClient = this.createClient()
|
||||||
|
|
||||||
|
authClient.bind(identifier, password, (err) => {
|
||||||
|
authClient.unbind()
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
if (err.name === 'InvalidCredentialsError') {
|
||||||
|
resolve(false)
|
||||||
|
} else {
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolve(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 📝 提取用户信息
|
// 📝 提取用户信息
|
||||||
extractUserInfo(ldapEntry, username) {
|
extractUserInfo(ldapEntry, username) {
|
||||||
try {
|
try {
|
||||||
@@ -478,10 +583,32 @@ class LdapService {
|
|||||||
return { success: false, message: 'Authentication service error' }
|
return { success: false, message: 'Authentication service error' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 验证用户密码
|
// 4. 验证用户密码 - 支持传统LDAP和Windows AD
|
||||||
const isPasswordValid = await this.authenticateUser(userDN, password)
|
let isPasswordValid = false
|
||||||
|
|
||||||
|
// 首先尝试传统的DN认证(保持原有LDAP逻辑)
|
||||||
|
try {
|
||||||
|
isPasswordValid = await this.authenticateUser(userDN, password)
|
||||||
|
if (isPasswordValid) {
|
||||||
|
logger.info(`✅ DN authentication successful for user: ${sanitizedUsername}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(
|
||||||
|
`DN authentication failed for user: ${sanitizedUsername}, error: ${error.message}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果DN认证失败,尝试Windows AD多格式认证
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
logger.info(`🚫 Invalid password for user: ${sanitizedUsername}`)
|
logger.debug(`🔄 Trying Windows AD authentication formats for user: ${sanitizedUsername}`)
|
||||||
|
isPasswordValid = await this.tryWindowsADAuthentication(sanitizedUsername, password)
|
||||||
|
if (isPasswordValid) {
|
||||||
|
logger.info(`✅ Windows AD authentication successful for user: ${sanitizedUsername}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
logger.info(`🚫 All authentication methods failed for user: ${sanitizedUsername}`)
|
||||||
return { success: false, message: 'Invalid username or password' }
|
return { success: false, message: 'Invalid username or password' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ class UserService {
|
|||||||
await redis.set(`${this.userPrefix}${user.id}`, JSON.stringify(user))
|
await redis.set(`${this.userPrefix}${user.id}`, JSON.stringify(user))
|
||||||
await redis.set(`${this.usernamePrefix}${username}`, user.id)
|
await redis.set(`${this.usernamePrefix}${username}`, user.id)
|
||||||
|
|
||||||
|
// 如果是新用户,尝试转移匹配的API Keys
|
||||||
|
if (isNewUser) {
|
||||||
|
await this.transferMatchingApiKeys(user)
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`📝 ${isNewUser ? 'Created' : 'Updated'} user: ${username} (${user.id})`)
|
logger.info(`📝 ${isNewUser ? 'Created' : 'Updated'} user: ${username} (${user.id})`)
|
||||||
return user
|
return user
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -509,6 +514,80 @@ class UserService {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔄 转移匹配的API Keys给新用户
|
||||||
|
async transferMatchingApiKeys(user) {
|
||||||
|
try {
|
||||||
|
const apiKeyService = require('./apiKeyService')
|
||||||
|
const { displayName, username, email } = user
|
||||||
|
|
||||||
|
// 获取所有API Keys
|
||||||
|
const allApiKeys = await apiKeyService.getAllApiKeys()
|
||||||
|
|
||||||
|
// 找到没有用户ID的API Keys(即由Admin创建的)
|
||||||
|
const unownedApiKeys = allApiKeys.filter((key) => !key.userId || key.userId === '')
|
||||||
|
|
||||||
|
if (unownedApiKeys.length === 0) {
|
||||||
|
logger.debug(`📝 No unowned API keys found for potential transfer to user: ${username}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建匹配字符串数组(只考虑displayName、username、email,去除空值和重复值)
|
||||||
|
const matchStrings = new Set()
|
||||||
|
if (displayName) {
|
||||||
|
matchStrings.add(displayName.toLowerCase().trim())
|
||||||
|
}
|
||||||
|
if (username) {
|
||||||
|
matchStrings.add(username.toLowerCase().trim())
|
||||||
|
}
|
||||||
|
if (email) {
|
||||||
|
matchStrings.add(email.toLowerCase().trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingKeys = []
|
||||||
|
|
||||||
|
// 查找名称匹配的API Keys(只进行完全匹配)
|
||||||
|
for (const apiKey of unownedApiKeys) {
|
||||||
|
const keyName = apiKey.name ? apiKey.name.toLowerCase().trim() : ''
|
||||||
|
|
||||||
|
// 检查API Key名称是否与用户信息完全匹配
|
||||||
|
for (const matchString of matchStrings) {
|
||||||
|
if (keyName === matchString) {
|
||||||
|
matchingKeys.push(apiKey)
|
||||||
|
break // 找到匹配后跳出内层循环
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转移匹配的API Keys
|
||||||
|
let transferredCount = 0
|
||||||
|
for (const apiKey of matchingKeys) {
|
||||||
|
try {
|
||||||
|
await apiKeyService.updateApiKey(apiKey.id, {
|
||||||
|
userId: user.id,
|
||||||
|
userUsername: user.username,
|
||||||
|
createdBy: user.username
|
||||||
|
})
|
||||||
|
|
||||||
|
transferredCount++
|
||||||
|
logger.info(`🔄 Transferred API key "${apiKey.name}" (${apiKey.id}) to user: ${username}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to transfer API key ${apiKey.id} to user ${username}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transferredCount > 0) {
|
||||||
|
logger.success(
|
||||||
|
`🎉 Successfully transferred ${transferredCount} API key(s) to new user: ${username} (${displayName})`
|
||||||
|
)
|
||||||
|
} else if (matchingKeys.length === 0) {
|
||||||
|
logger.debug(`📝 No matching API keys found for user: ${username} (${displayName})`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error transferring matching API keys:', error)
|
||||||
|
// Don't throw error to prevent blocking user creation
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new UserService()
|
module.exports = new UserService()
|
||||||
|
|||||||
@@ -33,12 +33,36 @@
|
|||||||
>名称</label
|
>名称</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
class="form-input w-full cursor-not-allowed bg-gray-100 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
v-model="form.name"
|
||||||
disabled
|
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
maxlength="100"
|
||||||
|
placeholder="请输入API Key名称"
|
||||||
|
required
|
||||||
type="text"
|
type="text"
|
||||||
:value="form.name"
|
|
||||||
/>
|
/>
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">名称不可修改</p>
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">
|
||||||
|
用于识别此 API Key 的用途
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 所有者选择 -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
|
||||||
|
>所有者</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
v-model="form.ownerId"
|
||||||
|
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||||
|
>
|
||||||
|
<option v-for="user in availableUsers" :key="user.id" :value="user.id">
|
||||||
|
{{ user.displayName }} ({{ user.username }})
|
||||||
|
<span v-if="user.role === 'admin'" class="text-gray-500">- 管理员</span>
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">
|
||||||
|
分配此 API Key 给指定用户或管理员,管理员分配时不受用户 API Key 数量限制
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 标签 -->
|
<!-- 标签 -->
|
||||||
@@ -666,6 +690,9 @@ const localAccounts = ref({
|
|||||||
// 支持的客户端列表
|
// 支持的客户端列表
|
||||||
const supportedClients = ref([])
|
const supportedClients = ref([])
|
||||||
|
|
||||||
|
// 可用用户列表
|
||||||
|
const availableUsers = ref([])
|
||||||
|
|
||||||
// 标签相关
|
// 标签相关
|
||||||
const newTag = ref('')
|
const newTag = ref('')
|
||||||
const availableTags = ref([])
|
const availableTags = ref([])
|
||||||
@@ -696,7 +723,8 @@ const form = reactive({
|
|||||||
enableClientRestriction: false,
|
enableClientRestriction: false,
|
||||||
allowedClients: [],
|
allowedClients: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
isActive: true
|
isActive: true,
|
||||||
|
ownerId: '' // 新增:所有者ID
|
||||||
})
|
})
|
||||||
|
|
||||||
// 添加限制的模型
|
// 添加限制的模型
|
||||||
@@ -774,6 +802,7 @@ const updateApiKey = async () => {
|
|||||||
try {
|
try {
|
||||||
// 准备提交的数据
|
// 准备提交的数据
|
||||||
const data = {
|
const data = {
|
||||||
|
name: form.name, // 添加名称字段
|
||||||
tokenLimit: 0, // 清除历史token限制
|
tokenLimit: 0, // 清除历史token限制
|
||||||
rateLimitWindow:
|
rateLimitWindow:
|
||||||
form.rateLimitWindow !== '' && form.rateLimitWindow !== null
|
form.rateLimitWindow !== '' && form.rateLimitWindow !== null
|
||||||
@@ -856,6 +885,11 @@ const updateApiKey = async () => {
|
|||||||
// 活跃状态
|
// 活跃状态
|
||||||
data.isActive = form.isActive
|
data.isActive = form.isActive
|
||||||
|
|
||||||
|
// 所有者
|
||||||
|
if (form.ownerId !== undefined) {
|
||||||
|
data.ownerId = form.ownerId
|
||||||
|
}
|
||||||
|
|
||||||
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
|
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -947,11 +981,45 @@ const refreshAccounts = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载用户列表
|
||||||
|
const loadUsers = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/admin/users')
|
||||||
|
if (response.success) {
|
||||||
|
availableUsers.value = response.data || []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load users:', error)
|
||||||
|
availableUsers.value = [
|
||||||
|
{
|
||||||
|
id: 'admin',
|
||||||
|
username: 'admin',
|
||||||
|
displayName: 'Admin',
|
||||||
|
email: '',
|
||||||
|
role: 'admin'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化表单数据
|
// 初始化表单数据
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 加载支持的客户端和已存在的标签
|
try {
|
||||||
supportedClients.value = await clientsStore.loadSupportedClients()
|
// 并行加载所有需要的数据
|
||||||
availableTags.value = await apiKeysStore.fetchTags()
|
const [clients, tags] = await Promise.all([
|
||||||
|
clientsStore.loadSupportedClients(),
|
||||||
|
apiKeysStore.fetchTags(),
|
||||||
|
loadUsers()
|
||||||
|
])
|
||||||
|
|
||||||
|
supportedClients.value = clients || []
|
||||||
|
availableTags.value = tags || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading initial data:', error)
|
||||||
|
// Fallback to empty arrays if loading fails
|
||||||
|
supportedClients.value = []
|
||||||
|
availableTags.value = []
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化账号数据
|
// 初始化账号数据
|
||||||
if (props.accounts) {
|
if (props.accounts) {
|
||||||
@@ -1001,6 +1069,9 @@ onMounted(async () => {
|
|||||||
form.enableClientRestriction = props.apiKey.enableClientRestriction || false
|
form.enableClientRestriction = props.apiKey.enableClientRestriction || false
|
||||||
// 初始化活跃状态,默认为 true
|
// 初始化活跃状态,默认为 true
|
||||||
form.isActive = props.apiKey.isActive !== undefined ? props.apiKey.isActive : true
|
form.isActive = props.apiKey.isActive !== undefined ? props.apiKey.isActive : true
|
||||||
|
|
||||||
|
// 初始化所有者
|
||||||
|
form.ownerId = props.apiKey.userId || 'admin'
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -159,7 +159,11 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="!(apiKey.isDeleted === 'true' || apiKey.deletedAt) && apiKey.isActive"
|
v-if="
|
||||||
|
!(apiKey.isDeleted === 'true' || apiKey.deletedAt) &&
|
||||||
|
apiKey.isActive &&
|
||||||
|
allowUserDeleteApiKeys
|
||||||
|
"
|
||||||
class="inline-flex items-center rounded border border-transparent p-1 text-red-400 hover:text-red-600"
|
class="inline-flex items-center rounded border border-transparent p-1 text-red-400 hover:text-red-600"
|
||||||
title="Delete API Key"
|
title="Delete API Key"
|
||||||
@click="deleteApiKey(apiKey)"
|
@click="deleteApiKey(apiKey)"
|
||||||
@@ -255,6 +259,7 @@ const userStore = useUserStore()
|
|||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const apiKeys = ref([])
|
const apiKeys = ref([])
|
||||||
const maxApiKeys = computed(() => userStore.config?.maxApiKeysPerUser || 5)
|
const maxApiKeys = computed(() => userStore.config?.maxApiKeysPerUser || 5)
|
||||||
|
const allowUserDeleteApiKeys = computed(() => userStore.config?.allowUserDeleteApiKeys === true)
|
||||||
|
|
||||||
const showCreateModal = ref(false)
|
const showCreateModal = ref(false)
|
||||||
const showViewModal = ref(false)
|
const showViewModal = ref(false)
|
||||||
|
|||||||
@@ -105,7 +105,7 @@
|
|||||||
<input
|
<input
|
||||||
v-model="searchKeyword"
|
v-model="searchKeyword"
|
||||||
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 pl-9 text-sm text-gray-700 placeholder-gray-400 shadow-sm transition-all duration-200 hover:border-gray-300 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:placeholder-gray-500 dark:hover:border-gray-500"
|
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 pl-9 text-sm text-gray-700 placeholder-gray-400 shadow-sm transition-all duration-200 hover:border-gray-300 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:placeholder-gray-500 dark:hover:border-gray-500"
|
||||||
placeholder="搜索名称..."
|
:placeholder="isLdapEnabled ? '搜索名称或所有者...' : '搜索名称...'"
|
||||||
type="text"
|
type="text"
|
||||||
@input="currentPage = 1"
|
@input="currentPage = 1"
|
||||||
/>
|
/>
|
||||||
@@ -404,6 +404,14 @@
|
|||||||
使用共享池
|
使用共享池
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 显示所有者信息 -->
|
||||||
|
<div
|
||||||
|
v-if="isLdapEnabled && key.ownerDisplayName"
|
||||||
|
class="mt-1 text-xs text-red-600"
|
||||||
|
>
|
||||||
|
<i class="fas fa-user mr-1" />
|
||||||
|
{{ key.ownerDisplayName }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -1025,6 +1033,11 @@
|
|||||||
<i class="fas fa-share-alt mr-1" />
|
<i class="fas fa-share-alt mr-1" />
|
||||||
使用共享池
|
使用共享池
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 显示所有者信息 -->
|
||||||
|
<div v-if="isLdapEnabled && key.ownerDisplayName" class="text-xs text-red-600">
|
||||||
|
<i class="fas fa-user mr-1" />
|
||||||
|
{{ key.ownerDisplayName }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 统计信息 -->
|
<!-- 统计信息 -->
|
||||||
@@ -1327,6 +1340,7 @@
|
|||||||
名称
|
名称
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
|
v-if="isLdapEnabled"
|
||||||
class="w-[15%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
class="w-[15%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
创建者
|
创建者
|
||||||
@@ -1383,7 +1397,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-4">
|
<td v-if="isLdapEnabled" class="px-3 py-4">
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<span v-if="key.createdBy === 'admin'" class="text-blue-600">
|
<span v-if="key.createdBy === 'admin'" class="text-blue-600">
|
||||||
<i class="fas fa-user-shield mr-1" />
|
<i class="fas fa-user-shield mr-1" />
|
||||||
@@ -1545,6 +1559,7 @@ import { ref, computed, onMounted, watch } from 'vue'
|
|||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/toast'
|
||||||
import { apiClient } from '@/config/api'
|
import { apiClient } from '@/config/api'
|
||||||
import { useClientsStore } from '@/stores/clients'
|
import { useClientsStore } from '@/stores/clients'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import CreateApiKeyModal from '@/components/apikeys/CreateApiKeyModal.vue'
|
import CreateApiKeyModal from '@/components/apikeys/CreateApiKeyModal.vue'
|
||||||
import EditApiKeyModal from '@/components/apikeys/EditApiKeyModal.vue'
|
import EditApiKeyModal from '@/components/apikeys/EditApiKeyModal.vue'
|
||||||
import RenewApiKeyModal from '@/components/apikeys/RenewApiKeyModal.vue'
|
import RenewApiKeyModal from '@/components/apikeys/RenewApiKeyModal.vue'
|
||||||
@@ -1558,8 +1573,12 @@ import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
|||||||
|
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
const clientsStore = useClientsStore()
|
const clientsStore = useClientsStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
const apiKeys = ref([])
|
const apiKeys = ref([])
|
||||||
|
|
||||||
|
// 获取 LDAP 启用状态
|
||||||
|
const isLdapEnabled = computed(() => authStore.oemSettings?.ldapEnabled || false)
|
||||||
|
|
||||||
// 多选相关状态
|
// 多选相关状态
|
||||||
const selectedApiKeys = ref([])
|
const selectedApiKeys = ref([])
|
||||||
const selectAllChecked = ref(false)
|
const selectAllChecked = ref(false)
|
||||||
@@ -1647,12 +1666,22 @@ const sortedApiKeys = computed(() => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 然后进行名称搜索
|
// 然后进行名称搜索(搜索API Key名称和所有者名称)
|
||||||
if (searchKeyword.value) {
|
if (searchKeyword.value) {
|
||||||
const keyword = searchKeyword.value.toLowerCase().trim()
|
const keyword = searchKeyword.value.toLowerCase().trim()
|
||||||
filteredKeys = filteredKeys.filter(
|
filteredKeys = filteredKeys.filter((key) => {
|
||||||
(key) => key.name && key.name.toLowerCase().includes(keyword)
|
// 搜索API Key名称
|
||||||
)
|
const nameMatch = key.name && key.name.toLowerCase().includes(keyword)
|
||||||
|
// 如果启用了 LDAP,搜索所有者名称
|
||||||
|
if (isLdapEnabled.value) {
|
||||||
|
const ownerMatch =
|
||||||
|
key.ownerDisplayName && key.ownerDisplayName.toLowerCase().includes(keyword)
|
||||||
|
// 如果API Key名称或所有者名称匹配,则包含该条目
|
||||||
|
return nameMatch || ownerMatch
|
||||||
|
}
|
||||||
|
// 未启用 LDAP 时只搜索名称
|
||||||
|
return nameMatch
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果没有排序字段,返回筛选后的结果
|
// 如果没有排序字段,返回筛选后的结果
|
||||||
|
|||||||
Reference in New Issue
Block a user