feat: better admin permissions (#5755)
* feat: add casbin admin permissions * feat: improve audit logging to associate logs with actual operators and target users * feat: enhance admin permissions and UI interactions for sensitive actions * Refactor authz RBAC and tighten channel permissions * Split channel authz field policy * Address channel authz review findings
This commit is contained in:
+11
-4
@@ -91,10 +91,17 @@ func recordManageAudit(c *gin.Context, action string, params map[string]interfac
|
||||
recordManageAuditFor(c, c.GetInt("id"), action, params)
|
||||
}
|
||||
|
||||
// recordManageAuditFor 记录一条归属于 logUserId 的管理审计日志(面向用户的操作:
|
||||
// 对目标用户的额度调整 / 解绑 / 2FA 等,使该用户也能在自己的日志中看到)。
|
||||
func recordManageAuditFor(c *gin.Context, logUserId int, action string, params map[string]interface{}) {
|
||||
model.RecordOperationAuditLog(logUserId, auditContentEN(action, params), c.ClientIP(), action, params, auditOperatorInfo(c), nil)
|
||||
// recordManageAuditFor 记录一条管理审计日志,日志归属于操作者;targetUserId
|
||||
// 只表示被操作用户,用于在结构化参数中保留目标上下文。
|
||||
func recordManageAuditFor(c *gin.Context, targetUserId int, action string, params map[string]interface{}) {
|
||||
if params == nil {
|
||||
params = map[string]interface{}{}
|
||||
}
|
||||
operatorUserId := c.GetInt("id")
|
||||
if _, ok := params["target_user_id"]; !ok && targetUserId > 0 && targetUserId != operatorUserId {
|
||||
params["target_user_id"] = targetUserId
|
||||
}
|
||||
model.RecordOperationAuditLog(operatorUserId, auditContentEN(action, params), c.ClientIP(), action, params, auditOperatorInfo(c), nil)
|
||||
markAuditLogged(c)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/service/authz"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetPermissionCatalog returns the permission schema used by the client to
|
||||
// render the permission editor: the registry of resources with their actions
|
||||
// and display label keys, plus the roles with their baseline grant matrices.
|
||||
// Defining it in the authz package keeps the schema in a single place.
|
||||
func GetPermissionCatalog(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": gin.H{
|
||||
"resources": authz.Catalog(),
|
||||
"roles": authz.Roles(),
|
||||
},
|
||||
})
|
||||
}
|
||||
+106
-4
@@ -12,11 +12,13 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relaychannel "github.com/QuantumNous/new-api/relay/channel"
|
||||
"github.com/QuantumNous/new-api/relay/channel/gemini"
|
||||
"github.com/QuantumNous/new-api/relay/channel/ollama"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/service/authz"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -820,6 +822,11 @@ func EditTagChannels(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if (channelTag.ParamOverride != nil || channelTag.HeaderOverride != nil) &&
|
||||
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) {
|
||||
common.ApiErrorI18n(c, i18n.MsgAuthInsufficientPrivilege)
|
||||
return
|
||||
}
|
||||
if channelTag.ParamOverride != nil {
|
||||
trimmed := strings.TrimSpace(*channelTag.ParamOverride)
|
||||
if trimmed != "" && !json.Valid([]byte(trimmed)) {
|
||||
@@ -896,13 +903,36 @@ type PatchChannel struct {
|
||||
KeyMode *string `json:"key_mode"` // 多key模式下密钥覆盖或者追加
|
||||
}
|
||||
|
||||
type ChannelStatusRequest struct {
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type ChannelStatusBatchRequest struct {
|
||||
Ids []int `json:"ids"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
func UpdateChannel(c *gin.Context) {
|
||||
channel := PatchChannel{}
|
||||
err := c.ShouldBindJSON(&channel)
|
||||
rawBody, err := c.GetRawData()
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if err := common.Unmarshal(rawBody, &channel); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
var requestData map[string]any
|
||||
if err := common.Unmarshal(rawBody, &requestData); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if _, ok := requestData["status"]; ok {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
clearChannelReadOnlyFields(&channel, requestData)
|
||||
|
||||
// 使用统一的校验函数
|
||||
if err := validateChannel(&channel.Channel, false); err != nil {
|
||||
@@ -925,6 +955,12 @@ func UpdateChannel(c *gin.Context) {
|
||||
// Always copy the original ChannelInfo so that fields like IsMultiKey and MultiKeySize are retained.
|
||||
channel.ChannelInfo = originChannel.ChannelInfo
|
||||
|
||||
if channelHasSensitiveChanges(&channel, originChannel, requestData) &&
|
||||
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) {
|
||||
common.ApiErrorI18n(c, i18n.MsgAuthInsufficientPrivilege)
|
||||
return
|
||||
}
|
||||
|
||||
// If the request explicitly specifies a new MultiKeyMode, apply it on top of the original info.
|
||||
if channel.MultiKeyMode != nil && *channel.MultiKeyMode != "" {
|
||||
channel.ChannelInfo.MultiKeyMode = constant.MultiKeyMode(*channel.MultiKeyMode)
|
||||
@@ -1019,9 +1055,6 @@ func UpdateChannel(c *gin.Context) {
|
||||
service.ResetProxyClientCache()
|
||||
// 记录变更的字段名(语言无关的字段标识),密钥仅记录"已更换"绝不记录内容。
|
||||
changedFields := make([]string, 0)
|
||||
if channel.Status != originChannel.Status {
|
||||
changedFields = append(changedFields, "status")
|
||||
}
|
||||
if channel.Models != originChannel.Models {
|
||||
changedFields = append(changedFields, "models")
|
||||
}
|
||||
@@ -1052,6 +1085,66 @@ func UpdateChannel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
func UpdateChannelStatus(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
req := ChannelStatusRequest{}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || !isManageableChannelStatus(req.Status) {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
changed := model.UpdateChannelStatus(id, "", req.Status, "manual operation")
|
||||
if changed {
|
||||
model.InitChannelCache()
|
||||
service.ResetProxyClientCache()
|
||||
}
|
||||
recordManageAudit(c, "channel.status_update", map[string]interface{}{
|
||||
"id": id,
|
||||
"status": req.Status,
|
||||
"changed": changed,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": changed,
|
||||
})
|
||||
}
|
||||
|
||||
func BatchUpdateChannelStatus(c *gin.Context) {
|
||||
req := ChannelStatusBatchRequest{}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.Ids) == 0 || !isManageableChannelStatus(req.Status) {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
changedCount := 0
|
||||
for _, id := range req.Ids {
|
||||
if model.UpdateChannelStatus(id, "", req.Status, "manual batch operation") {
|
||||
changedCount++
|
||||
}
|
||||
}
|
||||
if changedCount > 0 {
|
||||
model.InitChannelCache()
|
||||
service.ResetProxyClientCache()
|
||||
}
|
||||
recordManageAudit(c, "channel.status_update_batch", map[string]interface{}{
|
||||
"count": changedCount,
|
||||
"total": len(req.Ids),
|
||||
"status": req.Status,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": changedCount,
|
||||
})
|
||||
}
|
||||
|
||||
func isManageableChannelStatus(status int) bool {
|
||||
return status == common.ChannelStatusEnabled || status == common.ChannelStatusManuallyDisabled
|
||||
}
|
||||
|
||||
// equalStringPtr 比较两个 *string 是否相等(均为 nil 视为相等)。
|
||||
func equalStringPtr(a, b *string) bool {
|
||||
if a == nil && b == nil {
|
||||
@@ -1364,6 +1457,11 @@ func ManageMultiKeys(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if multiKeyActionRequiresSensitiveWrite(request.Action) &&
|
||||
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) {
|
||||
common.ApiErrorI18n(c, i18n.MsgAuthInsufficientPrivilege)
|
||||
return
|
||||
}
|
||||
|
||||
// get_key_status 为只读查询,不记录审计;其余为修改操作,记录审计并跳过中间件兜底。
|
||||
if request.Action == "get_key_status" {
|
||||
@@ -1808,6 +1906,10 @@ func ManageMultiKeys(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func multiKeyActionRequiresSensitiveWrite(action string) bool {
|
||||
return action == "delete_key" || action == "delete_disabled_keys"
|
||||
}
|
||||
|
||||
// OllamaPullModel 拉取 Ollama 模型
|
||||
func OllamaPullModel(c *gin.Context) {
|
||||
var req struct {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package controller
|
||||
|
||||
import "github.com/QuantumNous/new-api/model"
|
||||
|
||||
func channelHasSensitiveChanges(channel *PatchChannel, origin *model.Channel, requestData map[string]any) bool {
|
||||
if _, ok := requestData["type"]; ok && channel.Type != origin.Type {
|
||||
return true
|
||||
}
|
||||
if _, ok := requestData["key"]; ok && channel.Key != "" && channel.Key != origin.Key {
|
||||
return true
|
||||
}
|
||||
if _, ok := requestData["base_url"]; ok && !equalStringPtr(channel.BaseURL, origin.BaseURL) {
|
||||
return true
|
||||
}
|
||||
if _, ok := requestData["openai_organization"]; ok && !equalStringPtr(channel.OpenAIOrganization, origin.OpenAIOrganization) {
|
||||
return true
|
||||
}
|
||||
if _, ok := requestData["header_override"]; ok && !equalStringPtr(channel.HeaderOverride, origin.HeaderOverride) {
|
||||
return true
|
||||
}
|
||||
if _, ok := requestData["param_override"]; ok && !equalStringPtr(channel.ParamOverride, origin.ParamOverride) {
|
||||
return true
|
||||
}
|
||||
if _, ok := requestData["setting"]; ok && !equalStringPtr(channel.Setting, origin.Setting) {
|
||||
return true
|
||||
}
|
||||
if _, ok := requestData["other"]; ok && channel.Other != origin.Other {
|
||||
return true
|
||||
}
|
||||
if _, ok := requestData["settings"]; ok && channel.OtherSettings != origin.OtherSettings {
|
||||
return true
|
||||
}
|
||||
if _, ok := requestData["key_mode"]; ok && channel.KeyMode != nil {
|
||||
return true
|
||||
}
|
||||
// Fail closed: any field present in the request that is neither a known
|
||||
// sensitive field (gated above) nor an explicitly classified non-sensitive
|
||||
// field must be treated as sensitive. This keeps a newly added channel field
|
||||
// from silently becoming editable by ChannelWrite-only admins until it is
|
||||
// consciously classified in channelNonSensitiveFields.
|
||||
for field := range requestData {
|
||||
if _, ok := channelSensitiveFields[field]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := channelNonSensitiveFields[field]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := channelOperationalFields[field]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := channelReadOnlyFields[field]; ok {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// channelSensitiveFields lists the channel fields whose modification requires
|
||||
// ChannelSensitiveWrite. They are each checked individually in
|
||||
// channelHasSensitiveChanges with a precise old-vs-new comparison; this set is
|
||||
// used to exclude them from the fail-closed scan for unknown fields.
|
||||
var channelSensitiveFields = map[string]struct{}{
|
||||
"type": {},
|
||||
"key": {},
|
||||
"base_url": {},
|
||||
"openai_organization": {},
|
||||
"header_override": {},
|
||||
"param_override": {},
|
||||
"setting": {},
|
||||
"other": {},
|
||||
"settings": {},
|
||||
"key_mode": {},
|
||||
}
|
||||
|
||||
// channelOperationalFields lists fields managed by operation endpoints instead
|
||||
// of the general channel edit endpoint.
|
||||
var channelOperationalFields = map[string]struct{}{
|
||||
"status": {},
|
||||
}
|
||||
|
||||
// channelReadOnlyFields lists server-managed/accounting fields that the general
|
||||
// channel edit endpoint must ignore even if a client sends them.
|
||||
var channelReadOnlyFields = map[string]struct{}{
|
||||
"created_time": {},
|
||||
"test_time": {},
|
||||
"response_time": {},
|
||||
"balance": {},
|
||||
"balance_updated_time": {},
|
||||
"used_quota": {},
|
||||
}
|
||||
|
||||
func clearChannelReadOnlyFields(channel *PatchChannel, requestData map[string]any) {
|
||||
if _, ok := requestData["created_time"]; ok {
|
||||
channel.CreatedTime = 0
|
||||
}
|
||||
if _, ok := requestData["test_time"]; ok {
|
||||
channel.TestTime = 0
|
||||
}
|
||||
if _, ok := requestData["response_time"]; ok {
|
||||
channel.ResponseTime = 0
|
||||
}
|
||||
if _, ok := requestData["balance"]; ok {
|
||||
channel.Balance = 0
|
||||
}
|
||||
if _, ok := requestData["balance_updated_time"]; ok {
|
||||
channel.BalanceUpdatedTime = 0
|
||||
}
|
||||
if _, ok := requestData["used_quota"]; ok {
|
||||
channel.UsedQuota = 0
|
||||
}
|
||||
}
|
||||
|
||||
// channelNonSensitiveFields lists routing / server-managed channel
|
||||
// fields a ChannelWrite admin may edit without ChannelSensitiveWrite. When a new
|
||||
// field is added to model.Channel it must be added to either this set or
|
||||
// channelSensitiveFields or channelOperationalFields; otherwise it falls through
|
||||
// to the fail-closed branch and is treated as sensitive. The
|
||||
// TestChannelFieldsAreClassified guard test enforces this.
|
||||
var channelNonSensitiveFields = map[string]struct{}{
|
||||
"id": {},
|
||||
"test_model": {},
|
||||
"name": {},
|
||||
"weight": {},
|
||||
"models": {},
|
||||
"group": {},
|
||||
"model_mapping": {},
|
||||
"status_code_mapping": {},
|
||||
"priority": {},
|
||||
"auto_ban": {},
|
||||
"other_info": {},
|
||||
"tag": {},
|
||||
"remark": {},
|
||||
"channel_info": {},
|
||||
"multi_key_mode": {},
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestChannelHasSensitiveChanges(t *testing.T) {
|
||||
baseURL := "https://api.example.com"
|
||||
headerOverride := `{"Authorization":"Bearer {api_key}"}`
|
||||
origin := &model.Channel{
|
||||
Type: 1,
|
||||
Key: "old-key",
|
||||
BaseURL: &baseURL,
|
||||
HeaderOverride: &headerOverride,
|
||||
Models: "gpt-4o",
|
||||
Group: "default",
|
||||
}
|
||||
|
||||
t.Run("non-sensitive routing fields", func(t *testing.T) {
|
||||
updated := PatchChannel{Channel: *origin}
|
||||
updated.Models = "gpt-4o,gpt-4o-mini"
|
||||
updated.Group = "vip"
|
||||
|
||||
assert.False(t, channelHasSensitiveChanges(&updated, origin, map[string]any{
|
||||
"models": updated.Models,
|
||||
"group": updated.Group,
|
||||
}))
|
||||
})
|
||||
|
||||
t.Run("key change", func(t *testing.T) {
|
||||
updated := PatchChannel{Channel: *origin}
|
||||
updated.Key = "new-key"
|
||||
|
||||
assert.True(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"key": updated.Key}))
|
||||
})
|
||||
|
||||
t.Run("base url change", func(t *testing.T) {
|
||||
updated := PatchChannel{Channel: *origin}
|
||||
newBaseURL := "https://leak.example.com"
|
||||
updated.BaseURL = &newBaseURL
|
||||
|
||||
assert.True(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"base_url": newBaseURL}))
|
||||
})
|
||||
|
||||
t.Run("header override change", func(t *testing.T) {
|
||||
updated := PatchChannel{Channel: *origin}
|
||||
newHeaderOverride := `{"X-Key":"{api_key}"}`
|
||||
updated.HeaderOverride = &newHeaderOverride
|
||||
|
||||
assert.True(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"header_override": newHeaderOverride}))
|
||||
})
|
||||
|
||||
t.Run("omitted sensitive fields do not use zero values", func(t *testing.T) {
|
||||
updated := PatchChannel{}
|
||||
updated.Id = origin.Id
|
||||
updated.Priority = origin.Priority
|
||||
|
||||
assert.False(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"priority": 10}))
|
||||
})
|
||||
|
||||
t.Run("unknown field fails closed", func(t *testing.T) {
|
||||
updated := PatchChannel{Channel: *origin}
|
||||
|
||||
assert.True(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"future_secret_field": "x"}))
|
||||
})
|
||||
|
||||
t.Run("status is operational", func(t *testing.T) {
|
||||
updated := PatchChannel{Channel: *origin}
|
||||
updated.Status = common.ChannelStatusManuallyDisabled
|
||||
|
||||
assert.False(t, channelHasSensitiveChanges(&updated, origin, map[string]any{"status": updated.Status}))
|
||||
})
|
||||
|
||||
t.Run("read-only fields are ignored by sensitivity check", func(t *testing.T) {
|
||||
updated := PatchChannel{Channel: *origin}
|
||||
updated.Balance = 99
|
||||
updated.UsedQuota = 100
|
||||
updated.ResponseTime = 200
|
||||
|
||||
assert.False(t, channelHasSensitiveChanges(&updated, origin, map[string]any{
|
||||
"balance": updated.Balance,
|
||||
"used_quota": updated.UsedQuota,
|
||||
"response_time": updated.ResponseTime,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
func TestClearChannelReadOnlyFields(t *testing.T) {
|
||||
channel := PatchChannel{Channel: model.Channel{
|
||||
CreatedTime: 11,
|
||||
TestTime: 22,
|
||||
ResponseTime: 33,
|
||||
Balance: 44.5,
|
||||
BalanceUpdatedTime: 55,
|
||||
UsedQuota: 66,
|
||||
Models: "gpt-4o",
|
||||
Group: "default",
|
||||
}}
|
||||
|
||||
clearChannelReadOnlyFields(&channel, map[string]any{
|
||||
"created_time": channel.CreatedTime,
|
||||
"test_time": channel.TestTime,
|
||||
"response_time": channel.ResponseTime,
|
||||
"balance": channel.Balance,
|
||||
"balance_updated_time": channel.BalanceUpdatedTime,
|
||||
"used_quota": channel.UsedQuota,
|
||||
"models": channel.Models,
|
||||
"group": channel.Group,
|
||||
})
|
||||
|
||||
assert.Zero(t, channel.CreatedTime)
|
||||
assert.Zero(t, channel.TestTime)
|
||||
assert.Zero(t, channel.ResponseTime)
|
||||
assert.Zero(t, channel.Balance)
|
||||
assert.Zero(t, channel.BalanceUpdatedTime)
|
||||
assert.Zero(t, channel.UsedQuota)
|
||||
assert.Equal(t, "gpt-4o", channel.Models)
|
||||
assert.Equal(t, "default", channel.Group)
|
||||
}
|
||||
|
||||
func TestUpdateChannelRejectsStatusField(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/channel/",
|
||||
bytes.NewBufferString(`{"id":1,"status":2}`),
|
||||
)
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
UpdateChannel(ctx)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
|
||||
assert.False(t, response.Success)
|
||||
}
|
||||
|
||||
func TestChannelStatusValidation(t *testing.T) {
|
||||
assert.True(t, isManageableChannelStatus(common.ChannelStatusEnabled))
|
||||
assert.True(t, isManageableChannelStatus(common.ChannelStatusManuallyDisabled))
|
||||
assert.False(t, isManageableChannelStatus(common.ChannelStatusAutoDisabled))
|
||||
assert.False(t, isManageableChannelStatus(0))
|
||||
}
|
||||
|
||||
// TestChannelFieldsAreClassified guards the fail-closed sensitivity check: every
|
||||
// JSON field of PatchChannel (including the embedded model.Channel) must be listed
|
||||
// in channelSensitiveFields, channelNonSensitiveFields, or
|
||||
// channelOperationalFields. A newly added field that is left unclassified will
|
||||
// fail this test, forcing a conscious permission decision instead of silently
|
||||
// defaulting either way.
|
||||
func TestChannelFieldsAreClassified(t *testing.T) {
|
||||
classified := func(name string) bool {
|
||||
if _, ok := channelSensitiveFields[name]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := channelNonSensitiveFields[name]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := channelOperationalFields[name]; ok {
|
||||
return true
|
||||
}
|
||||
_, ok := channelReadOnlyFields[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
var collect func(rt reflect.Type) []string
|
||||
collect = func(rt reflect.Type) []string {
|
||||
var names []string
|
||||
for i := 0; i < rt.NumField(); i++ {
|
||||
field := rt.Field(i)
|
||||
if field.Anonymous && field.Type.Kind() == reflect.Struct {
|
||||
names = append(names, collect(field.Type)...)
|
||||
continue
|
||||
}
|
||||
name := strings.Split(field.Tag.Get("json"), ",")[0]
|
||||
if name == "" || name == "-" {
|
||||
continue
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
for _, name := range collect(reflect.TypeOf(PatchChannel{})) {
|
||||
assert.Truef(t, classified(name),
|
||||
"channel field %q is not classified; add it to channelSensitiveFields, channelNonSensitiveFields, channelOperationalFields, or channelReadOnlyFields in channel_authz.go", name)
|
||||
}
|
||||
}
|
||||
+82
-9
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/service/authz"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
@@ -334,6 +336,7 @@ func GetUser(c *gin.Context) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel)
|
||||
return
|
||||
}
|
||||
user.AdminPermissions = authz.Capabilities(user.Id, user.Role)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
@@ -443,6 +446,7 @@ func GetSelf(c *gin.Context) {
|
||||
|
||||
// 计算用户权限信息
|
||||
permissions := calculateUserPermissions(userRole)
|
||||
permissions["admin_permissions"] = authz.Capabilities(id, userRole)
|
||||
|
||||
// 获取用户设置并提取sidebar_modules
|
||||
userSetting := user.GetSetting()
|
||||
@@ -620,23 +624,41 @@ func UpdateUser(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if updatedUser.Role != common.RoleGuestUser && updatedUser.Role != originUser.Role {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
updatedUser.Role = originUser.Role
|
||||
myRole := c.GetInt("role")
|
||||
if !canManageTargetRole(myRole, originUser.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
|
||||
return
|
||||
}
|
||||
if !canManageTargetRole(myRole, updatedUser.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel)
|
||||
return
|
||||
}
|
||||
if updatedUser.Password == "$I_LOVE_U" {
|
||||
updatedUser.Password = "" // rollback to what it should be
|
||||
}
|
||||
updatePassword := updatedUser.Password != ""
|
||||
if err := updatedUser.Edit(updatePassword); err != nil {
|
||||
authzTouched := false
|
||||
if err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := updatedUser.EditWithTx(tx, updatePassword); err != nil {
|
||||
return err
|
||||
}
|
||||
touched, err := updateAdminPermissionsForUserInTx(c, tx, updatedUser.Id, originUser.Role, updatedUser.AdminPermissions)
|
||||
authzTouched = touched
|
||||
return err
|
||||
}); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if authzTouched {
|
||||
if err := authz.ReloadPolicy(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := model.InvalidateUserCache(updatedUser.Id); err != nil {
|
||||
common.SysLog(fmt.Sprintf("failed to invalidate user cache for user %d: %s", updatedUser.Id, err.Error()))
|
||||
}
|
||||
recordManageAuditFor(c, updatedUser.Id, "user.update", map[string]interface{}{
|
||||
"username": originUser.Username,
|
||||
"id": updatedUser.Id,
|
||||
@@ -901,10 +923,25 @@ func CreateUser(c *gin.Context) {
|
||||
DisplayName: user.DisplayName,
|
||||
Role: user.Role, // 保持管理员设置的角色
|
||||
}
|
||||
if err := cleanUser.Insert(0); err != nil {
|
||||
authzTouched := false
|
||||
if err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := cleanUser.InsertWithTx(tx, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
touched, err := updateAdminPermissionsForUserInTx(c, tx, cleanUser.Id, cleanUser.Role, user.AdminPermissions)
|
||||
authzTouched = touched
|
||||
return err
|
||||
}); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if authzTouched {
|
||||
if err := authz.ReloadPolicy(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
cleanUser.FinishInsert(0)
|
||||
|
||||
recordManageAuditFor(c, cleanUser.Id, "user.create", map[string]interface{}{
|
||||
"username": cleanUser.Username,
|
||||
@@ -917,6 +954,22 @@ func CreateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
func updateAdminPermissionsForUserInTx(c *gin.Context, tx *gorm.DB, userID int, userRole int, permissions map[string]map[string]bool) (bool, error) {
|
||||
if permissions == nil {
|
||||
if userRole < common.RoleAdminUser && c.GetInt("role") == common.RoleRootUser {
|
||||
return true, authz.ClearUserAuthorizationInTx(tx, userID)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if c.GetInt("role") != common.RoleRootUser {
|
||||
return false, fmt.Errorf("only root can update admin permissions")
|
||||
}
|
||||
if userRole < common.RoleAdminUser {
|
||||
return true, authz.ClearUserAuthorizationInTx(tx, userID)
|
||||
}
|
||||
return true, authz.SetUserPermissionsInTx(tx, userID, permissions)
|
||||
}
|
||||
|
||||
type ManageRequest struct {
|
||||
Id int `json:"id"`
|
||||
Action string `json:"action"`
|
||||
@@ -1040,9 +1093,29 @@ func ManageUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := user.Update(false); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
authzTouched := false
|
||||
if req.Action == "demote" {
|
||||
if err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := user.UpdateWithTx(tx, false); err != nil {
|
||||
return err
|
||||
}
|
||||
authzTouched = true
|
||||
return authz.ClearUserAuthorizationInTx(tx, user.Id)
|
||||
}); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if authzTouched {
|
||||
if err := authz.ReloadPolicy(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := user.Update(false); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 禁用 / 角色调整后,强制失效用户缓存与其全部令牌缓存,
|
||||
// 避免在 Redis TTL 过期前仍使用旧状态(尤其是禁用后仍可发起请求的问题)。
|
||||
|
||||
@@ -13,6 +13,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
|
||||
github.com/aws/smithy-go v1.24.2
|
||||
github.com/bytedance/gopkg v0.1.3
|
||||
github.com/casbin/casbin/v2 v2.135.0
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-contrib/gzip v0.0.6
|
||||
github.com/gin-contrib/sessions v0.0.5
|
||||
@@ -68,6 +69,8 @@ require (
|
||||
require (
|
||||
github.com/ClickHouse/ch-go v0.65.0 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.32.0 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
|
||||
github.com/casbin/govaluate v1.10.0 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
|
||||
@@ -608,8 +608,6 @@ github.com/Azure/azure-sdk-for-go v56.3.0+incompatible/go.mod h1:9XXNKU+eRnpl9mo
|
||||
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210608223527-2377c96fe795/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/Azure/go-autorest v10.8.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
|
||||
github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
|
||||
github.com/Azure/go-autorest/autorest v0.11.1/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw=
|
||||
@@ -626,6 +624,8 @@ github.com/Azure/go-autorest/autorest/to v0.4.0/go.mod h1:fE8iZBn7LQR7zH/9XU2NcP
|
||||
github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8=
|
||||
github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8=
|
||||
github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU=
|
||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
@@ -750,6 +750,8 @@ github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJm
|
||||
github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
||||
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
||||
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
|
||||
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
@@ -770,6 +772,11 @@ github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7
|
||||
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
|
||||
github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
|
||||
github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
|
||||
@@ -1243,6 +1250,7 @@ github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt
|
||||
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/relay"
|
||||
"github.com/QuantumNous/new-api/router"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/service/authz"
|
||||
_ "github.com/QuantumNous/new-api/setting/performance_setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
|
||||
@@ -100,6 +101,9 @@ func main() {
|
||||
// 热更新配置
|
||||
go model.SyncOptions(common.SyncFrequency)
|
||||
|
||||
// 周期性重载授权策略,保证多节点/多 master 部署下权限变更能传播到每个实例
|
||||
go authz.StartPolicySync(common.SyncFrequency)
|
||||
|
||||
// 数据看板
|
||||
go model.UpdateQuotaData()
|
||||
|
||||
@@ -284,6 +288,10 @@ func InitResources() error {
|
||||
common.FatalLog("failed to initialize database: " + err.Error())
|
||||
return err
|
||||
}
|
||||
if err = authz.Init(model.DB); err != nil {
|
||||
common.FatalLog("failed to initialize authorization: " + err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
model.CheckSetup()
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/service/authz"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
@@ -195,6 +196,22 @@ func RootAuth() func(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func RequirePermission(permission authz.Permission) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
role := c.GetInt("role")
|
||||
userID := c.GetInt("id")
|
||||
if authz.Can(userID, role, permission) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege),
|
||||
})
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
func WssAuth(c *gin.Context) {
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
type AuthzRole struct {
|
||||
Id uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Key string `json:"key" gorm:"size:64;uniqueIndex;not null"`
|
||||
Name string `json:"name" gorm:"size:100;not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
BuiltIn bool `json:"built_in"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Sort int `json:"sort"`
|
||||
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"`
|
||||
UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime;column:updated_at"`
|
||||
}
|
||||
|
||||
func (AuthzRole) TableName() string {
|
||||
return "authz_roles"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package model
|
||||
|
||||
type CasbinRule struct {
|
||||
Id uint `gorm:"primaryKey;autoIncrement"`
|
||||
Ptype string `gorm:"size:100;index:idx_casbin_rule,priority:1;uniqueIndex:idx_casbin_rule_unique,priority:1"`
|
||||
V0 string `gorm:"size:100;index:idx_casbin_rule,priority:2;uniqueIndex:idx_casbin_rule_unique,priority:2"`
|
||||
V1 string `gorm:"size:100;index:idx_casbin_rule,priority:3;uniqueIndex:idx_casbin_rule_unique,priority:3"`
|
||||
V2 string `gorm:"size:100;index:idx_casbin_rule,priority:4;uniqueIndex:idx_casbin_rule_unique,priority:4"`
|
||||
V3 string `gorm:"size:100;index:idx_casbin_rule,priority:5;uniqueIndex:idx_casbin_rule_unique,priority:5"`
|
||||
V4 string `gorm:"size:100;index:idx_casbin_rule,priority:6;uniqueIndex:idx_casbin_rule_unique,priority:6"`
|
||||
V5 string `gorm:"size:100;index:idx_casbin_rule,priority:7;uniqueIndex:idx_casbin_rule_unique,priority:7"`
|
||||
}
|
||||
|
||||
func (CasbinRule) TableName() string {
|
||||
return "casbin_rule"
|
||||
}
|
||||
+2
-2
@@ -196,8 +196,8 @@ func RecordLoginLog(userId int, username string, content string, ip string, acti
|
||||
}
|
||||
|
||||
// RecordOperationAuditLog 记录管理/高危操作审计日志(type=LogTypeManage)。
|
||||
// logUserId 为日志归属者(面向用户的操作如额度调整归属目标用户,资源类操作如渠道/系统设置归属操作者),
|
||||
// username 内部按 logUserId 查询。content 为英文兜底文本(导出/经典前端用)。
|
||||
// logUserId 为日志归属者,管理审计日志应归属实际操作者;目标资源/用户放入
|
||||
// action params。username 内部按 logUserId 查询。content 为英文兜底文本(导出/经典前端用)。
|
||||
// action+params 写入 Other.op,供前端本地化渲染(普通用户可见,不含敏感信息)。
|
||||
// adminInfo 存放操作者身份(写入 Other.admin_info,普通用户查询时剥离);
|
||||
// auditInfo 存放路由/方法/结果等中间件兜底信息(写入 Other.audit_info,普通用户查询时剥离)。
|
||||
|
||||
@@ -297,6 +297,8 @@ func migrateDB() error {
|
||||
&SystemInstance{},
|
||||
&SystemTask{},
|
||||
&SystemTaskLock{},
|
||||
&CasbinRule{},
|
||||
&AuthzRole{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+61
-42
@@ -22,37 +22,38 @@ const UserNameMaxLength = 20
|
||||
// User if you add sensitive fields, don't forget to clean them in setupLogin function.
|
||||
// Otherwise, the sensitive information will be saved on local storage in plain text!
|
||||
type User struct {
|
||||
Id int `json:"id"`
|
||||
Username string `json:"username" gorm:"unique;index" validate:"max=20"`
|
||||
Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"`
|
||||
OriginalPassword string `json:"original_password" gorm:"-:all"` // this field is only for Password change verification, don't save it to database!
|
||||
DisplayName string `json:"display_name" gorm:"index" validate:"max=20"`
|
||||
Role int `json:"role" gorm:"type:int;default:1"` // admin, common
|
||||
Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled
|
||||
Email string `json:"email" gorm:"index" validate:"max=50"`
|
||||
GitHubId string `json:"github_id" gorm:"column:github_id;index"`
|
||||
DiscordId string `json:"discord_id" gorm:"column:discord_id;index"`
|
||||
OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"`
|
||||
WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"`
|
||||
TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"`
|
||||
VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database!
|
||||
AccessToken *string `json:"-" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
|
||||
Quota int `json:"quota" gorm:"type:int;default:0"`
|
||||
UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota
|
||||
RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number
|
||||
Group string `json:"group" gorm:"type:varchar(64);default:'default'"`
|
||||
AffCode string `json:"aff_code" gorm:"type:varchar(32);column:aff_code;uniqueIndex"`
|
||||
AffCount int `json:"aff_count" gorm:"type:int;default:0;column:aff_count"`
|
||||
AffQuota int `json:"aff_quota" gorm:"type:int;default:0;column:aff_quota"` // 邀请剩余额度
|
||||
AffHistoryQuota int `json:"aff_history_quota" gorm:"type:int;default:0;column:aff_history"` // 邀请历史额度
|
||||
InviterId int `json:"inviter_id" gorm:"type:int;column:inviter_id;index"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"`
|
||||
Setting string `json:"setting" gorm:"type:text;column:setting"`
|
||||
Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"`
|
||||
StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"`
|
||||
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"`
|
||||
LastLoginAt int64 `json:"last_login_at" gorm:"default:0;column:last_login_at"`
|
||||
Id int `json:"id"`
|
||||
Username string `json:"username" gorm:"unique;index" validate:"max=20"`
|
||||
Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"`
|
||||
OriginalPassword string `json:"original_password" gorm:"-:all"` // this field is only for Password change verification, don't save it to database!
|
||||
DisplayName string `json:"display_name" gorm:"index" validate:"max=20"`
|
||||
Role int `json:"role" gorm:"type:int;default:1"` // admin, common
|
||||
Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled
|
||||
Email string `json:"email" gorm:"index" validate:"max=50"`
|
||||
GitHubId string `json:"github_id" gorm:"column:github_id;index"`
|
||||
DiscordId string `json:"discord_id" gorm:"column:discord_id;index"`
|
||||
OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"`
|
||||
WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"`
|
||||
TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"`
|
||||
VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database!
|
||||
AccessToken *string `json:"-" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
|
||||
Quota int `json:"quota" gorm:"type:int;default:0"`
|
||||
UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota
|
||||
RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number
|
||||
Group string `json:"group" gorm:"type:varchar(64);default:'default'"`
|
||||
AffCode string `json:"aff_code" gorm:"type:varchar(32);column:aff_code;uniqueIndex"`
|
||||
AffCount int `json:"aff_count" gorm:"type:int;default:0;column:aff_count"`
|
||||
AffQuota int `json:"aff_quota" gorm:"type:int;default:0;column:aff_quota"` // 邀请剩余额度
|
||||
AffHistoryQuota int `json:"aff_history_quota" gorm:"type:int;default:0;column:aff_history"` // 邀请历史额度
|
||||
InviterId int `json:"inviter_id" gorm:"type:int;column:inviter_id;index"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"`
|
||||
Setting string `json:"setting" gorm:"type:text;column:setting"`
|
||||
Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"`
|
||||
StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"`
|
||||
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"`
|
||||
LastLoginAt int64 `json:"last_login_at" gorm:"default:0;column:last_login_at"`
|
||||
AdminPermissions map[string]map[string]bool `json:"admin_permissions,omitempty" gorm:"-:all"`
|
||||
}
|
||||
|
||||
func (user *User) ToBaseUser() *UserBase {
|
||||
@@ -408,6 +409,11 @@ func (user *User) Insert(inviterId int) error {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
user.finishInsert(inviterId)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (user *User) finishInsert(inviterId int) {
|
||||
// 用户创建成功后,根据角色初始化边栏配置
|
||||
// 需要重新获取用户以确保有正确的ID和Role
|
||||
var createdUser User
|
||||
@@ -437,7 +443,10 @@ func (user *User) Insert(inviterId int) error {
|
||||
_ = inviteUser(inviterId)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (user *User) FinishInsert(inviterId int) {
|
||||
user.finishInsert(inviterId)
|
||||
}
|
||||
|
||||
// InsertWithTx inserts a new user within an existing transaction.
|
||||
@@ -500,6 +509,13 @@ func (user *User) FinalizeOAuthUserCreation(inviterId int) {
|
||||
}
|
||||
|
||||
func (user *User) Update(updatePassword bool) error {
|
||||
if err := user.UpdateWithTx(DB, updatePassword); err != nil {
|
||||
return err
|
||||
}
|
||||
return updateUserCache(*user)
|
||||
}
|
||||
|
||||
func (user *User) UpdateWithTx(tx *gorm.DB, updatePassword bool) error {
|
||||
var err error
|
||||
if updatePassword {
|
||||
user.Password, err = common.Password2Hash(user.Password)
|
||||
@@ -508,16 +524,21 @@ func (user *User) Update(updatePassword bool) error {
|
||||
}
|
||||
}
|
||||
newUser := *user
|
||||
DB.First(&user, user.Id)
|
||||
if err = DB.Model(user).Updates(newUser).Error; err != nil {
|
||||
tx.First(&user, user.Id)
|
||||
if err = tx.Model(user).Updates(newUser).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update cache
|
||||
return updateUserCache(*user)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (user *User) Edit(updatePassword bool) error {
|
||||
if err := user.EditWithTx(DB, updatePassword); err != nil {
|
||||
return err
|
||||
}
|
||||
return updateUserCache(*user)
|
||||
}
|
||||
|
||||
func (user *User) EditWithTx(tx *gorm.DB, updatePassword bool) error {
|
||||
var err error
|
||||
if updatePassword {
|
||||
user.Password, err = common.Password2Hash(user.Password)
|
||||
@@ -537,13 +558,11 @@ func (user *User) Edit(updatePassword bool) error {
|
||||
updates["password"] = newUser.Password
|
||||
}
|
||||
|
||||
DB.First(&user, user.Id)
|
||||
if err = DB.Model(user).Updates(updates).Error; err != nil {
|
||||
tx.First(&user, user.Id)
|
||||
if err = tx.Model(user).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update cache
|
||||
return updateUserCache(*user)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (user *User) ClearBinding(bindingType string) error {
|
||||
|
||||
+2
-42
@@ -225,48 +225,8 @@ func SetApiRouter(router *gin.Engine) {
|
||||
ratioSyncRoute.GET("/channels", controller.GetSyncableChannels)
|
||||
ratioSyncRoute.POST("/fetch", controller.FetchUpstreamRatios)
|
||||
}
|
||||
channelRoute := apiRouter.Group("/channel")
|
||||
channelRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
channelRoute.GET("/", controller.GetAllChannels)
|
||||
channelRoute.GET("/search", controller.SearchChannels)
|
||||
channelRoute.GET("/models", controller.ChannelListModels)
|
||||
channelRoute.GET("/models_enabled", controller.EnabledListModels)
|
||||
channelRoute.GET("/ops", controller.GetChannelOps)
|
||||
channelRoute.GET("/:id", controller.GetChannel)
|
||||
channelRoute.POST("/:id/key", middleware.RootAuth(), middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.SecureVerificationRequired(), controller.GetChannelKey)
|
||||
channelRoute.GET("/test", controller.TestAllChannels)
|
||||
channelRoute.GET("/test/:id", controller.TestChannel)
|
||||
channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance)
|
||||
channelRoute.GET("/update_balance/:id", controller.UpdateChannelBalance)
|
||||
channelRoute.POST("/", controller.AddChannel)
|
||||
channelRoute.PUT("/", controller.UpdateChannel)
|
||||
channelRoute.DELETE("/disabled", controller.DeleteDisabledChannel)
|
||||
channelRoute.POST("/tag/disabled", controller.DisableTagChannels)
|
||||
channelRoute.POST("/tag/enabled", controller.EnableTagChannels)
|
||||
channelRoute.PUT("/tag", controller.EditTagChannels)
|
||||
channelRoute.DELETE("/:id", controller.DeleteChannel)
|
||||
channelRoute.POST("/batch", controller.DeleteChannelBatch)
|
||||
channelRoute.POST("/fix", controller.FixChannelsAbilities)
|
||||
channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels)
|
||||
channelRoute.POST("/fetch_models", middleware.RootAuth(), controller.FetchModels)
|
||||
channelRoute.POST("/:id/codex/refresh", controller.RefreshCodexChannelCredential)
|
||||
channelRoute.GET("/:id/codex/usage", controller.GetCodexChannelUsage)
|
||||
channelRoute.GET("/:id/codex/usage/reset-credits", controller.GetCodexChannelRateLimitResetCredits)
|
||||
channelRoute.POST("/:id/codex/usage/reset", controller.ResetCodexChannelUsage)
|
||||
channelRoute.POST("/ollama/pull", controller.OllamaPullModel)
|
||||
channelRoute.POST("/ollama/pull/stream", controller.OllamaPullModelStream)
|
||||
channelRoute.DELETE("/ollama/delete", controller.OllamaDeleteModel)
|
||||
channelRoute.GET("/ollama/version/:id", controller.OllamaVersion)
|
||||
channelRoute.POST("/batch/tag", controller.BatchSetChannelTag)
|
||||
channelRoute.GET("/tag/models", controller.GetTagModels)
|
||||
channelRoute.POST("/copy/:id", controller.CopyChannel)
|
||||
channelRoute.POST("/multi_key/manage", controller.ManageMultiKeys)
|
||||
channelRoute.POST("/upstream_updates/apply", controller.ApplyChannelUpstreamModelUpdates)
|
||||
channelRoute.POST("/upstream_updates/apply_all", controller.ApplyAllChannelUpstreamModelUpdates)
|
||||
channelRoute.POST("/upstream_updates/detect", controller.DetectChannelUpstreamModelUpdates)
|
||||
channelRoute.POST("/upstream_updates/detect_all", controller.DetectAllChannelUpstreamModelUpdates)
|
||||
}
|
||||
registerChannelRoutes(apiRouter)
|
||||
registerAuthzRoutes(apiRouter)
|
||||
tokenRoute := apiRouter.Group("/token")
|
||||
tokenRoute.Use(middleware.UserAuth())
|
||||
{
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/controller"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// registerAuthzRoutes mounts the authorization API under its own /authz
|
||||
// namespace. GET /authz/catalog returns the permission schema (resources,
|
||||
// actions, and role baselines) used by the client permission editor.
|
||||
func registerAuthzRoutes(apiRouter *gin.RouterGroup) {
|
||||
authzRoute := apiRouter.Group("/authz")
|
||||
authzRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
authzRoute.GET("/catalog", controller.GetPermissionCatalog)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/controller"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/service/authz"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type permissionRoute struct {
|
||||
method string
|
||||
path string
|
||||
permission authz.Permission
|
||||
handler gin.HandlerFunc
|
||||
}
|
||||
|
||||
func registerChannelRoutes(apiRouter *gin.RouterGroup) {
|
||||
channelRoute := apiRouter.Group("/channel")
|
||||
channelRoute.Use(middleware.AdminAuth())
|
||||
|
||||
channelRoute.POST("/:id/key",
|
||||
middleware.RootAuth(),
|
||||
middleware.CriticalRateLimit(),
|
||||
middleware.DisableCache(),
|
||||
middleware.SecureVerificationRequired(),
|
||||
controller.GetChannelKey,
|
||||
)
|
||||
|
||||
for _, route := range channelPermissionRoutes {
|
||||
channelRoute.Handle(route.method, route.path,
|
||||
middleware.RequirePermission(route.permission),
|
||||
route.handler,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var channelPermissionRoutes = []permissionRoute{
|
||||
{method: http.MethodGet, path: "/", permission: authz.ChannelRead, handler: controller.GetAllChannels},
|
||||
{method: http.MethodGet, path: "/search", permission: authz.ChannelRead, handler: controller.SearchChannels},
|
||||
{method: http.MethodGet, path: "/models", permission: authz.ChannelRead, handler: controller.ChannelListModels},
|
||||
{method: http.MethodGet, path: "/models_enabled", permission: authz.ChannelRead, handler: controller.EnabledListModels},
|
||||
{method: http.MethodGet, path: "/ops", permission: authz.ChannelRead, handler: controller.GetChannelOps},
|
||||
{method: http.MethodGet, path: "/:id", permission: authz.ChannelRead, handler: controller.GetChannel},
|
||||
{method: http.MethodGet, path: "/test", permission: authz.ChannelOperate, handler: controller.TestAllChannels},
|
||||
{method: http.MethodGet, path: "/test/:id", permission: authz.ChannelOperate, handler: controller.TestChannel},
|
||||
{method: http.MethodGet, path: "/update_balance", permission: authz.ChannelOperate, handler: controller.UpdateAllChannelsBalance},
|
||||
{method: http.MethodGet, path: "/update_balance/:id", permission: authz.ChannelOperate, handler: controller.UpdateChannelBalance},
|
||||
{method: http.MethodPost, path: "/", permission: authz.ChannelSensitiveWrite, handler: controller.AddChannel},
|
||||
{method: http.MethodPut, path: "/", permission: authz.ChannelWrite, handler: controller.UpdateChannel},
|
||||
{method: http.MethodPost, path: "/status/batch", permission: authz.ChannelOperate, handler: controller.BatchUpdateChannelStatus},
|
||||
{method: http.MethodPost, path: "/:id/status", permission: authz.ChannelOperate, handler: controller.UpdateChannelStatus},
|
||||
{method: http.MethodDelete, path: "/disabled", permission: authz.ChannelSensitiveWrite, handler: controller.DeleteDisabledChannel},
|
||||
{method: http.MethodPost, path: "/tag/disabled", permission: authz.ChannelOperate, handler: controller.DisableTagChannels},
|
||||
{method: http.MethodPost, path: "/tag/enabled", permission: authz.ChannelOperate, handler: controller.EnableTagChannels},
|
||||
{method: http.MethodPut, path: "/tag", permission: authz.ChannelWrite, handler: controller.EditTagChannels},
|
||||
{method: http.MethodDelete, path: "/:id", permission: authz.ChannelSensitiveWrite, handler: controller.DeleteChannel},
|
||||
{method: http.MethodPost, path: "/batch", permission: authz.ChannelSensitiveWrite, handler: controller.DeleteChannelBatch},
|
||||
{method: http.MethodPost, path: "/fix", permission: authz.ChannelOperate, handler: controller.FixChannelsAbilities},
|
||||
{method: http.MethodGet, path: "/fetch_models/:id", permission: authz.ChannelOperate, handler: controller.FetchUpstreamModels},
|
||||
{method: http.MethodPost, path: "/fetch_models", permission: authz.ChannelSensitiveWrite, handler: controller.FetchModels},
|
||||
{method: http.MethodPost, path: "/:id/codex/refresh", permission: authz.ChannelSensitiveWrite, handler: controller.RefreshCodexChannelCredential},
|
||||
{method: http.MethodGet, path: "/:id/codex/usage", permission: authz.ChannelRead, handler: controller.GetCodexChannelUsage},
|
||||
{method: http.MethodGet, path: "/:id/codex/usage/reset-credits", permission: authz.ChannelRead, handler: controller.GetCodexChannelRateLimitResetCredits},
|
||||
{method: http.MethodPost, path: "/:id/codex/usage/reset", permission: authz.ChannelOperate, handler: controller.ResetCodexChannelUsage},
|
||||
{method: http.MethodPost, path: "/ollama/pull", permission: authz.ChannelSensitiveWrite, handler: controller.OllamaPullModel},
|
||||
{method: http.MethodPost, path: "/ollama/pull/stream", permission: authz.ChannelSensitiveWrite, handler: controller.OllamaPullModelStream},
|
||||
{method: http.MethodDelete, path: "/ollama/delete", permission: authz.ChannelSensitiveWrite, handler: controller.OllamaDeleteModel},
|
||||
{method: http.MethodGet, path: "/ollama/version/:id", permission: authz.ChannelSensitiveWrite, handler: controller.OllamaVersion},
|
||||
{method: http.MethodPost, path: "/batch/tag", permission: authz.ChannelWrite, handler: controller.BatchSetChannelTag},
|
||||
{method: http.MethodGet, path: "/tag/models", permission: authz.ChannelRead, handler: controller.GetTagModels},
|
||||
{method: http.MethodPost, path: "/copy/:id", permission: authz.ChannelSensitiveWrite, handler: controller.CopyChannel},
|
||||
{method: http.MethodPost, path: "/multi_key/manage", permission: authz.ChannelOperate, handler: controller.ManageMultiKeys},
|
||||
{method: http.MethodPost, path: "/upstream_updates/apply", permission: authz.ChannelWrite, handler: controller.ApplyChannelUpstreamModelUpdates},
|
||||
{method: http.MethodPost, path: "/upstream_updates/apply_all", permission: authz.ChannelWrite, handler: controller.ApplyAllChannelUpstreamModelUpdates},
|
||||
{method: http.MethodPost, path: "/upstream_updates/detect", permission: authz.ChannelOperate, handler: controller.DetectChannelUpstreamModelUpdates},
|
||||
{method: http.MethodPost, path: "/upstream_updates/detect_all", permission: authz.ChannelOperate, handler: controller.DetectAllChannelUpstreamModelUpdates},
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/controller"
|
||||
"github.com/QuantumNous/new-api/service/authz"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestChannelStatusRoutesUseOperatePermission(t *testing.T) {
|
||||
assertChannelRoutePermission(t, http.MethodPost, "/:id/status", authz.ChannelOperate, controller.UpdateChannelStatus)
|
||||
assertChannelRoutePermission(t, http.MethodPost, "/status/batch", authz.ChannelOperate, controller.BatchUpdateChannelStatus)
|
||||
assertChannelRoutePermission(t, http.MethodPut, "/", authz.ChannelWrite, controller.UpdateChannel)
|
||||
}
|
||||
|
||||
func TestChannelDeleteRoutesUseSensitiveWritePermission(t *testing.T) {
|
||||
assertChannelRoutePermission(t, http.MethodDelete, "/:id", authz.ChannelSensitiveWrite, controller.DeleteChannel)
|
||||
assertChannelRoutePermission(t, http.MethodPost, "/batch", authz.ChannelSensitiveWrite, controller.DeleteChannelBatch)
|
||||
assertChannelRoutePermission(t, http.MethodDelete, "/disabled", authz.ChannelSensitiveWrite, controller.DeleteDisabledChannel)
|
||||
assertChannelRoutePermission(t, http.MethodPut, "/", authz.ChannelWrite, controller.UpdateChannel)
|
||||
assertChannelRoutePermission(t, http.MethodPut, "/tag", authz.ChannelWrite, controller.EditTagChannels)
|
||||
assertChannelRoutePermission(t, http.MethodPost, "/batch/tag", authz.ChannelWrite, controller.BatchSetChannelTag)
|
||||
}
|
||||
|
||||
func TestChannelStatusRoutesRegisterWithoutConflict(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
api := engine.Group("/api")
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
registerChannelRoutes(api)
|
||||
})
|
||||
}
|
||||
|
||||
func assertChannelRoutePermission(t *testing.T, method string, path string, permission authz.Permission, handler any) {
|
||||
t.Helper()
|
||||
for _, route := range channelPermissionRoutes {
|
||||
if route.method == method && route.path == path {
|
||||
assert.Equal(t, permission, route.permission)
|
||||
assert.Equal(t, reflect.ValueOf(handler).Pointer(), reflect.ValueOf(route.handler).Pointer())
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("route %s %s not found", method, path)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
casbinmodel "github.com/casbin/casbin/v2/model"
|
||||
"github.com/casbin/casbin/v2/persist"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type gormAdapter struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func newGormAdapter(db *gorm.DB) *gormAdapter {
|
||||
return &gormAdapter{db: db}
|
||||
}
|
||||
|
||||
func (a *gormAdapter) LoadPolicy(m casbinmodel.Model) error {
|
||||
var rules []model.CasbinRule
|
||||
if err := a.db.Order("id asc").Find(&rules).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if err := persist.LoadPolicyLine(ruleToLine(rule), m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *gormAdapter) SavePolicy(m casbinmodel.Model) error {
|
||||
return a.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("1 = 1").Delete(&model.CasbinRule{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
rules := make([]model.CasbinRule, 0)
|
||||
for ptype, ast := range m["p"] {
|
||||
for _, policy := range ast.Policy {
|
||||
rules = append(rules, newRule(ptype, policy))
|
||||
}
|
||||
}
|
||||
for ptype, ast := range m["g"] {
|
||||
for _, policy := range ast.Policy {
|
||||
rules = append(rules, newRule(ptype, policy))
|
||||
}
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Create(&rules).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (a *gormAdapter) AddPolicy(_ string, ptype string, rule []string) error {
|
||||
casbinRule := newRule(ptype, rule)
|
||||
var count int64
|
||||
if err := a.ruleQuery(a.db.Model(&model.CasbinRule{}), ptype, rule).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
return a.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&casbinRule).Error
|
||||
}
|
||||
|
||||
func (a *gormAdapter) RemovePolicy(_ string, ptype string, rule []string) error {
|
||||
return a.ruleQuery(a.db, ptype, rule).Delete(&model.CasbinRule{}).Error
|
||||
}
|
||||
|
||||
func (a *gormAdapter) RemoveFilteredPolicy(_ string, ptype string, fieldIndex int, fieldValues ...string) error {
|
||||
query := a.db.Where("ptype = ?", ptype)
|
||||
for i, value := range fieldValues {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
query = query.Where("v"+string(rune('0'+fieldIndex+i))+" = ?", value)
|
||||
}
|
||||
return query.Delete(&model.CasbinRule{}).Error
|
||||
}
|
||||
|
||||
func (a *gormAdapter) ruleQuery(query *gorm.DB, ptype string, rule []string) *gorm.DB {
|
||||
query = query.Where("ptype = ?", ptype)
|
||||
for idx := 0; idx < 6; idx++ {
|
||||
value := ""
|
||||
if idx < len(rule) {
|
||||
value = rule[idx]
|
||||
}
|
||||
query = query.Where("v"+string(rune('0'+idx))+" = ?", value)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func newRule(ptype string, policy []string) model.CasbinRule {
|
||||
rule := model.CasbinRule{Ptype: ptype}
|
||||
values := []*string{&rule.V0, &rule.V1, &rule.V2, &rule.V3, &rule.V4, &rule.V5}
|
||||
for idx, value := range policy {
|
||||
if idx >= len(values) {
|
||||
break
|
||||
}
|
||||
*values[idx] = value
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
func ruleToLine(rule model.CasbinRule) string {
|
||||
parts := []string{rule.Ptype}
|
||||
values := []string{rule.V0, rule.V1, rule.V2, rule.V3, rule.V4, rule.V5}
|
||||
if rule.Ptype == "p" && rule.V0 != "" && rule.V1 != "" && rule.V2 != "" && rule.V3 == "" {
|
||||
values[3] = EffectAllow
|
||||
}
|
||||
for _, value := range values {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, value)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package authz
|
||||
|
||||
import "github.com/QuantumNous/new-api/common"
|
||||
|
||||
// resolveSubjectRoles returns the role keys assigned to a subject. The mapping
|
||||
// is derived from the caller's system role.
|
||||
var resolveSubjectRoles = func(userID int, systemRole int) []string {
|
||||
switch {
|
||||
case systemRole >= common.RoleRootUser:
|
||||
return []string{BuiltInRoleRoot}
|
||||
case systemRole >= common.RoleAdminUser:
|
||||
return []string{BuiltInRoleAdmin}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// managedRoleKey is the role whose baseline per-user overrides are expressed
|
||||
// relative to.
|
||||
const managedRoleKey = BuiltInRoleAdmin
|
||||
@@ -0,0 +1,229 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newAuthzTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
wasMaster := common.IsMasterNode
|
||||
common.IsMasterNode = true
|
||||
t.Cleanup(func() {
|
||||
common.IsMasterNode = wasMaster
|
||||
})
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
sqlDB, err := db.DB()
|
||||
require.NoError(t, err)
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
require.NoError(t, db.AutoMigrate(&model.CasbinRule{}, &model.AuthzRole{}))
|
||||
return db
|
||||
}
|
||||
|
||||
func TestInitSeedsBuiltInRolesAndPoliciesOnce(t *testing.T) {
|
||||
db := newAuthzTestDB(t)
|
||||
|
||||
require.NoError(t, Init(db))
|
||||
require.NoError(t, Init(db))
|
||||
|
||||
// root is a superuser role and is granted everything implicitly, so only the
|
||||
// admin baseline is written as explicit policy rows.
|
||||
var count int64
|
||||
require.NoError(t, db.Model(&model.CasbinRule{}).Count(&count).Error)
|
||||
assert.Equal(t, int64(len(PermissionsForRole(BuiltInRoleAdmin))), count)
|
||||
|
||||
var roles []model.AuthzRole
|
||||
require.NoError(t, db.Order("sort asc").Find(&roles).Error)
|
||||
require.Len(t, roles, 2)
|
||||
assert.Equal(t, BuiltInRoleRoot, roles[0].Key)
|
||||
assert.Equal(t, BuiltInRoleAdmin, roles[1].Key)
|
||||
|
||||
assert.True(t, Can(1, common.RoleRootUser, ChannelSensitiveWrite))
|
||||
assert.True(t, Can(2, common.RoleAdminUser, ChannelRead))
|
||||
assert.True(t, Can(2, common.RoleAdminUser, ChannelOperate))
|
||||
assert.True(t, Can(2, common.RoleAdminUser, ChannelWrite))
|
||||
assert.False(t, Can(2, common.RoleAdminUser, ChannelSensitiveWrite))
|
||||
assert.False(t, Can(3, common.RoleCommonUser, ChannelRead))
|
||||
}
|
||||
|
||||
func TestInitOnSlaveOnlyLoadsPolicies(t *testing.T) {
|
||||
wasMaster := common.IsMasterNode
|
||||
common.IsMasterNode = false
|
||||
t.Cleanup(func() {
|
||||
common.IsMasterNode = wasMaster
|
||||
})
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
sqlDB, err := db.DB()
|
||||
require.NoError(t, err)
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
require.NoError(t, db.AutoMigrate(&model.CasbinRule{}, &model.AuthzRole{}))
|
||||
|
||||
require.NoError(t, Init(db))
|
||||
|
||||
var roleCount int64
|
||||
require.NoError(t, db.Model(&model.AuthzRole{}).Count(&roleCount).Error)
|
||||
assert.Equal(t, int64(0), roleCount)
|
||||
var policyCount int64
|
||||
require.NoError(t, db.Model(&model.CasbinRule{}).Count(&policyCount).Error)
|
||||
assert.Equal(t, int64(0), policyCount)
|
||||
assert.False(t, Can(2, common.RoleAdminUser, ChannelRead))
|
||||
}
|
||||
|
||||
func TestSetUserPermissionsStoresOnlyOverrides(t *testing.T) {
|
||||
db := newAuthzTestDB(t)
|
||||
require.NoError(t, Init(db))
|
||||
|
||||
require.NoError(t, SetUserPermissions(42, PermissionsMap{
|
||||
ResourceChannel: {
|
||||
ActionRead: true,
|
||||
ActionOperate: true,
|
||||
ActionWrite: false,
|
||||
ActionSensitiveWrite: true,
|
||||
ActionSecretView: false,
|
||||
"unknown": true,
|
||||
},
|
||||
"unknown": {
|
||||
ActionRead: true,
|
||||
},
|
||||
}))
|
||||
|
||||
assert.True(t, Can(42, common.RoleAdminUser, ChannelSensitiveWrite))
|
||||
assert.False(t, Can(42, common.RoleAdminUser, ChannelWrite))
|
||||
assert.Equal(t, PermissionsMap{
|
||||
ResourceChannel: {
|
||||
ActionRead: true,
|
||||
ActionOperate: true,
|
||||
ActionWrite: false,
|
||||
ActionSensitiveWrite: true,
|
||||
ActionSecretView: false,
|
||||
},
|
||||
}, ExplicitUserPermissions(42))
|
||||
assert.Equal(t, PermissionsMap{
|
||||
ResourceChannel: {
|
||||
ActionSensitiveWrite: true,
|
||||
ActionWrite: false,
|
||||
},
|
||||
}, ExplicitUserOverrides(42))
|
||||
|
||||
var userPolicyCount int64
|
||||
require.NoError(t, db.Model(&model.CasbinRule{}).Where("v0 = ?", UserSubject(42)).Count(&userPolicyCount).Error)
|
||||
assert.Equal(t, int64(2), userPolicyCount)
|
||||
|
||||
require.NoError(t, SetUserPermissions(42, PermissionsMap{ResourceChannel: {
|
||||
ActionRead: true,
|
||||
ActionOperate: true,
|
||||
ActionWrite: true,
|
||||
ActionSensitiveWrite: false,
|
||||
ActionSecretView: false,
|
||||
}}))
|
||||
assert.False(t, Can(42, common.RoleAdminUser, ChannelSensitiveWrite))
|
||||
assert.Equal(t, PermissionsMap{
|
||||
ResourceChannel: {
|
||||
ActionRead: true,
|
||||
ActionOperate: true,
|
||||
ActionWrite: true,
|
||||
ActionSensitiveWrite: false,
|
||||
ActionSecretView: false,
|
||||
},
|
||||
}, ExplicitUserPermissions(42))
|
||||
assert.Empty(t, ExplicitUserOverrides(42))
|
||||
}
|
||||
|
||||
func TestClearUserAuthorizationRemovesOverrides(t *testing.T) {
|
||||
db := newAuthzTestDB(t)
|
||||
require.NoError(t, Init(db))
|
||||
|
||||
require.NoError(t, SetUserPermissions(90, PermissionsMap{ResourceChannel: {
|
||||
ActionWrite: false,
|
||||
ActionSensitiveWrite: true,
|
||||
}}))
|
||||
|
||||
assert.True(t, Can(90, common.RoleAdminUser, ChannelSensitiveWrite))
|
||||
assert.False(t, Can(90, common.RoleAdminUser, ChannelWrite))
|
||||
|
||||
require.NoError(t, ClearUserAuthorization(90))
|
||||
|
||||
assert.Empty(t, ExplicitUserOverrides(90))
|
||||
assert.True(t, Can(90, common.RoleAdminUser, ChannelRead))
|
||||
assert.True(t, Can(90, common.RoleAdminUser, ChannelWrite))
|
||||
assert.False(t, Can(90, common.RoleAdminUser, ChannelSensitiveWrite))
|
||||
assert.False(t, Can(90, common.RoleCommonUser, ChannelRead))
|
||||
}
|
||||
|
||||
func TestSetUserPermissionsInTxDoesNotMutateEnforcerBeforeReload(t *testing.T) {
|
||||
db := newAuthzTestDB(t)
|
||||
require.NoError(t, Init(db))
|
||||
|
||||
require.NoError(t, db.Transaction(func(tx *gorm.DB) error {
|
||||
return SetUserPermissionsInTx(tx, 42, PermissionsMap{ResourceChannel: {
|
||||
ActionRead: true,
|
||||
ActionOperate: true,
|
||||
ActionWrite: true,
|
||||
ActionSensitiveWrite: true,
|
||||
ActionSecretView: false,
|
||||
}})
|
||||
}))
|
||||
|
||||
assert.False(t, Can(42, common.RoleAdminUser, ChannelSensitiveWrite))
|
||||
require.NoError(t, ReloadPolicy())
|
||||
assert.True(t, Can(42, common.RoleAdminUser, ChannelSensitiveWrite))
|
||||
}
|
||||
|
||||
func TestSetUserPermissionsInTxRollbackLeavesNoPolicy(t *testing.T) {
|
||||
db := newAuthzTestDB(t)
|
||||
require.NoError(t, Init(db))
|
||||
|
||||
tx := db.Begin()
|
||||
require.NoError(t, tx.Error)
|
||||
require.NoError(t, SetUserPermissionsInTx(tx, 43, PermissionsMap{ResourceChannel: {
|
||||
ActionSensitiveWrite: true,
|
||||
}}))
|
||||
require.NoError(t, tx.Rollback().Error)
|
||||
require.NoError(t, ReloadPolicy())
|
||||
|
||||
assert.False(t, Can(43, common.RoleAdminUser, ChannelSensitiveWrite))
|
||||
var count int64
|
||||
require.NoError(t, db.Model(&model.CasbinRule{}).Where("v0 = ?", UserSubject(43)).Count(&count).Error)
|
||||
assert.Equal(t, int64(0), count)
|
||||
}
|
||||
|
||||
func TestAdapterAddPolicyIsIdempotent(t *testing.T) {
|
||||
db := newAuthzTestDB(t)
|
||||
adapter := newGormAdapter(db)
|
||||
rule := []string{UserSubject(55), ResourceChannel, ActionSensitiveWrite, EffectAllow}
|
||||
|
||||
require.NoError(t, adapter.AddPolicy("p", "p", rule))
|
||||
require.NoError(t, adapter.AddPolicy("p", "p", rule))
|
||||
|
||||
var count int64
|
||||
require.NoError(t, db.Model(&model.CasbinRule{}).Where(
|
||||
"ptype = ? AND v0 = ? AND v1 = ? AND v2 = ? AND v3 = ?",
|
||||
"p",
|
||||
UserSubject(55),
|
||||
ResourceChannel,
|
||||
ActionSensitiveWrite,
|
||||
EffectAllow,
|
||||
).Count(&count).Error)
|
||||
assert.Equal(t, int64(1), count)
|
||||
}
|
||||
|
||||
func TestCapabilitiesUseCatalogShape(t *testing.T) {
|
||||
db := newAuthzTestDB(t)
|
||||
require.NoError(t, Init(db))
|
||||
|
||||
capabilities := Capabilities(7, common.RoleAdminUser)
|
||||
|
||||
assert.True(t, capabilities[ResourceChannel][ActionRead])
|
||||
assert.True(t, capabilities[ResourceChannel][ActionOperate])
|
||||
assert.True(t, capabilities[ResourceChannel][ActionWrite])
|
||||
assert.False(t, capabilities[ResourceChannel][ActionSensitiveWrite])
|
||||
assert.False(t, capabilities[ResourceChannel][ActionSecretView])
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/casbin/casbin/v2"
|
||||
casbinmodel "github.com/casbin/casbin/v2/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
enforcerMu sync.RWMutex
|
||||
enforcer *casbin.SyncedEnforcer
|
||||
)
|
||||
|
||||
const modelText = `
|
||||
[request_definition]
|
||||
r = sub, obj, act
|
||||
|
||||
[policy_definition]
|
||||
p = sub, obj, act, eft
|
||||
|
||||
[policy_effect]
|
||||
e = some(where (p.eft == allow))
|
||||
|
||||
[matchers]
|
||||
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act && p.eft == "allow"
|
||||
`
|
||||
|
||||
func Init(db *gorm.DB) error {
|
||||
if common.IsMasterNode {
|
||||
if err := seedBuiltInRoles(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resetBuiltInRolePolicies(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
m, err := casbinmodel.NewModelFromString(modelText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e, err := casbin.NewSyncedEnforcer(m, newGormAdapter(db))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.EnableAutoSave(true)
|
||||
|
||||
enforcerMu.Lock()
|
||||
enforcer = e
|
||||
enforcerMu.Unlock()
|
||||
|
||||
if !common.IsMasterNode {
|
||||
return nil
|
||||
}
|
||||
return seedDefaultPolicies()
|
||||
}
|
||||
|
||||
func currentEnforcer() *casbin.SyncedEnforcer {
|
||||
enforcerMu.RLock()
|
||||
defer enforcerMu.RUnlock()
|
||||
return enforcer
|
||||
}
|
||||
|
||||
func ReloadPolicy() error {
|
||||
enforcerMu.Lock()
|
||||
defer enforcerMu.Unlock()
|
||||
if enforcer == nil {
|
||||
return fmt.Errorf("authz enforcer is not initialized")
|
||||
}
|
||||
return enforcer.LoadPolicy()
|
||||
}
|
||||
|
||||
// StartPolicySync periodically reloads the authorization policy from the database.
|
||||
// The enforcer keeps an in-memory snapshot, and permission changes are written
|
||||
// straight to the DB (see SetUserPermissionsInTx) with only the local node's
|
||||
// snapshot refreshed afterwards. Without this loop other instances in a
|
||||
// multi-node deployment would keep serving stale permissions (including not
|
||||
// honoring a revoked grant) until restart. Mirrors model.SyncOptions polling.
|
||||
func StartPolicySync(frequency int) {
|
||||
if frequency <= 0 {
|
||||
return
|
||||
}
|
||||
for {
|
||||
time.Sleep(time.Duration(frequency) * time.Second)
|
||||
if err := ReloadPolicy(); err != nil {
|
||||
common.SysError("failed to reload authz policy: " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/casbin/casbin/v2"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type overridePolicy struct {
|
||||
Resource string
|
||||
Action string
|
||||
Effect string
|
||||
}
|
||||
|
||||
func SetUserPermissions(userID int, permissions PermissionsMap) error {
|
||||
e := currentEnforcer()
|
||||
if e == nil {
|
||||
return fmt.Errorf("authz enforcer is not initialized")
|
||||
}
|
||||
|
||||
for resource, actions := range permissions {
|
||||
if !isKnownResource(resource) {
|
||||
continue
|
||||
}
|
||||
if _, err := e.RemoveFilteredPolicy(0, UserSubject(userID), resource); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, policy := range userOverridePolicies(e, resource, actions) {
|
||||
if _, err := e.AddPolicy(UserSubject(userID), policy.Resource, policy.Action, policy.Effect); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetUserPermissionsInTx(tx *gorm.DB, userID int, permissions PermissionsMap) error {
|
||||
e := currentEnforcer()
|
||||
if e == nil {
|
||||
return fmt.Errorf("authz enforcer is not initialized")
|
||||
}
|
||||
|
||||
for resource, actions := range permissions {
|
||||
if !isKnownResource(resource) {
|
||||
continue
|
||||
}
|
||||
if err := tx.Where("ptype = ? AND v0 = ? AND v1 = ?", "p", UserSubject(userID), resource).Delete(&model.CasbinRule{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
policies := userOverridePolicies(e, resource, actions)
|
||||
if len(policies) == 0 {
|
||||
continue
|
||||
}
|
||||
rules := make([]model.CasbinRule, 0, len(policies))
|
||||
for _, policy := range policies {
|
||||
rules = append(rules, newRule("p", []string{UserSubject(userID), policy.Resource, policy.Action, policy.Effect}))
|
||||
}
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rules).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClearUserPermissions(userID int) error {
|
||||
e := currentEnforcer()
|
||||
if e == nil {
|
||||
return fmt.Errorf("authz enforcer is not initialized")
|
||||
}
|
||||
|
||||
for _, resource := range registry {
|
||||
if _, err := e.RemoveFilteredPolicy(0, UserSubject(userID), resource.Resource); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClearUserPermissionsInTx(tx *gorm.DB, userID int) error {
|
||||
for _, resource := range registry {
|
||||
if err := tx.Where("ptype = ? AND v0 = ? AND v1 = ?", "p", UserSubject(userID), resource.Resource).Delete(&model.CasbinRule{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClearUserAuthorization(userID int) error {
|
||||
return ClearUserPermissions(userID)
|
||||
}
|
||||
|
||||
func ClearUserAuthorizationInTx(tx *gorm.DB, userID int) error {
|
||||
return ClearUserPermissionsInTx(tx, userID)
|
||||
}
|
||||
|
||||
// ExplicitUserPermissions returns the effective permission matrix for the
|
||||
// managed role plus any per-user overrides.
|
||||
func ExplicitUserPermissions(userID int) PermissionsMap {
|
||||
return Capabilities(userID, common.RoleAdminUser)
|
||||
}
|
||||
|
||||
// ExplicitUserOverrides returns only the per-user override entries.
|
||||
func ExplicitUserOverrides(userID int) PermissionsMap {
|
||||
e := currentEnforcer()
|
||||
if e == nil {
|
||||
return PermissionsMap{}
|
||||
}
|
||||
|
||||
result := PermissionsMap{}
|
||||
for _, resource := range registry {
|
||||
policies, err := e.GetFilteredPolicy(0, UserSubject(userID), resource.Resource)
|
||||
if err != nil {
|
||||
return PermissionsMap{}
|
||||
}
|
||||
actions := make(map[string]bool, len(policies))
|
||||
for _, policy := range policies {
|
||||
if len(policy) >= 3 && isKnownPermission(Permission{Resource: policy[1], Action: policy[2]}) {
|
||||
effect := policyEffect(policy)
|
||||
if effect == EffectAllow || effect == EffectDeny {
|
||||
actions[policy[2]] = effect == EffectAllow
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(actions) > 0 {
|
||||
result[resource.Resource] = actions
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// userOverridePolicies returns the override entries that differ from the managed
|
||||
// role baseline; entries matching the baseline are omitted.
|
||||
func userOverridePolicies(e *casbin.SyncedEnforcer, resource string, actions map[string]bool) []overridePolicy {
|
||||
overrides := make([]overridePolicy, 0, len(actions))
|
||||
for _, action := range catalogActions(resource) {
|
||||
desired, ok := actions[action.Action]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
permission := Permission{Resource: resource, Action: action.Action}
|
||||
if desired == roleBaselineAllows(e, managedRoleKey, permission) {
|
||||
continue
|
||||
}
|
||||
effect := EffectDeny
|
||||
if desired {
|
||||
effect = EffectAllow
|
||||
}
|
||||
overrides = append(overrides, overridePolicy{
|
||||
Resource: resource,
|
||||
Action: action.Action,
|
||||
Effect: effect,
|
||||
})
|
||||
}
|
||||
sort.Slice(overrides, func(i, j int) bool {
|
||||
return overrides[i].Action < overrides[j].Action
|
||||
})
|
||||
return overrides
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package authz
|
||||
|
||||
import "strconv"
|
||||
|
||||
// Permission identifies a single action on a resource.
|
||||
type Permission struct {
|
||||
Resource string
|
||||
Action string
|
||||
}
|
||||
|
||||
// PermissionsMap is a resource -> action -> allowed lookup.
|
||||
type PermissionsMap map[string]map[string]bool
|
||||
|
||||
const (
|
||||
EffectAllow = "allow"
|
||||
EffectDeny = "deny"
|
||||
)
|
||||
|
||||
// UserSubject is the casbin subject string for a single user.
|
||||
func UserSubject(userID int) string {
|
||||
return "user:" + strconv.Itoa(userID)
|
||||
}
|
||||
|
||||
// RoleSubject is the casbin subject string for a role.
|
||||
func RoleSubject(roleKey string) string {
|
||||
return "role:" + roleKey
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package authz
|
||||
|
||||
// ActionDefinition describes a single action exposed by a resource. DefaultRoles
|
||||
// lists the role keys that receive this action as part of their baseline grants.
|
||||
type ActionDefinition struct {
|
||||
Action string `json:"action"`
|
||||
LabelKey string `json:"label_key"`
|
||||
DescriptionKey string `json:"description_key"`
|
||||
DefaultRoles []string `json:"-"`
|
||||
}
|
||||
|
||||
// ResourceDefinition describes a resource and the actions it exposes.
|
||||
type ResourceDefinition struct {
|
||||
Resource string `json:"resource"`
|
||||
LabelKey string `json:"label_key"`
|
||||
Actions []ActionDefinition `json:"actions"`
|
||||
}
|
||||
|
||||
var registry []ResourceDefinition
|
||||
|
||||
// RegisterResource adds a resource definition to the permission registry.
|
||||
func RegisterResource(resource ResourceDefinition) {
|
||||
registry = append(registry, resource)
|
||||
}
|
||||
|
||||
// Catalog returns a copy of the registered resource definitions.
|
||||
func Catalog() []ResourceDefinition {
|
||||
result := make([]ResourceDefinition, 0, len(registry))
|
||||
for _, resource := range registry {
|
||||
result = append(result, ResourceDefinition{
|
||||
Resource: resource.Resource,
|
||||
LabelKey: resource.LabelKey,
|
||||
Actions: append([]ActionDefinition(nil), resource.Actions...),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// AllPermissions returns every registered permission.
|
||||
func AllPermissions() []Permission {
|
||||
permissions := make([]Permission, 0)
|
||||
for _, resource := range registry {
|
||||
for _, action := range resource.Actions {
|
||||
permissions = append(permissions, Permission{
|
||||
Resource: resource.Resource,
|
||||
Action: action.Action,
|
||||
})
|
||||
}
|
||||
}
|
||||
return permissions
|
||||
}
|
||||
|
||||
// PermissionsForRole returns the permissions whose DefaultRoles include roleKey.
|
||||
func PermissionsForRole(roleKey string) []Permission {
|
||||
permissions := make([]Permission, 0)
|
||||
for _, resource := range registry {
|
||||
for _, action := range resource.Actions {
|
||||
if actionHasRole(action, roleKey) {
|
||||
permissions = append(permissions, Permission{
|
||||
Resource: resource.Resource,
|
||||
Action: action.Action,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return permissions
|
||||
}
|
||||
|
||||
func actionHasRole(action ActionDefinition, roleKey string) bool {
|
||||
for _, r := range action.DefaultRoles {
|
||||
if r == roleKey {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isKnownResource(resource string) bool {
|
||||
for _, known := range registry {
|
||||
if known.Resource == resource {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func catalogActions(resource string) []ActionDefinition {
|
||||
for _, known := range registry {
|
||||
if known.Resource == resource {
|
||||
return known.Actions
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isKnownPermission(permission Permission) bool {
|
||||
for _, resource := range registry {
|
||||
if resource.Resource != permission.Resource {
|
||||
continue
|
||||
}
|
||||
for _, action := range resource.Actions {
|
||||
if action.Action == permission.Action {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package authz
|
||||
|
||||
import "github.com/casbin/casbin/v2"
|
||||
|
||||
// Can reports whether the subject may perform the permission. A superuser role
|
||||
// short-circuits to allow. Otherwise a per-user override wins, then the union of
|
||||
// the subject's role baselines applies.
|
||||
func Can(userID int, systemRole int, permission Permission) bool {
|
||||
roles := resolveSubjectRoles(userID, systemRole)
|
||||
if len(roles) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, role := range roles {
|
||||
if isSuperuserRole(role) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if !isKnownPermission(permission) {
|
||||
return false
|
||||
}
|
||||
|
||||
e := currentEnforcer()
|
||||
if e == nil {
|
||||
return false
|
||||
}
|
||||
if effect, ok := explicitSubjectEffect(e, UserSubject(userID), permission); ok {
|
||||
return effect == EffectAllow
|
||||
}
|
||||
for _, role := range roles {
|
||||
if roleBaselineAllows(e, role, permission) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Capabilities returns the full resource/action matrix the subject is allowed.
|
||||
func Capabilities(userID int, systemRole int) PermissionsMap {
|
||||
result := make(PermissionsMap, len(registry))
|
||||
for _, resource := range registry {
|
||||
actions := make(map[string]bool, len(resource.Actions))
|
||||
for _, action := range resource.Actions {
|
||||
actions[action.Action] = Can(userID, systemRole, Permission{
|
||||
Resource: resource.Resource,
|
||||
Action: action.Action,
|
||||
})
|
||||
}
|
||||
result[resource.Resource] = actions
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func roleBaselineAllows(e *casbin.SyncedEnforcer, roleKey string, permission Permission) bool {
|
||||
effect, ok := explicitSubjectEffect(e, RoleSubject(roleKey), permission)
|
||||
return ok && effect == EffectAllow
|
||||
}
|
||||
|
||||
func explicitSubjectEffect(e *casbin.SyncedEnforcer, subject string, permission Permission) (string, bool) {
|
||||
policies, err := e.GetFilteredPolicy(0, subject, permission.Resource, permission.Action)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
hasAllow := false
|
||||
for _, policy := range policies {
|
||||
switch policyEffect(policy) {
|
||||
case EffectDeny:
|
||||
return EffectDeny, true
|
||||
case EffectAllow:
|
||||
hasAllow = true
|
||||
}
|
||||
}
|
||||
if hasAllow {
|
||||
return EffectAllow, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func policyEffect(policy []string) string {
|
||||
if len(policy) < 4 || policy[3] == "" {
|
||||
return EffectAllow
|
||||
}
|
||||
return policy[3]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package authz
|
||||
|
||||
const (
|
||||
ResourceChannel = "channel"
|
||||
|
||||
ActionRead = "read"
|
||||
ActionOperate = "operate"
|
||||
ActionWrite = "write"
|
||||
ActionSensitiveWrite = "sensitive_write"
|
||||
ActionSecretView = "secret_view"
|
||||
)
|
||||
|
||||
var (
|
||||
ChannelRead = Permission{Resource: ResourceChannel, Action: ActionRead}
|
||||
ChannelOperate = Permission{Resource: ResourceChannel, Action: ActionOperate}
|
||||
ChannelWrite = Permission{Resource: ResourceChannel, Action: ActionWrite}
|
||||
ChannelSensitiveWrite = Permission{Resource: ResourceChannel, Action: ActionSensitiveWrite}
|
||||
ChannelSecretView = Permission{Resource: ResourceChannel, Action: ActionSecretView}
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterResource(ResourceDefinition{
|
||||
Resource: ResourceChannel,
|
||||
LabelKey: "Channel Management",
|
||||
Actions: []ActionDefinition{
|
||||
{
|
||||
Action: ActionRead,
|
||||
LabelKey: "Read channels",
|
||||
DescriptionKey: "View channel lists and details without secrets.",
|
||||
DefaultRoles: []string{BuiltInRoleAdmin},
|
||||
},
|
||||
{
|
||||
Action: ActionOperate,
|
||||
LabelKey: "Operate channels",
|
||||
DescriptionKey: "Test channels, refresh balances, and enable/disable individual, batch, or tagged channels.",
|
||||
DefaultRoles: []string{BuiltInRoleAdmin},
|
||||
},
|
||||
{
|
||||
Action: ActionWrite,
|
||||
LabelKey: "Edit channel routing",
|
||||
DescriptionKey: "Edit non-sensitive settings such as models, groups, and routing rules.",
|
||||
DefaultRoles: []string{BuiltInRoleAdmin},
|
||||
},
|
||||
{
|
||||
Action: ActionSensitiveWrite,
|
||||
LabelKey: "Edit sensitive channel settings",
|
||||
DescriptionKey: "Create channels or edit keys, base URLs, and overrides.",
|
||||
},
|
||||
{
|
||||
Action: ActionSecretView,
|
||||
LabelKey: "View channel secrets",
|
||||
DescriptionKey: "Reserved for viewing complete channel keys after secure verification.",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package authz
|
||||
|
||||
const (
|
||||
BuiltInRoleRoot = "root"
|
||||
BuiltInRoleAdmin = "admin"
|
||||
)
|
||||
|
||||
// RoleSpec describes a role. A superuser role is allowed every permission
|
||||
// without an explicit policy entry.
|
||||
type RoleSpec struct {
|
||||
Key string
|
||||
Name string
|
||||
Description string
|
||||
BuiltIn bool
|
||||
Superuser bool
|
||||
Sort int
|
||||
}
|
||||
|
||||
var builtInRoles = []RoleSpec{
|
||||
{
|
||||
Key: BuiltInRoleRoot,
|
||||
Name: "Root",
|
||||
Description: "Built-in root authorization role",
|
||||
BuiltIn: true,
|
||||
Superuser: true,
|
||||
Sort: 0,
|
||||
},
|
||||
{
|
||||
Key: BuiltInRoleAdmin,
|
||||
Name: "Admin",
|
||||
Description: "Built-in admin authorization role",
|
||||
BuiltIn: true,
|
||||
Superuser: false,
|
||||
Sort: 10,
|
||||
},
|
||||
}
|
||||
|
||||
// RoleDescriptor exposes a role together with its baseline grant matrix.
|
||||
type RoleDescriptor struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
BuiltIn bool `json:"built_in"`
|
||||
Superuser bool `json:"superuser"`
|
||||
Grants PermissionsMap `json:"grants"`
|
||||
}
|
||||
|
||||
// Roles returns the role descriptors with their baseline grants.
|
||||
func Roles() []RoleDescriptor {
|
||||
result := make([]RoleDescriptor, 0, len(builtInRoles))
|
||||
for _, spec := range builtInRoles {
|
||||
result = append(result, RoleDescriptor{
|
||||
Key: spec.Key,
|
||||
Name: spec.Name,
|
||||
BuiltIn: spec.BuiltIn,
|
||||
Superuser: spec.Superuser,
|
||||
Grants: roleGrants(spec),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func roleGrants(spec RoleSpec) PermissionsMap {
|
||||
grants := make(PermissionsMap, len(registry))
|
||||
for _, resource := range registry {
|
||||
actions := make(map[string]bool, len(resource.Actions))
|
||||
for _, action := range resource.Actions {
|
||||
actions[action.Action] = spec.Superuser || actionHasRole(action, spec.Key)
|
||||
}
|
||||
grants[resource.Resource] = actions
|
||||
}
|
||||
return grants
|
||||
}
|
||||
|
||||
func roleSpec(roleKey string) (RoleSpec, bool) {
|
||||
for _, spec := range builtInRoles {
|
||||
if spec.Key == roleKey {
|
||||
return spec, true
|
||||
}
|
||||
}
|
||||
return RoleSpec{}, false
|
||||
}
|
||||
|
||||
func isSuperuserRole(roleKey string) bool {
|
||||
spec, ok := roleSpec(roleKey)
|
||||
return ok && spec.Superuser
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func seedBuiltInRoles(db *gorm.DB) error {
|
||||
for _, spec := range builtInRoles {
|
||||
role := model.AuthzRole{
|
||||
Key: spec.Key,
|
||||
Name: spec.Name,
|
||||
Description: spec.Description,
|
||||
BuiltIn: spec.BuiltIn,
|
||||
Enabled: true,
|
||||
Sort: spec.Sort,
|
||||
}
|
||||
if err := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"name",
|
||||
"description",
|
||||
"built_in",
|
||||
"enabled",
|
||||
"sort",
|
||||
}),
|
||||
}).Create(&role).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetBuiltInRolePolicies(db *gorm.DB) error {
|
||||
subjects := make([]string, 0, len(builtInRoles))
|
||||
for _, spec := range builtInRoles {
|
||||
subjects = append(subjects, RoleSubject(spec.Key))
|
||||
}
|
||||
return db.Where("ptype = ? AND v0 IN ?", "p", subjects).Delete(&model.CasbinRule{}).Error
|
||||
}
|
||||
|
||||
func seedDefaultPolicies() error {
|
||||
e := currentEnforcer()
|
||||
if e == nil {
|
||||
return fmt.Errorf("authz enforcer is not initialized")
|
||||
}
|
||||
|
||||
for _, spec := range builtInRoles {
|
||||
if spec.Superuser {
|
||||
continue
|
||||
}
|
||||
for _, permission := range PermissionsForRole(spec.Key) {
|
||||
if _, err := e.AddPolicy(RoleSubject(spec.Key), permission.Resource, permission.Action, EffectAllow); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+30
@@ -138,6 +138,36 @@ export async function updateChannel(
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update channel enabled/disabled status.
|
||||
*/
|
||||
export async function updateChannelStatus(
|
||||
id: number,
|
||||
status: number
|
||||
): Promise<{ success: boolean; message?: string; data?: boolean }> {
|
||||
const res = await api.post(
|
||||
`/api/channel/${id}/status`,
|
||||
{ status },
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch update channel enabled/disabled status.
|
||||
*/
|
||||
export async function batchUpdateChannelStatus(
|
||||
ids: number[],
|
||||
status: number
|
||||
): Promise<{ success: boolean; message?: string; data?: number }> {
|
||||
const res = await api.post(
|
||||
'/api/channel/status/batch',
|
||||
{ ids, status },
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete single channel
|
||||
*/
|
||||
|
||||
+42
-11
@@ -32,6 +32,12 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
ADMIN_PERMISSION_ACTIONS,
|
||||
ADMIN_PERMISSION_RESOURCES,
|
||||
hasPermission,
|
||||
} from '@/lib/admin-permissions'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -43,6 +49,11 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import {
|
||||
handleDeleteAllDisabled,
|
||||
@@ -65,6 +76,12 @@ export function ChannelsPrimaryButtons() {
|
||||
} = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const currentUser = useAuthStore((s) => s.auth.user)
|
||||
const canEditSensitive = hasPermission(
|
||||
currentUser,
|
||||
ADMIN_PERMISSION_RESOURCES.CHANNEL,
|
||||
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
|
||||
)
|
||||
|
||||
const handleTagModeToggle = (checked: boolean) => {
|
||||
localStorage.setItem('enable-tag-mode', String(checked))
|
||||
@@ -105,17 +122,28 @@ export function ChannelsPrimaryButtons() {
|
||||
</div>
|
||||
|
||||
{/* Create Channel */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setCurrentRow(null)
|
||||
setOpen('create-channel')
|
||||
}}
|
||||
size='sm'
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
<span className='max-sm:hidden'>{t('Create Channel')}</span>
|
||||
<span className='sm:hidden'>{t('Create')}</span>
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span className='inline-flex' />}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!canEditSensitive) return
|
||||
setCurrentRow(null)
|
||||
setOpen('create-channel')
|
||||
}}
|
||||
size='sm'
|
||||
disabled={!canEditSensitive}
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
<span className='max-sm:hidden'>{t('Create Channel')}</span>
|
||||
<span className='sm:hidden'>{t('Create')}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{!canEditSensitive && (
|
||||
<TooltipContent>
|
||||
{t('No permission to perform this action')}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
{/* More Actions */}
|
||||
<DropdownMenu>
|
||||
@@ -209,8 +237,10 @@ export function ChannelsPrimaryButtons() {
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
if (!canEditSensitive) return
|
||||
setShowDeleteDialog(true)
|
||||
}}
|
||||
disabled={!canEditSensitive}
|
||||
className='text-destructive focus:text-destructive'
|
||||
>
|
||||
{t('Delete All Disabled')}
|
||||
@@ -231,6 +261,7 @@ export function ChannelsPrimaryButtons() {
|
||||
)}
|
||||
destructive
|
||||
handleConfirm={() => {
|
||||
if (!canEditSensitive) return
|
||||
handleDeleteAllDisabled(queryClient, (_count) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Deleted ${_count} channels`)
|
||||
|
||||
@@ -24,6 +24,13 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import {
|
||||
ADMIN_PERMISSION_ACTIONS,
|
||||
ADMIN_PERMISSION_RESOURCES,
|
||||
hasPermission,
|
||||
} from '@/lib/admin-permissions'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -51,6 +58,12 @@ export function DataTableBulkActions<TData>({
|
||||
const [showTagDialog, setShowTagDialog] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [tagValue, setTagValue] = useState('')
|
||||
const currentUser = useAuthStore((s) => s.auth.user)
|
||||
const canEditSensitive = hasPermission(
|
||||
currentUser,
|
||||
ADMIN_PERMISSION_RESOURCES.CHANNEL,
|
||||
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
|
||||
)
|
||||
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows
|
||||
const selectedIds = selectedRows.reduce<number[]>((ids, row) => {
|
||||
@@ -76,6 +89,7 @@ export function DataTableBulkActions<TData>({
|
||||
}
|
||||
|
||||
const handleDeleteAll = () => {
|
||||
if (!canEditSensitive) return
|
||||
handleBatchDelete(selectedIds, queryClient, () => {
|
||||
setShowDeleteConfirm(false)
|
||||
handleClearSelection()
|
||||
@@ -164,10 +178,21 @@ export function DataTableBulkActions<TData>({
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='icon'
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className='size-8'
|
||||
onClick={() => {
|
||||
if (!canEditSensitive) return
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
aria-disabled={!canEditSensitive}
|
||||
className={cn(
|
||||
'size-8',
|
||||
!canEditSensitive && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
aria-label={t('Delete selected channels')}
|
||||
title={t('Delete selected channels')}
|
||||
title={
|
||||
canEditSensitive
|
||||
? t('Delete selected channels')
|
||||
: t('No permission to perform this action')
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -175,7 +200,11 @@ export function DataTableBulkActions<TData>({
|
||||
<span className='sr-only'>{t('Delete selected channels')}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Delete selected channels')}</p>
|
||||
<p>
|
||||
{canEditSensitive
|
||||
? t('Delete selected channels')
|
||||
: t('No permission to perform this action')}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</BulkActionsToolbar>
|
||||
@@ -243,7 +272,11 @@ export function DataTableBulkActions<TData>({
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={handleDeleteAll}>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={handleDeleteAll}
|
||||
disabled={!canEditSensitive}
|
||||
>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -39,6 +39,12 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
ADMIN_PERMISSION_ACTIONS,
|
||||
ADMIN_PERMISSION_RESOURCES,
|
||||
hasPermission,
|
||||
} from '@/lib/admin-permissions'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -77,12 +83,18 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const channel = row.original
|
||||
const { setOpen, setCurrentRow, upstream } = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
const currentUser = useAuthStore((s) => s.auth.user)
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
const [isTogglingStatus, setIsTogglingStatus] = useState(false)
|
||||
|
||||
const isEnabled = isChannelEnabled(channel)
|
||||
const isMultiKey = isMultiKeyChannel(channel)
|
||||
const canEditSensitive = hasPermission(
|
||||
currentUser,
|
||||
ADMIN_PERMISSION_RESOURCES.CHANNEL,
|
||||
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
|
||||
)
|
||||
|
||||
const handleEdit = () => {
|
||||
setCurrentRow(channel)
|
||||
@@ -314,12 +326,20 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Copy Channel */}
|
||||
<DropdownMenuItem onClick={handleCopy}>
|
||||
<DropdownMenuItem
|
||||
disabled={!canEditSensitive}
|
||||
onClick={canEditSensitive ? handleCopy : undefined}
|
||||
>
|
||||
{t('Copy Channel')}
|
||||
<DropdownMenuShortcut>
|
||||
<Copy size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
{!canEditSensitive && (
|
||||
<DropdownMenuItem disabled className='text-xs normal-case'>
|
||||
{t('No permission to perform this action')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Manage Keys (only for multi-key channels) */}
|
||||
{isMultiKey && (
|
||||
@@ -335,8 +355,10 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
|
||||
{/* Delete */}
|
||||
<DropdownMenuItem
|
||||
disabled={!canEditSensitive}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
if (!canEditSensitive) return
|
||||
setDeleteConfirmOpen(true)
|
||||
}}
|
||||
className='text-destructive focus:text-destructive'
|
||||
@@ -360,6 +382,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
confirmText={t('Delete')}
|
||||
destructive
|
||||
handleConfirm={() => {
|
||||
if (!canEditSensitive) return
|
||||
handleDeleteChannel(channel.id, queryClient)
|
||||
setDeleteConfirmOpen(false)
|
||||
}}
|
||||
|
||||
+36
-1
@@ -21,6 +21,12 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, RefreshCw, Trash2, Power, PowerOff } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ADMIN_PERMISSION_ACTIONS,
|
||||
ADMIN_PERMISSION_RESOURCES,
|
||||
hasPermission,
|
||||
} from '@/lib/admin-permissions'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
@@ -69,6 +75,12 @@ export function MultiKeyManageDialog({
|
||||
const { t } = useTranslation()
|
||||
const { currentRow } = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
const currentUser = useAuthStore((s) => s.auth.user)
|
||||
const canEditSensitive = hasPermission(
|
||||
currentUser,
|
||||
ADMIN_PERMISSION_RESOURCES.CHANNEL,
|
||||
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
|
||||
)
|
||||
|
||||
// Data state
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -148,6 +160,14 @@ export function MultiKeyManageDialog({
|
||||
|
||||
const performAction = async () => {
|
||||
if (!confirmAction || !currentRow) return
|
||||
if (
|
||||
!canEditSensitive &&
|
||||
(confirmAction.type === 'delete' ||
|
||||
confirmAction.type === 'delete-disabled')
|
||||
) {
|
||||
setConfirmAction(null)
|
||||
return
|
||||
}
|
||||
|
||||
setIsPerformingAction(true)
|
||||
try {
|
||||
@@ -331,7 +351,16 @@ export function MultiKeyManageDialog({
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={() => setConfirmAction({ type: 'delete-disabled' })}
|
||||
onClick={() => {
|
||||
if (!canEditSensitive) return
|
||||
setConfirmAction({ type: 'delete-disabled' })
|
||||
}}
|
||||
disabled={!canEditSensitive}
|
||||
title={
|
||||
canEditSensitive
|
||||
? undefined
|
||||
: t('No permission to perform this action')
|
||||
}
|
||||
>
|
||||
<Trash2 className='mr-2 h-4 w-4' />
|
||||
{t('Delete Auto-Disabled')}
|
||||
@@ -339,6 +368,11 @@ export function MultiKeyManageDialog({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!canEditSensitive && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('No permission to perform this action')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
|
||||
@@ -392,6 +426,7 @@ export function MultiKeyManageDialog({
|
||||
<MultiKeyTableRowActions
|
||||
keyIndex={key.index}
|
||||
status={key.status}
|
||||
canDelete={canEditSensitive}
|
||||
onAction={setConfirmAction}
|
||||
/>
|
||||
),
|
||||
|
||||
+12
-1
@@ -23,12 +23,14 @@ import type { MultiKeyConfirmAction } from '../../types'
|
||||
type MultiKeyTableRowActionsProps = {
|
||||
keyIndex: number
|
||||
status: number
|
||||
canDelete: boolean
|
||||
onAction: (action: MultiKeyConfirmAction) => void
|
||||
}
|
||||
|
||||
export function MultiKeyTableRowActions({
|
||||
keyIndex,
|
||||
status,
|
||||
canDelete,
|
||||
onAction,
|
||||
}: MultiKeyTableRowActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
@@ -56,7 +58,16 @@ export function MultiKeyTableRowActions({
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={() => onAction({ type: 'delete', keyIndex })}
|
||||
onClick={() => {
|
||||
if (!canDelete) return
|
||||
onAction({ type: 'delete', keyIndex })
|
||||
}}
|
||||
disabled={!canDelete}
|
||||
title={
|
||||
canDelete
|
||||
? undefined
|
||||
: t('No permission to perform this action')
|
||||
}
|
||||
>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
|
||||
+243
-82
@@ -47,7 +47,13 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ADMIN_PERMISSION_ACTIONS,
|
||||
ADMIN_PERMISSION_RESOURCES,
|
||||
hasPermission,
|
||||
} from '@/lib/admin-permissions'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { useHiddenClickUnlock } from '@/hooks/use-hidden-click-unlock'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
@@ -104,6 +110,7 @@ import {
|
||||
SecureVerificationDialog,
|
||||
useSecureVerification,
|
||||
} from '@/features/auth/secure-verification'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import {
|
||||
fetchModels,
|
||||
getAllModels,
|
||||
@@ -198,6 +205,40 @@ const MODEL_MAPPING_PREVIEW_FALLBACK: Array<{
|
||||
|
||||
const ADVANCED_SETTINGS_EXPANDED_KEY = 'channel-advanced-settings-expanded'
|
||||
const UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT = 8
|
||||
const SENSITIVE_FORM_FIELDS = [
|
||||
'type',
|
||||
'base_url',
|
||||
'key',
|
||||
'openai_organization',
|
||||
'other',
|
||||
'key_mode',
|
||||
'param_override',
|
||||
'header_override',
|
||||
'settings',
|
||||
'setting',
|
||||
'advanced_custom',
|
||||
'is_enterprise_account',
|
||||
'vertex_key_type',
|
||||
'aws_key_type',
|
||||
'azure_responses_version',
|
||||
'force_format',
|
||||
'thinking_to_content',
|
||||
'proxy',
|
||||
'pass_through_body_enabled',
|
||||
'system_prompt',
|
||||
'system_prompt_override',
|
||||
'allow_service_tier',
|
||||
'disable_store',
|
||||
'allow_safety_identifier',
|
||||
'allow_include_obfuscation',
|
||||
'allow_inference_geo',
|
||||
'allow_speed',
|
||||
'claude_beta_query',
|
||||
'disable_task_polling_sleep',
|
||||
'upstream_model_update_check_enabled',
|
||||
'upstream_model_update_auto_sync_enabled',
|
||||
'upstream_model_update_ignored_models',
|
||||
] satisfies (keyof ChannelFormValues)[]
|
||||
|
||||
function readAdvancedSettingsPreference(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
@@ -280,6 +321,13 @@ export function ChannelMutateDrawer({
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { setOpen } = useChannels()
|
||||
const currentUser = useAuthStore((s) => s.auth.user)
|
||||
const canEditSensitive = hasPermission(
|
||||
currentUser,
|
||||
ADMIN_PERMISSION_RESOURCES.CHANNEL,
|
||||
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
|
||||
)
|
||||
const canRevealChannelKey = currentUser?.role === ROLE.SUPER_ADMIN
|
||||
const [fetchModelsDialogOpen, setFetchModelsDialogOpen] = useState(false)
|
||||
const [channelKey, setChannelKey] = useState<string | null>(null)
|
||||
const [isChannelKeyLoading, setIsChannelKeyLoading] = useState(false)
|
||||
@@ -307,6 +355,7 @@ export function ChannelMutateDrawer({
|
||||
|
||||
const isEditing = Boolean(currentRow)
|
||||
const channelId = currentRow?.id ?? null
|
||||
const sensitiveLocked = isEditing && !canEditSensitive
|
||||
|
||||
// Fetch channel details if editing
|
||||
const { data: channelData, isLoading: isChannelLoading } = useQuery({
|
||||
@@ -388,7 +437,7 @@ export function ChannelMutateDrawer({
|
||||
reset: resetDoubaoApiUnlock,
|
||||
} = useHiddenClickUnlock({
|
||||
requiredClicks: 10,
|
||||
disabled: currentType !== 45,
|
||||
disabled: currentType !== 45 || sensitiveLocked,
|
||||
onUnlock: () => {
|
||||
toast.info(t('Doubao custom API address editing unlocked'))
|
||||
},
|
||||
@@ -783,6 +832,11 @@ export function ChannelMutateDrawer({
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEditing && !canEditSensitive) {
|
||||
toast.error(t("You don't have necessary permission"))
|
||||
return
|
||||
}
|
||||
|
||||
// For creation mode, validate key before opening dialog
|
||||
if (!isEditing) {
|
||||
const key = form.getValues('key')
|
||||
@@ -793,9 +847,12 @@ export function ChannelMutateDrawer({
|
||||
}
|
||||
|
||||
setFetchModelsDialogOpen(true)
|
||||
}, [isEditing, form, t])
|
||||
}, [isEditing, canEditSensitive, form, t])
|
||||
|
||||
const createModeFetcher = useCallback(async (): Promise<string[]> => {
|
||||
if (!canEditSensitive) {
|
||||
throw new Error(t("You don't have necessary permission"))
|
||||
}
|
||||
const response = await fetchModels({
|
||||
type: form.getValues('type'),
|
||||
key: form.getValues('key'),
|
||||
@@ -805,7 +862,7 @@ export function ChannelMutateDrawer({
|
||||
return response.data
|
||||
}
|
||||
throw new Error(response.message || 'No models fetched from upstream')
|
||||
}, [form])
|
||||
}, [canEditSensitive, form, t])
|
||||
|
||||
// Handle model operations
|
||||
const handleFillRelatedModels = useCallback(() => {
|
||||
@@ -963,6 +1020,21 @@ export function ChannelMutateDrawer({
|
||||
return
|
||||
}
|
||||
|
||||
if (sensitiveLocked) {
|
||||
const dirtyFields = form.formState.dirtyFields as Partial<
|
||||
Record<keyof ChannelFormValues, unknown>
|
||||
>
|
||||
const hasSensitiveChanges = SENSITIVE_FORM_FIELDS.some((field) =>
|
||||
Boolean(dirtyFields[field])
|
||||
)
|
||||
if (hasSensitiveChanges) {
|
||||
toast.error(
|
||||
t('You do not have permission to edit sensitive channel settings.')
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Validate status_code_mapping entries
|
||||
if (data.status_code_mapping?.trim()) {
|
||||
const invalidEntries = collectInvalidStatusCodeEntries(
|
||||
@@ -1038,6 +1110,7 @@ export function ChannelMutateDrawer({
|
||||
},
|
||||
[
|
||||
isEditing,
|
||||
sensitiveLocked,
|
||||
form,
|
||||
confirmMissingModelMappings,
|
||||
confirmStatusCodeRisk,
|
||||
@@ -1105,6 +1178,17 @@ export function ChannelMutateDrawer({
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{sensitiveLocked && (
|
||||
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
|
||||
<AlertDescription>
|
||||
{t('Sensitive channel settings are read-only for your account.')}{' '}
|
||||
{t(
|
||||
'You can still edit non-sensitive operations fields such as models, groups, priority, and weight.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='channel-form'
|
||||
@@ -1135,78 +1219,103 @@ export function ChannelMutateDrawer({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='type'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Type *')}</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox
|
||||
options={channelTypeOptions}
|
||||
value={String(field.value)}
|
||||
onValueChange={(value) => {
|
||||
const nextType = Number(value)
|
||||
if (
|
||||
Number.isInteger(nextType) &&
|
||||
nextType > 0
|
||||
) {
|
||||
field.onChange(nextType)
|
||||
}
|
||||
}}
|
||||
placeholder={t('Select channel type')}
|
||||
searchPlaceholder={t('Search channel type...')}
|
||||
emptyText={t('No channel type found.')}
|
||||
allowCustomValue
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<fieldset
|
||||
disabled={sensitiveLocked}
|
||||
className='min-w-0 disabled:opacity-60'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='type'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Type *')}</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox
|
||||
options={channelTypeOptions}
|
||||
value={String(field.value)}
|
||||
onValueChange={(value) => {
|
||||
const nextType = Number(value)
|
||||
if (
|
||||
Number.isInteger(nextType) &&
|
||||
nextType > 0
|
||||
) {
|
||||
field.onChange(nextType)
|
||||
}
|
||||
}}
|
||||
placeholder={t('Select channel type')}
|
||||
searchPlaceholder={t(
|
||||
'Search channel type...'
|
||||
)}
|
||||
emptyText={t('No channel type found.')}
|
||||
allowCustomValue
|
||||
/>
|
||||
</FormControl>
|
||||
{sensitiveLocked && (
|
||||
<FormDescription>
|
||||
{t(
|
||||
'No permission to perform this action'
|
||||
)}
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='status'
|
||||
render={({ field }) => (
|
||||
<FormItem className={sideDrawerSwitchItemClassName()}>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<FormLabel>{t('Enabled')}</FormLabel>
|
||||
<FormDescription className='text-xs'>
|
||||
{t('Enable or disable this channel')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value === 1}
|
||||
onCheckedChange={(checked) =>
|
||||
field.onChange(checked ? 1 : 2)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{currentType === 1 && (
|
||||
{!isEditing && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='openai_organization'
|
||||
name='status'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('OpenAI Organization')}</FormLabel>
|
||||
<FormItem className={sideDrawerSwitchItemClassName()}>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<FormLabel>{t('Enabled')}</FormLabel>
|
||||
<FormDescription className='text-xs'>
|
||||
{t('Enable or disable this channel')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input placeholder={t('org-...')} {...field} />
|
||||
<Switch
|
||||
checked={field.value === 1}
|
||||
onCheckedChange={(checked) =>
|
||||
field.onChange(checked ? 1 : 2)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(FIELD_DESCRIPTIONS.OPENAI_ORG)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentType === 1 && (
|
||||
<fieldset
|
||||
disabled={sensitiveLocked}
|
||||
className='disabled:opacity-60'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='openai_organization'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('OpenAI Organization')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('org-...')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{sensitiveLocked
|
||||
? t(
|
||||
'No permission to perform this action'
|
||||
)
|
||||
: t(FIELD_DESCRIPTIONS.OPENAI_ORG)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
</ChannelBasicSection>
|
||||
|
||||
{/* ── API Access ── */}
|
||||
@@ -1219,6 +1328,20 @@ export function ChannelMutateDrawer({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{sensitiveLocked && (
|
||||
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'No permission to perform this action'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<fieldset
|
||||
disabled={sensitiveLocked}
|
||||
className='space-y-4 disabled:opacity-60'
|
||||
>
|
||||
{/* Azure (type 3) */}
|
||||
{currentType === 3 && (
|
||||
<>
|
||||
@@ -2004,7 +2127,7 @@ export function ChannelMutateDrawer({
|
||||
)}
|
||||
</div>
|
||||
</FormDescription>
|
||||
{isEditing && (
|
||||
{isEditing && canRevealChannelKey && (
|
||||
<div className='border-border/60 mt-4 flex flex-col gap-3 border-y border-dashed py-4'>
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div>
|
||||
@@ -2081,7 +2204,10 @@ export function ChannelMutateDrawer({
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleRefreshCodexCredential}
|
||||
disabled={isCodexCredentialRefreshing}
|
||||
disabled={
|
||||
sensitiveLocked ||
|
||||
isCodexCredentialRefreshing
|
||||
}
|
||||
>
|
||||
{isCodexCredentialRefreshing ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
@@ -2207,6 +2333,7 @@ export function ChannelMutateDrawer({
|
||||
/>
|
||||
)}
|
||||
</ChannelAuthSection>
|
||||
</fieldset>
|
||||
</ChannelApiAccessSection>
|
||||
|
||||
{/* ── Models & Groups ── */}
|
||||
@@ -2324,18 +2451,28 @@ export function ChannelMutateDrawer({
|
||||
{t('Fill All Models')}
|
||||
</Button>
|
||||
{MODEL_FETCHABLE_TYPES.has(currentType) && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleFetchModels}
|
||||
>
|
||||
<Sparkles
|
||||
className='mr-2 h-4 w-4'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
{t('Fetch from Upstream')}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleFetchModels}
|
||||
disabled={!isEditing && !canEditSensitive}
|
||||
>
|
||||
<Sparkles
|
||||
className='mr-2 h-4 w-4'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
{t('Fetch from Upstream')}
|
||||
</Button>
|
||||
{!isEditing && !canEditSensitive && (
|
||||
<span className='text-muted-foreground basis-full text-xs'>
|
||||
{t(
|
||||
'No permission to perform this action'
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type='button'
|
||||
@@ -2752,6 +2889,15 @@ export function ChannelMutateDrawer({
|
||||
)}
|
||||
/>
|
||||
|
||||
{sensitiveLocked && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('No permission to perform this action')}
|
||||
</p>
|
||||
)}
|
||||
<fieldset
|
||||
disabled={sensitiveLocked}
|
||||
className='space-y-4 disabled:opacity-60'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='param_override'
|
||||
@@ -2827,7 +2973,7 @@ export function ChannelMutateDrawer({
|
||||
<Textarea
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
disabled={isSubmitting}
|
||||
disabled={sensitiveLocked || isSubmitting}
|
||||
rows={8}
|
||||
placeholder={t(
|
||||
'Override request parameters. Cannot override stream parameter.'
|
||||
@@ -2923,7 +3069,7 @@ export function ChannelMutateDrawer({
|
||||
rows={6}
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
disabled={isSubmitting}
|
||||
disabled={sensitiveLocked || isSubmitting}
|
||||
placeholder={t(
|
||||
'Enter JSON to override request headers'
|
||||
)}
|
||||
@@ -2944,6 +3090,7 @@ export function ChannelMutateDrawer({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2953,6 +3100,19 @@ export function ChannelMutateDrawer({
|
||||
title={t('Channel Extra Settings')}
|
||||
icon={<Settings className='h-4 w-4' />}
|
||||
/>
|
||||
{sensitiveLocked && (
|
||||
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'No permission to perform this action'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<fieldset
|
||||
disabled={sensitiveLocked}
|
||||
className='space-y-4 disabled:opacity-60'
|
||||
>
|
||||
{(currentType === 1 || currentType === 14) && (
|
||||
<div className='border-border/60 flex flex-col gap-3 border-y py-4'>
|
||||
<SubHeading
|
||||
@@ -3468,6 +3628,7 @@ export function ChannelMutateDrawer({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
</div>
|
||||
</ChannelAdvancedSection>
|
||||
</>
|
||||
@@ -3491,7 +3652,7 @@ export function ChannelMutateDrawer({
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{paramOverrideEditorOpen && (
|
||||
{paramOverrideEditorOpen && !sensitiveLocked && (
|
||||
<ParamOverrideEditorDialog
|
||||
open={paramOverrideEditorOpen}
|
||||
value={form.watch('param_override') || ''}
|
||||
@@ -3505,7 +3666,7 @@ export function ChannelMutateDrawer({
|
||||
/>
|
||||
)}
|
||||
|
||||
{advancedCustomEditorOpen && (
|
||||
{advancedCustomEditorOpen && !sensitiveLocked && (
|
||||
<AdvancedCustomEditorDialog
|
||||
open={advancedCustomEditorOpen}
|
||||
value={form.watch('advanced_custom') || ''}
|
||||
|
||||
@@ -19,6 +19,12 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ADMIN_PERMISSION_ACTIONS,
|
||||
ADMIN_PERMISSION_RESOURCES,
|
||||
hasPermission,
|
||||
} from '@/lib/admin-permissions'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { createChannel, updateChannel } from '../api'
|
||||
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
|
||||
import {
|
||||
@@ -35,6 +41,18 @@ type UseChannelMutateFormParams = {
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const SENSITIVE_UPDATE_FIELDS = [
|
||||
'type',
|
||||
'key',
|
||||
'base_url',
|
||||
'openai_organization',
|
||||
'param_override',
|
||||
'header_override',
|
||||
'setting',
|
||||
'settings',
|
||||
'other',
|
||||
] satisfies (keyof Channel)[]
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
@@ -62,6 +80,12 @@ function getErrorMessage(error: unknown): string | undefined {
|
||||
|
||||
export function useChannelMutateForm(props: UseChannelMutateFormParams) {
|
||||
const { t } = useTranslation()
|
||||
const currentUser = useAuthStore((s) => s.auth.user)
|
||||
const canEditSensitive = hasPermission(
|
||||
currentUser,
|
||||
ADMIN_PERMISSION_RESOURCES.CHANNEL,
|
||||
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
|
||||
)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: ChannelFormValues): Promise<string> => {
|
||||
@@ -70,8 +94,19 @@ export function useChannelMutateForm(props: UseChannelMutateFormParams) {
|
||||
data,
|
||||
props.currentRow.id
|
||||
)
|
||||
if (!data.key?.trim()) {
|
||||
delete payload.key
|
||||
}
|
||||
if (!canEditSensitive) {
|
||||
for (const field of SENSITIVE_UPDATE_FIELDS) {
|
||||
delete payload[field]
|
||||
}
|
||||
}
|
||||
const payloadWithKeyMode =
|
||||
props.isMultiKeyChannel && data.key_mode
|
||||
canEditSensitive &&
|
||||
props.isMultiKeyChannel &&
|
||||
data.key?.trim() &&
|
||||
data.key_mode
|
||||
? {
|
||||
...payload,
|
||||
key_mode: data.key_mode,
|
||||
|
||||
+23
-24
@@ -25,6 +25,8 @@ import {
|
||||
deleteChannel,
|
||||
testChannel,
|
||||
updateChannel,
|
||||
updateChannelStatus,
|
||||
batchUpdateChannelStatus,
|
||||
batchDeleteChannels,
|
||||
batchSetChannelTag,
|
||||
enableTagChannels,
|
||||
@@ -119,7 +121,7 @@ export async function handleEnableChannel(
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await updateChannel(id, { status: CHANNEL_STATUS.ENABLED })
|
||||
const response = await updateChannelStatus(id, CHANNEL_STATUS.ENABLED)
|
||||
if (response.success) {
|
||||
toast.success(i18next.t(SUCCESS_MESSAGES.ENABLED))
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
@@ -141,9 +143,10 @@ export async function handleDisableChannel(
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await updateChannel(id, {
|
||||
status: CHANNEL_STATUS.MANUAL_DISABLED,
|
||||
})
|
||||
const response = await updateChannelStatus(
|
||||
id,
|
||||
CHANNEL_STATUS.MANUAL_DISABLED
|
||||
)
|
||||
if (response.success) {
|
||||
toast.success(i18next.t(SUCCESS_MESSAGES.DISABLED))
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
@@ -441,16 +444,12 @@ export async function handleBatchEnable(
|
||||
}
|
||||
|
||||
try {
|
||||
// Update each channel individually
|
||||
const promises = ids.map((id) =>
|
||||
updateChannel(id, { status: CHANNEL_STATUS.ENABLED })
|
||||
const response = await batchUpdateChannelStatus(
|
||||
ids,
|
||||
CHANNEL_STATUS.ENABLED
|
||||
)
|
||||
const results = await Promise.allSettled(promises)
|
||||
|
||||
const successCount = results.filter(
|
||||
(r) => r.status === 'fulfilled' && r.value.success
|
||||
).length
|
||||
const failCount = results.length - successCount
|
||||
const successCount = response.success ? response.data || 0 : 0
|
||||
const failCount = ids.length - successCount
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(
|
||||
@@ -460,7 +459,9 @@ export async function handleBatchEnable(
|
||||
onSuccess?.()
|
||||
}
|
||||
|
||||
if (failCount > 0) {
|
||||
if (!response.success) {
|
||||
toast.error(response.message || i18next.t('Failed to enable channels'))
|
||||
} else if (failCount > 0) {
|
||||
toast.error(
|
||||
i18next.t('{{count}} channel(s) failed to enable', { count: failCount })
|
||||
)
|
||||
@@ -484,16 +485,12 @@ export async function handleBatchDisable(
|
||||
}
|
||||
|
||||
try {
|
||||
// Update each channel individually
|
||||
const promises = ids.map((id) =>
|
||||
updateChannel(id, { status: CHANNEL_STATUS.MANUAL_DISABLED })
|
||||
const response = await batchUpdateChannelStatus(
|
||||
ids,
|
||||
CHANNEL_STATUS.MANUAL_DISABLED
|
||||
)
|
||||
const results = await Promise.allSettled(promises)
|
||||
|
||||
const successCount = results.filter(
|
||||
(r) => r.status === 'fulfilled' && r.value.success
|
||||
).length
|
||||
const failCount = results.length - successCount
|
||||
const successCount = response.success ? response.data || 0 : 0
|
||||
const failCount = ids.length - successCount
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(
|
||||
@@ -503,7 +500,9 @@ export async function handleBatchDisable(
|
||||
onSuccess?.()
|
||||
}
|
||||
|
||||
if (failCount > 0) {
|
||||
if (!response.success) {
|
||||
toast.error(response.message || i18next.t('Failed to disable channels'))
|
||||
} else if (failCount > 0) {
|
||||
toast.error(
|
||||
i18next.t('{{count}} channel(s) failed to disable', {
|
||||
count: failCount,
|
||||
|
||||
@@ -702,7 +702,6 @@ export function transformFormDataToUpdatePayload(
|
||||
weight: formData.weight ?? 0,
|
||||
test_model: formData.test_model || null,
|
||||
auto_ban: formData.auto_ban ?? 1,
|
||||
status: formData.status,
|
||||
status_code_mapping: formData.status_code_mapping || null,
|
||||
tag: formData.tag || null,
|
||||
remark: formData.remark || '',
|
||||
|
||||
+13
@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { api } from '@/lib/api'
|
||||
import type { PermissionCatalog } from '@/lib/admin-permissions'
|
||||
import type {
|
||||
User,
|
||||
GetUsersParams,
|
||||
@@ -149,6 +150,18 @@ export async function getGroups(): Promise<ApiResponse<string[]>> {
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the permission catalog (resources, actions, and role baselines).
|
||||
* Source of truth lives in the backend authz package.
|
||||
*/
|
||||
export async function getPermissionCatalog(): Promise<PermissionCatalog> {
|
||||
const res = await api.get('/api/authz/catalog')
|
||||
return {
|
||||
resources: res.data?.data?.resources ?? [],
|
||||
roles: res.data?.data?.roles ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Admin Binding Management APIs
|
||||
// ============================================================================
|
||||
|
||||
@@ -23,9 +23,19 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { Pencil } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ADMIN_PERMISSION_ACTIONS,
|
||||
ADMIN_PERMISSION_RESOURCES,
|
||||
EMPTY_PERMISSION_CATALOG,
|
||||
hasPermission,
|
||||
normalizeAdminPermissions,
|
||||
} from '@/lib/admin-permissions'
|
||||
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
|
||||
import { formatQuota, parseQuotaFromDollars } from '@/lib/format'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -62,7 +72,13 @@ import {
|
||||
sideDrawerFormClassName,
|
||||
sideDrawerHeaderClassName,
|
||||
} from '@/components/drawer-layout'
|
||||
import { createUser, updateUser, getUser, getGroups } from '../api'
|
||||
import {
|
||||
createUser,
|
||||
updateUser,
|
||||
getUser,
|
||||
getGroups,
|
||||
getPermissionCatalog,
|
||||
} from '../api'
|
||||
import { BINDING_FIELDS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
|
||||
import {
|
||||
userFormSchema,
|
||||
@@ -89,6 +105,7 @@ export function UsersMutateDrawer({
|
||||
const { t } = useTranslation()
|
||||
const isUpdate = !!currentRow
|
||||
const { triggerRefresh } = useUsers()
|
||||
const currentUser = useAuthStore((s) => s.auth.user)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [quotaDialogOpen, setQuotaDialogOpen] = useState(false)
|
||||
|
||||
@@ -101,6 +118,13 @@ export function UsersMutateDrawer({
|
||||
|
||||
const groups = groupsData?.data || []
|
||||
|
||||
// Permission catalog is owned by the backend; fetched once and reused.
|
||||
const { data: permissionCatalog = EMPTY_PERMISSION_CATALOG } = useQuery({
|
||||
queryKey: ['admin-permission-catalog'],
|
||||
queryFn: getPermissionCatalog,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const form = useForm<UserFormValues>({
|
||||
resolver: zodResolver(userFormSchema),
|
||||
defaultValues: USER_FORM_DEFAULT_VALUES,
|
||||
@@ -126,6 +150,9 @@ export function UsersMutateDrawer({
|
||||
const tokensOnly = currencyMeta.kind === 'tokens'
|
||||
|
||||
const currentQuotaRaw = form.watch('quota_dollars') || 0
|
||||
const selectedRole = form.watch('role')
|
||||
const canEditAdminPermissions = currentUser?.role === ROLE.SUPER_ADMIN
|
||||
const targetIsAdmin = (selectedRole ?? currentRow?.role ?? 0) >= ROLE.ADMIN
|
||||
|
||||
const onSubmit = async (data: UserFormValues) => {
|
||||
if (!isUpdate) {
|
||||
@@ -141,7 +168,11 @@ export function UsersMutateDrawer({
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const payload = transformFormDataToPayload(data, currentRow?.id)
|
||||
const payload = transformFormDataToPayload(
|
||||
data,
|
||||
currentRow?.id,
|
||||
permissionCatalog
|
||||
)
|
||||
const result = isUpdate
|
||||
? await updateUser(payload as typeof payload & { id: number })
|
||||
: await createUser(payload)
|
||||
@@ -417,6 +448,92 @@ export function UsersMutateDrawer({
|
||||
</SideDrawerSection>
|
||||
)}
|
||||
|
||||
{canEditAdminPermissions &&
|
||||
targetIsAdmin &&
|
||||
permissionCatalog.resources.length > 0 && (
|
||||
<SideDrawerSection>
|
||||
<h3 className='text-sm font-medium'>
|
||||
{t('Admin Permissions')}
|
||||
</h3>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Default administrator permissions can be overridden for this user.'
|
||||
)}
|
||||
</p>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='admin_permissions'
|
||||
render={({ field }) => {
|
||||
const selected = normalizeAdminPermissions(
|
||||
field.value,
|
||||
permissionCatalog
|
||||
)
|
||||
return (
|
||||
<FormItem>
|
||||
<div className='space-y-3'>
|
||||
{permissionCatalog.resources.map((resource) => (
|
||||
<div
|
||||
key={resource.resource}
|
||||
className='space-y-2 rounded-md border p-3'
|
||||
>
|
||||
<div className='text-sm font-medium'>
|
||||
{t(resource.label_key)}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
{resource.actions.map((option) => (
|
||||
<label
|
||||
key={option.action}
|
||||
className='flex items-start gap-3'
|
||||
>
|
||||
<Checkbox
|
||||
checked={
|
||||
selected[resource.resource]?.[
|
||||
option.action
|
||||
] === true
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange({
|
||||
...selected,
|
||||
[resource.resource]: {
|
||||
...selected[resource.resource],
|
||||
[option.action]: checked === true,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<span className='flex flex-col gap-1'>
|
||||
<span className='text-sm font-medium'>
|
||||
{t(option.label_key)}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t(option.description_key)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{currentUser && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{hasPermission(
|
||||
currentUser,
|
||||
ADMIN_PERMISSION_RESOURCES.CHANNEL,
|
||||
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
|
||||
)
|
||||
? t('Your account can edit sensitive channel settings.')
|
||||
: t('Your account cannot edit sensitive channel settings.')}
|
||||
</p>
|
||||
)}
|
||||
</SideDrawerSection>
|
||||
)}
|
||||
|
||||
{/* Binding Information (Read-only) */}
|
||||
{isUpdate && (
|
||||
<SideDrawerSection>
|
||||
|
||||
+28
-3
@@ -18,6 +18,12 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { quotaUnitsToDollars } from '@/lib/format'
|
||||
import {
|
||||
type PermissionCatalog,
|
||||
type AdminPermissionMatrix,
|
||||
normalizeAdminPermissions,
|
||||
} from '@/lib/admin-permissions'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { DEFAULT_GROUP } from '../constants'
|
||||
import { type UserFormData, type User } from '../types'
|
||||
|
||||
@@ -33,6 +39,7 @@ export const userFormSchema = z.object({
|
||||
quota_dollars: z.number().min(0).optional(),
|
||||
group: z.string().optional(),
|
||||
remark: z.string().optional(),
|
||||
admin_permissions: z.record(z.string(), z.record(z.string(), z.boolean())).optional(),
|
||||
})
|
||||
|
||||
export type UserFormValues = z.infer<typeof userFormSchema>
|
||||
@@ -49,6 +56,8 @@ export const USER_FORM_DEFAULT_VALUES: UserFormValues = {
|
||||
quota_dollars: 0,
|
||||
group: DEFAULT_GROUP,
|
||||
remark: '',
|
||||
// Filled against the backend catalog at render time; see UsersMutateDrawer.
|
||||
admin_permissions: {},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -60,7 +69,8 @@ export const USER_FORM_DEFAULT_VALUES: UserFormValues = {
|
||||
*/
|
||||
export function transformFormDataToPayload(
|
||||
data: UserFormValues,
|
||||
userId?: number
|
||||
userId?: number,
|
||||
catalog?: PermissionCatalog
|
||||
): UserFormData & { id?: number } {
|
||||
const payload: UserFormData & { id?: number } = {
|
||||
username: data.username,
|
||||
@@ -68,9 +78,21 @@ export function transformFormDataToPayload(
|
||||
password: data.password || undefined,
|
||||
}
|
||||
|
||||
const role = userId === undefined ? data.role || 1 : (data.role ?? 0)
|
||||
|
||||
// Only send the permission matrix when the target is an admin and the catalog
|
||||
// is available; without the catalog we cannot build a full matrix, so we omit
|
||||
// the field (the backend then leaves existing permissions untouched).
|
||||
if (role >= ROLE.ADMIN && catalog) {
|
||||
payload.admin_permissions = normalizeAdminPermissions(
|
||||
data.admin_permissions as AdminPermissionMatrix | undefined,
|
||||
catalog
|
||||
)
|
||||
}
|
||||
|
||||
// For create: only send required fields
|
||||
if (userId === undefined) {
|
||||
payload.role = data.role || 1 // Default to common user
|
||||
payload.role = role
|
||||
} else {
|
||||
// For update: quota is adjusted atomically via /api/user/manage, not sent here
|
||||
payload.group = data.group
|
||||
@@ -82,7 +104,9 @@ export function transformFormDataToPayload(
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform user data to form defaults
|
||||
* Transform user data to form defaults. The admin permission matrix is passed
|
||||
* through as-is (the backend already returns a full matrix); it is filled against
|
||||
* the catalog at render time in UsersMutateDrawer.
|
||||
*/
|
||||
export function transformUserToFormDefaults(user: User): UserFormValues {
|
||||
return {
|
||||
@@ -93,5 +117,6 @@ export function transformUserToFormDefaults(user: User): UserFormValues {
|
||||
quota_dollars: quotaUnitsToDollars(user.quota),
|
||||
group: user.group || DEFAULT_GROUP,
|
||||
remark: user.remark || '',
|
||||
admin_permissions: user.admin_permissions ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import type { AdminPermissionMatrix } from '@/lib/admin-permissions'
|
||||
|
||||
// ============================================================================
|
||||
// User Schema & Types
|
||||
@@ -57,6 +58,7 @@ export const userSchema = z.object({
|
||||
last_login_at: z.number().optional(),
|
||||
DeletedAt: z.any().nullable().optional(),
|
||||
remark: z.string().optional(),
|
||||
admin_permissions: z.record(z.string(), z.record(z.string(), z.boolean())).optional(),
|
||||
})
|
||||
export type User = z.infer<typeof userSchema>
|
||||
|
||||
@@ -106,6 +108,7 @@ export interface UserFormData {
|
||||
quota?: number // Only used when updating user
|
||||
group?: string // Only used when updating user
|
||||
remark?: string // Only used when updating user
|
||||
admin_permissions?: AdminPermissionMatrix
|
||||
}
|
||||
|
||||
export type ManageUserAction =
|
||||
|
||||
Vendored
+23
-3
@@ -223,8 +223,10 @@
|
||||
"Admin": "Admin",
|
||||
"Admin access required": "Admin access required",
|
||||
"Admin area": "Admin area",
|
||||
"Admin Channel Permissions": "Admin Channel Permissions",
|
||||
"Admin notes (only visible to admins)": "Admin notes (only visible to admins)",
|
||||
"Admin Only": "Admin Only",
|
||||
"Admin Permissions": "Admin Permissions",
|
||||
"Administer user accounts and roles.": "Administer user accounts and roles.",
|
||||
"Administrator account": "Administrator account",
|
||||
"Administrator username": "Administrator username",
|
||||
@@ -716,6 +718,7 @@
|
||||
"Channel ID is required": "Channel ID is required",
|
||||
"Channel key": "Channel key",
|
||||
"Channel key unlocked": "Channel key unlocked",
|
||||
"Channel Management": "Channel Management",
|
||||
"Channel models": "Channel models",
|
||||
"Channel name is required": "Channel name is required",
|
||||
"Channel test completed": "Channel test completed",
|
||||
@@ -1075,6 +1078,7 @@
|
||||
"Create cache": "Create cache",
|
||||
"Create cache ratio": "Create cache ratio",
|
||||
"Create Channel": "Create Channel",
|
||||
"Create channels or edit keys, base URLs, and overrides.": "Create channels or edit keys, base URLs, and overrides.",
|
||||
"Create Code": "Create Code",
|
||||
"Create credentials for the root user": "Create credentials for the root user",
|
||||
"Create deployment": "Create deployment",
|
||||
@@ -1193,6 +1197,7 @@
|
||||
"Default": "Default",
|
||||
"Default (New Frontend)": "Default (New Frontend)",
|
||||
"Default / range": "Default / range",
|
||||
"Default administrator permissions can be overridden for this user.": "Default administrator permissions can be overridden for this user.",
|
||||
"Default API Version *": "Default API Version *",
|
||||
"Default API version for this channel": "Default API version for this channel",
|
||||
"Default Bearer": "Default Bearer",
|
||||
@@ -1380,8 +1385,8 @@
|
||||
"Drawing": "Drawing",
|
||||
"Drawing logs": "Drawing logs",
|
||||
"Drawing Logs": "Drawing Logs",
|
||||
"Drawing task records": "Drawing task records",
|
||||
"Drawing task polling": "Drawing task polling",
|
||||
"Drawing task records": "Drawing task records",
|
||||
"Duplicate": "Duplicate",
|
||||
"Duplicate group names: {{names}}": "Duplicate group names: {{names}}",
|
||||
"Duplicate source model mappings are not allowed": "Duplicate source model mappings are not allowed",
|
||||
@@ -1448,6 +1453,7 @@
|
||||
"Edit API Shortcut": "Edit API Shortcut",
|
||||
"Edit billing ratios and user-selectable groups in one table.": "Edit billing ratios and user-selectable groups in one table.",
|
||||
"Edit Channel": "Edit Channel",
|
||||
"Edit channel routing": "Edit channel routing",
|
||||
"Edit chat preset": "Edit chat preset",
|
||||
"Edit discount tier": "Edit discount tier",
|
||||
"Edit FAQ": "Edit FAQ",
|
||||
@@ -1458,6 +1464,7 @@
|
||||
"Edit model": "Edit model",
|
||||
"Edit Model": "Edit Model",
|
||||
"Edit model pricing": "Edit model pricing",
|
||||
"Edit non-sensitive settings such as models, groups, and routing rules.": "Edit non-sensitive settings such as models, groups, and routing rules.",
|
||||
"Edit OAuth Provider": "Edit OAuth Provider",
|
||||
"Edit payment method": "Edit payment method",
|
||||
"Edit Prefill Group": "Edit Prefill Group",
|
||||
@@ -1465,6 +1472,7 @@
|
||||
"Edit ratio override": "Edit ratio override",
|
||||
"Edit Rule": "Edit Rule",
|
||||
"Edit selectable group": "Edit selectable group",
|
||||
"Edit sensitive channel settings": "Edit sensitive channel settings",
|
||||
"Edit Tag": "Edit Tag",
|
||||
"Edit Tag:": "Edit Tag:",
|
||||
"Edit Uptime Kuma Group": "Edit Uptime Kuma Group",
|
||||
@@ -2799,6 +2807,7 @@
|
||||
"No payment methods configured. Click \"Add method\" or use templates to get started.": "No payment methods configured. Click \"Add method\" or use templates to get started.",
|
||||
"No payment methods match your search": "No payment methods match your search",
|
||||
"No performance data available": "No performance data available",
|
||||
"No permission to perform this action": "No permission to perform this action",
|
||||
"No plans available": "No plans available",
|
||||
"No preference": "No preference",
|
||||
"No prefill groups yet": "No prefill groups yet",
|
||||
@@ -2974,6 +2983,7 @@
|
||||
"OpenAIMax": "OpenAIMax",
|
||||
"OpenRouter": "OpenRouter",
|
||||
"opens in an external client. Trigger it from the sidebar or API key actions to launch the configured application.": "opens in an external client. Trigger it from the sidebar or API key actions to launch the configured application.",
|
||||
"Operate channels": "Operate channels",
|
||||
"Operation": "Operation",
|
||||
"operation and charging behavior": "operation and charging behavior",
|
||||
"Operation Audit Info": "Operation Audit Info",
|
||||
@@ -3432,6 +3442,7 @@
|
||||
"Raw Quota": "Raw Quota",
|
||||
"Re-enable on success": "Re-enable on success",
|
||||
"Re-login": "Re-login",
|
||||
"Read channels": "Read channels",
|
||||
"Ready": "Ready",
|
||||
"Ready to initialize": "Ready to initialize",
|
||||
"Ready to simplify": "Ready to simplify",
|
||||
@@ -3603,6 +3614,7 @@
|
||||
"Reroll": "Reroll",
|
||||
"Research, analysis, scientific reasoning": "Research, analysis, scientific reasoning",
|
||||
"Resend ({{seconds}}s)": "Resend ({{seconds}}s)",
|
||||
"Reserved for viewing complete channel keys after secure verification.": "Reserved for viewing complete channel keys after secure verification.",
|
||||
"Reset": "Reset",
|
||||
"Reset 2FA": "Reset 2FA",
|
||||
"Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.",
|
||||
@@ -3739,7 +3751,6 @@
|
||||
"Save Preferences": "Save Preferences",
|
||||
"Save preview": "Save preview",
|
||||
"Save rate limits": "Save rate limits",
|
||||
"Save token limits": "Save token limits",
|
||||
"Save sensitive words": "Save sensitive words",
|
||||
"Save Settings": "Save Settings",
|
||||
"Save sidebar modules": "Save sidebar modules",
|
||||
@@ -3748,6 +3759,7 @@
|
||||
"Save Stripe settings": "Save Stripe settings",
|
||||
"Save these backup codes in a safe place. Each code can only be used once.": "Save these backup codes in a safe place. Each code can only be used once.",
|
||||
"Save these codes in a safe place. Each code can only be used once.": "Save these codes in a safe place. Each code can only be used once.",
|
||||
"Save token limits": "Save token limits",
|
||||
"Save tool prices": "Save tool prices",
|
||||
"Save Waffo Pancake settings": "Save Waffo Pancake settings",
|
||||
"Save Worker settings": "Save Worker settings",
|
||||
@@ -3891,6 +3903,7 @@
|
||||
"Send email alerts when a user falls below this quota": "Send email alerts when a user falls below this quota",
|
||||
"Send reset email": "Send reset email",
|
||||
"Sending...": "Sending...",
|
||||
"Sensitive channel settings are read-only for your account.": "Sensitive channel settings are read-only for your account.",
|
||||
"Sensitive Words": "Sensitive Words",
|
||||
"Sent the API key to FluentRead.": "Sent the API key to FluentRead.",
|
||||
"Separate image/audio prices are enabled.": "Separate image/audio prices are enabled.",
|
||||
@@ -3976,11 +3989,11 @@
|
||||
"Simple mode only returns message; status code and error type use system defaults.": "Simple mode only returns message; status code and error type use system defaults.",
|
||||
"Simple mode: prune objects by type, e.g. redacted_thinking.": "Simple mode: prune objects by type, e.g. redacted_thinking.",
|
||||
"Single Key": "Single Key",
|
||||
"Skip async task polling delay": "Skip async task polling delay",
|
||||
"Site & Branding": "Site & Branding",
|
||||
"Site Key": "Site Key",
|
||||
"Size:": "Size:",
|
||||
"sk_xxx or rk_xxx": "sk_xxx or rk_xxx",
|
||||
"Skip async task polling delay": "Skip async task polling delay",
|
||||
"Skip retry on failure": "Skip retry on failure",
|
||||
"Skip SMTP TLS certificate verification": "Skip SMTP TLS certificate verification",
|
||||
"Skip to Main": "Skip to Main",
|
||||
@@ -4206,6 +4219,7 @@
|
||||
"Test all {{count}} models": "Test all {{count}} models",
|
||||
"Test All Channels": "Test All Channels",
|
||||
"Test Channel Connection": "Test Channel Connection",
|
||||
"Test channels, refresh balances, and enable/disable individual, batch, or tagged channels.": "Test channels, refresh balances, and enable/disable individual, batch, or tagged channels.",
|
||||
"Test Connection": "Test Connection",
|
||||
"Test connectivity for:": "Test connectivity for:",
|
||||
"Test failed": "Test failed",
|
||||
@@ -4739,6 +4753,8 @@
|
||||
"Vidu": "Vidu",
|
||||
"View": "View",
|
||||
"View all currently available models": "View all currently available models",
|
||||
"View channel lists and details without secrets.": "View channel lists and details without secrets.",
|
||||
"View channel secrets": "View channel secrets",
|
||||
"View detailed information about this user including balance, usage statistics, and invitation details.": "View detailed information about this user including balance, usage statistics, and invitation details.",
|
||||
"View details": "View details",
|
||||
"View document": "View document",
|
||||
@@ -4881,8 +4897,10 @@
|
||||
"You can close this tab once the binding completes or a success message appears in the original window.": "You can close this tab once the binding completes or a success message appears in the original window.",
|
||||
"You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.": "You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.",
|
||||
"You can only check in once per day": "You can only check in once per day",
|
||||
"You can still edit non-sensitive operations fields such as models, groups, priority, and weight.": "You can still edit non-sensitive operations fields such as models, groups, priority, and weight.",
|
||||
"You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.": "You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.",
|
||||
"You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.",
|
||||
"You do not have permission to edit sensitive channel settings.": "You do not have permission to edit sensitive channel settings.",
|
||||
"You don't have necessary permission": "You don't have necessary permission",
|
||||
"You have legally obtained authorization for the connected model APIs, accounts, keys, and quotas.": "You have legally obtained authorization for the connected model APIs, accounts, keys, and quotas.",
|
||||
"You have unsaved changes": "You have unsaved changes",
|
||||
@@ -4893,6 +4911,8 @@
|
||||
"You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.": "You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.",
|
||||
"You will be redirected to Telegram to complete the binding process.": "You will be redirected to Telegram to complete the binding process.",
|
||||
"You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds.": "You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds.",
|
||||
"Your account can edit sensitive channel settings.": "Your account can edit sensitive channel settings.",
|
||||
"Your account cannot edit sensitive channel settings.": "Your account cannot edit sensitive channel settings.",
|
||||
"your AI integration?": "your AI integration?",
|
||||
"Your Azure OpenAI endpoint URL": "Your Azure OpenAI endpoint URL",
|
||||
"Your Bot Name": "Your Bot Name",
|
||||
|
||||
Vendored
+23
-3
@@ -223,8 +223,10 @@
|
||||
"Admin": "Administrateur",
|
||||
"Admin access required": "Accès administrateur requis",
|
||||
"Admin area": "Espace administrateur",
|
||||
"Admin Channel Permissions": "Autorisations des canaux administrateur",
|
||||
"Admin notes (only visible to admins)": "Notes d'administration (visibles uniquement par les administrateurs)",
|
||||
"Admin Only": "Administrateur uniquement",
|
||||
"Admin Permissions": "Autorisations administrateur",
|
||||
"Administer user accounts and roles.": "Gérer les comptes d'utilisateurs et les rôles.",
|
||||
"Administrator account": "Compte administrateur",
|
||||
"Administrator username": "Nom d'utilisateur administrateur",
|
||||
@@ -716,6 +718,7 @@
|
||||
"Channel ID is required": "L'ID du canal est requis",
|
||||
"Channel key": "Clé du canal",
|
||||
"Channel key unlocked": "Clé de canal déverrouillée",
|
||||
"Channel Management": "Gestion des canaux",
|
||||
"Channel models": "Modèles de canaux",
|
||||
"Channel name is required": "Le nom du canal est requis",
|
||||
"Channel test completed": "Test du canal terminé",
|
||||
@@ -1075,6 +1078,7 @@
|
||||
"Create cache": "Créer le cache",
|
||||
"Create cache ratio": "Créer un ratio de cache",
|
||||
"Create Channel": "Créer un canal",
|
||||
"Create channels or edit keys, base URLs, and overrides.": "Créer des canaux ou modifier les clés, URL de base et règles de remplacement.",
|
||||
"Create Code": "Créer un code",
|
||||
"Create credentials for the root user": "Créer les identifiants pour le compte administrateur",
|
||||
"Create deployment": "Créer un déploiement",
|
||||
@@ -1193,6 +1197,7 @@
|
||||
"Default": "Par défaut",
|
||||
"Default (New Frontend)": "Par défaut (Nouveau frontend)",
|
||||
"Default / range": "Défaut / plage",
|
||||
"Default administrator permissions can be overridden for this user.": "Les autorisations administrateur par défaut peuvent être remplacées pour cet utilisateur.",
|
||||
"Default API Version *": "Version API par défaut *",
|
||||
"Default API version for this channel": "Version API par défaut pour ce canal",
|
||||
"Default Bearer": "Bearer par defaut",
|
||||
@@ -1380,8 +1385,8 @@
|
||||
"Drawing": "Dessin",
|
||||
"Drawing logs": "Journaux de dessin",
|
||||
"Drawing Logs": "Journaux de dessin",
|
||||
"Drawing task records": "Historique des tâches de dessin",
|
||||
"Drawing task polling": "Interrogation des tâches de dessin",
|
||||
"Drawing task records": "Historique des tâches de dessin",
|
||||
"Duplicate": "Dupliquer",
|
||||
"Duplicate group names: {{names}}": "Noms de groupe en double : {{names}}",
|
||||
"Duplicate source model mappings are not allowed": "Les mappages de modèles source en double ne sont pas autorisés",
|
||||
@@ -1448,6 +1453,7 @@
|
||||
"Edit API Shortcut": "Modifier le raccourci API",
|
||||
"Edit billing ratios and user-selectable groups in one table.": "Modifiez les ratios de facturation et les groupes sélectionnables par les utilisateurs dans un seul tableau.",
|
||||
"Edit Channel": "Modifier le canal",
|
||||
"Edit channel routing": "Modifier le routage des canaux",
|
||||
"Edit chat preset": "Modifier le préréglage de chat",
|
||||
"Edit discount tier": "Modifier le palier de remise",
|
||||
"Edit FAQ": "Modifier la FAQ",
|
||||
@@ -1458,6 +1464,7 @@
|
||||
"Edit model": "Modifier le modèle",
|
||||
"Edit Model": "Modifier le modèle",
|
||||
"Edit model pricing": "Modifier la tarification du modèle",
|
||||
"Edit non-sensitive settings such as models, groups, and routing rules.": "Modifier les paramètres non sensibles comme les modèles, les groupes et les règles de routage.",
|
||||
"Edit OAuth Provider": "Modifier le fournisseur OAuth",
|
||||
"Edit payment method": "Modifier le mode de paiement",
|
||||
"Edit Prefill Group": "Modifier le groupe de préremplissage",
|
||||
@@ -1465,6 +1472,7 @@
|
||||
"Edit ratio override": "Modifier le remplacement de ratio",
|
||||
"Edit Rule": "Modifier la règle",
|
||||
"Edit selectable group": "Modifier le groupe sélectionnable",
|
||||
"Edit sensitive channel settings": "Modifier les paramètres sensibles des canaux",
|
||||
"Edit Tag": "Modifier l'étiquette",
|
||||
"Edit Tag:": "Modifier l'étiquette :",
|
||||
"Edit Uptime Kuma Group": "Modifier le groupe Uptime Kuma",
|
||||
@@ -2799,6 +2807,7 @@
|
||||
"No payment methods configured. Click \"Add method\" or use templates to get started.": "Aucune méthode de paiement configurée. Cliquez sur \"Ajouter une méthode\" ou utilisez des modèles pour commencer.",
|
||||
"No payment methods match your search": "Aucune méthode de paiement ne correspond à votre recherche",
|
||||
"No performance data available": "Aucune donnée de performance disponible",
|
||||
"No permission to perform this action": "Vous n’avez pas l’autorisation d’effectuer cette action",
|
||||
"No plans available": "Aucun plan disponible",
|
||||
"No preference": "Aucune préférence",
|
||||
"No prefill groups yet": "Aucun groupe de préremplissage pour l'instant",
|
||||
@@ -2974,6 +2983,7 @@
|
||||
"OpenAIMax": "OpenAIMax",
|
||||
"OpenRouter": "OpenRouter",
|
||||
"opens in an external client. Trigger it from the sidebar or API key actions to launch the configured application.": "s'ouvre dans un client externe. Déclenchez-le depuis la barre latérale ou les actions de clé API pour lancer l'application configurée.",
|
||||
"Operate channels": "Exploiter les canaux",
|
||||
"Operation": "Opération",
|
||||
"operation and charging behavior": "à l’exploitation et à la facturation",
|
||||
"Operation Audit Info": "Informations d'audit d'opération",
|
||||
@@ -3432,6 +3442,7 @@
|
||||
"Raw Quota": "Quota brut",
|
||||
"Re-enable on success": "Réactiver en cas de succès",
|
||||
"Re-login": "Se reconnecter",
|
||||
"Read channels": "Lire les canaux",
|
||||
"Ready": "Prêt",
|
||||
"Ready to initialize": "Prêt à initialiser",
|
||||
"Ready to simplify": "Prêt à simplifier",
|
||||
@@ -3603,6 +3614,7 @@
|
||||
"Reroll": "Relancer",
|
||||
"Research, analysis, scientific reasoning": "Recherche, analyse, raisonnement scientifique",
|
||||
"Resend ({{seconds}}s)": "Renvoyer ({{seconds}}s)",
|
||||
"Reserved for viewing complete channel keys after secure verification.": "Réservé à l'affichage des clés complètes des canaux après une vérification sécurisée.",
|
||||
"Reset": "Réinitialiser",
|
||||
"Reset 2FA": "Réinitialiser la 2FA",
|
||||
"Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "Réinitialiser la 2FA de {{username}} ? L’utilisateur devra configurer à nouveau la 2FA pour continuer à l’utiliser.",
|
||||
@@ -3739,7 +3751,6 @@
|
||||
"Save Preferences": "Enregistrer les préférences",
|
||||
"Save preview": "Aperçu de l’enregistrement",
|
||||
"Save rate limits": "Enregistrer les limites de débit",
|
||||
"Save token limits": "Enregistrer les limites de jetons",
|
||||
"Save sensitive words": "Enregistrer les mots sensibles",
|
||||
"Save Settings": "Enregistrer les paramètres",
|
||||
"Save sidebar modules": "Enregistrer les modules de la barre latérale",
|
||||
@@ -3748,6 +3759,7 @@
|
||||
"Save Stripe settings": "Enregistrer les paramètres Stripe",
|
||||
"Save these backup codes in a safe place. Each code can only be used once.": "Enregistrez ces codes de secours dans un endroit sûr. Chaque code ne peut être utilisé qu'une seule fois.",
|
||||
"Save these codes in a safe place. Each code can only be used once.": "Enregistrez ces codes dans un endroit sûr. Chaque code ne peut être utilisé qu'une seule fois.",
|
||||
"Save token limits": "Enregistrer les limites de jetons",
|
||||
"Save tool prices": "Enregistrer les prix des outils",
|
||||
"Save Waffo Pancake settings": "Enregistrer les paramètres Waffo Pancake",
|
||||
"Save Worker settings": "Enregistrer les paramètres Worker",
|
||||
@@ -3891,6 +3903,7 @@
|
||||
"Send email alerts when a user falls below this quota": "Envoyer des alertes par e-mail lorsqu'un utilisateur descend en dessous de ce quota",
|
||||
"Send reset email": "Envoyer l'e-mail de réinitialisation",
|
||||
"Sending...": "Envoi en cours...",
|
||||
"Sensitive channel settings are read-only for your account.": "Les paramètres sensibles des canaux sont en lecture seule pour votre compte.",
|
||||
"Sensitive Words": "Mots sensibles",
|
||||
"Sent the API key to FluentRead.": "Clé API envoyée à FluentRead.",
|
||||
"Separate image/audio prices are enabled.": "Les prix séparés pour l’image et l’audio sont activés.",
|
||||
@@ -3976,11 +3989,11 @@
|
||||
"Simple mode only returns message; status code and error type use system defaults.": "Le mode simple ne retourne que le message ; le code de statut et le type d'erreur utilisent les valeurs par défaut.",
|
||||
"Simple mode: prune objects by type, e.g. redacted_thinking.": "Mode simple : nettoyer les objets par type, ex. redacted_thinking.",
|
||||
"Single Key": "Clé unique",
|
||||
"Skip async task polling delay": "Ignorer le délai de polling des tâches asynchrones",
|
||||
"Site & Branding": "Site et marque",
|
||||
"Site Key": "Clé du site",
|
||||
"Size:": "Taille :",
|
||||
"sk_xxx or rk_xxx": "sk_xxx ou rk_xxx",
|
||||
"Skip async task polling delay": "Ignorer le délai de polling des tâches asynchrones",
|
||||
"Skip retry on failure": "Ne pas réessayer en cas d'échec",
|
||||
"Skip SMTP TLS certificate verification": "Ignorer la vérification du certificat TLS SMTP",
|
||||
"Skip to Main": "Aller au contenu principal",
|
||||
@@ -4206,6 +4219,7 @@
|
||||
"Test all {{count}} models": "Tester les {{count}} modèles",
|
||||
"Test All Channels": "Tester tous les canaux",
|
||||
"Test Channel Connection": "Tester la connexion du canal",
|
||||
"Test channels, refresh balances, and enable/disable individual, batch, or tagged channels.": "Tester les canaux, actualiser les soldes et activer/désactiver des canaux individuellement, par lot ou par tag.",
|
||||
"Test Connection": "Tester la connexion",
|
||||
"Test connectivity for:": "Tester la connectivité pour :",
|
||||
"Test failed": "Échec du test",
|
||||
@@ -4739,6 +4753,8 @@
|
||||
"Vidu": "Vidu",
|
||||
"View": "Afficher",
|
||||
"View all currently available models": "Voir tous les modèles actuellement disponibles",
|
||||
"View channel lists and details without secrets.": "Afficher les listes et détails des canaux sans secrets.",
|
||||
"View channel secrets": "Voir les secrets des canaux",
|
||||
"View detailed information about this user including balance, usage statistics, and invitation details.": "Afficher des informations détaillées sur cet utilisateur, y compris le solde, les statistiques d'utilisation et les détails d'invitation.",
|
||||
"View details": "Voir les détails",
|
||||
"View document": "Afficher le document",
|
||||
@@ -4881,8 +4897,10 @@
|
||||
"You can close this tab once the binding completes or a success message appears in the original window.": "Vous pouvez fermer cet onglet une fois la liaison terminée ou qu'un message de succès apparaît dans la fenêtre d'origine.",
|
||||
"You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.": "Vous pouvez les ajouter manuellement dans \"Noms de modèles personnalisés\", cliquer sur \"Remplir\" puis soumettre, ou utiliser les opérations ci-dessous pour les gérer automatiquement.",
|
||||
"You can only check in once per day": "Vous ne pouvez vous connecter qu'une fois par jour",
|
||||
"You can still edit non-sensitive operations fields such as models, groups, priority, and weight.": "Vous pouvez toujours modifier les champs opérationnels non sensibles, comme les modèles, les groupes, la priorité et le poids.",
|
||||
"You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.": "Vous vous engagez à ne pas utiliser ce système pour mettre en œuvre, faciliter ou indirectement réaliser des actes violant les lois et règlements applicables, les exigences réglementaires, les règles des plateformes, l’intérêt public ou les droits et intérêts légitimes de tiers.",
|
||||
"You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "Vous vous engagez à utiliser les API, comptes, clés, quotas et capacités de service en amont uniquement dans le cadre d’une autorisation légale obtenue auprès des fournisseurs de services en amont, fournisseurs de modèles ou ayants droit concernés, et à ne pas effectuer de revente, trafic, distribution ou autre commercialisation non conforme sans autorisation.",
|
||||
"You do not have permission to edit sensitive channel settings.": "Vous n’avez pas l’autorisation de modifier les paramètres sensibles des canaux.",
|
||||
"You don't have necessary permission": "Vous n'avez pas la permission nécessaire",
|
||||
"You have legally obtained authorization for the connected model APIs, accounts, keys, and quotas.": "Vous avez légalement obtenu l’autorisation pour les API de modèles, comptes, clés et quotas connectés.",
|
||||
"You have unsaved changes": "Vous avez des modifications non enregistrées",
|
||||
@@ -4893,6 +4911,8 @@
|
||||
"You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.": "Vous comprenez que ce rappel de conformité est uniquement un avis de risque et ne constitue ni un conseil juridique, ni une conclusion d’examen de conformité, ni une garantie de la légalité de votre utilisation de ce système ; vous devez consulter des conseillers juridiques ou conformité professionnels selon votre situation réelle.",
|
||||
"You will be redirected to Telegram to complete the binding process.": "Vous serez redirigé vers Telegram pour terminer le processus de liaison.",
|
||||
"You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds.": "Vous serez redirigé automatiquement. Vous pouvez revenir à la page précédente si rien ne se passe après quelques secondes.",
|
||||
"Your account can edit sensitive channel settings.": "Votre compte peut modifier les paramètres sensibles des canaux.",
|
||||
"Your account cannot edit sensitive channel settings.": "Votre compte ne peut pas modifier les paramètres sensibles des canaux.",
|
||||
"your AI integration?": "votre intégration IA ?",
|
||||
"Your Azure OpenAI endpoint URL": "Votre URL de point de terminaison Azure OpenAI",
|
||||
"Your Bot Name": "Nom de votre Bot",
|
||||
|
||||
Vendored
+23
-3
@@ -223,8 +223,10 @@
|
||||
"Admin": "管理者",
|
||||
"Admin access required": "管理者アクセスが必要です",
|
||||
"Admin area": "管理者エリア",
|
||||
"Admin Channel Permissions": "管理者のチャネル権限",
|
||||
"Admin notes (only visible to admins)": "管理者メモ (管理者のみに表示)",
|
||||
"Admin Only": "管理者のみ",
|
||||
"Admin Permissions": "管理者権限",
|
||||
"Administer user accounts and roles.": "ユーザーアカウントとロールを管理します。",
|
||||
"Administrator account": "管理者アカウント",
|
||||
"Administrator username": "管理者ユーザー名",
|
||||
@@ -716,6 +718,7 @@
|
||||
"Channel ID is required": "チャネル ID が必要です",
|
||||
"Channel key": "チャネルキー",
|
||||
"Channel key unlocked": "チャネルキーが解除されました",
|
||||
"Channel Management": "チャネル管理",
|
||||
"Channel models": "チャネルモデル",
|
||||
"Channel name is required": "チャネル名が必要です",
|
||||
"Channel test completed": "チャネルテストが完了しました",
|
||||
@@ -1075,6 +1078,7 @@
|
||||
"Create cache": "キャッシュを作成",
|
||||
"Create cache ratio": "キャッシュ倍率を作成",
|
||||
"Create Channel": "チャネルを作成",
|
||||
"Create channels or edit keys, base URLs, and overrides.": "チャネルの作成、キー、ベース URL、上書き設定の編集を許可します。",
|
||||
"Create Code": "コードを作成",
|
||||
"Create credentials for the root user": "管理者アカウントの認証情報を作成",
|
||||
"Create deployment": "デプロイを作成",
|
||||
@@ -1193,6 +1197,7 @@
|
||||
"Default": "デフォルト",
|
||||
"Default (New Frontend)": "デフォルト(新フロントエンド)",
|
||||
"Default / range": "デフォルト / 範囲",
|
||||
"Default administrator permissions can be overridden for this user.": "このユーザーには既定の管理者権限を上書きできます。",
|
||||
"Default API Version *": "デフォルトのAPIバージョン *",
|
||||
"Default API version for this channel": "このチャネルのデフォルトのAPIバージョン",
|
||||
"Default Bearer": "既定の Bearer",
|
||||
@@ -1380,8 +1385,8 @@
|
||||
"Drawing": "画像生成",
|
||||
"Drawing logs": "描画ログ",
|
||||
"Drawing Logs": "画像生成履歴",
|
||||
"Drawing task records": "描画タスク記録",
|
||||
"Drawing task polling": "描画タスクのポーリング",
|
||||
"Drawing task records": "描画タスク記録",
|
||||
"Duplicate": "複製",
|
||||
"Duplicate group names: {{names}}": "重複するグループ名: {{names}}",
|
||||
"Duplicate source model mappings are not allowed": "重複したソースモデルのマッピングは許可されていません",
|
||||
@@ -1448,6 +1453,7 @@
|
||||
"Edit API Shortcut": "API ショートカットを編集",
|
||||
"Edit billing ratios and user-selectable groups in one table.": "課金倍率とユーザーが選択できるグループを1つの表で編集します。",
|
||||
"Edit Channel": "チャネルを編集",
|
||||
"Edit channel routing": "チャネルルーティングを編集",
|
||||
"Edit chat preset": "チャットプリセットを編集",
|
||||
"Edit discount tier": "割引ティアを編集",
|
||||
"Edit FAQ": "FAQ を編集",
|
||||
@@ -1458,6 +1464,7 @@
|
||||
"Edit model": "モデルを編集",
|
||||
"Edit Model": "モデルを編集",
|
||||
"Edit model pricing": "モデル料金を編集",
|
||||
"Edit non-sensitive settings such as models, groups, and routing rules.": "モデル、グループ、ルーティングルールなどの非機密設定を編集します。",
|
||||
"Edit OAuth Provider": "OAuthプロバイダーを編集",
|
||||
"Edit payment method": "決済方法を編集",
|
||||
"Edit Prefill Group": "プリフィルグループを編集",
|
||||
@@ -1465,6 +1472,7 @@
|
||||
"Edit ratio override": "倍率オーバーライドを編集",
|
||||
"Edit Rule": "ルール編集",
|
||||
"Edit selectable group": "選択可能なグループを編集",
|
||||
"Edit sensitive channel settings": "機密チャネル設定を編集",
|
||||
"Edit Tag": "タグ編集",
|
||||
"Edit Tag:": "タグを編集:",
|
||||
"Edit Uptime Kuma Group": "Uptime Kuma グループを編集",
|
||||
@@ -2799,6 +2807,7 @@
|
||||
"No payment methods configured. Click \"Add method\" or use templates to get started.": "支払い方法が設定されていません。「メソッドを追加」をクリックするか、テンプレートを使用して開始してください。",
|
||||
"No payment methods match your search": "検索に一致する支払い方法がありません",
|
||||
"No performance data available": "利用可能なパフォーマンスデータはありません",
|
||||
"No permission to perform this action": "この操作を実行する権限がありません",
|
||||
"No plans available": "利用可能なプランがありません",
|
||||
"No preference": "設定なし",
|
||||
"No prefill groups yet": "まだ事前入力グループはありません",
|
||||
@@ -2974,6 +2983,7 @@
|
||||
"OpenAIMax": "OpenAIMax",
|
||||
"OpenRouter": "OpenRouter",
|
||||
"opens in an external client. Trigger it from the sidebar or API key actions to launch the configured application.": "外部クライアントで開きます。サイドバーまたはAPIキーアクションからトリガーして、設定されたアプリケーションを起動します。",
|
||||
"Operate channels": "チャネルを運用",
|
||||
"Operation": "操作",
|
||||
"operation and charging behavior": "運用および課金行為に起因する法的責任を負うことを確認します",
|
||||
"Operation Audit Info": "操作監査情報",
|
||||
@@ -3432,6 +3442,7 @@
|
||||
"Raw Quota": "元のクォータ",
|
||||
"Re-enable on success": "成功時に再有効化",
|
||||
"Re-login": "再ログイン",
|
||||
"Read channels": "チャネルを読み取り",
|
||||
"Ready": "準備完了",
|
||||
"Ready to initialize": "初期化準備完了",
|
||||
"Ready to simplify": "シンプルにする準備は",
|
||||
@@ -3603,6 +3614,7 @@
|
||||
"Reroll": "やり直し",
|
||||
"Research, analysis, scientific reasoning": "リサーチ・分析・科学的推論",
|
||||
"Resend ({{seconds}}s)": "再送信 ({{seconds}}秒)",
|
||||
"Reserved for viewing complete channel keys after secure verification.": "安全な検証後に完全なチャンネルキーを表示するために予約されています。",
|
||||
"Reset": "リセット",
|
||||
"Reset 2FA": "2FAをリセット",
|
||||
"Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "{{username}} の 2FA をリセットしますか?引き続き使用するには、2FA を再設定する必要があります。",
|
||||
@@ -3739,7 +3751,6 @@
|
||||
"Save Preferences": "設定を保存",
|
||||
"Save preview": "保存プレビュー",
|
||||
"Save rate limits": "レート制限を保存",
|
||||
"Save token limits": "トークン制限を保存",
|
||||
"Save sensitive words": "敏感な言葉を保存",
|
||||
"Save Settings": "設定を保存",
|
||||
"Save sidebar modules": "サイドバーモジュールを保存",
|
||||
@@ -3748,6 +3759,7 @@
|
||||
"Save Stripe settings": "Stripe設定を保存",
|
||||
"Save these backup codes in a safe place. Each code can only be used once.": "これらのバックアップコードを安全な場所に保存してください。各コードは一度だけ使用できます。",
|
||||
"Save these codes in a safe place. Each code can only be used once.": "これらのコードを安全な場所に保存してください。各コードは一度だけ使用できます。",
|
||||
"Save token limits": "トークン制限を保存",
|
||||
"Save tool prices": "ツール価格を保存",
|
||||
"Save Waffo Pancake settings": "Waffo Pancake 設定を保存",
|
||||
"Save Worker settings": "Worker設定を保存",
|
||||
@@ -3891,6 +3903,7 @@
|
||||
"Send email alerts when a user falls below this quota": "ユーザーがこのクォータを下回ったときにメールアラートを送信",
|
||||
"Send reset email": "リセットメールを送信",
|
||||
"Sending...": "送信中...",
|
||||
"Sensitive channel settings are read-only for your account.": "あなたのアカウントでは機密チャネル設定は読み取り専用です。",
|
||||
"Sensitive Words": "機密語",
|
||||
"Sent the API key to FluentRead.": "API キーを FluentRead に送信しました。",
|
||||
"Separate image/audio prices are enabled.": "画像/音声の個別料金が有効です。",
|
||||
@@ -3976,11 +3989,11 @@
|
||||
"Simple mode only returns message; status code and error type use system defaults.": "シンプルモードはメッセージのみ返します。ステータスコードとエラータイプはシステムデフォルトを使用します。",
|
||||
"Simple mode: prune objects by type, e.g. redacted_thinking.": "シンプルモード:typeでオブジェクトを削除(例:redacted_thinking)。",
|
||||
"Single Key": "単一キー",
|
||||
"Skip async task polling delay": "非同期タスクのポーリング遅延をスキップ",
|
||||
"Site & Branding": "サイトとブランド",
|
||||
"Site Key": "サイトキー",
|
||||
"Size:": "サイズ:",
|
||||
"sk_xxx or rk_xxx": "sk_xxx または rk_xxx",
|
||||
"Skip async task polling delay": "非同期タスクのポーリング遅延をスキップ",
|
||||
"Skip retry on failure": "失敗時にリトライしない",
|
||||
"Skip SMTP TLS certificate verification": "SMTP TLS証明書の検証をスキップ",
|
||||
"Skip to Main": "メインコンテンツへスキップ",
|
||||
@@ -4206,6 +4219,7 @@
|
||||
"Test all {{count}} models": "{{count}} 件すべてのモデルをテスト",
|
||||
"Test All Channels": "すべてのチャネルをテスト",
|
||||
"Test Channel Connection": "チャネル接続をテスト",
|
||||
"Test channels, refresh balances, and enable/disable individual, batch, or tagged channels.": "チャネルのテスト、残高の更新、個別・一括・タグ指定でのチャネル有効化/無効化を行います。",
|
||||
"Test Connection": "接続をテスト",
|
||||
"Test connectivity for:": "接続性をテスト:",
|
||||
"Test failed": "テストに失敗しました",
|
||||
@@ -4739,6 +4753,8 @@
|
||||
"Vidu": "Vidu",
|
||||
"View": "表示",
|
||||
"View all currently available models": "現在利用可能なすべてのモデルを表示",
|
||||
"View channel lists and details without secrets.": "シークレットを含まないチャネル一覧と詳細を表示します。",
|
||||
"View channel secrets": "チャンネルシークレットを表示",
|
||||
"View detailed information about this user including balance, usage statistics, and invitation details.": "残高、使用統計、招待の詳細など、このユーザーに関する詳細情報を表示します。",
|
||||
"View details": "詳細を表示",
|
||||
"View document": "ドキュメントを表示",
|
||||
@@ -4881,8 +4897,10 @@
|
||||
"You can close this tab once the binding completes or a success message appears in the original window.": "バインディングが完了するか、元のウィンドウに成功メッセージが表示されたら、このタブを閉じることができます。",
|
||||
"You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.": "\"カスタムモデル名\"で手動で追加し、\"入力\"をクリックしてから送信するか、以下の操作を使用して自動的に処理できます。",
|
||||
"You can only check in once per day": "チェックインできるのは1日1回のみです",
|
||||
"You can still edit non-sensitive operations fields such as models, groups, priority, and weight.": "モデル、グループ、優先度、重みなどの非機密の運用項目は引き続き編集できます。",
|
||||
"You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.": "適用される法令、規制要件、プラットフォーム規則、公共の利益、または第三者の正当な権利利益に違反する行為を、このシステムを用いて実施、支援、または間接的に実施しないことを約束します。",
|
||||
"You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "上流 API、アカウント、キー、クォータ、サービス機能を、上流サービス提供者、モデルサービス提供者、または関連する権利者から取得した合法的な許可の範囲内でのみ使用し、無許可の再販売、転売、配布、その他の不適切な商業利用を行わないことを約束します。",
|
||||
"You do not have permission to edit sensitive channel settings.": "機密チャネル設定を編集する権限がありません。",
|
||||
"You don't have necessary permission": "必要な権限がありません",
|
||||
"You have legally obtained authorization for the connected model APIs, accounts, keys, and quotas.": "接続されたモデル API、アカウント、キー、クォータについて合法的な許可を取得しています。",
|
||||
"You have unsaved changes": "未保存の変更があります",
|
||||
@@ -4893,6 +4911,8 @@
|
||||
"You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.": "このコンプライアンス注意事項はリスク通知にすぎず、法的助言、コンプライアンス審査の結論、または本システム利用の合法性の保証ではないことを理解しています。実際の事業状況に応じて、専門の法律またはコンプライアンス担当者に相談してください。",
|
||||
"You will be redirected to Telegram to complete the binding process.": "バインドプロセスを完了するためにTelegramにリダイレクトされます。",
|
||||
"You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds.": "自動的にリダイレクトされます。数秒経っても何も起こらない場合は、前のページに戻ることができます。",
|
||||
"Your account can edit sensitive channel settings.": "あなたのアカウントは機密チャネル設定を編集できます。",
|
||||
"Your account cannot edit sensitive channel settings.": "あなたのアカウントは機密チャネル設定を編集できません。",
|
||||
"your AI integration?": "AIインテグレーションを?",
|
||||
"Your Azure OpenAI endpoint URL": "あなたのAzure OpenAIエンドポイント URL",
|
||||
"Your Bot Name": "あなたのボット名",
|
||||
|
||||
Vendored
+23
-3
@@ -223,8 +223,10 @@
|
||||
"Admin": "Администратор",
|
||||
"Admin access required": "Требуется доступ администратора",
|
||||
"Admin area": "Область администратора",
|
||||
"Admin Channel Permissions": "Права администратора для каналов",
|
||||
"Admin notes (only visible to admins)": "Заметки администратора (видны только администраторам)",
|
||||
"Admin Only": "Только для администраторов",
|
||||
"Admin Permissions": "Права администратора",
|
||||
"Administer user accounts and roles.": "Управление учетными записями пользователей и ролями.",
|
||||
"Administrator account": "Учетная запись администратора",
|
||||
"Administrator username": "Имя пользователя администратора",
|
||||
@@ -716,6 +718,7 @@
|
||||
"Channel ID is required": "Требуется ID канала",
|
||||
"Channel key": "Ключ канала",
|
||||
"Channel key unlocked": "Ключ канала разблокирован",
|
||||
"Channel Management": "Управление каналами",
|
||||
"Channel models": "Модели каналов",
|
||||
"Channel name is required": "Имя канала обязательно",
|
||||
"Channel test completed": "Тест канала завершён",
|
||||
@@ -1075,6 +1078,7 @@
|
||||
"Create cache": "Создать кеш",
|
||||
"Create cache ratio": "Создать коэффициент кэширования",
|
||||
"Create Channel": "Создать канал",
|
||||
"Create channels or edit keys, base URLs, and overrides.": "Создание каналов или изменение ключей, базовых URL и переопределений.",
|
||||
"Create Code": "Создать код",
|
||||
"Create credentials for the root user": "Создайте учётные данные для администратора",
|
||||
"Create deployment": "Создать развертывание",
|
||||
@@ -1193,6 +1197,7 @@
|
||||
"Default": "По умолчанию",
|
||||
"Default (New Frontend)": "По умолчанию (Новый интерфейс)",
|
||||
"Default / range": "По умолчанию / диапазон",
|
||||
"Default administrator permissions can be overridden for this user.": "Для этого пользователя можно переопределить стандартные права администратора.",
|
||||
"Default API Version *": "Версия API по умолчанию *",
|
||||
"Default API version for this channel": "Версия API по умолчанию для этого канала",
|
||||
"Default Bearer": "Bearer по умолчанию",
|
||||
@@ -1380,8 +1385,8 @@
|
||||
"Drawing": "Рисование",
|
||||
"Drawing logs": "Журналы рисования",
|
||||
"Drawing Logs": "Журнал рисования",
|
||||
"Drawing task records": "Записи задач рисования",
|
||||
"Drawing task polling": "Опрос задач рисования",
|
||||
"Drawing task records": "Записи задач рисования",
|
||||
"Duplicate": "Дублировать",
|
||||
"Duplicate group names: {{names}}": "Повторяющиеся имена групп: {{names}}",
|
||||
"Duplicate source model mappings are not allowed": "Повторяющиеся сопоставления исходных моделей не допускаются",
|
||||
@@ -1448,6 +1453,7 @@
|
||||
"Edit API Shortcut": "Редактировать ярлык API",
|
||||
"Edit billing ratios and user-selectable groups in one table.": "Редактируйте коэффициенты тарификации и доступные пользователю группы в одной таблице.",
|
||||
"Edit Channel": "Редактировать канал",
|
||||
"Edit channel routing": "Изменение маршрутизации каналов",
|
||||
"Edit chat preset": "Редактировать пресет чата",
|
||||
"Edit discount tier": "Редактировать уровень скидки",
|
||||
"Edit FAQ": "Редактировать FAQ",
|
||||
@@ -1458,6 +1464,7 @@
|
||||
"Edit model": "Редактировать модель",
|
||||
"Edit Model": "Редактировать модель",
|
||||
"Edit model pricing": "Изменить тариф модели",
|
||||
"Edit non-sensitive settings such as models, groups, and routing rules.": "Изменение нечувствительных настроек, таких как модели, группы и правила маршрутизации.",
|
||||
"Edit OAuth Provider": "Редактировать поставщика OAuth",
|
||||
"Edit payment method": "Редактировать способ оплаты",
|
||||
"Edit Prefill Group": "Редактировать группу предзаполнения",
|
||||
@@ -1465,6 +1472,7 @@
|
||||
"Edit ratio override": "Редактировать переопределение коэффициента",
|
||||
"Edit Rule": "Редактировать правило",
|
||||
"Edit selectable group": "Редактировать выбираемую группу",
|
||||
"Edit sensitive channel settings": "Изменение чувствительных настроек каналов",
|
||||
"Edit Tag": "Редактировать тег",
|
||||
"Edit Tag:": "Редактировать тег:",
|
||||
"Edit Uptime Kuma Group": "Редактировать группу Uptime Kuma",
|
||||
@@ -2799,6 +2807,7 @@
|
||||
"No payment methods configured. Click \"Add method\" or use templates to get started.": "Способы оплаты не настроены. Нажмите \"Добавить способ\" или используйте шаблоны, чтобы начать.",
|
||||
"No payment methods match your search": "Нет способов оплаты, соответствующих вашему поиску",
|
||||
"No performance data available": "Нет доступных данных о производительности",
|
||||
"No permission to perform this action": "Нет прав для выполнения этого действия",
|
||||
"No plans available": "Нет доступных планов",
|
||||
"No preference": "Без предпочтений",
|
||||
"No prefill groups yet": "Пока нет групп предзаполнения",
|
||||
@@ -2974,6 +2983,7 @@
|
||||
"OpenAIMax": "OpenAIMax",
|
||||
"OpenRouter": "OpenRouter",
|
||||
"opens in an external client. Trigger it from the sidebar or API key actions to launch the configured application.": "открывается во внешнем клиенте. Запустите его из боковой панели или действий с ключом API, чтобы запустить настроенное приложение.",
|
||||
"Operate channels": "Обслуживание каналов",
|
||||
"Operation": "Операция",
|
||||
"operation and charging behavior": "эксплуатацию и взимание платы",
|
||||
"Operation Audit Info": "Информация об аудите операций",
|
||||
@@ -3432,6 +3442,7 @@
|
||||
"Raw Quota": "Исходная квота",
|
||||
"Re-enable on success": "Повторно включить при успехе",
|
||||
"Re-login": "Повторный вход",
|
||||
"Read channels": "Чтение каналов",
|
||||
"Ready": "Готово",
|
||||
"Ready to initialize": "Готов к инициализации",
|
||||
"Ready to simplify": "Готовы упростить",
|
||||
@@ -3603,6 +3614,7 @@
|
||||
"Reroll": "Повторить",
|
||||
"Research, analysis, scientific reasoning": "Исследования, анализ, научные рассуждения",
|
||||
"Resend ({{seconds}}s)": "Отправить повторно ({{seconds}}с)",
|
||||
"Reserved for viewing complete channel keys after secure verification.": "Зарезервировано для просмотра полных ключей каналов после безопасной проверки.",
|
||||
"Reset": "Сброс",
|
||||
"Reset 2FA": "Сбросить 2FA",
|
||||
"Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "Сбросить 2FA для {{username}}? Пользователь должен будет настроить 2FA заново, чтобы продолжить ее использовать.",
|
||||
@@ -3739,7 +3751,6 @@
|
||||
"Save Preferences": "Сохранить настройки",
|
||||
"Save preview": "Предпросмотр сохранения",
|
||||
"Save rate limits": "Сохранить лимиты скорости",
|
||||
"Save token limits": "Сохранить лимиты токенов",
|
||||
"Save sensitive words": "Сохранить чувствительные слова",
|
||||
"Save Settings": "Сохранить настройки",
|
||||
"Save sidebar modules": "Сохранить модули боковой панели",
|
||||
@@ -3748,6 +3759,7 @@
|
||||
"Save Stripe settings": "Сохранить настройки Stripe",
|
||||
"Save these backup codes in a safe place. Each code can only be used once.": "Сохраните эти резервные коды в безопасном месте. Каждый код может быть использован только один раз.",
|
||||
"Save these codes in a safe place. Each code can only be used once.": "Сохраните эти коды в безопасном месте. Каждый код может быть использован только один раз.",
|
||||
"Save token limits": "Сохранить лимиты токенов",
|
||||
"Save tool prices": "Сохранить цены инструментов",
|
||||
"Save Waffo Pancake settings": "Сохранить настройки Waffo Pancake",
|
||||
"Save Worker settings": "Сохранить настройки Worker",
|
||||
@@ -3891,6 +3903,7 @@
|
||||
"Send email alerts when a user falls below this quota": "Отправлять оповещения по электронной почте, когда пользователь опускается ниже этой квоты",
|
||||
"Send reset email": "Отправить письмо для сброса пароля",
|
||||
"Sending...": "Отправка...",
|
||||
"Sensitive channel settings are read-only for your account.": "Чувствительные настройки каналов доступны вашей учетной записи только для чтения.",
|
||||
"Sensitive Words": "Чувствительные слова",
|
||||
"Sent the API key to FluentRead.": "API-ключ отправлен в FluentRead.",
|
||||
"Separate image/audio prices are enabled.": "Отдельные цены для изображений и аудио включены.",
|
||||
@@ -3976,11 +3989,11 @@
|
||||
"Simple mode only returns message; status code and error type use system defaults.": "Простой режим возвращает только сообщение; код статуса и тип ошибки используют системные значения по умолчанию.",
|
||||
"Simple mode: prune objects by type, e.g. redacted_thinking.": "Простой режим: очистка объектов по типу, например redacted_thinking.",
|
||||
"Single Key": "Одиночный ключ",
|
||||
"Skip async task polling delay": "Пропускать задержку опроса асинхронных задач",
|
||||
"Site & Branding": "Сайт и брендинг",
|
||||
"Site Key": "Ключ сайта",
|
||||
"Size:": "Размер:",
|
||||
"sk_xxx or rk_xxx": "sk_xxx или rk_xxx",
|
||||
"Skip async task polling delay": "Пропускать задержку опроса асинхронных задач",
|
||||
"Skip retry on failure": "Не повторять при ошибке",
|
||||
"Skip SMTP TLS certificate verification": "Пропустить проверку TLS-сертификата SMTP",
|
||||
"Skip to Main": "Перейти к основному содержимому",
|
||||
@@ -4206,6 +4219,7 @@
|
||||
"Test all {{count}} models": "Проверить все модели: {{count}}",
|
||||
"Test All Channels": "Проверить все каналы",
|
||||
"Test Channel Connection": "Проверить подключение канала",
|
||||
"Test channels, refresh balances, and enable/disable individual, batch, or tagged channels.": "Тестирование каналов, обновление балансов и включение/отключение отдельных, пакетных или помеченных каналов.",
|
||||
"Test Connection": "Проверить подключение",
|
||||
"Test connectivity for:": "Проверить подключение для:",
|
||||
"Test failed": "Тест не выполнен",
|
||||
@@ -4739,6 +4753,8 @@
|
||||
"Vidu": "Vidu",
|
||||
"View": "Просмотр",
|
||||
"View all currently available models": "Просмотреть все доступные модели",
|
||||
"View channel lists and details without secrets.": "Просмотр списков и сведений о каналах без секретов.",
|
||||
"View channel secrets": "Просматривать секреты каналов",
|
||||
"View detailed information about this user including balance, usage statistics, and invitation details.": "Просмотр подробной информации об этом пользователе, включая баланс, статистику использования и данные приглашения.",
|
||||
"View details": "Просмотреть детали",
|
||||
"View document": "Просмотреть документ",
|
||||
@@ -4881,8 +4897,10 @@
|
||||
"You can close this tab once the binding completes or a success message appears in the original window.": "Вы можете закрыть эту вкладку, как только привязка завершится или в исходном окне появится сообщение об успехе.",
|
||||
"You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.": "Вы можете вручную добавить их в \"Пользовательские имена моделей\", нажать \"Заполнить\", а затем отправить, или использовать операции ниже для автоматической обработки.",
|
||||
"You can only check in once per day": "Вы можете заселяться только один раз в день",
|
||||
"You can still edit non-sensitive operations fields such as models, groups, priority, and weight.": "Вы по-прежнему можете изменять нечувствительные операционные поля, такие как модели, группы, приоритет и вес.",
|
||||
"You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.": "Вы обязуетесь не использовать эту систему для совершения, содействия или косвенного совершения действий, нарушающих применимые законы и нормы, регуляторные требования, правила платформ, общественные интересы либо законные права и интересы третьих лиц.",
|
||||
"You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "Вы обязуетесь использовать вышестоящие API, аккаунты, ключи, квоты и сервисные возможности только в пределах законного разрешения, полученного от вышестоящих поставщиков услуг, поставщиков моделей или соответствующих правообладателей, и не осуществлять несанкционированную перепродажу, оборот, распространение или иную несоответствующую коммерциализацию.",
|
||||
"You do not have permission to edit sensitive channel settings.": "У вас нет права изменять чувствительные настройки каналов.",
|
||||
"You don't have necessary permission": "У вас нет необходимых разрешений",
|
||||
"You have legally obtained authorization for the connected model APIs, accounts, keys, and quotas.": "Вы законно получили разрешение на подключенные API моделей, аккаунты, ключи и квоты.",
|
||||
"You have unsaved changes": "У вас есть несохранённые изменения",
|
||||
@@ -4893,6 +4911,8 @@
|
||||
"You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.": "Вы понимаете, что это напоминание о соответствии является только уведомлением о рисках и не является юридической консультацией, заключением проверки соответствия или гарантией законности использования этой системы; вам следует обратиться к профессиональным юридическим или комплаенс-консультантам с учетом вашей реальной бизнес-ситуации.",
|
||||
"You will be redirected to Telegram to complete the binding process.": "Вы будете перенаправлены в Telegram для завершения процесса привязки.",
|
||||
"You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds.": "Вы будете автоматически перенаправлены. Если через несколько секунд ничего не происходит, вы можете вернуться на предыдущую страницу.",
|
||||
"Your account can edit sensitive channel settings.": "Ваша учетная запись может изменять чувствительные настройки каналов.",
|
||||
"Your account cannot edit sensitive channel settings.": "Ваша учетная запись не может изменять чувствительные настройки каналов.",
|
||||
"your AI integration?": "вашу интеграцию с ИИ?",
|
||||
"Your Azure OpenAI endpoint URL": "Ваш URL конечной точки Azure OpenAI",
|
||||
"Your Bot Name": "Имя бота",
|
||||
|
||||
Vendored
+23
-3
@@ -223,8 +223,10 @@
|
||||
"Admin": "Quản trị viên",
|
||||
"Admin access required": "Yêu cầu quyền truy cập Admin",
|
||||
"Admin area": "Khu vực quản trị",
|
||||
"Admin Channel Permissions": "Quyền kênh của quản trị viên",
|
||||
"Admin notes (only visible to admins)": "Ghi chú của quản trị viên (chỉ hiển thị với quản trị viên)",
|
||||
"Admin Only": "Chỉ dành cho quản trị viên",
|
||||
"Admin Permissions": "Quyền quản trị viên",
|
||||
"Administer user accounts and roles.": "Quản lý tài khoản người dùng và vai trò.",
|
||||
"Administrator account": "Tài khoản quản trị viên",
|
||||
"Administrator username": "Tên người dùng quản trị viên",
|
||||
@@ -716,6 +718,7 @@
|
||||
"Channel ID is required": "Cần có ID kênh",
|
||||
"Channel key": "Khóa kênh",
|
||||
"Channel key unlocked": "Khóa kênh đã được mở khóa",
|
||||
"Channel Management": "Quản lý kênh",
|
||||
"Channel models": "Mô hình kênh",
|
||||
"Channel name is required": "Tên kênh là bắt buộc",
|
||||
"Channel test completed": "Kiểm tra kênh hoàn tất",
|
||||
@@ -1075,6 +1078,7 @@
|
||||
"Create cache": "Tạo bộ nhớ đệm",
|
||||
"Create cache ratio": "Tạo tỷ lệ bộ nhớ đệm",
|
||||
"Create Channel": "Tạo Kênh",
|
||||
"Create channels or edit keys, base URLs, and overrides.": "Tạo kênh hoặc chỉnh sửa khóa, URL cơ sở và quy tắc ghi đè.",
|
||||
"Create Code": "Tạo Mã",
|
||||
"Create credentials for the root user": "Tạo thông tin đăng nhập cho tài khoản quản trị",
|
||||
"Create deployment": "Tạo triển khai",
|
||||
@@ -1193,6 +1197,7 @@
|
||||
"Default": "Mặc định",
|
||||
"Default (New Frontend)": "Mặc định (Frontend mới)",
|
||||
"Default / range": "Mặc định / khoảng",
|
||||
"Default administrator permissions can be overridden for this user.": "Có thể ghi đè quyền quản trị viên mặc định cho người dùng này.",
|
||||
"Default API Version *": "Phiên bản API mặc định *",
|
||||
"Default API version for this channel": "Phiên bản API mặc định cho kênh này",
|
||||
"Default Bearer": "Bearer mặc định",
|
||||
@@ -1380,8 +1385,8 @@
|
||||
"Drawing": "Vẽ",
|
||||
"Drawing logs": "Nhật ký vẽ",
|
||||
"Drawing Logs": "Nhật ký bản vẽ",
|
||||
"Drawing task records": "Lịch sử tác vụ vẽ",
|
||||
"Drawing task polling": "Thăm dò tác vụ vẽ",
|
||||
"Drawing task records": "Lịch sử tác vụ vẽ",
|
||||
"Duplicate": "Nhân bản",
|
||||
"Duplicate group names: {{names}}": "Tên nhóm bị trùng: {{names}}",
|
||||
"Duplicate source model mappings are not allowed": "Không cho phép ánh xạ mô hình nguồn trùng lặp",
|
||||
@@ -1448,6 +1453,7 @@
|
||||
"Edit API Shortcut": "Chỉnh sửa lối tắt API",
|
||||
"Edit billing ratios and user-selectable groups in one table.": "Chỉnh sửa tỷ lệ tính phí và nhóm người dùng có thể chọn trong một bảng.",
|
||||
"Edit Channel": "Chỉnh sửa Kênh",
|
||||
"Edit channel routing": "Chỉnh sửa định tuyến kênh",
|
||||
"Edit chat preset": "Chỉnh sửa cài đặt trước trò chuyện",
|
||||
"Edit discount tier": "Chỉnh sửa bậc giảm giá",
|
||||
"Edit FAQ": "Chỉnh sửa câu hỏi thường gặp",
|
||||
@@ -1458,6 +1464,7 @@
|
||||
"Edit model": "Chỉnh sửa mô hình",
|
||||
"Edit Model": "Chỉnh sửa Mô hình",
|
||||
"Edit model pricing": "Chỉnh sửa giá mô hình",
|
||||
"Edit non-sensitive settings such as models, groups, and routing rules.": "Chỉnh sửa các thiết lập không nhạy cảm như mô hình, nhóm và quy tắc định tuyến.",
|
||||
"Edit OAuth Provider": "Chỉnh Sửa Nhà Cung Cấp OAuth",
|
||||
"Edit payment method": "Sửa phương thức thanh toán",
|
||||
"Edit Prefill Group": "Chỉnh sửa Nhóm Điền sẵn",
|
||||
@@ -1465,6 +1472,7 @@
|
||||
"Edit ratio override": "Chỉnh sửa ghi đè tỷ lệ",
|
||||
"Edit Rule": "Sửa quy tắc",
|
||||
"Edit selectable group": "Chỉnh sửa nhóm có thể chọn",
|
||||
"Edit sensitive channel settings": "Chỉnh sửa cài đặt kênh nhạy cảm",
|
||||
"Edit Tag": "Chỉnh sửa Thẻ",
|
||||
"Edit Tag:": "Chỉnh sửa thẻ:",
|
||||
"Edit Uptime Kuma Group": "Chỉnh sửa Nhóm Uptime Kuma",
|
||||
@@ -2799,6 +2807,7 @@
|
||||
"No payment methods configured. Click \"Add method\" or use templates to get started.": "Chưa cấu hình phương thức thanh toán. Nhấp vào \"Thêm phương thức\" hoặc sử dụng mẫu để bắt đầu.",
|
||||
"No payment methods match your search": "Không có phương thức thanh toán nào khớp với tìm kiếm của bạn",
|
||||
"No performance data available": "Không có dữ liệu hiệu năng",
|
||||
"No permission to perform this action": "Không có quyền thực hiện thao tác này",
|
||||
"No plans available": "Không có gói nào khả dụng",
|
||||
"No preference": "Không có ưu tiên",
|
||||
"No prefill groups yet": "Chưa có nhóm điền sẵn nào",
|
||||
@@ -2974,6 +2983,7 @@
|
||||
"OpenAIMax": "OpenAIMax",
|
||||
"OpenRouter": "OpenRouter",
|
||||
"opens in an external client. Trigger it from the sidebar or API key actions to launch the configured application.": "mở trong một ứng dụng bên ngoài. Kích hoạt nó từ thanh bên hoặc các hành động khóa API để khởi chạy ứng dụng đã cấu hình.",
|
||||
"Operate channels": "Vận hành kênh",
|
||||
"Operation": "Thao tác",
|
||||
"operation and charging behavior": "vận hành và thu phí",
|
||||
"Operation Audit Info": "Thông tin kiểm toán thao tác",
|
||||
@@ -3432,6 +3442,7 @@
|
||||
"Raw Quota": "Hạn mức gốc",
|
||||
"Re-enable on success": "Kích hoạt lại khi thành công",
|
||||
"Re-login": "Đăng nhập lại",
|
||||
"Read channels": "Đọc kênh",
|
||||
"Ready": "Sẵn sàng",
|
||||
"Ready to initialize": "Sẵn sàng khởi tạo",
|
||||
"Ready to simplify": "Sẵn sàng đơn giản hóa",
|
||||
@@ -3603,6 +3614,7 @@
|
||||
"Reroll": "Quay lại",
|
||||
"Research, analysis, scientific reasoning": "Nghiên cứu, phân tích, suy luận khoa học",
|
||||
"Resend ({{seconds}}s)": "Gửi lại ({{seconds}}s)",
|
||||
"Reserved for viewing complete channel keys after secure verification.": "Dành riêng để xem khóa kênh đầy đủ sau khi xác minh bảo mật.",
|
||||
"Reset": "Đặt lại",
|
||||
"Reset 2FA": "Đặt lại 2FA",
|
||||
"Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "Đặt lại 2FA cho {{username}}? Người dùng phải thiết lập lại 2FA để tiếp tục sử dụng.",
|
||||
@@ -3739,7 +3751,6 @@
|
||||
"Save Preferences": "Lưu tùy chọn",
|
||||
"Save preview": "Xem trước lưu",
|
||||
"Save rate limits": "Lưu giới hạn tốc độ",
|
||||
"Save token limits": "Lưu giới hạn token",
|
||||
"Save sensitive words": "Lưu từ nhạy cảm",
|
||||
"Save Settings": "Lưu Cài đặt",
|
||||
"Save sidebar modules": "Lưu các mô-đun thanh bên",
|
||||
@@ -3748,6 +3759,7 @@
|
||||
"Save Stripe settings": "Lưu cài đặt Stripe",
|
||||
"Save these backup codes in a safe place. Each code can only be used once.": "Lưu các mã dự phòng này ở nơi an toàn. Mỗi mã chỉ được sử dụng một lần.",
|
||||
"Save these codes in a safe place. Each code can only be used once.": "Hãy lưu các mã này ở nơi an toàn. Mỗi mã chỉ có thể được sử dụng một lần.",
|
||||
"Save token limits": "Lưu giới hạn token",
|
||||
"Save tool prices": "Lưu giá công cụ",
|
||||
"Save Waffo Pancake settings": "Lưu cài đặt Waffo Pancake",
|
||||
"Save Worker settings": "Lưu cài đặt Worker",
|
||||
@@ -3891,6 +3903,7 @@
|
||||
"Send email alerts when a user falls below this quota": "Gửi cảnh báo email khi người dùng xuống dưới hạn mức này",
|
||||
"Send reset email": "Gửi email đặt lại",
|
||||
"Sending...": "Đang gửi...",
|
||||
"Sensitive channel settings are read-only for your account.": "Các cài đặt kênh nhạy cảm chỉ đọc đối với tài khoản của bạn.",
|
||||
"Sensitive Words": "Từ ngữ nhạy cảm",
|
||||
"Sent the API key to FluentRead.": "Đã gửi khóa API đến FluentRead.",
|
||||
"Separate image/audio prices are enabled.": "Giá riêng cho hình ảnh/âm thanh đã được bật.",
|
||||
@@ -3976,11 +3989,11 @@
|
||||
"Simple mode only returns message; status code and error type use system defaults.": "Chế độ đơn giản chỉ trả về message; mã trạng thái và loại lỗi sử dụng giá trị mặc định.",
|
||||
"Simple mode: prune objects by type, e.g. redacted_thinking.": "Chế độ đơn giản: dọn dẹp đối tượng theo type, ví dụ redacted_thinking.",
|
||||
"Single Key": "Khóa đơn",
|
||||
"Skip async task polling delay": "Bỏ qua độ trễ thăm dò tác vụ bất đồng bộ",
|
||||
"Site & Branding": "Trang web & thương hiệu",
|
||||
"Site Key": "Khóa trang web",
|
||||
"Size:": "Kích thước:",
|
||||
"sk_xxx or rk_xxx": "sk_xxx hoặc rk_xxx",
|
||||
"Skip async task polling delay": "Bỏ qua độ trễ thăm dò tác vụ bất đồng bộ",
|
||||
"Skip retry on failure": "Không thử lại khi thất bại",
|
||||
"Skip SMTP TLS certificate verification": "Bỏ qua xác minh chứng chỉ TLS SMTP",
|
||||
"Skip to Main": "Bỏ qua đến nội dung chính",
|
||||
@@ -4206,6 +4219,7 @@
|
||||
"Test all {{count}} models": "Kiểm thử tất cả {{count}} mô hình",
|
||||
"Test All Channels": "Kiểm tra tất cả các kênh",
|
||||
"Test Channel Connection": "Kiểm tra kết nối kênh",
|
||||
"Test channels, refresh balances, and enable/disable individual, batch, or tagged channels.": "Kiểm thử kênh, làm mới số dư và bật/tắt từng kênh, hàng loạt hoặc theo thẻ.",
|
||||
"Test Connection": "Kiểm tra kết nối",
|
||||
"Test connectivity for:": "Kiểm tra kết nối cho:",
|
||||
"Test failed": "Kiểm tra thất bại",
|
||||
@@ -4739,6 +4753,8 @@
|
||||
"Vidu": "Vidu",
|
||||
"View": "Xem",
|
||||
"View all currently available models": "Xem tất cả mô hình hiện có",
|
||||
"View channel lists and details without secrets.": "Xem danh sách và chi tiết kênh không chứa bí mật.",
|
||||
"View channel secrets": "Xem bí mật kênh",
|
||||
"View detailed information about this user including balance, usage statistics, and invitation details.": "Xem thông tin chi tiết về người dùng này bao gồm số dư, thống kê sử dụng và chi tiết lời mời.",
|
||||
"View details": "Xem chi tiết",
|
||||
"View document": "Xem tài liệu",
|
||||
@@ -4881,8 +4897,10 @@
|
||||
"You can close this tab once the binding completes or a success message appears in the original window.": "Bạn có thể đóng tab này sau khi quá trình liên kết hoàn tất hoặc thông báo thành công xuất hiện trong cửa sổ gốc.",
|
||||
"You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.": "Bạn có thể thêm chúng theo cách thủ công trong \"Tên mô hình tùy chỉnh\", nhấp vào \"Điền\" rồi gửi, hoặc sử dụng các thao tác bên dưới để xử lý tự động.",
|
||||
"You can only check in once per day": "Bạn chỉ có thể điểm danh một lần mỗi ngày",
|
||||
"You can still edit non-sensitive operations fields such as models, groups, priority, and weight.": "Bạn vẫn có thể chỉnh sửa các trường vận hành không nhạy cảm như mô hình, nhóm, độ ưu tiên và trọng số.",
|
||||
"You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.": "Bạn cam kết không sử dụng hệ thống này để thực hiện, hỗ trợ hoặc gián tiếp thực hiện các hành vi vi phạm luật và quy định hiện hành, yêu cầu quản lý, quy tắc nền tảng, lợi ích công cộng hoặc quyền và lợi ích hợp pháp của bên thứ ba.",
|
||||
"You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "Bạn cam kết chỉ sử dụng API upstream, tài khoản, khóa, hạn mức và năng lực dịch vụ trong phạm vi ủy quyền hợp pháp nhận được từ nhà cung cấp dịch vụ upstream, nhà cung cấp mô hình hoặc chủ thể quyền liên quan, và sẽ không thực hiện bán lại, giao dịch, phân phối trái phép hoặc thương mại hóa không tuân thủ khác.",
|
||||
"You do not have permission to edit sensitive channel settings.": "Bạn không có quyền chỉnh sửa cài đặt kênh nhạy cảm.",
|
||||
"You don't have necessary permission": "Bạn không có quyền cần thiết",
|
||||
"You have legally obtained authorization for the connected model APIs, accounts, keys, and quotas.": "Bạn đã nhận được ủy quyền hợp pháp cho API mô hình, tài khoản, khóa và hạn mức được kết nối.",
|
||||
"You have unsaved changes": "Bạn có thay đổi chưa được lưu",
|
||||
@@ -4893,6 +4911,8 @@
|
||||
"You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.": "Bạn hiểu rằng nhắc nhở tuân thủ này chỉ là thông báo rủi ro, không cấu thành tư vấn pháp lý, kết luận rà soát tuân thủ hoặc bảo đảm tính hợp pháp của việc sử dụng hệ thống; bạn nên tham khảo cố vấn pháp lý hoặc tuân thủ chuyên nghiệp dựa trên tình huống kinh doanh thực tế.",
|
||||
"You will be redirected to Telegram to complete the binding process.": "Bạn sẽ được chuyển hướng đến Telegram để hoàn tất quá trình liên kết.",
|
||||
"You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds.": "Bạn sẽ được chuyển hướng tự động. Bạn có thể quay lại trang trước nếu không có gì xảy ra sau vài giây.",
|
||||
"Your account can edit sensitive channel settings.": "Tài khoản của bạn có thể chỉnh sửa cài đặt kênh nhạy cảm.",
|
||||
"Your account cannot edit sensitive channel settings.": "Tài khoản của bạn không thể chỉnh sửa cài đặt kênh nhạy cảm.",
|
||||
"your AI integration?": "tích hợp AI của bạn?",
|
||||
"Your Azure OpenAI endpoint URL": "URL điểm cuối Azure OpenAI của bạn",
|
||||
"Your Bot Name": "Tên Bot của bạn",
|
||||
|
||||
Vendored
+23
-3
@@ -223,8 +223,10 @@
|
||||
"Admin": "管理员",
|
||||
"Admin access required": "需要管理员权限",
|
||||
"Admin area": "管理员区域",
|
||||
"Admin Channel Permissions": "管理员渠道权限",
|
||||
"Admin notes (only visible to admins)": "管理员备注(仅管理员可见)",
|
||||
"Admin Only": "仅限管理员",
|
||||
"Admin Permissions": "管理员权限",
|
||||
"Administer user accounts and roles.": "管理用户账户和角色。",
|
||||
"Administrator account": "管理员账户",
|
||||
"Administrator username": "管理员用户名",
|
||||
@@ -716,6 +718,7 @@
|
||||
"Channel ID is required": "缺少渠道 ID",
|
||||
"Channel key": "渠道密钥",
|
||||
"Channel key unlocked": "渠道密钥已解锁",
|
||||
"Channel Management": "渠道管理",
|
||||
"Channel models": "渠道模型",
|
||||
"Channel name is required": "渠道名称是必填的",
|
||||
"Channel test completed": "渠道测试完成",
|
||||
@@ -1075,6 +1078,7 @@
|
||||
"Create cache": "创建缓存",
|
||||
"Create cache ratio": "创建缓存倍率",
|
||||
"Create Channel": "创建渠道",
|
||||
"Create channels or edit keys, base URLs, and overrides.": "创建渠道或编辑密钥、基础 URL 和覆盖规则。",
|
||||
"Create Code": "创建代码",
|
||||
"Create credentials for the root user": "为管理员创建登录凭据",
|
||||
"Create deployment": "创建部署",
|
||||
@@ -1193,6 +1197,7 @@
|
||||
"Default": "默认",
|
||||
"Default (New Frontend)": "新版前端(默认)",
|
||||
"Default / range": "默认值 / 范围",
|
||||
"Default administrator permissions can be overridden for this user.": "可以为此用户覆盖默认管理员权限。",
|
||||
"Default API Version *": "默认 API 版本 *",
|
||||
"Default API version for this channel": "此渠道的默认 API 版本",
|
||||
"Default Bearer": "默认 Bearer",
|
||||
@@ -1380,8 +1385,8 @@
|
||||
"Drawing": "绘图",
|
||||
"Drawing logs": "绘制日志",
|
||||
"Drawing Logs": "绘图日志",
|
||||
"Drawing task records": "绘图任务记录",
|
||||
"Drawing task polling": "绘图任务轮询",
|
||||
"Drawing task records": "绘图任务记录",
|
||||
"Duplicate": "重复",
|
||||
"Duplicate group names: {{names}}": "存在重复的分组名称:{{names}}",
|
||||
"Duplicate source model mappings are not allowed": "不允许重复的源模型映射",
|
||||
@@ -1448,6 +1453,7 @@
|
||||
"Edit API Shortcut": "编辑 API 快捷方式",
|
||||
"Edit billing ratios and user-selectable groups in one table.": "在一个表格中编辑计费倍率和用户可选分组。",
|
||||
"Edit Channel": "编辑渠道",
|
||||
"Edit channel routing": "编辑渠道路由",
|
||||
"Edit chat preset": "编辑聊天预设",
|
||||
"Edit discount tier": "编辑折扣档位",
|
||||
"Edit FAQ": "编辑常见问题",
|
||||
@@ -1458,6 +1464,7 @@
|
||||
"Edit model": "编辑模型",
|
||||
"Edit Model": "编辑模型",
|
||||
"Edit model pricing": "编辑模型定价",
|
||||
"Edit non-sensitive settings such as models, groups, and routing rules.": "编辑模型、分组和路由规则等非敏感设置。",
|
||||
"Edit OAuth Provider": "编辑 OAuth 提供商",
|
||||
"Edit payment method": "编辑支付方式",
|
||||
"Edit Prefill Group": "编辑预填充组",
|
||||
@@ -1465,6 +1472,7 @@
|
||||
"Edit ratio override": "编辑倍率覆盖",
|
||||
"Edit Rule": "编辑规则",
|
||||
"Edit selectable group": "编辑可选分组",
|
||||
"Edit sensitive channel settings": "编辑敏感渠道设置",
|
||||
"Edit Tag": "编辑标签",
|
||||
"Edit Tag:": "编辑标签:",
|
||||
"Edit Uptime Kuma Group": "编辑 Uptime Kuma 分组",
|
||||
@@ -2799,6 +2807,7 @@
|
||||
"No payment methods configured. Click \"Add method\" or use templates to get started.": "未配置支付方式。点击\"添加方式\"或使用模板开始。",
|
||||
"No payment methods match your search": "没有匹配的支付方式",
|
||||
"No performance data available": "暂无性能数据",
|
||||
"No permission to perform this action": "无权进行此操作",
|
||||
"No plans available": "暂无可购买套餐",
|
||||
"No preference": "无偏好",
|
||||
"No prefill groups yet": "暂无预填充分组",
|
||||
@@ -2974,6 +2983,7 @@
|
||||
"OpenAIMax": "OpenAIMax",
|
||||
"OpenRouter": "OpenRouter",
|
||||
"opens in an external client. Trigger it from the sidebar or API key actions to launch the configured application.": "在外部客户端中打开。从侧边栏或 API 密钥操作中触发,以启动配置的应用。",
|
||||
"Operate channels": "运维渠道",
|
||||
"Operation": "操作",
|
||||
"operation and charging behavior": "运营和收费行为产生的法律责任",
|
||||
"Operation Audit Info": "操作审计信息",
|
||||
@@ -3432,6 +3442,7 @@
|
||||
"Raw Quota": "原生额度",
|
||||
"Re-enable on success": "成功后重新启用",
|
||||
"Re-login": "重新登录",
|
||||
"Read channels": "读取渠道",
|
||||
"Ready": "就绪",
|
||||
"Ready to initialize": "准备初始化",
|
||||
"Ready to simplify": "准备好简化",
|
||||
@@ -3603,6 +3614,7 @@
|
||||
"Reroll": "重绘",
|
||||
"Research, analysis, scientific reasoning": "研究、分析与科学推理",
|
||||
"Resend ({{seconds}}s)": "重新发送 ({{seconds}}s)",
|
||||
"Reserved for viewing complete channel keys after secure verification.": "预留用于在安全验证后查看完整渠道密钥。",
|
||||
"Reset": "重置",
|
||||
"Reset 2FA": "重置 2FA",
|
||||
"Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "要重置 {{username}} 的 2FA 吗?该用户必须重新设置 2FA 后才能继续使用。",
|
||||
@@ -3739,7 +3751,6 @@
|
||||
"Save Preferences": "保存偏好设置",
|
||||
"Save preview": "保存预览",
|
||||
"Save rate limits": "保存速率限制",
|
||||
"Save token limits": "保存令牌限制",
|
||||
"Save sensitive words": "保存敏感词",
|
||||
"Save Settings": "保存设置",
|
||||
"Save sidebar modules": "保存侧边栏模块",
|
||||
@@ -3748,6 +3759,7 @@
|
||||
"Save Stripe settings": "保存 Stripe 设置",
|
||||
"Save these backup codes in a safe place. Each code can only be used once.": "将这些备份代码保存在安全的地方。每个代码只能使用一次。",
|
||||
"Save these codes in a safe place. Each code can only be used once.": "将这些代码保存在安全的地方。每个代码只能使用一次。",
|
||||
"Save token limits": "保存令牌限制",
|
||||
"Save tool prices": "保存工具价格",
|
||||
"Save Waffo Pancake settings": "保存 Waffo Pancake 设置",
|
||||
"Save Worker settings": "保存 Worker 设置",
|
||||
@@ -3891,6 +3903,7 @@
|
||||
"Send email alerts when a user falls below this quota": "当用户低于此配额时发送电子邮件警报",
|
||||
"Send reset email": "发送重置邮件",
|
||||
"Sending...": "发送中...",
|
||||
"Sensitive channel settings are read-only for your account.": "你的账号只能查看敏感渠道设置。",
|
||||
"Sensitive Words": "敏感词",
|
||||
"Sent the API key to FluentRead.": "API 密钥已发送至 FluentRead。",
|
||||
"Separate image/audio prices are enabled.": "已启用图像/音频单独定价。",
|
||||
@@ -3976,11 +3989,11 @@
|
||||
"Simple mode only returns message; status code and error type use system defaults.": "简洁模式仅返回 message;状态码和错误类型将使用系统默认值。",
|
||||
"Simple mode: prune objects by type, e.g. redacted_thinking.": "简洁模式:按 type 全量清理对象,例如 redacted_thinking。",
|
||||
"Single Key": "单密钥",
|
||||
"Skip async task polling delay": "跳过异步任务轮询延迟",
|
||||
"Site & Branding": "站点与品牌",
|
||||
"Site Key": "站点密钥",
|
||||
"Size:": "大小:",
|
||||
"sk_xxx or rk_xxx": "sk_xxx 或 rk_xxx",
|
||||
"Skip async task polling delay": "跳过异步任务轮询延迟",
|
||||
"Skip retry on failure": "失败后不重试",
|
||||
"Skip SMTP TLS certificate verification": "跳过 SMTP TLS 证书验证",
|
||||
"Skip to Main": "跳到主内容",
|
||||
@@ -4206,6 +4219,7 @@
|
||||
"Test all {{count}} models": "测试全部 {{count}} 个模型",
|
||||
"Test All Channels": "测试所有渠道",
|
||||
"Test Channel Connection": "测试渠道连接",
|
||||
"Test channels, refresh balances, and enable/disable individual, batch, or tagged channels.": "测试渠道、刷新余额,并启用/禁用单个、批量或带标签的渠道。",
|
||||
"Test Connection": "测试连接",
|
||||
"Test connectivity for:": "测试连接性:",
|
||||
"Test failed": "测试失败",
|
||||
@@ -4739,6 +4753,8 @@
|
||||
"Vidu": "Vidu",
|
||||
"View": "查看",
|
||||
"View all currently available models": "查看当前可用的所有模型",
|
||||
"View channel lists and details without secrets.": "查看不含密钥的渠道列表和详情。",
|
||||
"View channel secrets": "查看渠道密钥",
|
||||
"View detailed information about this user including balance, usage statistics, and invitation details.": "查看此用户的详细信息,包括余额、使用统计和邀请详情。",
|
||||
"View details": "查看详情",
|
||||
"View document": "查看文档",
|
||||
@@ -4881,8 +4897,10 @@
|
||||
"You can close this tab once the binding completes or a success message appears in the original window.": "绑定完成后或原窗口出现成功消息后,您可以关闭此标签页。",
|
||||
"You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.": "你可以在\"自定义模型名称\"处手动添加它们,然后点击\"填入\"后再提交,或者直接使用下方操作自动处理。",
|
||||
"You can only check in once per day": "每日仅可签到一次,请勿重复签到",
|
||||
"You can still edit non-sensitive operations fields such as models, groups, priority, and weight.": "你仍可编辑模型、分组、优先级和权重等非敏感运维字段。",
|
||||
"You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.": "你承诺不会使用本系统实施、协助实施或间接实施违反适用法律法规、监管要求、平台规则、公共利益或第三方合法权益的行为。",
|
||||
"You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "你承诺仅在从上游服务提供商、模型服务提供商或相关权利人处获得合法授权的范围内使用上游 API、账户、密钥、额度和服务能力,并不会进行未经授权的转售、倒卖、分发或其他不合规商业化行为。",
|
||||
"You do not have permission to edit sensitive channel settings.": "你没有权限编辑敏感渠道设置。",
|
||||
"You don't have necessary permission": "您没有必要的权限",
|
||||
"You have legally obtained authorization for the connected model APIs, accounts, keys, and quotas.": "你已合法取得所连接模型 API、账户、密钥和额度的授权。",
|
||||
"You have unsaved changes": "您有未保存的更改",
|
||||
@@ -4893,6 +4911,8 @@
|
||||
"You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.": "你理解此合规提醒仅用于风险提示,不构成法律意见、合规审查结论或对你使用本系统合法性的保证;你应结合实际业务场景咨询专业法律或合规顾问。",
|
||||
"You will be redirected to Telegram to complete the binding process.": "您将被重定向到 Telegram 以完成绑定过程。",
|
||||
"You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds.": "您将被自动重定向。如果几秒钟后无反应,您可以返回上一页。",
|
||||
"Your account can edit sensitive channel settings.": "你的账号可以编辑敏感渠道设置。",
|
||||
"Your account cannot edit sensitive channel settings.": "你的账号不能编辑敏感渠道设置。",
|
||||
"your AI integration?": "你的 AI 集成了吗?",
|
||||
"Your Azure OpenAI endpoint URL": "您的 Azure OpenAI 端点 URL",
|
||||
"Your Bot Name": "您的机器人名称",
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import { ROLE } from './roles'
|
||||
import type { AuthUser } from '@/stores/auth-store'
|
||||
|
||||
export type AdminPermissionMatrix = Record<string, Record<string, boolean>>
|
||||
export type AdminCapabilities = AdminPermissionMatrix
|
||||
|
||||
export const ADMIN_PERMISSION_RESOURCES = {
|
||||
CHANNEL: 'channel',
|
||||
} as const
|
||||
|
||||
export const ADMIN_PERMISSION_ACTIONS = {
|
||||
READ: 'read',
|
||||
OPERATE: 'operate',
|
||||
WRITE: 'write',
|
||||
SENSITIVE_WRITE: 'sensitive_write',
|
||||
SECRET_VIEW: 'secret_view',
|
||||
} as const
|
||||
|
||||
// The role whose baseline grants are used as defaults in the permission editor.
|
||||
export const ADMIN_ROLE_KEY = 'admin'
|
||||
|
||||
// The permission catalog (resources, actions, labels and role baselines) is owned
|
||||
// by the backend authz package and fetched from GET /api/authz/catalog. It is
|
||||
// intentionally NOT duplicated here so the schema stays defined in one place.
|
||||
// These types mirror the backend JSON shape.
|
||||
export interface PermissionActionDef {
|
||||
action: string
|
||||
label_key: string
|
||||
description_key: string
|
||||
}
|
||||
|
||||
export interface PermissionResourceDef {
|
||||
resource: string
|
||||
label_key: string
|
||||
actions: PermissionActionDef[]
|
||||
}
|
||||
|
||||
export interface PermissionRoleDef {
|
||||
key: string
|
||||
name: string
|
||||
built_in: boolean
|
||||
superuser: boolean
|
||||
grants: AdminPermissionMatrix
|
||||
}
|
||||
|
||||
export interface PermissionCatalog {
|
||||
resources: PermissionResourceDef[]
|
||||
roles: PermissionRoleDef[]
|
||||
}
|
||||
|
||||
export const EMPTY_PERMISSION_CATALOG: PermissionCatalog = {
|
||||
resources: [],
|
||||
roles: [],
|
||||
}
|
||||
|
||||
export function hasPermission(
|
||||
user: AuthUser | null | undefined,
|
||||
resource: string,
|
||||
action: string
|
||||
): boolean {
|
||||
if (!user) return false
|
||||
if (user.role === ROLE.SUPER_ADMIN) return true
|
||||
return user.permissions?.admin_permissions?.[resource]?.[action] === true
|
||||
}
|
||||
|
||||
// roleGrants returns the baseline grant matrix for the given role key.
|
||||
export function roleGrants(
|
||||
catalog: PermissionCatalog,
|
||||
roleKey: string
|
||||
): AdminPermissionMatrix {
|
||||
return catalog.roles.find((role) => role.key === roleKey)?.grants ?? {}
|
||||
}
|
||||
|
||||
// normalizeAdminPermissions produces a full matrix for the catalog, filling any
|
||||
// value missing from `value` with the admin role's baseline grant.
|
||||
export function normalizeAdminPermissions(
|
||||
value: AdminPermissionMatrix | null | undefined,
|
||||
catalog: PermissionCatalog
|
||||
): AdminPermissionMatrix {
|
||||
const baseline = roleGrants(catalog, ADMIN_ROLE_KEY)
|
||||
const normalized: AdminPermissionMatrix = {}
|
||||
for (const resource of catalog.resources) {
|
||||
const actions: Record<string, boolean> = {}
|
||||
for (const action of resource.actions) {
|
||||
actions[action.action] =
|
||||
value?.[resource.resource]?.[action.action] ??
|
||||
baseline[resource.resource]?.[action.action] ??
|
||||
false
|
||||
}
|
||||
normalized[resource.resource] = actions
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
Vendored
+2
@@ -17,10 +17,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { create } from 'zustand'
|
||||
import type { AdminCapabilities } from '@/lib/admin-permissions'
|
||||
|
||||
export type UserPermissions = {
|
||||
sidebar_settings?: boolean
|
||||
sidebar_modules?: Record<string, unknown>
|
||||
admin_permissions?: AdminCapabilities
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
|
||||
Reference in New Issue
Block a user