Commit 3f8a50cf by CaIon

feat(audit): complete token and quota operation records

Record successful and failed API token operations with safe target metadata. Capture quota adjustments in a transaction, synchronize committed cache differences, and correlate audit and top-up records.

Show operation targets, changes, quota balances, and failure details consistently across audit and usage logs, with translations for all seven locales.

Validated controller, middleware, and model tests; 78 frontend tests; typecheck and lint; real SQLite 3.50.4, MySQL 8.4.11, and PostgreSQL 16.15 with shared and separate log databases.
parent 0973dc2b
......@@ -62,6 +62,14 @@ web/ — Frontend (React 19, Rsbuild, Base UI, Tailwind)
- A separate function is appropriate when it represents reusable behavior, a required interface/framework callback, an exported API, a test fixture, or complex business logic that deserves direct tests.
- If a single-use helper is kept, its name must describe a durable domain concept rather than a mechanical step extracted only to shorten the caller.
### Authentication Security (OWASP Mandatory)
- Any implementation, modification, or review involving authentication-related flows MUST comply with the applicable requirements of the latest stable [OWASP Application Security Verification Standard (ASVS)](https://owasp.org/www-project-application-security-verification-standard/) and the relevant [OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/). This applies to both backend and frontend changes, including registration, login/logout, password changes and recovery, email verification, MFA, WebAuthn/Passkeys, OAuth/OIDC, account linking/unlinking, sessions, JWTs, API credentials, and re-authentication for sensitive actions.
- Before changing these flows, read the applicable OWASP guidance, starting with the [Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html) and [Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html). Consult the password storage, forgot password, MFA, OAuth, and CSRF guidance when those mechanisms are involved. Identify the applicable controls before implementation; existing code is not a justification for retaining or introducing an insecure pattern.
- Enforce security controls on the server. Apply the relevant requirements for credential storage and transport, resistance to account enumeration and brute force, CSRF and replay protection, token/challenge expiry and single use where required, protocol-specific verification, session rotation and invalidation, and re-authentication for sensitive account changes. Frontend checks MUST NOT substitute for server-side enforcement, and recovery or alternative login paths MUST NOT bypass the required authentication assurance.
- Authentication audit events MUST exclude passwords, verification codes, recovery codes, private keys, and usable session or authentication tokens. Record enough non-secret context to investigate authentication failures and sensitive account changes.
- Verify affected security controls with focused regression tests, including applicable failure, expiry, replay, and bypass cases, following the existing backend/frontend test conventions. Record the OWASP references (including the ASVS version and requirement IDs when used), validation performed, and any unresolved gaps in the change summary or PR description. Do not claim compliance or completion while an applicable security requirement remains unmet or unverified.
### Backend Rules
**relaykit module independence:** The `relaykit/` Go module MUST remain independently buildable.
......
......@@ -74,4 +74,9 @@ const (
// fallback in authHelper (finishAdminAudit) skips its record to avoid
// duplicate entries.
ContextKeyAuditLogged ContextKey = "audit_logged"
// ContextKeyTokenAuditParams contains only the API token operation's safe metadata.
ContextKeyTokenAuditParams ContextKey = "token_audit_params"
// ContextKeyTokenAuditSucceeded disambiguates token responses that exceed the audit buffer.
ContextKeyTokenAuditSucceeded ContextKey = "token_audit_succeeded"
)
......@@ -138,3 +138,23 @@ func recordUserSecurityAudit(c *gin.Context, userId int, action string, params m
}
model.RecordOperationAuditLog(userId, c.GetInt("role"), auditContentEN(action, params), c.ClientIP(), action, params, nil, auditInfo, c)
}
func tokenAuditParams(c *gin.Context) model.AuditFields {
params, ok := common.GetContextKeyType[model.AuditFields](c, constant.ContextKeyTokenAuditParams)
if !ok {
params = model.AuditFields{}
common.SetContextKey(c, constant.ContextKeyTokenAuditParams, params)
}
return params
}
func tokenBatchAuditParams(c *gin.Context, ids []int) model.AuditFields {
params := tokenAuditParams(c)
params["total"] = len(ids)
// Bound audit payloads without changing the batch operation's limits.
params["requested_ids"] = append([]int{}, ids[:min(len(ids), 100)]...)
if len(ids) > 100 {
params["requested_ids_truncated"] = true
}
return params
}
......@@ -413,7 +413,7 @@ func recordSubscriptionResetUserLogs(c *gin.Context, result *model.SubscriptionR
}
content := fmt.Sprintf("管理员重置订阅套餐 %s(ID: %d)额度", result.PlanTitle, result.PlanId)
for _, userId := range result.AffectedUserIds {
model.RecordLogWithAdminInfo(userId, model.LogTypeManage, content, adminInfo, c)
model.RecordLogWithAdminInfo(userId, model.LogTypeManage, content, adminInfo, nil, c)
}
}
......
......@@ -197,6 +197,9 @@ func GetTokenKey(c *gin.Context) {
common.ApiError(c, err)
return
}
params := tokenAuditParams(c)
params["id"], params["name"] = token.Id, token.Name
common.SetContextKey(c, constant.ContextKeyTokenAuditSucceeded, true)
common.ApiSuccess(c, gin.H{
"key": token.GetFullKey(),
})
......@@ -284,6 +287,8 @@ func AddToken(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
return
}
params := tokenAuditParams(c)
params["name"] = token.Name
// 非无限额度时,检查额度值是否超出有效范围
if !token.UnlimitedQuota {
if token.RemainQuota < 0 {
......@@ -345,6 +350,8 @@ func AddToken(c *gin.Context) {
common.ApiError(c, err)
return
}
params["id"] = cleanToken.Id
common.SetContextKey(c, constant.ContextKeyTokenAuditSucceeded, true)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
......@@ -354,11 +361,19 @@ func AddToken(c *gin.Context) {
func DeleteToken(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
userId := c.GetInt("id")
err := model.DeleteTokenById(id, userId)
token, err := model.GetTokenByIds(id, userId)
if err != nil {
common.ApiError(c, err)
return
}
params := tokenAuditParams(c)
params["id"], params["name"] = token.Id, token.Name
err = token.Delete()
if err != nil {
common.ApiError(c, err)
return
}
common.SetContextKey(c, constant.ContextKeyTokenAuditSucceeded, true)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
......@@ -375,6 +390,10 @@ func UpdateToken(c *gin.Context) {
return
}
token := request.Token
params := tokenAuditParams(c)
if token.Id > 0 {
params["id"] = token.Id
}
if len(token.Name) > 50 {
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
return
......@@ -395,6 +414,8 @@ func UpdateToken(c *gin.Context) {
common.ApiError(c, err)
return
}
params["name"] = cleanToken.Name
previous := *cleanToken
if token.Status == common.TokenStatusEnabled {
if cleanToken.Status == common.TokenStatusExpired && cleanToken.ExpiredTime <= common.GetTimestamp() && cleanToken.ExpiredTime != -1 {
common.ApiErrorI18n(c, i18n.MsgTokenExpiredCannotEnable)
......@@ -432,6 +453,34 @@ func UpdateToken(c *gin.Context) {
common.ApiError(c, err)
return
}
params["name"] = cleanToken.Name
if statusOnly != "" {
params["from"], params["to"] = previous.Status, cleanToken.Status
} else {
changedFields := []string{}
for _, field := range []struct {
name string
changed bool
}{
{"name", previous.Name != cleanToken.Name},
{"expired_time", previous.ExpiredTime != cleanToken.ExpiredTime},
{"remain_quota", previous.RemainQuota != cleanToken.RemainQuota},
{"unlimited_quota", previous.UnlimitedQuota != cleanToken.UnlimitedQuota},
{"model_limits_enabled", previous.ModelLimitsEnabled != cleanToken.ModelLimitsEnabled},
{"model_limits", previous.ModelLimits != cleanToken.ModelLimits},
{"allow_ips", (previous.AllowIps == nil) != (cleanToken.AllowIps == nil) ||
(previous.AllowIps != nil && cleanToken.AllowIps != nil && *previous.AllowIps != *cleanToken.AllowIps)},
{"group", previous.Group != cleanToken.Group},
{"cross_group_retry", previous.CrossGroupRetry != cleanToken.CrossGroupRetry},
{"auto_groups", previous.AutoGroups != cleanToken.AutoGroups},
} {
if field.changed {
changedFields = append(changedFields, field.name)
}
}
params["changed_fields"] = changedFields
}
common.SetContextKey(c, constant.ContextKeyTokenAuditSucceeded, true)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
......@@ -445,7 +494,12 @@ type TokenBatch struct {
func DeleteTokenBatch(c *gin.Context) {
tokenBatch := TokenBatch{}
if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 {
if err := c.ShouldBindJSON(&tokenBatch); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
params := tokenBatchAuditParams(c, tokenBatch.Ids)
if len(tokenBatch.Ids) == 0 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
......@@ -455,6 +509,8 @@ func DeleteTokenBatch(c *gin.Context) {
common.ApiError(c, err)
return
}
params["count"] = count
common.SetContextKey(c, constant.ContextKeyTokenAuditSucceeded, true)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
......@@ -464,7 +520,12 @@ func DeleteTokenBatch(c *gin.Context) {
func GetTokenKeysBatch(c *gin.Context) {
tokenBatch := TokenBatch{}
if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 {
if err := c.ShouldBindJSON(&tokenBatch); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
params := tokenBatchAuditParams(c, tokenBatch.Ids)
if len(tokenBatch.Ids) == 0 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
......@@ -479,8 +540,13 @@ func GetTokenKeysBatch(c *gin.Context) {
return
}
keysMap := make(map[int]string)
returnedIDs := make([]int, 0, len(tokens))
for _, t := range tokens {
keysMap[t.Id] = t.GetFullKey()
returnedIDs = append(returnedIDs, t.Id)
}
params["count"] = len(tokens)
params["returned_ids"] = returnedIDs
common.SetContextKey(c, constant.ContextKeyTokenAuditSucceeded, true)
common.ApiSuccess(c, gin.H{"keys": keysMap})
}
......@@ -1066,6 +1066,10 @@ func ManageUser(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
if req.Action == "add_quota" {
manageUserQuota(c, req)
return
}
user := model.User{
Id: req.Id,
}
......@@ -1136,59 +1140,6 @@ func ManageUser(c *gin.Context) {
return
}
user.Role = common.RoleCommonUser
case "add_quota":
switch req.Mode {
case "add":
if req.Value <= 0 {
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
return
}
if err := common.ValidateWalletQuota(req.Value); err != nil {
common.ApiError(c, err)
return
}
if err := model.IncreaseUserQuota(user.Id, req.Value, true); err != nil {
common.ApiError(c, err)
return
}
recordManageAuditFor(c, user.Id, "user.quota_add", map[string]interface{}{
"quota": logger.LogQuota(req.Value),
})
case "subtract":
if req.Value <= 0 {
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
return
}
if err := model.DecreaseUserQuota(user.Id, req.Value, true); err != nil {
common.ApiError(c, err)
return
}
recordManageAuditFor(c, user.Id, "user.quota_subtract", map[string]interface{}{
"quota": logger.LogQuota(req.Value),
})
case "override":
if err := common.ValidateWalletQuota(req.Value); err != nil {
common.ApiError(c, err)
return
}
oldQuota := user.Quota
if err := model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("quota", req.Value).Error; err != nil {
common.ApiError(c, err)
return
}
recordManageAuditFor(c, user.Id, "user.quota_override", map[string]interface{}{
"from": logger.LogQuota(oldQuota),
"to": logger.LogQuota(req.Value),
})
default:
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
default:
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
......
package controller
import (
"errors"
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func manageUserQuota(c *gin.Context, req ManageRequest) {
action := "generic"
params := model.AuditFields{
"target_user_id": req.Id,
"mode": req.Mode,
"requested_quota": req.Value,
}
switch req.Mode {
case "add":
action = "user.quota_add"
case "subtract":
action = "user.quota_subtract"
case "override":
action = "user.quota_override"
default:
params["action"] = "add_quota"
params["method"] = c.Request.Method
params["route"] = c.FullPath()
}
success := false
defer func() {
content := auditContentEN(action, params)
if !success {
// Failed requests have no committed balance changes to render.
content = "Failed user quota adjustment"
}
model.RecordOperationAuditLog(c.GetInt("id"), c.GetInt("role"), content, c.ClientIP(), action, params,
auditOperatorInfo(c), &model.AuditRequestInfo{
Method: c.Request.Method, Route: c.FullPath(), Status: c.Writer.Status(), Success: success,
}, c)
markAuditLogged(c)
}()
adjustment, err := model.AdjustUserQuota(req.Id, c.GetInt("role"), req.Mode, req.Value)
if err != nil {
switch {
case errors.Is(err, model.ErrInvalidUserQuotaAdjustment):
params["failure_reason"] = "invalid_parameters"
if (req.Mode == "add" || req.Mode == "subtract") && req.Value <= 0 {
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
} else {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
}
case errors.Is(err, model.ErrUserQuotaPermission):
params["failure_reason"] = "permission_denied"
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
case errors.Is(err, gorm.ErrRecordNotFound):
params["failure_reason"] = "target_not_found"
common.ApiErrorI18n(c, i18n.MsgUserNotExists)
case errors.Is(err, model.ErrWalletQuotaLimitExceeded):
params["failure_reason"] = "quota_limit_exceeded"
common.ApiError(c, err)
default:
params["failure_reason"] = "database_error"
common.ApiError(c, err)
}
return
}
params["target_username"] = adjustment.Username
params["from"] = adjustment.Before
params["to"] = adjustment.After
if req.Mode != "override" {
params["quota"] = req.Value
}
success = true
operation := model.AuditOperation{Action: action, Params: params}
model.RecordLogWithAdminInfo(adjustment.UserID, model.LogTypeTopup,
auditContentEN(action, params), auditOperatorInfo(c), &operation, c)
c.JSON(http.StatusOK, gin.H{"success": true, "message": ""})
}
......@@ -2,6 +2,7 @@ package middleware
import (
"bytes"
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
......@@ -203,6 +204,56 @@ func auditResponseSuccess(status int, body []byte) bool {
const accessTokenAuditContextKey = "access_token_request_audit"
// TokenOperationAudit runs after UserAuth and before endpoint rate limits.
// Handlers add only allowlisted metadata; neither bodies nor raw errors are persisted.
func TokenOperationAudit() gin.HandlerFunc {
return func(c *gin.Context) {
var action, content string
switch c.Request.Method + " " + c.FullPath() {
case "POST /api/token/":
action, content = "token.create", "API token creation"
case "PUT /api/token/":
action, content = "token.update", "API token configuration update"
if c.Query("status_only") != "" {
action, content = "token.status_update", "API token status update"
}
case "DELETE /api/token/:id":
action, content = "token.delete", "API token deletion"
case "POST /api/token/batch":
action, content = "token.delete_batch", "API token batch deletion"
case "POST /api/token/:id/key":
action, content = "token.key_view", "API token key access"
case "POST /api/token/batch/keys":
action, content = "token.key_view_batch", "API token batch key access"
default:
c.Next()
return
}
params := model.AuditFields{}
if id, err := strconv.Atoi(c.Param("id")); err == nil && id > 0 {
params["id"] = id
}
common.SetContextKey(c, constant.ContextKeyTokenAuditParams, params)
entry := model.AuditLog{
UserId: c.GetInt("id"), Username: c.GetString("username"), ActorRole: c.GetInt("role"),
Category: model.AuditCategorySecurity, Action: action, Content: content,
Other: model.AuditOther{Op: &model.AuditOperation{Action: action, Params: params}},
}
writer := &auditResponseWriter{ResponseWriter: c.Writer, body: bytes.NewBuffer(nil), maxSize: 64 * 1024}
c.Writer = writer
c.Next()
entry.Status = writer.Status()
entry.Success = auditResponseSuccess(entry.Status, writer.body.Bytes())
if writer.body.Len() == writer.maxSize {
// JSON may be truncated before its success field. A completed handler
// supplies the result without retaining an unbounded response body.
entry.Success = entry.Status < 400 && common.GetContextKeyBool(c, constant.ContextKeyTokenAuditSucceeded)
}
model.RecordAuditLog(c, entry)
}
}
type accessTokenRequestAudit struct {
entry model.AuditLog
writer *auditResponseWriter
......
......@@ -165,8 +165,9 @@ func RecordLog(userId int, logType int, content string) {
}
}
// RecordLogWithAdminInfo 记录操作日志,并将管理员相关信息存入 Other.admin_info,
func RecordLogWithAdminInfo(userId int, logType int, content string, adminInfo *AuditAdminInfo, request ...*gin.Context) {
// RecordLogWithAdminInfo stores operator metadata under other.admin_info and
// an optional, user-visible operation descriptor under other.op for localization.
func RecordLogWithAdminInfo(userId int, logType int, content string, adminInfo *AuditAdminInfo, operation *AuditOperation, request ...*gin.Context) {
if logType == LogTypeConsume && !common.LogConsumeEnabled {
return
}
......@@ -187,11 +188,14 @@ func RecordLogWithAdminInfo(userId int, logType int, content string, adminInfo *
if c != nil {
actorRole = c.GetInt("role")
}
RecordAuditLog(c, AuditLog{UserId: userId, Username: username, ActorRole: actorRole, Category: AuditCategoryOperation, Content: content, Other: AuditOther{AdminInfo: adminInfo}, Success: true})
RecordAuditLog(c, AuditLog{UserId: userId, Username: username, ActorRole: actorRole, Category: AuditCategoryOperation, Content: content, Other: AuditOther{AdminInfo: adminInfo, Op: operation}, Success: true})
return
}
if adminInfo != nil {
data, err := common.Marshal(AuditOther{AdminInfo: adminInfo})
if len(request) > 0 && request[0] != nil {
log.RequestId = request[0].GetString(common.RequestIdKey)
}
if adminInfo != nil || operation != nil {
data, err := common.Marshal(AuditOther{AdminInfo: adminInfo, Op: operation})
if err != nil {
common.SysError("failed to encode log admin info: " + err.Error())
return
......
......@@ -36,7 +36,7 @@ if tonumber(redis.call('HGET', KEYS[1], 'Id') or '0') ~= tonumber(ARGV[2])
or redis.call('HEXISTS', KEYS[1], 'Quota') == 0 then
return -1
end
redis.call('HINCRBY', KEYS[1], 'Quota', tonumber(ARGV[1]))
redis.call('HINCRBY', KEYS[1], 'Quota', ARGV[1])
return 1`
const tokenQuotaReserveScript = `
......
package model
import (
"errors"
"fmt"
"github.com/QuantumNous/new-api/common"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
var (
ErrInvalidUserQuotaAdjustment = errors.New("invalid user quota adjustment")
ErrUserQuotaPermission = errors.New("cannot adjust quota for this user role")
)
// UserQuotaAdjustment is the immutable database snapshot of a committed manual
// adjustment. Pending relay deductions in the quota cache are not part of it.
type UserQuotaAdjustment struct {
UserID int
Username string
Before int
After int
}
func AdjustUserQuota(userID, operatorRole int, mode string, value int) (*UserQuotaAdjustment, error) {
if userID <= 0 || (mode != "add" && mode != "subtract" && mode != "override") {
return nil, ErrInvalidUserQuotaAdjustment
}
if mode != "override" && value <= 0 {
return nil, ErrInvalidUserQuotaAdjustment
}
if value > common.MaxWalletQuota || value < -common.MaxWalletQuota {
return nil, ErrWalletQuotaLimitExceeded
}
var adjustment UserQuotaAdjustment
err := DB.Transaction(func(tx *gorm.DB) error {
var user User
if err := lockForUpdate(tx).First(&user, userID).Error; err != nil {
return err
}
if operatorRole != common.RoleRootUser && operatorRole <= user.Role {
return ErrUserQuotaPermission
}
if user.Quota > common.MaxWalletQuota || user.Quota < -common.MaxWalletQuota {
return ErrWalletQuotaLimitExceeded
}
quota := decimal.NewFromInt(int64(value))
switch mode {
case "add":
quota = decimal.NewFromInt(int64(user.Quota)).Add(quota)
case "subtract":
quota = decimal.NewFromInt(int64(user.Quota)).Sub(quota)
}
after, err := common.WalletQuotaFromDecimalStrict(quota)
if err != nil {
return ErrWalletQuotaLimitExceeded
}
// An unchanged override is a successful operation, including on MySQL
// configurations that count only changed rows in RowsAffected.
if after != user.Quota {
result := tx.Model(&User{}).Where("id = ?", userID).Update("quota", after)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
}
adjustment = UserQuotaAdjustment{UserID: user.Id, Username: user.Username, Before: user.Quota, After: after}
return nil
})
if err != nil {
return nil, err
}
// Apply only the committed difference, preserving outstanding reservations.
// Both balances are bounded above, so their difference fits in int64.
delta := int64(adjustment.After) - int64(adjustment.Before)
if delta != 0 {
if err := cacheIncrUserQuota(userID, delta); err != nil {
common.SysError(fmt.Sprintf("failed to sync manual quota adjustment for user %d: %s", userID, err))
}
}
return &adjustment, nil
}
......@@ -261,6 +261,7 @@ func SetApiRouter(router *gin.Engine) {
registerAuthzRoutes(apiRouter)
tokenRoute := apiRouter.Group("/token")
tokenRoute.Use(middleware.UserAuth())
tokenRoute.Use(middleware.TokenOperationAudit())
{
tokenRoute.GET("/", controller.GetAllTokens)
tokenRoute.GET("/search", middleware.SearchRateLimit(), controller.SearchTokens)
......
......@@ -44,6 +44,202 @@ import { useAuthStore } from '@/stores/auth-store'
import { AuditLogs } from '..'
import { AuditLogViewer } from '../components/audit-log-viewer'
it.each([
[
'generic',
{
action: 'add_quota',
target_user_id: 11,
mode: 'unsupported',
requested_quota: 500000,
failure_reason: 'invalid_parameters',
},
'Adjust user quota',
'Requested quota: $1 · Invalid adjustment parameters',
],
[
'user.quota_add',
{
target_user_id: 11,
target_username: 'quota-owner',
requested_quota: 500000,
quota: 500000,
from: 500000,
to: 1000000,
},
'Increase quota for user “quota-owner”',
'Requested quota: $1 · $1 → $2',
],
[
'user.quota_subtract',
{
target_user_id: 11,
target_username: 'quota-owner',
requested_quota: 500000,
quota: 500000,
from: 1000000,
to: 500000,
},
'Decrease quota for user “quota-owner”',
'Requested quota: $1 · $2 → $1',
],
[
'user.quota_override',
{
target_user_id: 11,
target_username: 'quota-owner',
requested_quota: 0,
from: 500000,
to: 0,
},
'Override quota for user “quota-owner”',
'Requested quota: $0 · $1 → $0',
],
['token.create', { id: 11, name: '1' }, 'Create API token “1”', ''],
[
'token.update',
{
id: 11,
name: 'production',
changed_fields: ['remain_quota', 'expired_time'],
},
'Update API token “production”',
'Changed fields: Remaining quota, Expiration Time',
],
[
'token.status_update',
{ id: 11, name: 'production', from: 1, to: 2 },
'Update API token “production”',
'Enabled → Disabled',
],
[
'token.delete',
{ id: 11, name: 'production' },
'Delete API token “production”',
'',
],
[
'token.key_view',
{ id: 11, name: 'production' },
'View key for API token “production”',
'',
],
[
'token.delete_batch',
{ total: 4, count: 1, requested_ids: [11, 11, 12, 99] },
'Batch delete API tokens',
'Requested: 4 · Deleted: 1',
],
[
'token.key_view_batch',
{ total: 4, count: 0, returned_ids: [] },
'View API token keys in batch',
'Requested: 4 · Returned: 0',
],
])(
'shows the target and business outcome for %s directly in the event cell',
async (action, params, headline, outcome) => {
vi.spyOn(api, 'get').mockResolvedValue({
data: {
success: true,
data: {
total: 1,
items: [
{
event_id: 'token-event',
created_at: 1788600600,
username: 'root',
actor_role: 100,
category: 'security',
action,
success: action !== 'generic',
status: 200,
other: { op: { action, params } },
},
],
},
},
})
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
render(
<QueryClientProvider client={client}>
<AuditLogViewer scope='self' />
</QueryClientProvider>
)
const cell = await screen.findByRole('cell', { name: new RegExp(headline) })
expect(cell).toHaveTextContent(headline)
if ('id' in params || 'target_user_id' in params) {
expect(cell).toHaveTextContent('(ID: 11)')
}
if (outcome) expect(cell).toHaveTextContent(outcome)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
}
)
it.each([false, true])(
'keeps the ID outside long-name truncation and shows complete details (mobile=%s)',
async (mobile) => {
const matchMedia = window.matchMedia.bind(window)
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
...matchMedia(query),
matches: mobile && query === '(max-width: 640px)',
}))
const name = 'production-europe-primary-customer-routing-token'
vi.spyOn(api, 'get').mockResolvedValue({
data: {
success: true,
data: {
total: 1,
items: [
{
event_id: 'long-token-name',
created_at: 1788600600,
username: 'root',
actor_role: 100,
category: 'security',
action: 'token.status_update',
success: true,
status: 200,
other: {
op: {
action: 'token.status_update',
params: { id: 11, name, from: 1, to: 2 },
},
},
},
],
},
},
})
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
render(
<QueryClientProvider client={client}>
<AuditLogViewer scope='self' />
</QueryClientProvider>
)
const id = await screen.findByText('(ID: 11)')
expect(id).toBeVisible()
expect(id).toHaveClass('shrink-0')
expect(id.closest('.truncate')).toBeNull()
expect(screen.getByText('Enabled → Disabled')).toBeVisible()
if (mobile) expect(screen.queryByRole('table')).not.toBeInTheDocument()
else expect(screen.getByRole('table')).toBeVisible()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Details' }))
const dialog = await screen.findByRole('dialog', { name: 'Log Details' })
expect(
within(dialog).getByText(`Update API token “${name}” (ID: 11)`)
).toBeVisible()
expect(
within(dialog).getByText('Token Name').parentElement
).toHaveTextContent(name)
}
)
it('uses the shared log toolbar and opens details in a keyboard-accessible dialog without expanding the row', async () => {
vi.spyOn(api, 'get').mockResolvedValue({
data: {
......
......@@ -19,12 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
import { useTranslation } from 'react-i18next'
import { DetailRow } from '../../components/dialogs/log-detail-layout'
import { auditFieldLabel, isAuditDetailObject } from '../lib/audit-details'
import {
auditFieldLabel,
isAuditDetailObject,
type AuditDetailField,
} from '../lib/audit-details'
import { AuditDetailValue } from './audit-detail-value'
export function AuditDetailFields(props: {
fields: { label: string; value: unknown }[]
}) {
export function AuditDetailFields(props: { fields: AuditDetailField[] }) {
const { t } = useTranslation()
return props.fields.map((field) => {
const value = field.value
......@@ -56,7 +58,13 @@ export function AuditDetailFields(props: {
<DetailRow
key={field.label}
label={field.label}
value={<AuditDetailValue label={field.label} value={text} />}
value={
<AuditDetailValue
label={field.label}
value={text}
copyable={field.copyable}
/>
}
/>
)
})
......
......@@ -57,13 +57,49 @@ export function useAuditLogColumns(
{
id: 'event',
header: t('Event'),
size: 260,
accessorFn: (entry) => buildAuditDetails(entry, t).summary,
cell: ({ getValue }) => (
<TruncatedCell className='max-w-64'>
{getValue<string>()}
</TruncatedCell>
),
size: 360,
accessorFn: (entry) => {
const detail = buildAuditDetails(entry, t)
return [detail.summary, detail.operation?.description]
.filter(Boolean)
.join(' · ')
},
cell: ({ row, getValue }) => {
const operation = buildAuditDetails(row.original, t).operation
if (!operation) {
return (
<TruncatedCell className='max-w-64'>
{getValue<string>()}
</TruncatedCell>
)
}
return (
<div className='min-w-0 space-y-1'>
<div className='flex min-w-0 items-baseline gap-1'>
<TruncatedCell
className='min-w-0 font-medium'
tooltipContent={operation.summary}
>
{operation.headline}
</TruncatedCell>
{operation.identifier && (
<span className='shrink-0 whitespace-nowrap'>
{operation.identifier}
</span>
)}
</div>
{operation.description && (
<TruncatedCell
className='text-muted-foreground'
contentClassName='line-clamp-2 whitespace-normal break-words'
tooltipContent={operation.description}
>
{operation.description}
</TruncatedCell>
)}
</div>
)
},
meta: { label: t('Event') },
}
)
......
......@@ -72,6 +72,11 @@ export function AuditLogDetailsDialog(props: { entry: AuditLog }) {
<p className='text-sm leading-relaxed font-medium break-words'>
{detail.summary}
</p>
{detail.operation?.description && (
<p className='text-muted-foreground text-sm leading-relaxed break-words'>
{detail.operation.description}
</p>
)}
<div className='flex flex-wrap items-center gap-2 text-xs'>
<StatusBadge
label={props.entry.success ? t('Success') : t('Failed')}
......@@ -85,6 +90,17 @@ export function AuditLogDetailsDialog(props: { entry: AuditLog }) {
)}
</div>
</div>
{detail.operation && (
<DetailSection
label={
detail.quotaOperation
? t('Quota adjustment details')
: t('Token operation details')
}
>
<AuditDetailFields fields={detail.operation.fields} />
</DetailSection>
)}
{hasOperation && (
<DetailSection label={t('Operation Audit Info')}>
{detail.actor && (
......@@ -93,7 +109,7 @@ export function AuditLogDetailsDialog(props: { entry: AuditLog }) {
{detail.actorRole && (
<DetailRow label={t('Role')} value={detail.actorRole} />
)}
{detail.target && (
{!detail.operation && detail.target && (
<DetailRow label={t('Target')} value={detail.target} />
)}
{detail.authentication && (
......@@ -102,7 +118,7 @@ export function AuditLogDetailsDialog(props: { entry: AuditLog }) {
value={detail.authentication}
/>
)}
<AuditDetailFields fields={detail.fields} />
{!detail.operation && <AuditDetailFields fields={detail.fields} />}
{detail.metadataUnavailable && (
<p className='text-muted-foreground text-xs'>
{t('Audit metadata is unavailable')}
......
......@@ -22,6 +22,7 @@ import { loginMethodLabel } from '@/features/security/components/login-session-u
import { ROLE } from '@/lib/roles'
import { renderAuditContent } from '../../lib/format'
import { buildQuotaAuditOperation } from '../../lib/quota-audit-operation'
import type { LogOtherData } from '../../types'
import type { AuditLog } from '../api'
......@@ -32,6 +33,40 @@ const AUDIT_ROLE_NAMES: Record<number, string> = {
[ROLE.SUPER_ADMIN]: 'root',
}
const TOKEN_AUDIT_OPERATIONS: Record<
string,
{ labelKey: string; namedKey?: string }
> = {
'token.create': {
labelKey: 'Create API token',
namedKey: 'Create API token “{{name}}”',
},
'token.update': {
labelKey: 'Update API token',
namedKey: 'Update API token “{{name}}”',
},
'token.status_update': {
labelKey: 'Update API token',
namedKey: 'Update API token “{{name}}”',
},
'token.delete': {
labelKey: 'Delete API token',
namedKey: 'Delete API token “{{name}}”',
},
'token.key_view': {
labelKey: 'View API token key',
namedKey: 'View key for API token “{{name}}”',
},
'token.delete_batch': { labelKey: 'Batch delete API tokens' },
'token.key_view_batch': { labelKey: 'View API token keys in batch' },
}
export type AuditDetailField = {
label: string
value: unknown
copyable?: boolean
}
export function isAuditDetailObject(
value: unknown
): value is Record<string, unknown> {
......@@ -52,6 +87,28 @@ export function auditFieldLabel(key: string, t: TFunction): string {
return t('Count')
case 'total':
return t('Total')
case 'requested_ids':
return t('Requested token IDs')
case 'returned_ids':
return t('Returned token IDs')
case 'requested_ids_truncated':
return t('Requested token IDs truncated')
case 'expired_time':
return t('Expiration Time')
case 'remain_quota':
return t('Remaining quota')
case 'unlimited_quota':
return t('Unlimited Quota')
case 'model_limits_enabled':
return t('Model limits enabled')
case 'model_limits':
return t('Model Limits')
case 'allow_ips':
return t('IP Whitelist (supports CIDR)')
case 'auto_groups':
return t('Auto Group Chain')
case 'cross_group_retry':
return t('Cross-group retry')
case 'sourceId':
return t('Source ID')
case 'id':
......@@ -105,13 +162,182 @@ export function auditFieldLabel(key: string, t: TFunction): string {
}
}
function buildTokenAuditOperation(
action: string,
params: Record<string, unknown>,
success: boolean,
t: TFunction
) {
const operation = TOKEN_AUDIT_OPERATIONS[action]
if (!operation) return null
const fields: AuditDetailField[] = []
let headline = t(operation.labelKey)
let summary = headline
let identifier = ''
let description = ''
if (operation.namedKey) {
const name =
typeof params.name === 'string' && params.name.trim() ? params.name : ''
let id = ''
if (typeof params.id === 'number' && Number.isFinite(params.id)) {
id = String(params.id)
} else if (typeof params.id === 'string' && params.id.trim()) {
id = params.id
}
if (name) {
headline = t(operation.namedKey, { name })
fields.push({ label: t('Token Name'), value: name })
}
summary = headline
if (id) {
identifier = t('(ID: {{id}})', { id })
summary = t('{{operation}} (ID: {{id}})', { operation: headline, id })
fields.push({ label: t('Token ID'), value: id, copyable: true })
}
if (!name && !id) {
headline = `${headline} · ${t('Target not recorded')}`
summary = headline
fields.push({ label: t('Target'), value: t('Target not recorded') })
}
}
if (success && action === 'token.update') {
const changed = params.changed_fields
description = t('Field change details were not recorded')
let changes = description
if (
Array.isArray(changed) &&
changed.every((field) => typeof field === 'string')
) {
changes = changed.length
? changed.map((field) => auditFieldLabel(field, t)).join(', ')
: t('No changes')
description = changed.length
? t('Changed fields: {{fields}}', { fields: changes })
: changes
}
fields.push({ label: t('Changed Fields'), value: changes })
}
if (success && action === 'token.status_update') {
const statuses: Record<string, string> = {
'1': t('Enabled'),
'2': t('Disabled'),
'3': t('Expired'),
'4': t('Exhausted'),
}
const states = [params.from, params.to].map((value) => {
if (typeof value !== 'number' && typeof value !== 'string') return ''
const status = String(value)
return statuses[status] || status
})
if (!states[0] && !states[1]) {
description = t('Field change details were not recorded')
} else if (states[0] && states[0] === states[1]) {
description = t('State unchanged: {{status}}', { status: states[0] })
} else {
description = `${states[0] || t('Not recorded')}${states[1] || t('Not recorded')}`
}
fields.push({ label: t('Status change'), value: description })
}
if (!operation.namedKey) {
const total =
typeof params.total === 'number' &&
Number.isFinite(params.total) &&
params.total >= 0
? params.total
: undefined
const processed =
success &&
typeof params.count === 'number' &&
Number.isFinite(params.count) &&
params.count >= 0
? params.count
: undefined
if (total !== undefined) {
description = t('Requested: {{total}}', { total })
fields.push({ label: t('Requested items'), value: total })
}
if (processed !== undefined) {
if (action === 'token.delete_batch') {
description =
total === undefined
? t('Deleted: {{processed}}', { processed })
: t('Requested: {{total}} · Deleted: {{processed}}', {
total,
processed,
})
fields.push({ label: t('Deleted tokens'), value: processed })
} else {
description =
total === undefined
? t('Returned: {{processed}}', { processed })
: t('Requested: {{total}} · Returned: {{processed}}', {
total,
processed,
})
fields.push({ label: t('Returned keys'), value: processed })
}
}
for (const key of ['requested_ids', 'returned_ids']) {
if (key === 'returned_ids' && !success) continue
const ids = params[key]
if (ids === undefined) continue
const valid =
Array.isArray(ids) &&
ids.every((id) => typeof id === 'number' || typeof id === 'string')
let value = t('Not recorded')
if (valid) value = ids.length ? ids.join(', ') : t('None')
fields.push({
label: auditFieldLabel(key, t),
value,
copyable: valid && ids.length > 0,
})
if (
key === 'requested_ids' &&
valid &&
params.requested_ids_truncated === true
) {
const note =
total === undefined
? t('Only the first {{shown}} IDs were recorded', {
shown: ids.length,
})
: t(
'Only the first {{shown}} IDs were recorded ({{total}} requested)',
{ shown: ids.length, total }
)
fields.push({ label: t('Note'), value: note })
}
}
}
return { headline, summary, identifier, description, fields }
}
export function buildAuditDetails(entry: AuditLog, t: TFunction) {
const metadata = isAuditDetailObject(entry.other) ? entry.other : {}
const metadataUnavailable =
entry.other != null && !isAuditDetailObject(entry.other)
const op = isAuditDetailObject(metadata.op) ? metadata.op : {}
const action = typeof op.action === 'string' ? op.action : entry.action
const action = typeof op.action === 'string' ? op.action : entry.action || ''
const params = isAuditDetailObject(op.params) ? { ...op.params } : {}
const tokenOperation = buildTokenAuditOperation(
action,
params,
entry.success,
t
)
const quotaOperation = buildQuotaAuditOperation(
action,
params,
entry.success,
t
)
const operation = tokenOperation ?? quotaOperation
const summaryParams: NonNullable<NonNullable<LogOtherData['op']>['params']> =
{}
for (const [key, value] of Object.entries(params)) {
......@@ -189,18 +415,20 @@ export function buildAuditDetails(entry: AuditLog, t: TFunction) {
delete params.username
}
const fields: { label: string; value: unknown }[] = []
const fields: AuditDetailField[] = []
if (
Array.isArray(params.changed_fields) &&
params.changed_fields.every((field) => typeof field === 'string')
) {
let changes = t('Field change details were not recorded')
if (params.changed_fields.length) {
changes = params.changed_fields
.map((field) => auditFieldLabel(field, t))
.join(', ')
}
fields.push({
label: t('Changed Fields'),
value: params.changed_fields.length
? params.changed_fields
.map((field) => auditFieldLabel(String(field), t))
.join(', ')
: t('Field change details were not recorded'),
value: changes,
})
delete params.changed_fields
}
......@@ -267,7 +495,10 @@ export function buildAuditDetails(entry: AuditLog, t: TFunction) {
if (Object.keys(auditExtra).length) extra.audit_info = auditExtra
}
return {
summary,
summary: operation?.summary ?? summary,
tokenOperation,
quotaOperation,
operation,
actor,
actorRole,
target,
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table'
import { act, render, screen, within } from '@testing-library/react'
import { createInstance } from 'i18next'
import { I18nextProvider } from 'react-i18next'
import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
test,
vi,
} from 'vitest'
import en from '@/i18n/locales/en.json'
import zh from '@/i18n/locales/zh.json'
import {
DEFAULT_CURRENCY_CONFIG,
useSystemConfigStore,
} from '@/stores/system-config-store'
import type { UsageLog } from '../../data/schema'
import { renderAuditContent } from '../../lib/format'
import type { LogOtherData } from '../../types'
import { useCommonLogsColumns } from '../columns/common-logs-columns'
import { DetailsDialog } from '../dialogs/details-dialog'
// Provider icons are unused by quota logs; their browser-only dependencies
// cannot be loaded by Vitest's Node ESM resolver.
vi.mock('@lobehub/icons', () => ({}))
vi.hoisted(() => {
vi.stubGlobal('localStorage', {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
})
})
afterAll(() => vi.unstubAllGlobals())
function QuotaLogPreview(props: { log: UsageLog }) {
const table = useReactTable({
data: [props.log],
columns: useCommonLogsColumns(false, false),
getCoreRowModel: getCoreRowModel(),
})
const cell = table
.getRowModel()
.rows[0].getAllCells()
.find((item) => item.column.id === 'content')
if (!cell) throw new Error('The quota log must have a content column')
return (
<>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
<DetailsDialog
log={props.log}
isAdmin={false}
isRoot={false}
open
onOpenChange={() => undefined}
/>
</>
)
}
const cases = [
{
action: 'user.quota_add',
params: {
target_user_id: 42,
target_username: 'quota-owner',
mode: 'add',
requested_quota: 500000,
quota: 500000,
from: 500000,
to: 1000000,
},
english:
'Increase quota for user “quota-owner” (ID: 42) · Requested quota: $1 · $1 → $2',
chinese: '增加用户「quota-owner」的额度(ID: 42) · 请求数额:$1 · $1 → $2',
},
{
action: 'user.quota_add',
params: { quota: 500000 },
english:
'Increase user quota · Target not recorded · Requested quota: $1 · Not recorded → Not recorded',
chinese: '增加用户额度 · 目标未记录 · 请求数额:$1 · 未记录 → 未记录',
},
{
action: 'user.quota_subtract',
params: { quota: 500000 },
english:
'Decrease user quota · Target not recorded · Requested quota: $1 · Not recorded → Not recorded',
chinese: '减少用户额度 · 目标未记录 · 请求数额:$1 · 未记录 → 未记录',
},
{
action: 'user.quota_override',
params: { from: 500000, to: 0 },
english:
'Override user quota · Target not recorded · Requested quota: $0 · $1 → $0',
chinese: '覆盖用户额度 · 目标未记录 · 请求数额:$0 · $1 → $0',
},
]
describe('quota adjustment log localization', () => {
const previousConfig = useSystemConfigStore.getState().config
let queryClient: QueryClient
beforeEach(() => {
useSystemConfigStore.getState().setConfig({
currency: { ...DEFAULT_CURRENCY_CONFIG },
})
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
queryClient.setQueryData(['status'], {}, { updatedAt: Date.now() + 60_000 })
})
afterEach(() => {
queryClient.clear()
useSystemConfigStore.getState().setConfig(previousConfig)
})
test.each(cases)(
'$action switches language in the preview and details',
async (scenario) => {
const i18n = createInstance()
await i18n.init({
lng: 'en',
fallbackLng: 'en',
resources: { en, zh },
interpolation: { escapeValue: false },
})
const log: UsageLog = {
id: 1,
user_id: 1,
created_at: 1,
type: 1,
content: 'English export fallback',
username: 'quota-user',
token_name: '',
model_name: '',
quota: 0,
prompt_tokens: 0,
completion_tokens: 0,
use_time: 0,
is_stream: false,
channel: 0,
channel_name: '',
token_id: 0,
group: '',
ip: '',
request_id: 'quota-request',
upstream_request_id: '',
other: JSON.stringify({
op: { action: scenario.action, params: scenario.params },
}),
}
render(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={queryClient}>
<QuotaLogPreview log={log} />
</QueryClientProvider>
</I18nextProvider>
)
// The modal makes the table preview inert, but both remain rendered.
expect(screen.getAllByText(scenario.english)).toHaveLength(2)
expect(
within(screen.getByRole('dialog')).getByText(scenario.english)
).toBeInTheDocument()
await act(() => i18n.changeLanguage('zh'))
expect(screen.getAllByText(scenario.chinese)).toHaveLength(2)
expect(
within(screen.getByRole('dialog')).getByText(scenario.chinese)
).toBeInTheDocument()
expect(screen.queryByText('English export fallback')).toBeNull()
if ('target_user_id' in scenario.params) {
const dialog = within(screen.getByRole('dialog'))
expect(dialog.getByText('quota-owner')).toBeVisible()
expect(dialog.getByText('调整前额度')).toBeVisible()
expect(dialog.getByText('调整后额度')).toBeVisible()
}
}
)
test('preserves legacy formatted quota parameters and unknown-action fallback', async () => {
const i18n = createInstance()
await i18n.init({ lng: 'en', resources: { en } })
const other: LogOtherData = {
op: {
action: 'user.quota_add',
params: { quota: 'legacy formatted quota' },
},
}
expect(renderAuditContent(other, i18n.t)).toBe(
'Increase user quota · Target not recorded · Requested quota: legacy formatted quota · Not recorded → Not recorded'
)
expect(
renderAuditContent({ op: { action: 'unknown', params: {} } }, i18n.t)
).toBeNull()
expect(renderAuditContent({}, i18n.t)).toBeNull()
})
})
......@@ -124,9 +124,8 @@ function buildTypeDetailSegments(
other: LogOtherData | null,
t: (key: string, opts?: Record<string, unknown>) => string
): DetailSegment[] {
// Audit (type=3) and login (type=7) logs: render localized content from the
// structured op descriptor instead of the raw (English-fallback) content.
if (log.type === 3 || log.type === 7) {
// Top-up, audit, and login logs can carry a localized operation descriptor.
if (log.type === 1 || log.type === 3 || log.type === 7) {
const text = renderAuditContent(other, t)
return text ? [{ text }] : []
}
......@@ -739,6 +738,7 @@ export function useCommonLogsColumns(
accessorKey: 'content',
header: t('Details'),
cell: function DetailsCell({ row }) {
const { t } = useTranslation()
const [dialogOpen, setDialogOpen] = useState(false)
const log = row.original
const other = parseLogOther(log.other)
......
......@@ -63,6 +63,7 @@ import { formatBillingCurrencyFromUSD } from '@/lib/currency'
import { formatLogQuota, formatTokens, formatUseTime } from '@/lib/format'
import { cn } from '@/lib/utils'
import { AuditDetailFields } from '../../audit/components/audit-detail-fields'
import type { UsageLog } from '../../data/schema'
import {
parseLogOther,
......@@ -77,6 +78,7 @@ import {
getReasoningEffortVariant,
renderAuditContent,
} from '../../lib/format'
import { buildQuotaAuditOperation } from '../../lib/quota-audit-operation'
import {
getLogTypeConfig,
isPerCallBilling,
......@@ -441,7 +443,6 @@ interface DetailsDialogProps {
export function DetailsDialog(props: DetailsDialogProps) {
const { t } = useTranslation()
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
const details = props.log.content ?? ''
const other = parseLogOther(props.log.other)
const typeConfig = getLogTypeConfig(props.log.type)
......@@ -517,9 +518,17 @@ export function DetailsDialog(props: DetailsDialogProps) {
return String(adminInfo.auth_method)
})()
// Localized operation text rendered from the language-independent op
// descriptor (shared by audit type=3 and login type=7).
// Top-up, audit, and login logs share the language-independent descriptor.
const quotaOperation = isTopup
? buildQuotaAuditOperation(
other?.op?.action ?? '',
other?.op?.params ?? {},
true,
t
)
: null
const operationText = renderAuditContent(other, t)
const details = (isTopup ? operationText : null) ?? props.log.content ?? ''
const auditRoute = isManage && props.isAdmin ? other?.audit_info : undefined
// Channel update records which fields changed (stable field tokens); render
// them with their localized labels for admins.
......@@ -919,6 +928,12 @@ export function DetailsDialog(props: DetailsDialogProps) {
</DetailSection>
)}
{quotaOperation && (
<DetailSection label={t('Quota adjustment details')}>
<AuditDetailFields fields={quotaOperation.fields} />
</DetailSection>
)}
{/* Manage operator (type=3, admin only) */}
{manageOperator && (
<DetailRow
......
......@@ -26,6 +26,7 @@ import {
import type { UsageLog } from '../data/schema'
import type { LogOtherData } from '../types'
import { buildQuotaAuditOperation } from './quota-audit-operation'
export { normalizeTierLabel }
......@@ -389,6 +390,13 @@ export function formatDuration(
* translatable instead of being frozen to whatever language was written to DB.
*/
const AUDIT_TEMPLATES: Record<string, string> = {
'token.create': 'API token creation',
'token.update': 'API token configuration update',
'token.status_update': 'API token status update',
'token.delete': 'API token deletion',
'token.delete_batch': 'API token batch deletion',
'token.key_view': 'API token key access',
'token.key_view_batch': 'API token batch key access',
'access_token.generate': 'Generated a system access token',
'access_token.revoke': 'Revoked the system access token',
'user.2fa_setup': 'Started two-factor authentication setup',
......@@ -488,7 +496,7 @@ const AUDIT_TEMPLATES: Record<string, string> = {
}
/**
* Render the localized content of an audit/login log from its structured
* Render the localized content of an operation log from its structured
* `other.op` descriptor. Returns null when the log has no recognized action,
* letting callers fall back to the raw `content` field.
*/
......@@ -500,5 +508,15 @@ export function renderAuditContent(
if (!op?.action) return null
const template = AUDIT_TEMPLATES[op.action]
if (!template) return null
return t(template, (op.params ?? {}) as Record<string, unknown>)
const quotaOperation = buildQuotaAuditOperation(
op.action,
op.params ?? {},
other?.audit_info?.success !== false,
t
)
if (quotaOperation) {
return `${quotaOperation.summary} · ${quotaOperation.description}`
}
const params = { ...op.params }
return t(template, params)
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { formatLogQuota } from '@/lib/format'
type Translate = (key: string, opts?: Record<string, unknown>) => string
const QUOTA_OPERATIONS: Record<string, { label: string; named: string }> = {
'user.quota_add': {
label: 'Increase user quota',
named: 'Increase quota for user “{{name}}”',
},
'user.quota_subtract': {
label: 'Decrease user quota',
named: 'Decrease quota for user “{{name}}”',
},
'user.quota_override': {
label: 'Override user quota',
named: 'Override quota for user “{{name}}”',
},
}
function quotaText(value: unknown, t: Translate): string {
if (typeof value === 'number' && Number.isFinite(value)) {
return formatLogQuota(value)
}
if (typeof value === 'string' && value.trim()) return value
return t('Not recorded')
}
export function buildQuotaAuditOperation(
action: string,
params: Record<string, unknown>,
success: boolean,
t: Translate
) {
const unknownMode = action === 'generic' && params.action === 'add_quota'
const operation = unknownMode
? { label: 'Adjust user quota', named: 'Adjust quota for user “{{name}}”' }
: QUOTA_OPERATIONS[action]
if (!operation) return null
const name =
typeof params.target_username === 'string'
? params.target_username.trim()
: ''
let id = ''
if (
typeof params.target_user_id === 'number' &&
Number.isFinite(params.target_user_id)
) {
id = String(params.target_user_id)
} else if (typeof params.target_user_id === 'string') {
id = params.target_user_id.trim()
}
let headline = name ? t(operation.named, { name }) : t(operation.label)
if (!name && !id) headline = `${headline} · ${t('Target not recorded')}`
const identifier = id ? t('(ID: {{id}})', { id }) : ''
const summary = id
? t('{{operation}} (ID: {{id}})', { operation: headline, id })
: headline
let requested = params.requested_quota ?? params.quota
if (requested === undefined && success && action === 'user.quota_override') {
requested = params.to
}
const amount = quotaText(requested, t)
let description = t('Requested quota: {{quota}}', { quota: amount })
const fields: { label: string; value: string; copyable?: boolean }[] = [
{ label: t('Target username'), value: name || t('Not recorded') },
{ label: t('User ID'), value: id || t('Not recorded'), copyable: !!id },
{
label: t('Adjustment mode'),
value: unknownMode
? String(params.mode || t('Not recorded'))
: t(operation.label),
},
{ label: t('Requested quota'), value: amount },
]
if (success) {
const before = quotaText(params.from, t)
const after = quotaText(params.to, t)
const unchanged =
params.from !== undefined &&
params.from !== null &&
params.from !== '' &&
params.from === params.to &&
(typeof params.from === 'string' || typeof params.from === 'number')
let change = `${before}${after}`
if (unchanged) change = `${t('Quota unchanged')} · ${change}`
description = `${description} · ${change}`
fields.push(
{ label: t('Quota before adjustment'), value: before },
{ label: t('Quota after adjustment'), value: after }
)
} else {
const reasons: Record<string, string> = {
invalid_parameters: t('Invalid adjustment parameters'),
permission_denied: t('Insufficient permission to adjust this user'),
target_not_found: t('Target user not found'),
quota_limit_exceeded: t('Wallet quota limit exceeded'),
database_error: t('Quota update failed'),
}
const reason =
typeof params.failure_reason === 'string'
? reasons[params.failure_reason]
: undefined
if (reason) description = `${description} · ${reason}`
fields.push({
label: t('Failure reason'),
value: reason || t('Not recorded'),
})
}
return { headline, summary, identifier, description, fields }
}
......@@ -552,6 +552,25 @@ export const STATIC_I18N_KEYS = [
'The model that was requested',
'The upstream channel that served the requests',
// API token audit events
'Create API token',
'Create API token “{{name}}”',
'Update API token',
'Update API token “{{name}}”',
'Delete API token',
'Delete API token “{{name}}”',
'View API token key',
'View key for API token “{{name}}”',
'Batch delete API tokens',
'View API token keys in batch',
'API token creation',
'API token configuration update',
'API token status update',
'API token deletion',
'API token batch deletion',
'API token key access',
'API token batch key access',
// Channel status audit events
"View other accounts' audit logs",
'View audit records from user and admin roles. Root records are always excluded.',
......@@ -588,6 +607,14 @@ export const STATIC_I18N_KEYS = [
"Verification does not match this action's details. Please verify again.",
'The action details are invalid.',
'You do not have permission to perform this action.',
'Increase user quota',
'Decrease user quota',
'Override user quota',
'Increase quota for user “{{name}}”',
'Decrease quota for user “{{name}}”',
'Override quota for user “{{name}}”',
'Adjust user quota',
'Adjust quota for user “{{name}}”',
// Account binding and password-operation messages.
'Account bindings have changed. Start this operation again.',
'Add another login method before unlinking this account.',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment