feat(identity): 支持用户和用户组批量管理
增加原子批量启用、禁用和删除接口及管理端多选操作,目标缺失时整批回滚。\n\n拆分管理端与 API Key 权限缓存并在弹窗保存后刷新候选;补齐失效规则一键清理样式、固定右侧操作列和 OpenAPI 契约。
This commit is contained in:
+190
-10
@@ -19,7 +19,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "管理端返回用户组、租户、用户或 API Key 到平台、平台模型、基础模型的访问规则。",
|
"description": "管理端返回用户组、租户、用户或 API Key 到平台、平台模型、基础模型的分层访问规则。主体当前层无 allow 时继承上级,存在 allow 时仅允许白名单,deny 始终优先。",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "管理端创建一条访问控制规则。",
|
"description": "管理端创建一条访问控制规则;同一主体层存在任意有效 allow 后该层启用白名单,deny 始终优先。",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "管理端为同一主体批量新增、更新或删除资源访问规则。",
|
"description": "管理端为同一主体批量新增、更新或删除资源访问规则。清空该主体全部 allow 会恢复上级继承。",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -4844,6 +4844,75 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/admin/user-groups/batch": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "管理端原子批量启用、禁用或删除最多 500 个用户组;删除时同步删除其访问规则,关联默认用户组外键按数据库约束置空。",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"identity"
|
||||||
|
],
|
||||||
|
"summary": "批量操作用户组",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "用户组批量操作",
|
||||||
|
"name": "input",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/store.IdentityBatchInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.IdentityBatchResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/admin/user-groups/{groupID}": {
|
"/api/admin/user-groups/{groupID}": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -5089,6 +5158,75 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/admin/users/batch": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "管理端原子批量启用、禁用或软删除最多 500 个用户;任一目标不存在时整批不变更。",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"identity"
|
||||||
|
],
|
||||||
|
"summary": "批量操作用户",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "用户批量操作",
|
||||||
|
"name": "input",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/store.IdentityBatchInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.IdentityBatchResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/admin/users/{userID}": {
|
"/api/admin/users/{userID}": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -5600,7 +5738,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "返回当前本地用户可管理的 API Key 访问规则。",
|
"description": "返回当前本地用户拥有的 API Key 访问规则;不会混入其他用户或其他 API Key 的规则。",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -5649,7 +5787,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "当前本地用户为自己的 API Key 批量新增、更新或删除可访问资源。",
|
"description": "当前本地用户为自己的 API Key 批量新增、更新或删除白名单/拒绝资源;Key 无 allow 时继承父级范围,存在 allow 后仅允许命中项。",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -5718,7 +5856,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "按当前用户自身的用户、租户和用户组权限返回可分配给 API Key 的启用模型,不受任何 API Key 权限规则影响。",
|
"description": "按当前用户自身的租户、用户组和用户分层白名单返回可分配给 API Key 的启用模型,不应用任何 API Key 层规则。",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -5817,7 +5955,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "返回指定 API Key 所属用户组允许、全局启用且符合 KEY scope 的平台来源,并附带已有规则有效性诊断。",
|
"description": "返回全局启用、命中指定 API Key 的租户/用户组/用户基线且符合 Key scope 的可分配平台来源;当前 Key 的 allow/deny 不缩减候选,仅作为已有规则有效性诊断返回。",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -7725,7 +7863,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "按当前用户权限返回可用于 Playground 或 API 调用的模型列表。",
|
"description": "按全局启用、租户、用户组、用户、当前 API Key 分层白名单及 scope 的交集返回可用于 Playground 或 API 调用的平台来源;其他主体规则不参与求值。",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -7823,7 +7961,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "按当前用户权限返回可用于 Playground 或 API 调用的模型列表。",
|
"description": "按全局启用、租户、用户组、用户、当前 API Key 分层白名单及 scope 的交集返回可用于 Playground 或 API 调用的平台来源;其他主体规则不参与求值。",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -10345,7 +10483,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "按当前身份、API Key 访问规则及 scope 返回去重后的逻辑模型列表。",
|
"description": "按全局启用、租户、用户组、用户、当前 API Key 分层白名单及 scope 的交集返回去重后的逻辑模型列表;其他主体规则不参与求值。",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -11217,6 +11355,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"httpapi.IdentityBatchResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "disable"
|
||||||
|
},
|
||||||
|
"affectedCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 2
|
||||||
|
},
|
||||||
|
"ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requestedCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"httpapi.ImageVectorizeRequest": {
|
"httpapi.ImageVectorizeRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -15263,6 +15424,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"store.IdentityBatchInput": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"enable",
|
||||||
|
"disable",
|
||||||
|
"delete"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"store.LocalLoginInput": {
|
"store.LocalLoginInput": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
+132
-10
@@ -586,6 +586,22 @@ definitions:
|
|||||||
example: easyai-ai-gateway
|
example: easyai-ai-gateway
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
httpapi.IdentityBatchResponse:
|
||||||
|
properties:
|
||||||
|
action:
|
||||||
|
example: disable
|
||||||
|
type: string
|
||||||
|
affectedCount:
|
||||||
|
example: 2
|
||||||
|
type: integer
|
||||||
|
ids:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
requestedCount:
|
||||||
|
example: 2
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
httpapi.ImageVectorizeRequest:
|
httpapi.ImageVectorizeRequest:
|
||||||
properties:
|
properties:
|
||||||
cleanupLevel:
|
cleanupLevel:
|
||||||
@@ -3353,6 +3369,19 @@ definitions:
|
|||||||
transactionType:
|
transactionType:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
store.IdentityBatchInput:
|
||||||
|
properties:
|
||||||
|
action:
|
||||||
|
enum:
|
||||||
|
- enable
|
||||||
|
- disable
|
||||||
|
- delete
|
||||||
|
type: string
|
||||||
|
ids:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
store.LocalLoginInput:
|
store.LocalLoginInput:
|
||||||
properties:
|
properties:
|
||||||
account:
|
account:
|
||||||
@@ -4330,7 +4359,8 @@ info:
|
|||||||
paths:
|
paths:
|
||||||
/api/admin/access-rules:
|
/api/admin/access-rules:
|
||||||
get:
|
get:
|
||||||
description: 管理端返回用户组、租户、用户或 API Key 到平台、平台模型、基础模型的访问规则。
|
description: 管理端返回用户组、租户、用户或 API Key 到平台、平台模型、基础模型的分层访问规则。主体当前层无 allow 时继承上级,存在
|
||||||
|
allow 时仅允许白名单,deny 始终优先。
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -4358,7 +4388,7 @@ paths:
|
|||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: 管理端创建一条访问控制规则。
|
description: 管理端创建一条访问控制规则;同一主体层存在任意有效 allow 后该层启用白名单,deny 始终优先。
|
||||||
parameters:
|
parameters:
|
||||||
- description: 访问规则请求
|
- description: 访问规则请求
|
||||||
in: body
|
in: body
|
||||||
@@ -4489,7 +4519,7 @@ paths:
|
|||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: 管理端为同一主体批量新增、更新或删除资源访问规则。
|
description: 管理端为同一主体批量新增、更新或删除资源访问规则。清空该主体全部 allow 会恢复上级继承。
|
||||||
parameters:
|
parameters:
|
||||||
- description: 访问规则批量请求
|
- description: 访问规则批量请求
|
||||||
in: body
|
in: body
|
||||||
@@ -7508,6 +7538,50 @@ paths:
|
|||||||
summary: 更新用户组
|
summary: 更新用户组
|
||||||
tags:
|
tags:
|
||||||
- identity
|
- identity
|
||||||
|
/api/admin/user-groups/batch:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: 管理端原子批量启用、禁用或删除最多 500 个用户组;删除时同步删除其访问规则,关联默认用户组外键按数据库约束置空。
|
||||||
|
parameters:
|
||||||
|
- description: 用户组批量操作
|
||||||
|
in: body
|
||||||
|
name: input
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/store.IdentityBatchInput'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.IdentityBatchResponse'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
"403":
|
||||||
|
description: Forbidden
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 批量操作用户组
|
||||||
|
tags:
|
||||||
|
- identity
|
||||||
/api/admin/users:
|
/api/admin/users:
|
||||||
get:
|
get:
|
||||||
description: 管理端返回网关用户列表及钱包摘要。
|
description: 管理端返回网关用户列表及钱包摘要。
|
||||||
@@ -7763,6 +7837,50 @@ paths:
|
|||||||
summary: 充值用户钱包余额
|
summary: 充值用户钱包余额
|
||||||
tags:
|
tags:
|
||||||
- billing
|
- billing
|
||||||
|
/api/admin/users/batch:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: 管理端原子批量启用、禁用或软删除最多 500 个用户;任一目标不存在时整批不变更。
|
||||||
|
parameters:
|
||||||
|
- description: 用户批量操作
|
||||||
|
in: body
|
||||||
|
name: input
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/store.IdentityBatchInput'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.IdentityBatchResponse'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
"403":
|
||||||
|
description: Forbidden
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 批量操作用户
|
||||||
|
tags:
|
||||||
|
- identity
|
||||||
/api/playground/api-keys:
|
/api/playground/api-keys:
|
||||||
get:
|
get:
|
||||||
description: 返回当前本地用户可在 Playground 中直接使用的 API Key 和 secret。
|
description: 返回当前本地用户可在 Playground 中直接使用的 API Key 和 secret。
|
||||||
@@ -7938,7 +8056,8 @@ paths:
|
|||||||
- api-keys
|
- api-keys
|
||||||
/api/v1/api-keys/{apiKeyID}/assignable-models:
|
/api/v1/api-keys/{apiKeyID}/assignable-models:
|
||||||
get:
|
get:
|
||||||
description: 返回指定 API Key 所属用户组允许、全局启用且符合 KEY scope 的平台来源,并附带已有规则有效性诊断。
|
description: 返回全局启用、命中指定 API Key 的租户/用户组/用户基线且符合 Key scope 的可分配平台来源;当前 Key 的
|
||||||
|
allow/deny 不缩减候选,仅作为已有规则有效性诊断返回。
|
||||||
parameters:
|
parameters:
|
||||||
- description: API Key ID
|
- description: API Key ID
|
||||||
in: path
|
in: path
|
||||||
@@ -8057,7 +8176,7 @@ paths:
|
|||||||
- api-keys
|
- api-keys
|
||||||
/api/v1/api-keys/access-rules:
|
/api/v1/api-keys/access-rules:
|
||||||
get:
|
get:
|
||||||
description: 返回当前本地用户可管理的 API Key 访问规则。
|
description: 返回当前本地用户拥有的 API Key 访问规则;不会混入其他用户或其他 API Key 的规则。
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -8090,7 +8209,8 @@ paths:
|
|||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: 当前本地用户为自己的 API Key 批量新增、更新或删除可访问资源。
|
description: 当前本地用户为自己的 API Key 批量新增、更新或删除白名单/拒绝资源;Key 无 allow 时继承父级范围,存在 allow
|
||||||
|
后仅允许命中项。
|
||||||
parameters:
|
parameters:
|
||||||
- description: API Key 访问规则批量请求,subjectType 必须为 api_key
|
- description: API Key 访问规则批量请求,subjectType 必须为 api_key
|
||||||
in: body
|
in: body
|
||||||
@@ -8133,7 +8253,7 @@ paths:
|
|||||||
/api/v1/api-keys/assignable-models:
|
/api/v1/api-keys/assignable-models:
|
||||||
get:
|
get:
|
||||||
deprecated: true
|
deprecated: true
|
||||||
description: 按当前用户自身的用户、租户和用户组权限返回可分配给 API Key 的启用模型,不受任何 API Key 权限规则影响。
|
description: 按当前用户自身的租户、用户组和用户分层白名单返回可分配给 API Key 的启用模型,不应用任何 API Key 层规则。
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -9277,7 +9397,8 @@ paths:
|
|||||||
- agent-resources
|
- agent-resources
|
||||||
/api/v1/platform-models:
|
/api/v1/platform-models:
|
||||||
get:
|
get:
|
||||||
description: 按当前用户权限返回可用于 Playground 或 API 调用的模型列表。
|
description: 按全局启用、租户、用户组、用户、当前 API Key 分层白名单及 scope 的交集返回可用于 Playground 或 API
|
||||||
|
调用的平台来源;其他主体规则不参与求值。
|
||||||
parameters:
|
parameters:
|
||||||
- description: 模型可选场景;不传时保持原有行为
|
- description: 模型可选场景;不传时保持原有行为
|
||||||
enum:
|
enum:
|
||||||
@@ -9339,7 +9460,8 @@ paths:
|
|||||||
- playground
|
- playground
|
||||||
/api/v1/playground/models:
|
/api/v1/playground/models:
|
||||||
get:
|
get:
|
||||||
description: 按当前用户权限返回可用于 Playground 或 API 调用的模型列表。
|
description: 按全局启用、租户、用户组、用户、当前 API Key 分层白名单及 scope 的交集返回可用于 Playground 或 API
|
||||||
|
调用的平台来源;其他主体规则不参与求值。
|
||||||
parameters:
|
parameters:
|
||||||
- description: 模型可选场景;不传时保持原有行为
|
- description: 模型可选场景;不传时保持原有行为
|
||||||
enum:
|
enum:
|
||||||
@@ -10974,7 +11096,7 @@ paths:
|
|||||||
- static
|
- static
|
||||||
/v1/models:
|
/v1/models:
|
||||||
get:
|
get:
|
||||||
description: 按当前身份、API Key 访问规则及 scope 返回去重后的逻辑模型列表。
|
description: 按全局启用、租户、用户组、用户、当前 API Key 分层白名单及 scope 的交集返回去重后的逻辑模型列表;其他主体规则不参与求值。
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
|
|||||||
@@ -2,12 +2,83 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// batchGatewayUsers godoc
|
||||||
|
// @Summary 批量操作用户
|
||||||
|
// @Description 管理端原子批量启用、禁用或软删除最多 500 个用户;任一目标不存在时整批不变更。
|
||||||
|
// @Tags identity
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param input body store.IdentityBatchInput true "用户批量操作"
|
||||||
|
// @Success 200 {object} IdentityBatchResponse
|
||||||
|
// @Failure 400 {object} ErrorEnvelope
|
||||||
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
|
// @Failure 500 {object} ErrorEnvelope
|
||||||
|
// @Router /api/admin/users/batch [post]
|
||||||
|
func (s *Server) batchGatewayUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input store.IdentityBatchInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := s.store.BatchGatewayUsers(r.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
writeIdentityBatchError(w, s, err, "users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchUserGroups godoc
|
||||||
|
// @Summary 批量操作用户组
|
||||||
|
// @Description 管理端原子批量启用、禁用或删除最多 500 个用户组;删除时同步删除其访问规则,关联默认用户组外键按数据库约束置空。
|
||||||
|
// @Tags identity
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param input body store.IdentityBatchInput true "用户组批量操作"
|
||||||
|
// @Success 200 {object} IdentityBatchResponse
|
||||||
|
// @Failure 400 {object} ErrorEnvelope
|
||||||
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
|
// @Failure 500 {object} ErrorEnvelope
|
||||||
|
// @Router /api/admin/user-groups/batch [post]
|
||||||
|
func (s *Server) batchUserGroups(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input store.IdentityBatchInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := s.store.BatchUserGroups(r.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
writeIdentityBatchError(w, s, err, "user groups")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeIdentityBatchError(w http.ResponseWriter, s *Server, err error, target string) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, store.ErrInvalidIdentityBatch):
|
||||||
|
writeError(w, http.StatusBadRequest, "action must be enable, disable or delete and ids must contain 1 to 500 UUIDs")
|
||||||
|
case errors.Is(err, store.ErrIdentityBatchTargetNotFound):
|
||||||
|
writeError(w, http.StatusNotFound, "one or more "+target+" were not found")
|
||||||
|
default:
|
||||||
|
s.logger.Error("batch identity operation failed", "target", target, "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "batch "+target+" operation failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// createTenant godoc
|
// createTenant godoc
|
||||||
// @Summary 创建租户
|
// @Summary 创建租户
|
||||||
// @Description 管理端创建网关租户,tenantKey 和 name 必填。
|
// @Description 管理端创建网关租户,tenantKey 和 name 必填。
|
||||||
|
|||||||
@@ -144,6 +144,13 @@ type UserGroupListResponse struct {
|
|||||||
Items []store.UserGroup `json:"items"`
|
Items []store.UserGroup `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IdentityBatchResponse struct {
|
||||||
|
Action string `json:"action" example:"disable"`
|
||||||
|
RequestedCount int `json:"requestedCount" example:"2"`
|
||||||
|
AffectedCount int `json:"affectedCount" example:"2"`
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
}
|
||||||
|
|
||||||
type AccessRuleListResponse struct {
|
type AccessRuleListResponse struct {
|
||||||
Items []store.AccessRule `json:"items"`
|
Items []store.AccessRule `json:"items"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ func NewServerWithStores(
|
|||||||
mux.Handle("DELETE /api/admin/tenants/{tenantID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteTenant)))
|
mux.Handle("DELETE /api/admin/tenants/{tenantID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteTenant)))
|
||||||
mux.Handle("GET /api/admin/users", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listUsers)))
|
mux.Handle("GET /api/admin/users", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listUsers)))
|
||||||
mux.Handle("POST /api/admin/users", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createGatewayUser)))
|
mux.Handle("POST /api/admin/users", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createGatewayUser)))
|
||||||
|
mux.Handle("POST /api/admin/users/batch", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.batchGatewayUsers)))
|
||||||
mux.Handle("PATCH /api/admin/users/{userID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateGatewayUser)))
|
mux.Handle("PATCH /api/admin/users/{userID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateGatewayUser)))
|
||||||
mux.Handle("PATCH /api/admin/users/{userID}/wallet", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.setUserWalletBalance)))
|
mux.Handle("PATCH /api/admin/users/{userID}/wallet", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.setUserWalletBalance)))
|
||||||
mux.Handle("POST /api/admin/users/{userID}/wallet/recharge", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.rechargeUserWalletBalance)))
|
mux.Handle("POST /api/admin/users/{userID}/wallet/recharge", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.rechargeUserWalletBalance)))
|
||||||
@@ -204,6 +205,7 @@ func NewServerWithStores(
|
|||||||
mux.Handle("GET /api/admin/audit-logs", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listAuditLogs)))
|
mux.Handle("GET /api/admin/audit-logs", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listAuditLogs)))
|
||||||
mux.Handle("GET /api/admin/user-groups", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listUserGroups)))
|
mux.Handle("GET /api/admin/user-groups", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listUserGroups)))
|
||||||
mux.Handle("POST /api/admin/user-groups", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createUserGroup)))
|
mux.Handle("POST /api/admin/user-groups", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createUserGroup)))
|
||||||
|
mux.Handle("POST /api/admin/user-groups/batch", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.batchUserGroups)))
|
||||||
mux.Handle("PATCH /api/admin/user-groups/{groupID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateUserGroup)))
|
mux.Handle("PATCH /api/admin/user-groups/{groupID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateUserGroup)))
|
||||||
mux.Handle("DELETE /api/admin/user-groups/{groupID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteUserGroup)))
|
mux.Handle("DELETE /api/admin/user-groups/{groupID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteUserGroup)))
|
||||||
mux.Handle("GET /api/admin/access-rules", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listAccessRules)))
|
mux.Handle("GET /api/admin/access-rules", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listAccessRules)))
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxIdentityBatchSize = 500
|
||||||
|
|
||||||
|
type IdentityBatchInput struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
Action string `json:"action" enums:"enable,disable,delete"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdentityBatchResult struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
RequestedCount int `json:"requestedCount"`
|
||||||
|
AffectedCount int `json:"affectedCount"`
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) BatchGatewayUsers(ctx context.Context, input IdentityBatchInput) (IdentityBatchResult, error) {
|
||||||
|
input, err := normalizeIdentityBatchInput(input)
|
||||||
|
if err != nil {
|
||||||
|
return IdentityBatchResult{}, err
|
||||||
|
}
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return IdentityBatchResult{}, err
|
||||||
|
}
|
||||||
|
defer rollbackTransaction(tx)
|
||||||
|
|
||||||
|
locked, err := lockIdentityBatchTargets(ctx, tx, `
|
||||||
|
SELECT id::text
|
||||||
|
FROM gateway_users
|
||||||
|
WHERE id = ANY($1::uuid[])
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
FOR UPDATE`, input.IDs)
|
||||||
|
if err != nil {
|
||||||
|
return IdentityBatchResult{}, err
|
||||||
|
}
|
||||||
|
if len(locked) != len(input.IDs) {
|
||||||
|
return IdentityBatchResult{}, ErrIdentityBatchTargetNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
switch input.Action {
|
||||||
|
case "enable":
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
UPDATE gateway_users
|
||||||
|
SET status = 'active', updated_at = now()
|
||||||
|
WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`, input.IDs)
|
||||||
|
case "disable":
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
UPDATE gateway_users
|
||||||
|
SET status = 'disabled', updated_at = now()
|
||||||
|
WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`, input.IDs)
|
||||||
|
case "delete":
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
UPDATE gateway_users
|
||||||
|
SET deleted_at = now(),
|
||||||
|
status = 'deleted',
|
||||||
|
user_key = user_key || ':deleted:' || left(id::text, 8),
|
||||||
|
external_user_id = CASE WHEN source = 'oidc' THEN external_user_id ELSE NULL END,
|
||||||
|
email = NULL,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`, input.IDs)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return IdentityBatchResult{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return IdentityBatchResult{}, err
|
||||||
|
}
|
||||||
|
return identityBatchResult(input), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) BatchUserGroups(ctx context.Context, input IdentityBatchInput) (IdentityBatchResult, error) {
|
||||||
|
input, err := normalizeIdentityBatchInput(input)
|
||||||
|
if err != nil {
|
||||||
|
return IdentityBatchResult{}, err
|
||||||
|
}
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return IdentityBatchResult{}, err
|
||||||
|
}
|
||||||
|
defer rollbackTransaction(tx)
|
||||||
|
|
||||||
|
locked, err := lockIdentityBatchTargets(ctx, tx, `
|
||||||
|
SELECT id::text
|
||||||
|
FROM gateway_user_groups
|
||||||
|
WHERE id = ANY($1::uuid[])
|
||||||
|
FOR UPDATE`, input.IDs)
|
||||||
|
if err != nil {
|
||||||
|
return IdentityBatchResult{}, err
|
||||||
|
}
|
||||||
|
if len(locked) != len(input.IDs) {
|
||||||
|
return IdentityBatchResult{}, ErrIdentityBatchTargetNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
switch input.Action {
|
||||||
|
case "enable":
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
UPDATE gateway_user_groups
|
||||||
|
SET status = 'active', updated_at = now()
|
||||||
|
WHERE id = ANY($1::uuid[])`, input.IDs)
|
||||||
|
case "disable":
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
UPDATE gateway_user_groups
|
||||||
|
SET status = 'disabled', updated_at = now()
|
||||||
|
WHERE id = ANY($1::uuid[])`, input.IDs)
|
||||||
|
case "delete":
|
||||||
|
if _, err = tx.Exec(ctx, `
|
||||||
|
DELETE FROM gateway_access_rules
|
||||||
|
WHERE subject_type = 'user_group'
|
||||||
|
AND subject_id = ANY($1::uuid[])`, input.IDs); err == nil {
|
||||||
|
_, err = tx.Exec(ctx, `DELETE FROM gateway_user_groups WHERE id = ANY($1::uuid[])`, input.IDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return IdentityBatchResult{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return IdentityBatchResult{}, err
|
||||||
|
}
|
||||||
|
return identityBatchResult(input), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeIdentityBatchInput(input IdentityBatchInput) (IdentityBatchInput, error) {
|
||||||
|
input.Action = strings.ToLower(strings.TrimSpace(input.Action))
|
||||||
|
if input.Action != "enable" && input.Action != "disable" && input.Action != "delete" {
|
||||||
|
return IdentityBatchInput{}, ErrInvalidIdentityBatch
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool, len(input.IDs))
|
||||||
|
ids := make([]string, 0, len(input.IDs))
|
||||||
|
for _, value := range input.IDs {
|
||||||
|
id := strings.TrimSpace(value)
|
||||||
|
parsed, err := uuid.Parse(id)
|
||||||
|
if err != nil {
|
||||||
|
return IdentityBatchInput{}, ErrInvalidIdentityBatch
|
||||||
|
}
|
||||||
|
id = parsed.String()
|
||||||
|
if seen[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
if len(ids) == 0 || len(ids) > maxIdentityBatchSize {
|
||||||
|
return IdentityBatchInput{}, ErrInvalidIdentityBatch
|
||||||
|
}
|
||||||
|
input.IDs = ids
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockIdentityBatchTargets(ctx context.Context, tx pgx.Tx, query string, ids []string) ([]string, error) {
|
||||||
|
rows, err := tx.Query(ctx, query, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
locked := make([]string, 0, len(ids))
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
locked = append(locked, id)
|
||||||
|
}
|
||||||
|
return locked, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func identityBatchResult(input IdentityBatchInput) IdentityBatchResult {
|
||||||
|
return IdentityBatchResult{
|
||||||
|
Action: input.Action,
|
||||||
|
RequestedCount: len(input.IDs),
|
||||||
|
AffectedCount: len(input.IDs),
|
||||||
|
IDs: append([]string(nil), input.IDs...),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeIdentityBatchInput(t *testing.T) {
|
||||||
|
id := uuid.NewString()
|
||||||
|
input, err := normalizeIdentityBatchInput(IdentityBatchInput{
|
||||||
|
Action: " DISABLE ",
|
||||||
|
IDs: []string{id, " " + id + " "},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize identity batch: %v", err)
|
||||||
|
}
|
||||||
|
if input.Action != "disable" || len(input.IDs) != 1 || input.IDs[0] != id {
|
||||||
|
t.Fatalf("unexpected normalized batch: %+v", input)
|
||||||
|
}
|
||||||
|
for _, invalid := range []IdentityBatchInput{
|
||||||
|
{Action: "archive", IDs: []string{id}},
|
||||||
|
{Action: "enable", IDs: nil},
|
||||||
|
{Action: "delete", IDs: []string{"not-a-uuid"}},
|
||||||
|
} {
|
||||||
|
if _, err := normalizeIdentityBatchInput(invalid); !errors.Is(err, ErrInvalidIdentityBatch) {
|
||||||
|
t.Fatalf("invalid batch %+v error=%v", invalid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentityBatchOperationsAreAtomic(t *testing.T) {
|
||||||
|
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||||
|
if databaseURL == "" {
|
||||||
|
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run identity batch PostgreSQL integration tests")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
applyOIDCJITTestMigrations(t, ctx, databaseURL)
|
||||||
|
db, err := Connect(ctx, databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect identity batch test database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
suffix := strings.ReplaceAll(time.Now().UTC().Format("20060102150405.000000000"), ".", "")
|
||||||
|
groupA, err := db.CreateUserGroup(ctx, UserGroupInput{GroupKey: "batch-a-" + suffix, Name: "Batch A", Source: "gateway", Status: "active"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create group A: %v", err)
|
||||||
|
}
|
||||||
|
groupB, err := db.CreateUserGroup(ctx, UserGroupInput{GroupKey: "batch-b-" + suffix, Name: "Batch B", Source: "gateway", Status: "active"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create group B: %v", err)
|
||||||
|
}
|
||||||
|
userA, err := db.CreateGatewayUser(ctx, GatewayUserInput{UserKey: "batch-user-a-" + suffix, Username: "batch-user-a-" + suffix, Source: "gateway", DefaultUserGroupID: groupA.ID, Status: "active"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create user A: %v", err)
|
||||||
|
}
|
||||||
|
userB, err := db.CreateGatewayUser(ctx, GatewayUserInput{UserKey: "batch-user-b-" + suffix, Username: "batch-user-b-" + suffix, Source: "gateway", DefaultUserGroupID: groupB.ID, Status: "active"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create user B: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_access_rules WHERE subject_id = ANY($1::uuid[])`, []string{groupA.ID, groupB.ID})
|
||||||
|
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_users WHERE id = ANY($1::uuid[])`, []string{userA.ID, userB.ID})
|
||||||
|
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_user_groups WHERE id = ANY($1::uuid[])`, []string{groupA.ID, groupB.ID})
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := db.CreateAccessRule(ctx, AccessRuleInput{
|
||||||
|
SubjectType: "user_group", SubjectID: groupA.ID,
|
||||||
|
ResourceType: "platform_model", ResourceID: uuid.NewString(), Effect: "deny", Status: "active",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create group access rule: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := db.BatchGatewayUsers(ctx, IdentityBatchInput{IDs: []string{userA.ID, userA.ID, userB.ID}, Action: "disable"})
|
||||||
|
if err != nil || result.AffectedCount != 2 || result.RequestedCount != 2 {
|
||||||
|
t.Fatalf("disable users result=%+v err=%v", result, err)
|
||||||
|
}
|
||||||
|
assertIdentityStatuses(t, ctx, db, "gateway_users", []string{userA.ID, userB.ID}, "disabled")
|
||||||
|
if _, err := db.BatchGatewayUsers(ctx, IdentityBatchInput{IDs: []string{userA.ID, userB.ID}, Action: "enable"}); err != nil {
|
||||||
|
t.Fatalf("enable users: %v", err)
|
||||||
|
}
|
||||||
|
assertIdentityStatuses(t, ctx, db, "gateway_users", []string{userA.ID, userB.ID}, "active")
|
||||||
|
|
||||||
|
if _, err := db.BatchUserGroups(ctx, IdentityBatchInput{IDs: []string{groupA.ID, uuid.NewString()}, Action: "disable"}); !errors.Is(err, ErrIdentityBatchTargetNotFound) {
|
||||||
|
t.Fatalf("missing group batch error=%v", err)
|
||||||
|
}
|
||||||
|
assertIdentityStatuses(t, ctx, db, "gateway_user_groups", []string{groupA.ID}, "active")
|
||||||
|
|
||||||
|
if _, err := db.BatchUserGroups(ctx, IdentityBatchInput{IDs: []string{groupA.ID, groupB.ID}, Action: "disable"}); err != nil {
|
||||||
|
t.Fatalf("disable groups: %v", err)
|
||||||
|
}
|
||||||
|
assertIdentityStatuses(t, ctx, db, "gateway_user_groups", []string{groupA.ID, groupB.ID}, "disabled")
|
||||||
|
if _, err := db.BatchUserGroups(ctx, IdentityBatchInput{IDs: []string{groupA.ID, groupB.ID}, Action: "delete"}); err != nil {
|
||||||
|
t.Fatalf("delete groups: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupCount, groupRuleCount, usersWithDeletedGroup int
|
||||||
|
if err := db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM gateway_user_groups WHERE id = ANY($1::uuid[])`, []string{groupA.ID, groupB.ID}).Scan(&groupCount); err != nil {
|
||||||
|
t.Fatalf("count deleted groups: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM gateway_access_rules WHERE subject_type = 'user_group' AND subject_id = ANY($1::uuid[])`, []string{groupA.ID, groupB.ID}).Scan(&groupRuleCount); err != nil {
|
||||||
|
t.Fatalf("count deleted group rules: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM gateway_users WHERE id = ANY($1::uuid[]) AND default_user_group_id IS NOT NULL`, []string{userA.ID, userB.ID}).Scan(&usersWithDeletedGroup); err != nil {
|
||||||
|
t.Fatalf("count stale default groups: %v", err)
|
||||||
|
}
|
||||||
|
if groupCount != 0 || groupRuleCount != 0 || usersWithDeletedGroup != 0 {
|
||||||
|
t.Fatalf("group batch delete left data: groups=%d rules=%d userRefs=%d", groupCount, groupRuleCount, usersWithDeletedGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.BatchGatewayUsers(ctx, IdentityBatchInput{IDs: []string{userA.ID, userB.ID}, Action: "delete"}); err != nil {
|
||||||
|
t.Fatalf("delete users: %v", err)
|
||||||
|
}
|
||||||
|
var deletedUsers int
|
||||||
|
if err := db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM gateway_users WHERE id = ANY($1::uuid[]) AND status = 'deleted' AND deleted_at IS NOT NULL`, []string{userA.ID, userB.ID}).Scan(&deletedUsers); err != nil {
|
||||||
|
t.Fatalf("count deleted users: %v", err)
|
||||||
|
}
|
||||||
|
if deletedUsers != 2 {
|
||||||
|
t.Fatalf("soft-deleted users=%d, want 2", deletedUsers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertIdentityStatuses(t *testing.T, ctx context.Context, db *Store, table string, ids []string, want string) {
|
||||||
|
t.Helper()
|
||||||
|
query := `SELECT COUNT(*) FROM ` + table + ` WHERE id = ANY($1::uuid[]) AND status = $2`
|
||||||
|
var count int
|
||||||
|
if err := db.pool.QueryRow(ctx, query, ids, want).Scan(&count); err != nil {
|
||||||
|
t.Fatalf("read %s statuses: %v", table, err)
|
||||||
|
}
|
||||||
|
if count != len(ids) {
|
||||||
|
t.Fatalf("%s status %s count=%d, want %d", table, want, count, len(ids))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,8 @@ var (
|
|||||||
ErrInvalidCredentials = errors.New("invalid account or password")
|
ErrInvalidCredentials = errors.New("invalid account or password")
|
||||||
ErrInvalidInvitation = errors.New("invalid or expired invitation code")
|
ErrInvalidInvitation = errors.New("invalid or expired invitation code")
|
||||||
ErrInvalidAPIKeyScopes = errors.New("api key scopes must not be empty")
|
ErrInvalidAPIKeyScopes = errors.New("api key scopes must not be empty")
|
||||||
|
ErrInvalidIdentityBatch = errors.New("identity batch action or ids are invalid")
|
||||||
|
ErrIdentityBatchTargetNotFound = errors.New("one or more identity batch targets were not found")
|
||||||
ErrAccessRuleResourceDenied = errors.New("access rule resource is not available")
|
ErrAccessRuleResourceDenied = errors.New("access rule resource is not available")
|
||||||
ErrInsufficientWalletBalance = errors.New("insufficient wallet balance")
|
ErrInsufficientWalletBalance = errors.New("insufficient wallet balance")
|
||||||
ErrLocalUserRequired = errors.New("local gateway user is required")
|
ErrLocalUserRequired = errors.New("local gateway user is required")
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { dataKeysForRoute } from './App';
|
||||||
|
|
||||||
|
describe('access-rule route data isolation', () => {
|
||||||
|
it('uses independent cache keys for workspace and admin access rules', () => {
|
||||||
|
expect(dataKeysForRoute('workspace', 'overview', 'apiKeys', true)).toContain('apiKeyAccessRules');
|
||||||
|
expect(dataKeysForRoute('workspace', 'overview', 'apiKeys', true)).not.toContain('adminAccessRules');
|
||||||
|
|
||||||
|
expect(dataKeysForRoute('admin', 'userGroups', 'overview', true)).toContain('adminAccessRules');
|
||||||
|
expect(dataKeysForRoute('admin', 'userGroups', 'overview', true)).not.toContain('apiKeyAccessRules');
|
||||||
|
});
|
||||||
|
});
|
||||||
+89
-21
@@ -24,6 +24,7 @@ import type {
|
|||||||
GatewayUser,
|
GatewayUser,
|
||||||
GatewayWalletAccount,
|
GatewayWalletAccount,
|
||||||
GatewayWalletTransaction,
|
GatewayWalletTransaction,
|
||||||
|
IdentityBatchAction,
|
||||||
IntegrationPlatform,
|
IntegrationPlatform,
|
||||||
ModelCatalogResponse,
|
ModelCatalogResponse,
|
||||||
ModelRateLimitStatus,
|
ModelRateLimitStatus,
|
||||||
@@ -42,6 +43,8 @@ import type {
|
|||||||
import {
|
import {
|
||||||
batchAccessRules,
|
batchAccessRules,
|
||||||
batchApiKeyAccessRules,
|
batchApiKeyAccessRules,
|
||||||
|
batchGatewayUsers,
|
||||||
|
batchUserGroups,
|
||||||
createAccessRule,
|
createAccessRule,
|
||||||
createApiKey,
|
createApiKey,
|
||||||
createFileStorageChannel,
|
createFileStorageChannel,
|
||||||
@@ -179,7 +182,7 @@ import type {
|
|||||||
WorkspaceSection,
|
WorkspaceSection,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
type DataKey =
|
export type DataKey =
|
||||||
| 'health'
|
| 'health'
|
||||||
| 'currentUser'
|
| 'currentUser'
|
||||||
| 'currentUserGroups'
|
| 'currentUserGroups'
|
||||||
@@ -210,7 +213,8 @@ type DataKey =
|
|||||||
| 'tasks'
|
| 'tasks'
|
||||||
| 'wallet'
|
| 'wallet'
|
||||||
| 'walletTransactions'
|
| 'walletTransactions'
|
||||||
| 'accessRules'
|
| 'adminAccessRules'
|
||||||
|
| 'apiKeyAccessRules'
|
||||||
| 'auditLogs'
|
| 'auditLogs'
|
||||||
| 'apiKeys';
|
| 'apiKeys';
|
||||||
|
|
||||||
@@ -251,7 +255,8 @@ export function App() {
|
|||||||
const [pricingRuleSets, setPricingRuleSets] = useState<PricingRuleSet[]>([]);
|
const [pricingRuleSets, setPricingRuleSets] = useState<PricingRuleSet[]>([]);
|
||||||
const [runnerPolicy, setRunnerPolicy] = useState<GatewayRunnerPolicy | null>(null);
|
const [runnerPolicy, setRunnerPolicy] = useState<GatewayRunnerPolicy | null>(null);
|
||||||
const [runtimePolicySets, setRuntimePolicySets] = useState<RuntimePolicySet[]>([]);
|
const [runtimePolicySets, setRuntimePolicySets] = useState<RuntimePolicySet[]>([]);
|
||||||
const [accessRules, setAccessRules] = useState<GatewayAccessRule[]>([]);
|
const [adminAccessRules, setAdminAccessRules] = useState<GatewayAccessRule[]>([]);
|
||||||
|
const [apiKeyAccessRules, setApiKeyAccessRules] = useState<GatewayAccessRule[]>([]);
|
||||||
const [auditLogs, setAuditLogs] = useState<GatewayAuditLog[]>([]);
|
const [auditLogs, setAuditLogs] = useState<GatewayAuditLog[]>([]);
|
||||||
const [rateLimitWindows, setRateLimitWindows] = useState<RateLimitWindow[]>([]);
|
const [rateLimitWindows, setRateLimitWindows] = useState<RateLimitWindow[]>([]);
|
||||||
const [modelRateLimits, setModelRateLimits] = useState<ModelRateLimitStatus[]>([]);
|
const [modelRateLimits, setModelRateLimits] = useState<ModelRateLimitStatus[]>([]);
|
||||||
@@ -424,13 +429,13 @@ export function App() {
|
|||||||
{ label: 'Provider', value: activeProviders || providers.length, tone: 'amber' },
|
{ label: 'Provider', value: activeProviders || providers.length, tone: 'amber' },
|
||||||
{ label: '定价规则', value: pricingRules.length, tone: 'cyan' },
|
{ label: '定价规则', value: pricingRules.length, tone: 'cyan' },
|
||||||
{ label: '运行策略', value: runtimePolicySets.length, tone: 'slate' },
|
{ label: '运行策略', value: runtimePolicySets.length, tone: 'slate' },
|
||||||
{ label: '访问规则', value: accessRules.length, tone: 'amber' },
|
{ label: '访问规则', value: adminAccessRules.length, tone: 'amber' },
|
||||||
{ label: '限流窗口', value: activeRateWindows, tone: 'rose' },
|
{ label: '限流窗口', value: activeRateWindows, tone: 'rose' },
|
||||||
];
|
];
|
||||||
}, [accessRules.length, models, platforms, pricingRules.length, providers, rateLimitWindows, runtimePolicySets.length, tenants.length, userGroups.length, users.length]);
|
}, [adminAccessRules.length, models, platforms, pricingRules.length, providers, rateLimitWindows, runtimePolicySets.length, tenants.length, userGroups.length, users.length]);
|
||||||
|
|
||||||
const data = useMemo<ConsoleData>(() => ({
|
const data = useMemo<ConsoleData>(() => ({
|
||||||
accessRules,
|
accessRules: activePage === 'workspace' ? apiKeyAccessRules : adminAccessRules,
|
||||||
adminTasks,
|
adminTasks,
|
||||||
auditLogs,
|
auditLogs,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
@@ -461,7 +466,7 @@ export function App() {
|
|||||||
users,
|
users,
|
||||||
walletAccounts,
|
walletAccounts,
|
||||||
walletTransactions,
|
walletTransactions,
|
||||||
}), [accessRules, adminTasks, apiKeys, auditLogs, baseModels, clientCustomizationSettings, currentUser, currentUserGroups, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, securityEventConnection, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions, workerClusterRuntime]);
|
}), [activePage, adminAccessRules, adminTasks, apiKeyAccessRules, apiKeys, auditLogs, baseModels, clientCustomizationSettings, currentUser, currentUserGroups, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, securityEventConnection, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions, workerClusterRuntime]);
|
||||||
|
|
||||||
async function refresh(nextToken = token) {
|
async function refresh(nextToken = token) {
|
||||||
await ensureRouteData(nextToken, true);
|
await ensureRouteData(nextToken, true);
|
||||||
@@ -653,10 +658,11 @@ export function App() {
|
|||||||
loadedTransactionQueryKeyRef.current = requestKey;
|
loadedTransactionQueryKeyRef.current = requestKey;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'accessRules':
|
case 'adminAccessRules':
|
||||||
setAccessRules((await (activePage === 'workspace' && workspaceSection === 'apiKeys'
|
setAdminAccessRules((await listAccessRules(nextToken)).items);
|
||||||
? listApiKeyAccessRules(nextToken)
|
return;
|
||||||
: listAccessRules(nextToken))).items);
|
case 'apiKeyAccessRules':
|
||||||
|
setApiKeyAccessRules((await listApiKeyAccessRules(nextToken)).items);
|
||||||
return;
|
return;
|
||||||
case 'auditLogs':
|
case 'auditLogs':
|
||||||
setAuditLogs((await listAuditLogs(nextToken)).items);
|
setAuditLogs((await listAuditLogs(nextToken)).items);
|
||||||
@@ -957,6 +963,28 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function batchOperateUsers(ids: string[], action: IdentityBatchAction) {
|
||||||
|
setCoreState('loading');
|
||||||
|
setCoreMessage('');
|
||||||
|
try {
|
||||||
|
const response = await batchGatewayUsers(token, { ids, action });
|
||||||
|
const affected = new Set(response.ids);
|
||||||
|
if (action === 'delete') {
|
||||||
|
setUsers((current) => current.filter((user) => !affected.has(user.id)));
|
||||||
|
} else {
|
||||||
|
const status = action === 'enable' ? 'active' : 'disabled';
|
||||||
|
setUsers((current) => current.map((user) => affected.has(user.id) ? { ...user, status } : user));
|
||||||
|
}
|
||||||
|
invalidateDataKeys('playgroundModels');
|
||||||
|
setCoreState('ready');
|
||||||
|
setCoreMessage(`已批量${identityBatchActionLabel(action)} ${response.affectedCount} 个用户。`);
|
||||||
|
} catch (err) {
|
||||||
|
setCoreState('error');
|
||||||
|
setCoreMessage(err instanceof Error ? err.message : '批量操作用户失败');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveUserGroup(input: UserGroupUpsertRequest, groupId?: string) {
|
async function saveUserGroup(input: UserGroupUpsertRequest, groupId?: string) {
|
||||||
setCoreState('loading');
|
setCoreState('loading');
|
||||||
setCoreMessage('');
|
setCoreMessage('');
|
||||||
@@ -981,6 +1009,7 @@ export function App() {
|
|||||||
setUserGroups((current) => current.filter((group) => group.id !== groupId));
|
setUserGroups((current) => current.filter((group) => group.id !== groupId));
|
||||||
setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId === groupId ? { ...tenant, defaultUserGroupId: undefined } : tenant));
|
setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId === groupId ? { ...tenant, defaultUserGroupId: undefined } : tenant));
|
||||||
setUsers((current) => current.map((user) => user.defaultUserGroupId === groupId ? { ...user, defaultUserGroupId: undefined } : user));
|
setUsers((current) => current.map((user) => user.defaultUserGroupId === groupId ? { ...user, defaultUserGroupId: undefined } : user));
|
||||||
|
setAdminAccessRules((current) => current.filter((rule) => !(rule.subjectType === 'user_group' && rule.subjectId === groupId)));
|
||||||
invalidateDataKeys('modelCatalog', 'playgroundModels');
|
invalidateDataKeys('modelCatalog', 'playgroundModels');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage('用户组已删除。');
|
setCoreMessage('用户组已删除。');
|
||||||
@@ -991,13 +1020,42 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function batchOperateUserGroups(ids: string[], action: IdentityBatchAction) {
|
||||||
|
setCoreState('loading');
|
||||||
|
setCoreMessage('');
|
||||||
|
try {
|
||||||
|
const response = await batchUserGroups(token, { ids, action });
|
||||||
|
const affected = new Set(response.ids);
|
||||||
|
if (action === 'delete') {
|
||||||
|
setUserGroups((current) => current.filter((group) => !affected.has(group.id)));
|
||||||
|
setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId && affected.has(tenant.defaultUserGroupId)
|
||||||
|
? { ...tenant, defaultUserGroupId: undefined }
|
||||||
|
: tenant));
|
||||||
|
setUsers((current) => current.map((user) => user.defaultUserGroupId && affected.has(user.defaultUserGroupId)
|
||||||
|
? { ...user, defaultUserGroupId: undefined }
|
||||||
|
: user));
|
||||||
|
setAdminAccessRules((current) => current.filter((rule) => !(rule.subjectType === 'user_group' && affected.has(rule.subjectId))));
|
||||||
|
} else {
|
||||||
|
const status = action === 'enable' ? 'active' : 'disabled';
|
||||||
|
setUserGroups((current) => current.map((group) => affected.has(group.id) ? { ...group, status } : group));
|
||||||
|
}
|
||||||
|
invalidateDataKeys('modelCatalog', 'playgroundModels');
|
||||||
|
setCoreState('ready');
|
||||||
|
setCoreMessage(`已批量${identityBatchActionLabel(action)} ${response.affectedCount} 个用户组。`);
|
||||||
|
} catch (err) {
|
||||||
|
setCoreState('error');
|
||||||
|
setCoreMessage(err instanceof Error ? err.message : '批量操作用户组失败');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removeAPIKey(apiKeyId: string) {
|
async function removeAPIKey(apiKeyId: string) {
|
||||||
setCoreState('loading');
|
setCoreState('loading');
|
||||||
setCoreMessage('');
|
setCoreMessage('');
|
||||||
try {
|
try {
|
||||||
await deleteApiKey(token, apiKeyId);
|
await deleteApiKey(token, apiKeyId);
|
||||||
setApiKeys((current) => current.filter((item) => item.id !== apiKeyId));
|
setApiKeys((current) => current.filter((item) => item.id !== apiKeyId));
|
||||||
setAccessRules((current) => current.filter((rule) => !(rule.subjectType === 'api_key' && rule.subjectId === apiKeyId)));
|
setApiKeyAccessRules((current) => current.filter((rule) => !(rule.subjectType === 'api_key' && rule.subjectId === apiKeyId)));
|
||||||
setApiKeySecretsById((current) => {
|
setApiKeySecretsById((current) => {
|
||||||
const next = { ...current };
|
const next = { ...current };
|
||||||
delete next[apiKeyId];
|
delete next[apiKeyId];
|
||||||
@@ -1034,7 +1092,7 @@ export function App() {
|
|||||||
setCoreMessage('');
|
setCoreMessage('');
|
||||||
try {
|
try {
|
||||||
const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input);
|
const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input);
|
||||||
setAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]);
|
setAdminAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]);
|
||||||
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。');
|
setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。');
|
||||||
@@ -1050,7 +1108,7 @@ export function App() {
|
|||||||
setCoreMessage('');
|
setCoreMessage('');
|
||||||
try {
|
try {
|
||||||
await deleteAccessRule(token, ruleId);
|
await deleteAccessRule(token, ruleId);
|
||||||
setAccessRules((current) => current.filter((rule) => rule.id !== ruleId));
|
setAdminAccessRules((current) => current.filter((rule) => rule.id !== ruleId));
|
||||||
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage('访问权限规则已删除。');
|
setCoreMessage('访问权限规则已删除。');
|
||||||
@@ -1066,7 +1124,7 @@ export function App() {
|
|||||||
setCoreMessage('');
|
setCoreMessage('');
|
||||||
try {
|
try {
|
||||||
const response = await batchAccessRules(token, input);
|
const response = await batchAccessRules(token, input);
|
||||||
setAccessRules(response.items);
|
setAdminAccessRules(response.items);
|
||||||
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage('访问权限已更新。');
|
setCoreMessage('访问权限已更新。');
|
||||||
@@ -1192,7 +1250,7 @@ export function App() {
|
|||||||
setCoreMessage('');
|
setCoreMessage('');
|
||||||
try {
|
try {
|
||||||
const response = await batchApiKeyAccessRules(token, input);
|
const response = await batchApiKeyAccessRules(token, input);
|
||||||
setAccessRules(response.items);
|
setApiKeyAccessRules(response.items);
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage('API Key 权限已更新。');
|
setCoreMessage('API Key 权限已更新。');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1258,7 +1316,8 @@ export function App() {
|
|||||||
setPricingRuleSets([]);
|
setPricingRuleSets([]);
|
||||||
setRunnerPolicy(null);
|
setRunnerPolicy(null);
|
||||||
setRuntimePolicySets([]);
|
setRuntimePolicySets([]);
|
||||||
setAccessRules([]);
|
setAdminAccessRules([]);
|
||||||
|
setApiKeyAccessRules([]);
|
||||||
setAuditLogs([]);
|
setAuditLogs([]);
|
||||||
setRateLimitWindows([]);
|
setRateLimitWindows([]);
|
||||||
setModelRateLimits([]);
|
setModelRateLimits([]);
|
||||||
@@ -1466,6 +1525,7 @@ export function App() {
|
|||||||
transactionQuery={workspaceTransactionQuery}
|
transactionQuery={workspaceTransactionQuery}
|
||||||
transactionTotal={walletTransactionTotal}
|
transactionTotal={walletTransactionTotal}
|
||||||
onBatchAccessRules={batchSaveAPIKeyAccessRules}
|
onBatchAccessRules={batchSaveAPIKeyAccessRules}
|
||||||
|
onRefreshAccessRules={() => ensureData(['apiKeyAccessRules'], token, true)}
|
||||||
onDeleteApiKey={removeAPIKey}
|
onDeleteApiKey={removeAPIKey}
|
||||||
onApiKeyFormChange={setApiKeyForm}
|
onApiKeyFormChange={setApiKeyForm}
|
||||||
onSectionChange={navigateWorkspaceSection}
|
onSectionChange={navigateWorkspaceSection}
|
||||||
@@ -1517,6 +1577,8 @@ export function App() {
|
|||||||
onDeleteTenant={removeTenant}
|
onDeleteTenant={removeTenant}
|
||||||
onDeleteUser={removeUser}
|
onDeleteUser={removeUser}
|
||||||
onDeleteUserGroup={removeUserGroup}
|
onDeleteUserGroup={removeUserGroup}
|
||||||
|
onBatchUsers={batchOperateUsers}
|
||||||
|
onBatchUserGroups={batchOperateUserGroups}
|
||||||
onSaveBaseModel={saveBaseModel}
|
onSaveBaseModel={saveBaseModel}
|
||||||
onResetAllBaseModels={resetAllBaseModelsToDefault}
|
onResetAllBaseModels={resetAllBaseModelsToDefault}
|
||||||
onResetBaseModel={resetBaseModelToDefault}
|
onResetBaseModel={resetBaseModelToDefault}
|
||||||
@@ -1710,7 +1772,13 @@ function clampTransactionPageSize(value: number) {
|
|||||||
return Math.min(100, Math.max(1, normalized));
|
return Math.min(100, Math.max(1, normalized));
|
||||||
}
|
}
|
||||||
|
|
||||||
function dataKeysForRoute(
|
function identityBatchActionLabel(action: IdentityBatchAction) {
|
||||||
|
if (action === 'enable') return '启用';
|
||||||
|
if (action === 'disable') return '禁用';
|
||||||
|
return '删除';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dataKeysForRoute(
|
||||||
activePage: PageKey,
|
activePage: PageKey,
|
||||||
adminSection: AdminSection,
|
adminSection: AdminSection,
|
||||||
workspaceSection: WorkspaceSection,
|
workspaceSection: WorkspaceSection,
|
||||||
@@ -1729,7 +1797,7 @@ function dataKeysForRoute(
|
|||||||
if (activePage === 'workspace') {
|
if (activePage === 'workspace') {
|
||||||
if (workspaceSection === 'overview') return ['currentUser', 'currentUserGroups', 'apiKeys'];
|
if (workspaceSection === 'overview') return ['currentUser', 'currentUserGroups', 'apiKeys'];
|
||||||
if (workspaceSection === 'billing') return ['wallet'];
|
if (workspaceSection === 'billing') return ['wallet'];
|
||||||
if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules'];
|
if (workspaceSection === 'apiKeys') return ['apiKeys', 'apiKeyAccessRules'];
|
||||||
if (workspaceSection === 'tasks') return ['tasks'];
|
if (workspaceSection === 'tasks') return ['tasks'];
|
||||||
if (workspaceSection === 'transactions') return ['wallet', 'walletTransactions'];
|
if (workspaceSection === 'transactions') return ['wallet', 'walletTransactions'];
|
||||||
return [];
|
return [];
|
||||||
@@ -1738,7 +1806,7 @@ function dataKeysForRoute(
|
|||||||
if (activePage !== 'admin') return [];
|
if (activePage !== 'admin') return [];
|
||||||
switch (adminSection) {
|
switch (adminSection) {
|
||||||
case 'overview':
|
case 'overview':
|
||||||
return ['platforms', 'models', 'providers', 'pricingRules', 'runtimePolicySets', 'rateLimitWindows', 'modelRateLimits', 'tenants', 'users', 'userGroups', 'accessRules'];
|
return ['platforms', 'models', 'providers', 'pricingRules', 'runtimePolicySets', 'rateLimitWindows', 'modelRateLimits', 'tenants', 'users', 'userGroups', 'adminAccessRules'];
|
||||||
case 'globalModels':
|
case 'globalModels':
|
||||||
return ['providers'];
|
return ['providers'];
|
||||||
case 'pricing':
|
case 'pricing':
|
||||||
@@ -1760,7 +1828,7 @@ function dataKeysForRoute(
|
|||||||
case 'users':
|
case 'users':
|
||||||
return ['users', 'tenants', 'userGroups'];
|
return ['users', 'tenants', 'userGroups'];
|
||||||
case 'userGroups':
|
case 'userGroups':
|
||||||
return ['userGroups', 'accessRules', 'platforms', 'models'];
|
return ['userGroups', 'adminAccessRules', 'platforms', 'models'];
|
||||||
case 'auditLogs':
|
case 'auditLogs':
|
||||||
return ['auditLogs'];
|
return ['auditLogs'];
|
||||||
case 'systemSettings':
|
case 'systemSettings':
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
batchGatewayUsers,
|
||||||
|
batchUserGroups,
|
||||||
cancelIdentityPairing,
|
cancelIdentityPairing,
|
||||||
connectSecurityEventTransmitter,
|
connectSecurityEventTransmitter,
|
||||||
createResponse,
|
createResponse,
|
||||||
@@ -253,6 +255,33 @@ describe('API Key permission resources', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('identity batch transports', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts explicit actions and selected ids to the user and user-group batch endpoints', async () => {
|
||||||
|
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify({
|
||||||
|
action: 'disable', requestedCount: 2, affectedCount: 2, ids: ['id-a', 'id-b'],
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await batchGatewayUsers('manager-token', { action: 'disable', ids: ['id-a', 'id-b'] });
|
||||||
|
await batchUserGroups('manager-token', { action: 'delete', ids: ['group-a'] });
|
||||||
|
|
||||||
|
const [userURL, userInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(userURL).toContain('/api/admin/users/batch');
|
||||||
|
expect(userInit.method).toBe('POST');
|
||||||
|
expect(JSON.parse(String(userInit.body))).toEqual({ action: 'disable', ids: ['id-a', 'id-b'] });
|
||||||
|
const [groupURL, groupInit] = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||||
|
expect(groupURL).toContain('/api/admin/user-groups/batch');
|
||||||
|
expect(JSON.parse(String(groupInit.body))).toEqual({ action: 'delete', ids: ['group-a'] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Public Agent resources', () => {
|
describe('Public Agent resources', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import type {
|
|||||||
GatewayUser,
|
GatewayUser,
|
||||||
GatewayUserUpsertRequest,
|
GatewayUserUpsertRequest,
|
||||||
GatewayWalletTransaction,
|
GatewayWalletTransaction,
|
||||||
|
IdentityBatchRequest,
|
||||||
|
IdentityBatchResponse,
|
||||||
IntegrationPlatform,
|
IntegrationPlatform,
|
||||||
ListResponse,
|
ListResponse,
|
||||||
ModelCatalogResponse,
|
ModelCatalogResponse,
|
||||||
@@ -419,6 +421,17 @@ export async function deleteGatewayUser(token: string, userId: string): Promise<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function batchGatewayUsers(
|
||||||
|
token: string,
|
||||||
|
input: IdentityBatchRequest,
|
||||||
|
): Promise<IdentityBatchResponse> {
|
||||||
|
return request<IdentityBatchResponse>('/api/admin/users/batch', {
|
||||||
|
body: input,
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function listAuditLogs(token: string): Promise<ListResponse<GatewayAuditLog>> {
|
export async function listAuditLogs(token: string): Promise<ListResponse<GatewayAuditLog>> {
|
||||||
return request<ListResponse<GatewayAuditLog>>('/api/admin/audit-logs', { token });
|
return request<ListResponse<GatewayAuditLog>>('/api/admin/audit-logs', { token });
|
||||||
}
|
}
|
||||||
@@ -454,6 +467,17 @@ export async function deleteUserGroup(token: string, groupId: string): Promise<v
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function batchUserGroups(
|
||||||
|
token: string,
|
||||||
|
input: IdentityBatchRequest,
|
||||||
|
): Promise<IdentityBatchResponse> {
|
||||||
|
return request<IdentityBatchResponse>('/api/admin/user-groups/batch', {
|
||||||
|
body: input,
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function listAccessRules(token: string): Promise<ListResponse<GatewayAccessRule>> {
|
export async function listAccessRules(token: string): Promise<ListResponse<GatewayAccessRule>> {
|
||||||
return request<ListResponse<GatewayAccessRule>>('/api/admin/access-rules', { token });
|
return request<ListResponse<GatewayAccessRule>>('/api/admin/access-rules', { token });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
GatewayTenantUpsertRequest,
|
GatewayTenantUpsertRequest,
|
||||||
GatewayRunnerPolicyUpsertRequest,
|
GatewayRunnerPolicyUpsertRequest,
|
||||||
GatewayUserUpsertRequest,
|
GatewayUserUpsertRequest,
|
||||||
|
IdentityBatchAction,
|
||||||
IntegrationPlatform,
|
IntegrationPlatform,
|
||||||
PlatformDynamicPriorityUpdateRequest,
|
PlatformDynamicPriorityUpdateRequest,
|
||||||
PricingRuleSetUpsertRequest,
|
PricingRuleSetUpsertRequest,
|
||||||
@@ -73,6 +74,8 @@ export function AdminPage(props: {
|
|||||||
onDeleteTenant: (tenantId: string) => Promise<void>;
|
onDeleteTenant: (tenantId: string) => Promise<void>;
|
||||||
onDeleteUser: (userId: string) => Promise<void>;
|
onDeleteUser: (userId: string) => Promise<void>;
|
||||||
onDeleteUserGroup: (groupId: string) => Promise<void>;
|
onDeleteUserGroup: (groupId: string) => Promise<void>;
|
||||||
|
onBatchUsers: (ids: string[], action: IdentityBatchAction) => Promise<void>;
|
||||||
|
onBatchUserGroups: (ids: string[], action: IdentityBatchAction) => Promise<void>;
|
||||||
onSaveBaseModel: (input: BaseModelUpsertRequest, baseModelId?: string) => Promise<void>;
|
onSaveBaseModel: (input: BaseModelUpsertRequest, baseModelId?: string) => Promise<void>;
|
||||||
onResetAllBaseModels: () => Promise<void>;
|
onResetAllBaseModels: () => Promise<void>;
|
||||||
onResetBaseModel: (baseModelId: string) => Promise<void>;
|
onResetBaseModel: (baseModelId: string) => Promise<void>;
|
||||||
@@ -227,6 +230,8 @@ function identityPanelProps(props: {
|
|||||||
onDeleteTenant: (tenantId: string) => Promise<void>;
|
onDeleteTenant: (tenantId: string) => Promise<void>;
|
||||||
onDeleteUser: (userId: string) => Promise<void>;
|
onDeleteUser: (userId: string) => Promise<void>;
|
||||||
onDeleteUserGroup: (groupId: string) => Promise<void>;
|
onDeleteUserGroup: (groupId: string) => Promise<void>;
|
||||||
|
onBatchUsers: (ids: string[], action: IdentityBatchAction) => Promise<void>;
|
||||||
|
onBatchUserGroups: (ids: string[], action: IdentityBatchAction) => Promise<void>;
|
||||||
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
|
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
|
||||||
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
||||||
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
|
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
|
||||||
@@ -240,6 +245,8 @@ function identityPanelProps(props: {
|
|||||||
onDeleteTenant: props.onDeleteTenant,
|
onDeleteTenant: props.onDeleteTenant,
|
||||||
onDeleteUser: props.onDeleteUser,
|
onDeleteUser: props.onDeleteUser,
|
||||||
onDeleteUserGroup: props.onDeleteUserGroup,
|
onDeleteUserGroup: props.onDeleteUserGroup,
|
||||||
|
onBatchUsers: props.onBatchUsers,
|
||||||
|
onBatchUserGroups: props.onBatchUserGroups,
|
||||||
onSaveTenant: props.onSaveTenant,
|
onSaveTenant: props.onSaveTenant,
|
||||||
onSaveUser: props.onSaveUser,
|
onSaveUser: props.onSaveUser,
|
||||||
onRechargeUserWalletBalance: props.onRechargeUserWalletBalance,
|
onRechargeUserWalletBalance: props.onRechargeUserWalletBalance,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export function WorkspacePage(props: {
|
|||||||
transactionQuery: WorkspaceTransactionQuery;
|
transactionQuery: WorkspaceTransactionQuery;
|
||||||
transactionTotal: number;
|
transactionTotal: number;
|
||||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||||
|
onRefreshAccessRules: () => Promise<void>;
|
||||||
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
||||||
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
||||||
onSectionChange: (value: WorkspaceSection) => void;
|
onSectionChange: (value: WorkspaceSection) => void;
|
||||||
@@ -396,6 +397,7 @@ function ApiKeyPanel(props: {
|
|||||||
state: LoadState;
|
state: LoadState;
|
||||||
token: string;
|
token: string;
|
||||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||||
|
onRefreshAccessRules: () => Promise<void>;
|
||||||
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
||||||
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
||||||
onSaveApiKeyScopes: (apiKeyId: string, input: GatewayApiKeyScopeUpdateRequest) => Promise<void>;
|
onSaveApiKeyScopes: (apiKeyId: string, input: GatewayApiKeyScopeUpdateRequest) => Promise<void>;
|
||||||
@@ -413,6 +415,7 @@ function ApiKeyPanel(props: {
|
|||||||
const [policyDiagnostics, setPolicyDiagnostics] = useState<GatewayAPIKeyAccessRuleDiagnostic[]>([]);
|
const [policyDiagnostics, setPolicyDiagnostics] = useState<GatewayAPIKeyAccessRuleDiagnostic[]>([]);
|
||||||
const [policyState, setPolicyState] = useState<LoadState>('idle');
|
const [policyState, setPolicyState] = useState<LoadState>('idle');
|
||||||
const [policyError, setPolicyError] = useState('');
|
const [policyError, setPolicyError] = useState('');
|
||||||
|
const policyRequestIdRef = useRef(0);
|
||||||
const selectedPolicyKey = useMemo(
|
const selectedPolicyKey = useMemo(
|
||||||
() => props.data.apiKeys.find((item) => item.id === policyApiKeyId),
|
() => props.data.apiKeys.find((item) => item.id === policyApiKeyId),
|
||||||
[policyApiKeyId, props.data.apiKeys],
|
[policyApiKeyId, props.data.apiKeys],
|
||||||
@@ -423,41 +426,46 @@ function ApiKeyPanel(props: {
|
|||||||
);
|
);
|
||||||
const permissionPlatforms = useMemo(() => platformsForPermissionTree(policyModels), [policyModels]);
|
const permissionPlatforms = useMemo(() => platformsForPermissionTree(policyModels), [policyModels]);
|
||||||
|
|
||||||
useEffect(() => {
|
async function loadPolicy(apiKeyId: string) {
|
||||||
if (!policyApiKeyId) {
|
const requestId = ++policyRequestIdRef.current;
|
||||||
setPolicyModels([]);
|
|
||||||
setPolicyDiagnostics([]);
|
|
||||||
setPolicyState('idle');
|
|
||||||
setPolicyError('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
setPolicyState('loading');
|
setPolicyState('loading');
|
||||||
setPolicyError('');
|
setPolicyError('');
|
||||||
void listApiKeyAssignableModels(props.token, policyApiKeyId).then((response) => {
|
try {
|
||||||
if (cancelled) return;
|
const [response] = await Promise.all([
|
||||||
|
listApiKeyAssignableModels(props.token, apiKeyId),
|
||||||
|
props.onRefreshAccessRules(),
|
||||||
|
]);
|
||||||
|
if (requestId !== policyRequestIdRef.current) return;
|
||||||
setPolicyModels(response.items);
|
setPolicyModels(response.items);
|
||||||
setPolicyDiagnostics(response.ruleDiagnostics);
|
setPolicyDiagnostics(response.ruleDiagnostics);
|
||||||
setPolicyState('ready');
|
setPolicyState('ready');
|
||||||
}).catch((error) => {
|
} catch (error) {
|
||||||
if (cancelled) return;
|
if (requestId !== policyRequestIdRef.current) return;
|
||||||
setPolicyModels([]);
|
setPolicyModels([]);
|
||||||
setPolicyDiagnostics([]);
|
setPolicyDiagnostics([]);
|
||||||
setPolicyState('error');
|
setPolicyState('error');
|
||||||
setPolicyError(error instanceof Error ? error.message : 'API Key 可分配模型加载失败');
|
setPolicyError(error instanceof Error ? error.message : 'API Key 可分配模型加载失败');
|
||||||
});
|
}
|
||||||
return () => {
|
}
|
||||||
cancelled = true;
|
|
||||||
};
|
function openPolicyDialog(item: GatewayApiKey) {
|
||||||
}, [policyApiKeyId, props.token]);
|
setPolicyApiKeyId(item.id);
|
||||||
|
void loadPolicy(item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePolicyDialog() {
|
||||||
|
policyRequestIdRef.current += 1;
|
||||||
|
setPolicyApiKeyId('');
|
||||||
|
setPolicyModels([]);
|
||||||
|
setPolicyDiagnostics([]);
|
||||||
|
setPolicyState('idle');
|
||||||
|
setPolicyError('');
|
||||||
|
}
|
||||||
|
|
||||||
async function savePolicyRules(input: GatewayAccessRuleBatchRequest) {
|
async function savePolicyRules(input: GatewayAccessRuleBatchRequest) {
|
||||||
await props.onBatchAccessRules(input);
|
await props.onBatchAccessRules(input);
|
||||||
if (!policyApiKeyId) return;
|
if (!policyApiKeyId) return;
|
||||||
const response = await listApiKeyAssignableModels(props.token, policyApiKeyId);
|
await loadPolicy(policyApiKeyId);
|
||||||
setPolicyModels(response.items);
|
|
||||||
setPolicyDiagnostics(response.ruleDiagnostics);
|
|
||||||
setPolicyState('ready');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyApiKey(item: GatewayApiKey) {
|
async function copyApiKey(item: GatewayApiKey) {
|
||||||
@@ -572,7 +580,7 @@ function ApiKeyPanel(props: {
|
|||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<button type="button" className="apiKeyPolicyButton" onClick={() => setPolicyApiKeyId(item.id)}>
|
<button type="button" className="apiKeyPolicyButton" onClick={() => openPolicyDialog(item)}>
|
||||||
<ShieldCheck size={14} />
|
<ShieldCheck size={14} />
|
||||||
<span>{permissionSummaryText(summary)}</span>
|
<span>{permissionSummaryText(summary)}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -657,10 +665,10 @@ function ApiKeyPanel(props: {
|
|||||||
ariaLabel="维护 API Key 权限策略"
|
ariaLabel="维护 API Key 权限策略"
|
||||||
bodyClassName="apiKeyPolicyDialogBody"
|
bodyClassName="apiKeyPolicyDialogBody"
|
||||||
className="apiKeyPolicyDialog"
|
className="apiKeyPolicyDialog"
|
||||||
footer={<Button type="button" size="sm" onClick={() => setPolicyApiKeyId('')}>关闭</Button>}
|
footer={<Button type="button" size="sm" onClick={closePolicyDialog}>关闭</Button>}
|
||||||
open={Boolean(selectedPolicyKey)}
|
open={Boolean(selectedPolicyKey)}
|
||||||
title={selectedPolicyKey ? `权限策略:${selectedPolicyKey.name}` : '权限策略'}
|
title={selectedPolicyKey ? `权限策略:${selectedPolicyKey.name}` : '权限策略'}
|
||||||
onClose={() => setPolicyApiKeyId('')}
|
onClose={closePolicyDialog}
|
||||||
onSubmit={(event) => event.preventDefault()}
|
onSubmit={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
{policyError && <p className="formMessage error">{policyError}</p>}
|
{policyError && <p className="formMessage error">{policyError}</p>}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { IdentityBatchToolbar, identityBatchActionCopy } from './IdentityBatchToolbar';
|
||||||
|
import { pruneSelectedIds, updateSelectedIds } from './IdentityManagementPanels';
|
||||||
|
|
||||||
|
describe('identity batch operations', () => {
|
||||||
|
it('renders enable, disable and delete actions with the current selection count', () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<IdentityBatchToolbar entityLabel="用户" loading={false} selectedCount={2} onAction={vi.fn()} />,
|
||||||
|
);
|
||||||
|
expect(html).toContain('已选');
|
||||||
|
expect(html).toContain('批量启用');
|
||||||
|
expect(html).toContain('批量禁用');
|
||||||
|
expect(html).toContain('批量删除');
|
||||||
|
expect(identityBatchActionCopy('delete', '用户组', 3).description).toContain('3 个用户组');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds, removes and prunes selections after list updates', () => {
|
||||||
|
const selected = updateSelectedIds(new Set(['a']), 'b', true);
|
||||||
|
expect(Array.from(selected).sort()).toEqual(['a', 'b']);
|
||||||
|
expect(Array.from(updateSelectedIds(selected, 'a', false))).toEqual(['b']);
|
||||||
|
expect(Array.from(pruneSelectedIds(selected, ['b', 'c']))).toEqual(['b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Ban, CheckCircle2, Trash2 } from 'lucide-react';
|
||||||
|
import type { IdentityBatchAction } from '@easyai-ai-gateway/contracts';
|
||||||
|
import { Button, ConfirmDialog } from '../../components/ui';
|
||||||
|
|
||||||
|
export function IdentityBatchToolbar(props: {
|
||||||
|
entityLabel: string;
|
||||||
|
loading: boolean;
|
||||||
|
selectedCount: number;
|
||||||
|
onAction: (action: IdentityBatchAction) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [pendingAction, setPendingAction] = useState<IdentityBatchAction | null>(null);
|
||||||
|
const disabled = props.loading || props.selectedCount === 0;
|
||||||
|
const copy = pendingAction ? identityBatchActionCopy(pendingAction, props.entityLabel, props.selectedCount) : null;
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
if (!pendingAction) return;
|
||||||
|
await props.onAction(pendingAction);
|
||||||
|
setPendingAction(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="identityBatchToolbar" aria-label={`${props.entityLabel}批量操作`}>
|
||||||
|
<span>已选 <strong>{props.selectedCount}</strong> 项</span>
|
||||||
|
<div>
|
||||||
|
<Button type="button" size="sm" variant="outline" disabled={disabled} onClick={() => setPendingAction('enable')}>
|
||||||
|
<CheckCircle2 size={14} />批量启用
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" variant="outline" disabled={disabled} onClick={() => setPendingAction('disable')}>
|
||||||
|
<Ban size={14} />批量禁用
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" variant="destructive" disabled={disabled} onClick={() => setPendingAction('delete')}>
|
||||||
|
<Trash2 size={14} />批量删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ConfirmDialog
|
||||||
|
confirmLabel={copy?.confirmLabel}
|
||||||
|
confirmVariant={pendingAction === 'delete' ? 'destructive' : 'default'}
|
||||||
|
description={copy?.description}
|
||||||
|
loading={props.loading}
|
||||||
|
open={Boolean(pendingAction)}
|
||||||
|
title={copy?.title ?? ''}
|
||||||
|
onCancel={() => setPendingAction(null)}
|
||||||
|
onConfirm={confirm}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function identityBatchActionCopy(action: IdentityBatchAction, entityLabel: string, count: number) {
|
||||||
|
if (action === 'enable') {
|
||||||
|
return {
|
||||||
|
confirmLabel: '确认启用',
|
||||||
|
description: `选中的 ${count} 个${entityLabel}将恢复为 active 状态。`,
|
||||||
|
title: `批量启用${entityLabel}?`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (action === 'disable') {
|
||||||
|
return {
|
||||||
|
confirmLabel: '确认禁用',
|
||||||
|
description: `选中的 ${count} 个${entityLabel}将被设为 disabled。`,
|
||||||
|
title: `批量禁用${entityLabel}?`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
confirmLabel: '确认批量删除',
|
||||||
|
description: `将删除选中的 ${count} 个${entityLabel},该操作不能从当前页面撤销。`,
|
||||||
|
title: `批量删除${entityLabel}?`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
||||||
import { Building2, CircleDollarSign, KeyRound, Pencil, Plus, RotateCcw, ShieldCheck, Trash2, UserRound, UsersRound } from 'lucide-react';
|
import { Building2, CircleDollarSign, KeyRound, Pencil, Plus, RotateCcw, ShieldCheck, Trash2, UserRound, UsersRound } from 'lucide-react';
|
||||||
import type {
|
import type {
|
||||||
GatewayTenant,
|
GatewayTenant,
|
||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
GatewayUser,
|
GatewayUser,
|
||||||
GatewayUserUpsertRequest,
|
GatewayUserUpsertRequest,
|
||||||
GatewayWalletAccount,
|
GatewayWalletAccount,
|
||||||
|
IdentityBatchAction,
|
||||||
UserGroup,
|
UserGroup,
|
||||||
UserGroupUpsertRequest,
|
UserGroupUpsertRequest,
|
||||||
WalletRechargeRequest,
|
WalletRechargeRequest,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
|
Checkbox,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
FormDialog,
|
FormDialog,
|
||||||
Input,
|
Input,
|
||||||
@@ -33,6 +35,7 @@ import {
|
|||||||
import type { ConsoleData } from '../../app-state';
|
import type { ConsoleData } from '../../app-state';
|
||||||
import type { LoadState } from '../../types';
|
import type { LoadState } from '../../types';
|
||||||
import { AccessPermissionEditor, countAccessPermissionRules } from './AccessPermissionEditor';
|
import { AccessPermissionEditor, countAccessPermissionRules } from './AccessPermissionEditor';
|
||||||
|
import { IdentityBatchToolbar } from './IdentityBatchToolbar';
|
||||||
import {
|
import {
|
||||||
quotaPolicyFromForm,
|
quotaPolicyFromForm,
|
||||||
quotaPolicySummary,
|
quotaPolicySummary,
|
||||||
@@ -244,8 +247,15 @@ export function UsersPanel(props: IdentityPanelProps) {
|
|||||||
const [pendingDeleteUser, setPendingDeleteUser] = useState<GatewayUser | null>(null);
|
const [pendingDeleteUser, setPendingDeleteUser] = useState<GatewayUser | null>(null);
|
||||||
const [walletUser, setWalletUser] = useState<GatewayUser | null>(null);
|
const [walletUser, setWalletUser] = useState<GatewayUser | null>(null);
|
||||||
const [walletForm, setWalletForm] = useState<WalletForm>(() => defaultWalletForm());
|
const [walletForm, setWalletForm] = useState<WalletForm>(() => defaultWalletForm());
|
||||||
|
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
const tenantById = useMemo(() => new Map(props.data.tenants.map((tenant) => [tenant.id, tenant])), [props.data.tenants]);
|
const tenantById = useMemo(() => new Map(props.data.tenants.map((tenant) => [tenant.id, tenant])), [props.data.tenants]);
|
||||||
|
const allUsersSelected = props.data.users.length > 0 && props.data.users.every((user) => selectedUserIds.has(user.id));
|
||||||
|
const someUsersSelected = props.data.users.some((user) => selectedUserIds.has(user.id));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedUserIds((current) => pruneSelectedIds(current, props.data.users.map((user) => user.id)));
|
||||||
|
}, [props.data.users]);
|
||||||
|
|
||||||
function openCreateDialog() {
|
function openCreateDialog() {
|
||||||
setEditingId('');
|
setEditingId('');
|
||||||
@@ -303,6 +313,16 @@ export function UsersPanel(props: IdentityPanelProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function batchUsers(action: IdentityBatchAction) {
|
||||||
|
setLocalError('');
|
||||||
|
try {
|
||||||
|
await props.onBatchUsers(Array.from(selectedUserIds), action);
|
||||||
|
setSelectedUserIds(new Set());
|
||||||
|
} catch (err) {
|
||||||
|
setLocalError(err instanceof Error ? err.message : '批量操作用户失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submitWallet(event: FormEvent<HTMLFormElement>) {
|
async function submitWallet(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
@@ -346,9 +366,25 @@ export function UsersPanel(props: IdentityPanelProps) {
|
|||||||
actionLabel="新增用户"
|
actionLabel="新增用户"
|
||||||
onCreate={openCreateDialog}
|
onCreate={openCreateDialog}
|
||||||
/>
|
/>
|
||||||
|
{props.data.users.length > 0 && (
|
||||||
|
<IdentityBatchToolbar
|
||||||
|
entityLabel="用户"
|
||||||
|
loading={props.state === 'loading'}
|
||||||
|
selectedCount={selectedUserIds.size}
|
||||||
|
onAction={batchUsers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{props.data.users.length ? (
|
{props.data.users.length ? (
|
||||||
<Table className="identityDataTable userTable">
|
<Table className="identityDataTable userTable">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead className="identitySelectionCell">
|
||||||
|
<Checkbox
|
||||||
|
aria-label="选择全部用户"
|
||||||
|
checked={allUsersSelected ? true : someUsersSelected ? 'indeterminate' : false}
|
||||||
|
disabled={props.state === 'loading'}
|
||||||
|
onCheckedChange={(checked) => setSelectedUserIds(checked === true ? new Set(props.data.users.map((user) => user.id)) : new Set())}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
<TableHead>用户</TableHead>
|
<TableHead>用户</TableHead>
|
||||||
<TableHead>角色</TableHead>
|
<TableHead>角色</TableHead>
|
||||||
<TableHead>租户</TableHead>
|
<TableHead>租户</TableHead>
|
||||||
@@ -360,6 +396,14 @@ export function UsersPanel(props: IdentityPanelProps) {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
{props.data.users.map((user) => (
|
{props.data.users.map((user) => (
|
||||||
<TableRow key={user.id}>
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="identitySelectionCell">
|
||||||
|
<Checkbox
|
||||||
|
aria-label={`选择用户 ${user.username}`}
|
||||||
|
checked={selectedUserIds.has(user.id)}
|
||||||
|
disabled={props.state === 'loading'}
|
||||||
|
onCheckedChange={(checked) => setSelectedUserIds((current) => updateSelectedIds(current, user.id, checked === true))}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell><IdentityName title={user.displayName || user.username} subtitle={user.email || user.username} /></TableCell>
|
<TableCell><IdentityName title={user.displayName || user.username} subtitle={user.email || user.username} /></TableCell>
|
||||||
<TableCell>{roleLabel(user.roles)}</TableCell>
|
<TableCell>{roleLabel(user.roles)}</TableCell>
|
||||||
<TableCell>{tenantName(props.data.tenants, user.gatewayTenantId, user.tenantKey)}</TableCell>
|
<TableCell>{tenantName(props.data.tenants, user.gatewayTenantId, user.tenantKey)}</TableCell>
|
||||||
@@ -443,6 +487,13 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
|
|||||||
const [localError, setLocalError] = useState('');
|
const [localError, setLocalError] = useState('');
|
||||||
const [pendingDeleteGroup, setPendingDeleteGroup] = useState<UserGroup | null>(null);
|
const [pendingDeleteGroup, setPendingDeleteGroup] = useState<UserGroup | null>(null);
|
||||||
const [permissionGroup, setPermissionGroup] = useState<UserGroup | null>(null);
|
const [permissionGroup, setPermissionGroup] = useState<UserGroup | null>(null);
|
||||||
|
const [selectedGroupIds, setSelectedGroupIds] = useState<Set<string>>(() => new Set());
|
||||||
|
const allGroupsSelected = props.data.userGroups.length > 0 && props.data.userGroups.every((group) => selectedGroupIds.has(group.id));
|
||||||
|
const someGroupsSelected = props.data.userGroups.some((group) => selectedGroupIds.has(group.id));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedGroupIds((current) => pruneSelectedIds(current, props.data.userGroups.map((group) => group.id)));
|
||||||
|
}, [props.data.userGroups]);
|
||||||
|
|
||||||
function openCreateDialog() {
|
function openCreateDialog() {
|
||||||
setEditingId('');
|
setEditingId('');
|
||||||
@@ -484,6 +535,16 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function batchGroups(action: IdentityBatchAction) {
|
||||||
|
setLocalError('');
|
||||||
|
try {
|
||||||
|
await props.onBatchUserGroups(Array.from(selectedGroupIds), action);
|
||||||
|
setSelectedGroupIds(new Set());
|
||||||
|
} catch (err) {
|
||||||
|
setLocalError(err instanceof Error ? err.message : '批量操作用户组失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pageStack">
|
<div className="pageStack">
|
||||||
<ScreenMessage message={identityLocalErrorMessage(localError, props)} variant="error" duration={0} onClose={() => setLocalError('')} />
|
<ScreenMessage message={identityLocalErrorMessage(localError, props)} variant="error" duration={0} onClose={() => setLocalError('')} />
|
||||||
@@ -496,9 +557,25 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
|
|||||||
actionLabel="新增用户组"
|
actionLabel="新增用户组"
|
||||||
onCreate={openCreateDialog}
|
onCreate={openCreateDialog}
|
||||||
/>
|
/>
|
||||||
|
{props.data.userGroups.length > 0 && (
|
||||||
|
<IdentityBatchToolbar
|
||||||
|
entityLabel="用户组"
|
||||||
|
loading={props.state === 'loading'}
|
||||||
|
selectedCount={selectedGroupIds.size}
|
||||||
|
onAction={batchGroups}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{props.data.userGroups.length ? (
|
{props.data.userGroups.length ? (
|
||||||
<Table className="identityDataTable groupTable">
|
<Table className="identityDataTable groupTable">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead className="identitySelectionCell">
|
||||||
|
<Checkbox
|
||||||
|
aria-label="选择全部用户组"
|
||||||
|
checked={allGroupsSelected ? true : someGroupsSelected ? 'indeterminate' : false}
|
||||||
|
disabled={props.state === 'loading'}
|
||||||
|
onCheckedChange={(checked) => setSelectedGroupIds(checked === true ? new Set(props.data.userGroups.map((group) => group.id)) : new Set())}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
<TableHead>用户组</TableHead>
|
<TableHead>用户组</TableHead>
|
||||||
<TableHead>来源</TableHead>
|
<TableHead>来源</TableHead>
|
||||||
<TableHead>优先级</TableHead>
|
<TableHead>优先级</TableHead>
|
||||||
@@ -512,6 +589,14 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
|
|||||||
const permissionSummary = countAccessPermissionRules(props.data.accessRules, 'user_group', group.id);
|
const permissionSummary = countAccessPermissionRules(props.data.accessRules, 'user_group', group.id);
|
||||||
return (
|
return (
|
||||||
<TableRow key={group.id}>
|
<TableRow key={group.id}>
|
||||||
|
<TableCell className="identitySelectionCell">
|
||||||
|
<Checkbox
|
||||||
|
aria-label={`选择用户组 ${group.name}`}
|
||||||
|
checked={selectedGroupIds.has(group.id)}
|
||||||
|
disabled={props.state === 'loading'}
|
||||||
|
onCheckedChange={(checked) => setSelectedGroupIds((current) => updateSelectedIds(current, group.id, checked === true))}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell><IdentityName title={group.name} subtitle={group.groupKey} /></TableCell>
|
<TableCell><IdentityName title={group.name} subtitle={group.groupKey} /></TableCell>
|
||||||
<TableCell>{group.source}</TableCell>
|
<TableCell>{group.source}</TableCell>
|
||||||
<TableCell>{group.priority}</TableCell>
|
<TableCell>{group.priority}</TableCell>
|
||||||
@@ -575,7 +660,7 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
|
|||||||
<>
|
<>
|
||||||
<div className="accessGroupHint userGroupPermissionHint">
|
<div className="accessGroupHint userGroupPermissionHint">
|
||||||
<ShieldCheck size={15} />
|
<ShieldCheck size={15} />
|
||||||
<span>未配置规则时默认放行;拒绝规则优先于专属规则,修改后立即生效。</span>
|
<span>未配置白名单时继承上级可用范围;配置允许项后仅允许所选资源,拒绝规则始终优先。</span>
|
||||||
</div>
|
</div>
|
||||||
<AccessPermissionEditor
|
<AccessPermissionEditor
|
||||||
key={permissionGroup.id}
|
key={permissionGroup.id}
|
||||||
@@ -611,6 +696,8 @@ type IdentityPanelProps = {
|
|||||||
onDeleteTenant: (tenantId: string) => Promise<void>;
|
onDeleteTenant: (tenantId: string) => Promise<void>;
|
||||||
onDeleteUser: (userId: string) => Promise<void>;
|
onDeleteUser: (userId: string) => Promise<void>;
|
||||||
onDeleteUserGroup: (groupId: string) => Promise<void>;
|
onDeleteUserGroup: (groupId: string) => Promise<void>;
|
||||||
|
onBatchUsers: (ids: string[], action: IdentityBatchAction) => Promise<void>;
|
||||||
|
onBatchUserGroups: (ids: string[], action: IdentityBatchAction) => Promise<void>;
|
||||||
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
|
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
|
||||||
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
||||||
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
|
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
|
||||||
@@ -618,6 +705,18 @@ type IdentityPanelProps = {
|
|||||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function updateSelectedIds(current: Set<string>, id: string, selected: boolean) {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (selected) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneSelectedIds(current: Set<string>, availableIds: string[]) {
|
||||||
|
const available = new Set(availableIds);
|
||||||
|
return new Set(Array.from(current).filter((id) => available.has(id)));
|
||||||
|
}
|
||||||
|
|
||||||
const defaultRechargeReason = '手动充值';
|
const defaultRechargeReason = '手动充值';
|
||||||
|
|
||||||
function identityHeaderMessage(props: Pick<IdentityPanelProps, 'operationMessage' | 'state'>) {
|
function identityHeaderMessage(props: Pick<IdentityPanelProps, 'operationMessage' | 'state'>) {
|
||||||
@@ -1018,8 +1117,8 @@ function PolicySummary(props: { parts: string[] }) {
|
|||||||
function permissionRuleSummary(summary: ReturnType<typeof countAccessPermissionRules>) {
|
function permissionRuleSummary(summary: ReturnType<typeof countAccessPermissionRules>) {
|
||||||
const allow = summary.allow.platforms + summary.allow.models;
|
const allow = summary.allow.platforms + summary.allow.models;
|
||||||
const deny = summary.deny.platforms + summary.deny.models;
|
const deny = summary.deny.platforms + summary.deny.models;
|
||||||
if (!allow && !deny) return '模型权限:未配置,默认放行';
|
if (!allow && !deny) return '模型权限:未配置白名单,继承上级';
|
||||||
return `模型权限:专属 ${allow} 条,拒绝 ${deny} 条`;
|
return `模型权限:允许 ${allow} 条,拒绝 ${deny} 条`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stringifyJson(value: unknown) {
|
function stringifyJson(value: unknown) {
|
||||||
|
|||||||
@@ -435,6 +435,30 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.identityBatchToolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.identityBatchToolbar > div {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identitySelectionCell {
|
||||||
|
flex: 0 0 2.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.accessGroupToolbar,
|
.accessGroupToolbar,
|
||||||
.accessGroupHint {
|
.accessGroupHint {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -494,6 +518,10 @@
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.accessRuleCleanupButton {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.accessRuleDiagnosticRow {
|
.accessRuleDiagnosticRow {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding-top: 0.625rem;
|
padding-top: 0.625rem;
|
||||||
@@ -774,15 +802,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.userTable .shTableRow {
|
.userTable .shTableRow {
|
||||||
grid-template-columns: minmax(210px, 1.25fr) minmax(100px, 0.55fr) minmax(150px, 0.8fr) minmax(150px, 0.8fr) minmax(120px, 0.65fr) minmax(100px, 0.55fr) minmax(88px, 0.5fr) minmax(112px, 0.6fr);
|
grid-template-columns: 40px minmax(210px, 1.25fr) minmax(100px, 0.55fr) minmax(150px, 0.8fr) minmax(150px, 0.8fr) minmax(120px, 0.65fr) minmax(100px, 0.55fr) minmax(88px, 0.5fr) minmax(112px, 0.6fr);
|
||||||
min-width: 1120px;
|
min-width: 1160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.groupTable .shTableRow {
|
.groupTable .shTableRow {
|
||||||
grid-template-columns: minmax(190px, 1.15fr) minmax(90px, 0.5fr) minmax(82px, 0.42fr) minmax(145px, 0.8fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(82px, 0.42fr) minmax(154px, 0.78fr);
|
grid-template-columns: 40px minmax(190px, 1.15fr) minmax(90px, 0.5fr) minmax(82px, 0.42fr) minmax(145px, 0.8fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(82px, 0.42fr) minmax(154px, 0.78fr);
|
||||||
min-width: 1305px;
|
min-width: 1345px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.userTable .shTableRow > :last-child,
|
||||||
.groupTable .shTableRow > :last-child {
|
.groupTable .shTableRow > :last-child {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -791,6 +820,7 @@
|
|||||||
box-shadow: -8px 0 12px -12px rgba(16, 24, 40, 0.45);
|
box-shadow: -8px 0 12px -12px rgba(16, 24, 40, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.userTable > .shTableRow:first-child > :last-child,
|
||||||
.groupTable > .shTableRow:first-child > :last-child {
|
.groupTable > .shTableRow:first-child > :last-child {
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
|
|||||||
@@ -399,6 +399,20 @@ export interface GatewayUserUpsertRequest {
|
|||||||
status?: 'active' | 'disabled' | 'locked' | 'deleted' | string;
|
status?: 'active' | 'disabled' | 'locked' | 'deleted' | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IdentityBatchAction = 'enable' | 'disable' | 'delete';
|
||||||
|
|
||||||
|
export interface IdentityBatchRequest {
|
||||||
|
ids: string[];
|
||||||
|
action: IdentityBatchAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdentityBatchResponse {
|
||||||
|
action: IdentityBatchAction;
|
||||||
|
requestedCount: number;
|
||||||
|
affectedCount: number;
|
||||||
|
ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface GatewayTenant {
|
export interface GatewayTenant {
|
||||||
id: string;
|
id: string;
|
||||||
tenantKey: string;
|
tenantKey: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user