Commit d8cb1774 by CaIon

feat(security): add access token management and audit logs

Move account security settings into a dedicated page and add token status, rotation, revocation, and access history.

Store audit events with role snapshots and JSON metadata, add audit.read authorization and an independent audit page, and upgrade the ClickHouse driver to v2.46.0.
parent 49ec4696
package controller
import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"strconv"
)
func GetAccessTokenStatus(c *gin.Context) {
status, err := model.GetUserAccessTokenStatus(c.GetInt("id"))
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, status)
}
func RevokeAccessToken(c *gin.Context) {
ref, err := model.RevokeUserAccessToken(c.GetInt("id"))
if err != nil {
common.ApiError(c, err)
return
}
if ref != "" {
recordUserSecurityAudit(c, c.GetInt("id"), "access_token.revoke", map[string]interface{}{"token_ref": ref})
}
common.ApiSuccess(c, nil)
}
func GetAuditLogs(c *gin.Context) {
page := common.GetPageQuery(c)
if page.Page < 1 || page.PageSize < 1 || page.Page > 100000000 {
common.ApiErrorMsg(c, "Invalid audit pagination")
return
}
filter := model.AuditLogFilter{Username: c.Query("username"), Category: c.Query("category"), TokenRef: c.Query("token_ref"), ExcludeTokenRef: c.Query("exclude_token_ref"), RequestId: c.Query("request_id")}
viewerRole := c.GetInt("role")
if c.FullPath() == "/api/audit/self" {
filter.UserId = c.GetInt("id")
filter.Username = ""
filter.SelfView = true
}
if !model.ValidAuditCategory(filter.Category) || !model.ValidTokenFingerprint(filter.TokenRef) || !model.ValidTokenFingerprint(filter.ExcludeTokenRef) {
common.ApiErrorMsg(c, "Invalid audit filters")
return
}
for name, target := range map[string]*int64{"start_timestamp": &filter.StartTimestamp, "end_timestamp": &filter.EndTimestamp} {
if raw := c.Query(name); raw != "" {
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil || parsed < 0 {
common.ApiErrorMsg(c, "Invalid audit time range")
return
}
*target = parsed
}
}
if filter.EndTimestamp > 0 && filter.EndTimestamp < filter.StartTimestamp {
common.ApiErrorMsg(c, "Invalid audit time range")
return
}
if raw := c.Query("success"); raw != "" {
if raw != "true" && raw != "false" {
common.ApiErrorMsg(c, "Invalid audit result")
return
}
success := raw == "true"
filter.Success = &success
}
logs, total, err := model.GetAuditLogs(filter, page.GetStartIdx(), page.GetPageSize(), viewerRole)
if err != nil {
common.ApiError(c, err)
return
}
page.SetItems(logs)
page.SetTotal(int(total))
common.ApiSuccess(c, page)
}
...@@ -26,6 +26,13 @@ var auditContentTemplates = map[string]string{ ...@@ -26,6 +26,13 @@ var auditContentTemplates = map[string]string{
"user.binding_clear": "Cleared ${bindingType} binding for user ${username}", "user.binding_clear": "Cleared ${bindingType} binding for user ${username}",
"user.2fa_disable": "Force-disabled two-factor authentication for the user", "user.2fa_disable": "Force-disabled two-factor authentication for the user",
"user.passkey_register": "Registered a passkey", "user.passkey_register": "Registered a passkey",
"access_token.generate": "Generated a system access token",
"access_token.revoke": "Revoked the system access token",
"user.2fa_setup": "Started two-factor authentication setup",
"user.2fa_enable": "Enabled two-factor authentication",
"user.2fa_disable_self": "Disabled two-factor authentication",
"user.2fa_backup_codes": "Regenerated two-factor backup codes",
"user.security_verify": "Completed security verification",
"user.passkey_delete": "Deleted a passkey", "user.passkey_delete": "Deleted a passkey",
"user.reset_passkey": "Reset the user passkey", "user.reset_passkey": "Reset the user passkey",
"option.update": "Updated system setting ${key}", "option.update": "Updated system setting ${key}",
...@@ -66,12 +73,12 @@ func auditContentEN(action string, params map[string]interface{}) string { ...@@ -66,12 +73,12 @@ func auditContentEN(action string, params map[string]interface{}) string {
} }
// auditOperatorInfo 从上下文构建操作者身份信息(管理员 id/用户名/角色)。 // auditOperatorInfo 从上下文构建操作者身份信息(管理员 id/用户名/角色)。
func auditOperatorInfo(c *gin.Context) map[string]interface{} { func auditOperatorInfo(c *gin.Context) *model.AuditAdminInfo {
return map[string]interface{}{ return &model.AuditAdminInfo{
"admin_id": c.GetInt("id"), AdminID: c.GetInt("id"),
"admin_username": c.GetString("username"), AdminUsername: c.GetString("username"),
"admin_role": c.GetInt("role"), AdminRole: c.GetInt("role"),
"auth_method": auditAuthMethod(c), AuthMethod: auditAuthMethod(c),
} }
} }
...@@ -104,12 +111,12 @@ func recordManageAuditFor(c *gin.Context, targetUserId int, action string, param ...@@ -104,12 +111,12 @@ func recordManageAuditFor(c *gin.Context, targetUserId int, action string, param
if _, ok := params["target_user_id"]; !ok && targetUserId > 0 && targetUserId != operatorUserId { if _, ok := params["target_user_id"]; !ok && targetUserId > 0 && targetUserId != operatorUserId {
params["target_user_id"] = targetUserId params["target_user_id"] = targetUserId
} }
model.RecordOperationAuditLog(operatorUserId, auditContentEN(action, params), c.ClientIP(), action, params, auditOperatorInfo(c), nil) model.RecordOperationAuditLog(operatorUserId, c.GetInt("role"), auditContentEN(action, params), c.ClientIP(), action, params, auditOperatorInfo(c), nil, c)
markAuditLogged(c) markAuditLogged(c)
} }
// recordUserSecurityAudit 记录普通用户自己的安全敏感操作(如 passkey 绑定/解绑)。 // recordUserSecurityAudit 记录普通用户自己的安全敏感操作(如 passkey 绑定/解绑)。
// 这类日志没有管理员操作者,不写 admin_info;同时不依赖 AdminAuth/RootAuth 的兜底。 // 这类日志没有管理员操作者,不写 admin_info;同时不依赖 AdminAuth/RootAuth 的兜底。
func recordUserSecurityAudit(c *gin.Context, userId int, action string, params map[string]interface{}) { func recordUserSecurityAudit(c *gin.Context, userId int, action string, params map[string]interface{}) {
model.RecordOperationAuditLog(userId, auditContentEN(action, params), c.ClientIP(), action, params, nil, nil) model.RecordOperationAuditLog(userId, c.GetInt("role"), auditContentEN(action, params), c.ClientIP(), action, params, nil, nil, c)
} }
...@@ -113,7 +113,7 @@ func setupBillingAliasOptionDB(t *testing.T) { ...@@ -113,7 +113,7 @@ func setupBillingAliasOptionDB(t *testing.T) {
previousRedis := common.RedisEnabled previousRedis := common.RedisEnabled
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Option{}, &model.Log{}, &model.User{})) require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Option{}, &model.Log{}, &model.AuditLog{}, &model.User{}))
model.DB = database model.DB = database
model.LOG_DB = database model.LOG_DB = database
common.SetMainDatabaseType(common.DatabaseTypeSQLite) common.SetMainDatabaseType(common.DatabaseTypeSQLite)
......
...@@ -31,7 +31,7 @@ func setupTaskPluginBindChannelTest(t *testing.T) { ...@@ -31,7 +31,7 @@ func setupTaskPluginBindChannelTest(t *testing.T) {
sqlDB, err := database.DB() sqlDB, err := database.DB()
require.NoError(t, err) require.NoError(t, err)
sqlDB.SetMaxOpenConns(1) sqlDB.SetMaxOpenConns(1)
require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Ability{}, &model.CasbinRule{}, &model.AuthzRole{}, &model.Log{}, &model.User{})) require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Ability{}, &model.CasbinRule{}, &model.AuthzRole{}, &model.Log{}, &model.AuditLog{}, &model.User{}))
model.DB = database model.DB = database
model.LOG_DB = database model.LOG_DB = database
require.NoError(t, authz.Init(database)) require.NoError(t, authz.Init(database))
......
...@@ -166,7 +166,7 @@ func TestCopyChannelRejectsInvalidLegacyProxySettings(t *testing.T) { ...@@ -166,7 +166,7 @@ func TestCopyChannelRejectsInvalidLegacyProxySettings(t *testing.T) {
func TestDeleteChannelResetsProxyCacheWhenPreReadFails(t *testing.T) { func TestDeleteChannelResetsProxyCacheWhenPreReadFails(t *testing.T) {
db := setupModelListControllerTestDB(t) db := setupModelListControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.Log{})) require.NoError(t, db.AutoMigrate(&model.Log{}, &model.AuditLog{}))
service.ResetProxyClientCache() service.ResetProxyClientCache()
t.Cleanup(service.ResetProxyClientCache) t.Cleanup(service.ResetProxyClientCache)
...@@ -189,7 +189,7 @@ func TestDeleteChannelResetsProxyCacheWhenPreReadFails(t *testing.T) { ...@@ -189,7 +189,7 @@ func TestDeleteChannelResetsProxyCacheWhenPreReadFails(t *testing.T) {
func TestDeleteChannelBatchReportsAndAuditsActualDeletedCount(t *testing.T) { func TestDeleteChannelBatchReportsAndAuditsActualDeletedCount(t *testing.T) {
db := setupModelListControllerTestDB(t) db := setupModelListControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.Log{})) require.NoError(t, db.AutoMigrate(&model.Log{}, &model.AuditLog{}))
channel := &model.Channel{Name: "existing", Key: "test-key"} channel := &model.Channel{Name: "existing", Key: "test-key"}
require.NoError(t, db.Create(channel).Error) require.NoError(t, db.Create(channel).Error)
...@@ -210,14 +210,16 @@ func TestDeleteChannelBatchReportsAndAuditsActualDeletedCount(t *testing.T) { ...@@ -210,14 +210,16 @@ func TestDeleteChannelBatchReportsAndAuditsActualDeletedCount(t *testing.T) {
assert.True(t, response.Success) assert.True(t, response.Success)
assert.Equal(t, int64(1), response.Data) assert.Equal(t, int64(1), response.Data)
var auditLog model.Log var auditLog model.AuditLog
require.NoError(t, db.Order("id desc").First(&auditLog).Error) require.NoError(t, db.Order("id desc").First(&auditLog).Error)
var auditData struct { var auditData struct {
Operation struct { Operation struct {
Params map[string]any `json:"params"` Params map[string]any `json:"params"`
} `json:"op"` } `json:"op"`
} }
require.NoError(t, common.UnmarshalJsonStr(auditLog.Other, &auditData)) encodedAudit, err := common.Marshal(auditLog.Other)
require.NoError(t, err)
require.NoError(t, common.Unmarshal(encodedAudit, &auditData))
assert.Equal(t, float64(1), auditData.Operation.Params["count"]) assert.Equal(t, float64(1), auditData.Operation.Params["count"])
} }
......
...@@ -535,7 +535,7 @@ func TestCheckUpdatePasswordRejectsHistoricalEmptyPassword(t *testing.T) { ...@@ -535,7 +535,7 @@ func TestCheckUpdatePasswordRejectsHistoricalEmptyPassword(t *testing.T) {
func TestSetupLoginDoesNotTouchPasswordWhenPasswordFieldOmitted(t *testing.T) { func TestSetupLoginDoesNotTouchPasswordWhenPasswordFieldOmitted(t *testing.T) {
db := setupModelListControllerTestDB(t) db := setupModelListControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.Log{}, &model.UserSession{})) require.NoError(t, db.AutoMigrate(&model.Log{}, &model.AuditLog{}, &model.UserSession{}))
hashedPassword, err := common.Password2Hash("CurrentPassword123") hashedPassword, err := common.Password2Hash("CurrentPassword123")
require.NoError(t, err) require.NoError(t, err)
......
...@@ -486,6 +486,11 @@ func RelayNotImplemented(c *gin.Context) { ...@@ -486,6 +486,11 @@ func RelayNotImplemented(c *gin.Context) {
} }
func RelayNotFound(c *gin.Context) { func RelayNotFound(c *gin.Context) {
// The web fallback may already have applied static-asset cache headers.
// A missing API or asset can appear after an upgrade; never cache its 404.
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, private, max-age=0")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
err := types.OpenAIError{ err := types.OpenAIError{
Message: fmt.Sprintf("Invalid URL (%s %s)", c.Request.Method, c.Request.URL.Path), Message: fmt.Sprintf("Invalid URL (%s %s)", c.Request.Method, c.Request.URL.Path),
Type: "invalid_request_error", Type: "invalid_request_error",
......
...@@ -65,7 +65,7 @@ func UniversalVerify(c *gin.Context) { ...@@ -65,7 +65,7 @@ func UniversalVerify(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
model.RecordLog(identity.UserID, model.LogTypeSystem, "通用安全验证成功 (验证方式: 2FA)") recordUserSecurityAudit(c, identity.UserID, "user.security_verify", map[string]interface{}{"method": "2fa"})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "验证成功", "message": "验证成功",
......
...@@ -407,13 +407,13 @@ func resolveAdvanceResetTime(value *bool) bool { ...@@ -407,13 +407,13 @@ func resolveAdvanceResetTime(value *bool) bool {
return *value return *value
} }
func recordSubscriptionResetUserLogs(result *model.SubscriptionResetResult, adminInfo map[string]interface{}) { func recordSubscriptionResetUserLogs(c *gin.Context, result *model.SubscriptionResetResult, adminInfo *model.AuditAdminInfo) {
if result == nil || result.ResetCount == 0 { if result == nil || result.ResetCount == 0 {
return return
} }
content := fmt.Sprintf("管理员重置订阅套餐 %s(ID: %d)额度", result.PlanTitle, result.PlanId) content := fmt.Sprintf("管理员重置订阅套餐 %s(ID: %d)额度", result.PlanTitle, result.PlanId)
for _, userId := range result.AffectedUserIds { for _, userId := range result.AffectedUserIds {
model.RecordLogWithAdminInfo(userId, model.LogTypeManage, content, adminInfo) model.RecordLogWithAdminInfo(userId, model.LogTypeManage, content, adminInfo, c)
} }
} }
...@@ -466,7 +466,7 @@ func AdminResetUserSubscriptionsByPlan(c *gin.Context) { ...@@ -466,7 +466,7 @@ func AdminResetUserSubscriptionsByPlan(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
recordSubscriptionResetUserLogs(result, auditOperatorInfo(c)) recordSubscriptionResetUserLogs(c, result, auditOperatorInfo(c))
recordManageAuditFor(c, userId, "subscription.user_plan_reset", map[string]interface{}{ recordManageAuditFor(c, userId, "subscription.user_plan_reset", map[string]interface{}{
"target_user_id": userId, "target_user_id": userId,
"plan_id": result.PlanId, "plan_id": result.PlanId,
...@@ -495,7 +495,7 @@ func AdminResetPlanSubscriptions(c *gin.Context) { ...@@ -495,7 +495,7 @@ func AdminResetPlanSubscriptions(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
recordSubscriptionResetUserLogs(result, auditOperatorInfo(c)) recordSubscriptionResetUserLogs(c, result, auditOperatorInfo(c))
common.SysLog(fmt.Sprintf("admin reset subscription plan %d quota: reset_count=%d user_count=%d advance_reset_time=%t", common.SysLog(fmt.Sprintf("admin reset subscription plan %d quota: reset_count=%d user_count=%d advance_reset_time=%t",
result.PlanId, result.ResetCount, result.UserCount, result.AdvanceResetTime)) result.PlanId, result.ResetCount, result.UserCount, result.AdvanceResetTime))
recordManageAudit(c, "subscription.plan_reset", map[string]interface{}{ recordManageAudit(c, "subscription.plan_reset", map[string]interface{}{
......
...@@ -117,7 +117,7 @@ func Setup2FA(c *gin.Context) { ...@@ -117,7 +117,7 @@ func Setup2FA(c *gin.Context) {
} }
// 记录操作日志 // 记录操作日志
model.RecordLog(userId, model.LogTypeSystem, "开始设置两步验证") recordUserSecurityAudit(c, userId, "user.2fa_setup", nil)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
...@@ -199,7 +199,7 @@ func Enable2FA(c *gin.Context) { ...@@ -199,7 +199,7 @@ func Enable2FA(c *gin.Context) {
} }
// 记录操作日志 // 记录操作日志
model.RecordLog(userId, model.LogTypeSystem, "成功启用两步验证") recordUserSecurityAudit(c, userId, "user.2fa_enable", nil)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
...@@ -282,7 +282,7 @@ func Disable2FA(c *gin.Context) { ...@@ -282,7 +282,7 @@ func Disable2FA(c *gin.Context) {
} }
// 记录操作日志 // 记录操作日志
model.RecordLog(userId, model.LogTypeSystem, "禁用两步验证") recordUserSecurityAudit(c, userId, "user.2fa_disable_self", nil)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
...@@ -412,7 +412,7 @@ func RegenerateBackupCodes(c *gin.Context) { ...@@ -412,7 +412,7 @@ func RegenerateBackupCodes(c *gin.Context) {
} }
// 记录操作日志 // 记录操作日志
model.RecordLog(userId, model.LogTypeSystem, "重新生成两步验证备用码") recordUserSecurityAudit(c, userId, "user.2fa_backup_codes", nil)
data := authRotationData(bundle) data := authRotationData(bundle)
data["backup_codes"] = backupCodes data["backup_codes"] = backupCodes
......
...@@ -169,14 +169,14 @@ func loginMethodFromContext(c *gin.Context) string { ...@@ -169,14 +169,14 @@ func loginMethodFromContext(c *gin.Context) string {
func recordLoginAudit(user *model.User, c *gin.Context) { func recordLoginAudit(user *model.User, c *gin.Context) {
method := loginMethodFromContext(c) method := loginMethodFromContext(c)
ip := c.ClientIP() ip := c.ClientIP()
extra := map[string]interface{}{ extra := model.AuditOther{
"login_method": method, LoginMethod: method,
"user_agent": c.Request.UserAgent(), UserAgent: c.Request.UserAgent(),
} }
content := fmt.Sprintf("Logged in successfully via %s", method) content := fmt.Sprintf("Logged in successfully via %s", method)
model.RecordLoginLog(user.Id, user.Username, content, ip, "login", map[string]interface{}{ model.RecordLoginLog(user.Id, user.Role, user.Username, content, ip, "login", map[string]interface{}{
"method": method, "method": method,
}, extra) }, extra, c)
} }
// setupLogin creates a server-controlled login Session and returns the shared // setupLogin creates a server-controlled login Session and returns the shared
...@@ -447,6 +447,8 @@ func GenerateAccessToken(c *gin.Context) { ...@@ -447,6 +447,8 @@ func GenerateAccessToken(c *gin.Context) {
return return
} }
recordUserSecurityAudit(c, id, "access_token.generate", map[string]interface{}{"token_ref": model.AccessTokenFingerprint(key)})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
......
...@@ -32,7 +32,7 @@ func setupManageUserTestDB(t *testing.T) *gorm.DB { ...@@ -32,7 +32,7 @@ func setupManageUserTestDB(t *testing.T) *gorm.DB {
require.NoError(t, err) require.NoError(t, err)
model.DB, model.LOG_DB = db, db model.DB, model.LOG_DB = db, db
require.NoError(t, db.AutoMigrate( require.NoError(t, db.AutoMigrate(
&model.User{}, &model.UserSession{}, &model.Log{}, &model.CasbinRule{}, &model.AuthzRole{}, &model.User{}, &model.UserSession{}, &model.Log{}, &model.AuditLog{}, &model.CasbinRule{}, &model.AuthzRole{},
)) ))
t.Cleanup(func() { t.Cleanup(func() {
......
...@@ -6,7 +6,7 @@ go 1.25.1 ...@@ -6,7 +6,7 @@ go 1.25.1
require ( require (
github.com/Calcium-Ion/go-epay v0.0.4 github.com/Calcium-Ion/go-epay v0.0.4
github.com/abema/go-mp4 v1.4.1 github.com/abema/go-mp4 v1.4.1
github.com/andybalholm/brotli v1.1.1 github.com/andybalholm/brotli v1.2.0
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0
github.com/aws/aws-sdk-go-v2 v1.41.5 github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 github.com/aws/aws-sdk-go-v2/credentials v1.19.10
...@@ -31,7 +31,7 @@ require ( ...@@ -31,7 +31,7 @@ require (
github.com/jfreymuth/oggvorbis v1.0.5 github.com/jfreymuth/oggvorbis v1.0.5
github.com/jinzhu/copier v0.4.0 github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/klauspost/compress v1.18.0 github.com/klauspost/compress v1.18.3
github.com/mewkiz/flac v1.0.13 github.com/mewkiz/flac v1.0.13
github.com/nicksnyder/go-i18n/v2 v2.6.1 github.com/nicksnyder/go-i18n/v2 v2.6.1
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
...@@ -67,7 +67,7 @@ require ( ...@@ -67,7 +67,7 @@ require (
) )
require ( require (
github.com/ClickHouse/ch-go v0.65.0 // indirect github.com/ClickHouse/ch-go v0.71.0 // indirect
github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
github.com/casbin/govaluate v1.10.0 // indirect github.com/casbin/govaluate v1.10.0 // indirect
github.com/dlclark/regexp2/v2 v2.2.2 // indirect github.com/dlclark/regexp2/v2 v2.2.2 // indirect
...@@ -75,14 +75,15 @@ require ( ...@@ -75,14 +75,15 @@ require (
github.com/go-faster/errors v0.7.1 // indirect github.com/go-faster/errors v0.7.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/go-version v1.8.0 // indirect
github.com/paulmach/orb v0.11.1 // indirect github.com/paulmach/orb v0.12.0 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/segmentio/asm v1.2.0 // indirect github.com/segmentio/asm v1.2.1 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect
go.opentelemetry.io/otel v1.34.0 // indirect go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.34.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
) )
require ( require (
...@@ -157,7 +158,7 @@ require ( ...@@ -157,7 +158,7 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.3 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/arch v0.21.0 // indirect golang.org/x/arch v0.21.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
google.golang.org/protobuf v1.36.5 // indirect google.golang.org/protobuf v1.36.5 // indirect
...@@ -168,7 +169,7 @@ require ( ...@@ -168,7 +169,7 @@ require (
) )
require ( require (
github.com/ClickHouse/clickhouse-go/v2 v2.32.0 github.com/ClickHouse/clickhouse-go/v2 v2.46.0
github.com/QuantumNous/new-api/relaykit v0.0.0 github.com/QuantumNous/new-api/relaykit v0.0.0
) )
......
...@@ -6,8 +6,8 @@ import ( ...@@ -6,8 +6,8 @@ import (
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
...@@ -158,26 +158,22 @@ func finishAdminAudit(c *gin.Context, writer *auditResponseWriter) { ...@@ -158,26 +158,22 @@ func finishAdminAudit(c *gin.Context, writer *auditResponseWriter) {
// content 为英文兜底文本(供导出等非本地化消费者使用)。 // content 为英文兜底文本(供导出等非本地化消费者使用)。
content := method + " " + route content := method + " " + route
adminInfo := map[string]interface{}{ adminInfo := &model.AuditAdminInfo{
"admin_id": operatorId, AdminID: operatorId,
"admin_username": operatorName, AdminUsername: operatorName,
"admin_role": operatorRole, AdminRole: operatorRole,
"auth_method": auditAuthMethod(c), AuthMethod: auditAuthMethod(c),
} }
auditInfo := map[string]interface{}{ auditInfo := &model.AuditRequestInfo{
"method": method, Method: method,
"route": route, Route: route,
"path": c.Request.URL.Path, Path: route,
"status": status, Status: status,
"success": success, Success: success,
} Params: routeParams,
if len(routeParams) > 0 {
auditInfo["params"] = routeParams
} }
gopool.Go(func() { model.RecordOperationAuditLog(operatorId, operatorRole, content, ip, action, opParams, adminInfo, auditInfo, c)
model.RecordOperationAuditLog(operatorId, content, ip, action, opParams, adminInfo, auditInfo)
})
} }
func auditAuthMethod(c *gin.Context) string { func auditAuthMethod(c *gin.Context) string {
...@@ -204,3 +200,55 @@ func auditResponseSuccess(status int, body []byte) bool { ...@@ -204,3 +200,55 @@ func auditResponseSuccess(status int, body []byte) bool {
} }
return status < 400 return status < 400
} }
const accessTokenAuditContextKey = "access_token_request_audit"
type accessTokenRequestAudit struct {
entry model.AuditLog
writer *auditResponseWriter
}
// AccessTokenAudit also captures public reads and rejections before route-level
// authentication (for example, rate limiting). It does not grant authentication.
func AccessTokenAudit() gin.HandlerFunc {
return func(c *gin.Context) {
raw, present := authorizationToken(c.GetHeader("Authorization"))
if present {
_, internal, _ := service.ParseDashboardAccessToken(raw)
if !internal {
user, err := model.ValidateAccessToken(raw)
if err == nil && user != nil && user.Id > 0 {
beginAccessTokenAudit(c, user, raw)
defer finishAccessTokenAudit(c)
}
}
}
c.Next()
}
}
func beginAccessTokenAudit(c *gin.Context, user *model.User, token string) {
if _, exists := c.Get(accessTokenAuditContextKey); exists {
return
}
writer := &auditResponseWriter{ResponseWriter: c.Writer, body: bytes.NewBuffer(nil), maxSize: 64 * 1024}
c.Writer = writer
c.Set(accessTokenAuditContextKey, &accessTokenRequestAudit{
entry: model.AuditLog{UserId: user.Id, Username: user.Username, ActorRole: user.Role, Category: model.AuditCategoryAccessToken, AuthMethod: "access_token", TokenRef: model.AccessTokenFingerprint(token), CreatedAt: common.GetTimestamp(), EventId: common.NewRequestId()},
writer: writer,
})
}
func finishAccessTokenAudit(c *gin.Context) {
value, exists := c.Get(accessTokenAuditContextKey)
if !exists {
return
}
audit, ok := value.(*accessTokenRequestAudit)
if !ok {
return
}
audit.entry.Status = audit.writer.Status()
audit.entry.Success = auditResponseSuccess(audit.entry.Status, audit.writer.body.Bytes())
model.RecordAuditLog(c, audit.entry)
}
...@@ -45,6 +45,9 @@ func validUserInfo(username string, role int) bool { ...@@ -45,6 +45,9 @@ func validUserInfo(username string, role int) bool {
} }
func authHelper(c *gin.Context, minRole int) { func authHelper(c *gin.Context, minRole int) {
if _, started := c.Get(accessTokenAuditContextKey); !started {
defer finishAccessTokenAudit(c)
}
user, identity, useAccessToken, err := authenticateDashboardRequest(c) user, identity, useAccessToken, err := authenticateDashboardRequest(c)
if err != nil { if err != nil {
writeDashboardAuthError(c, err) writeDashboardAuthError(c, err)
...@@ -79,6 +82,9 @@ func authHelper(c *gin.Context, minRole int) { ...@@ -79,6 +82,9 @@ func authHelper(c *gin.Context, minRole int) {
func TryUserAuth() func(c *gin.Context) { func TryUserAuth() func(c *gin.Context) {
return func(c *gin.Context) { return func(c *gin.Context) {
if _, started := c.Get(accessTokenAuditContextKey); !started {
defer finishAccessTokenAudit(c)
}
user, identity, credentialKind, err := classifyDashboardCredential(c) user, identity, credentialKind, err := classifyDashboardCredential(c)
if err != nil { if err != nil {
writeDashboardAuthError(c, err) writeDashboardAuthError(c, err)
...@@ -172,6 +178,7 @@ func classifyDashboardCredential(c *gin.Context) (*model.UserBase, service.AuthI ...@@ -172,6 +178,7 @@ func classifyDashboardCredential(c *gin.Context) (*model.UserBase, service.AuthI
if patUser == nil || patUser.Id <= 0 { if patUser == nil || patUser.Id <= 0 {
return nil, service.AuthIdentity{}, dashboardCredentialUnmatched, nil return nil, service.AuthIdentity{}, dashboardCredentialUnmatched, nil
} }
beginAccessTokenAudit(c, patUser, raw)
user, err := model.GetUserCache(patUser.Id) user, err := model.GetUserCache(patUser.Id)
if err != nil { if err != nil {
return nil, service.AuthIdentity{}, dashboardCredentialPAT, err return nil, service.AuthIdentity{}, dashboardCredentialPAT, err
......
...@@ -24,18 +24,21 @@ import ( ...@@ -24,18 +24,21 @@ import (
func setupDashboardAuthMiddlewareTest(t *testing.T) { func setupDashboardAuthMiddlewareTest(t *testing.T) {
t.Helper() t.Helper()
previousDB := model.DB previousDB := model.DB
previousLogDB := model.LOG_DB
previousType := common.MainDatabaseType() previousType := common.MainDatabaseType()
previousRedis := common.RedisEnabled previousRedis := common.RedisEnabled
previousSecret := common.SessionSecret previousSecret := common.SessionSecret
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{})) require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.AuditLog{}))
model.DB = db model.DB = db
model.LOG_DB = db
common.SetMainDatabaseType(common.DatabaseTypeSQLite) common.SetMainDatabaseType(common.DatabaseTypeSQLite)
common.RedisEnabled = false common.RedisEnabled = false
common.SessionSecret = "middleware-auth-test-secret" common.SessionSecret = "middleware-auth-test-secret"
t.Cleanup(func() { t.Cleanup(func() {
model.DB = previousDB model.DB = previousDB
model.LOG_DB = previousLogDB
common.SetMainDatabaseType(previousType) common.SetMainDatabaseType(previousType)
common.RedisEnabled = previousRedis common.RedisEnabled = previousRedis
common.SessionSecret = previousSecret common.SessionSecret = previousSecret
......
package model
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"strings"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const (
AuditCategoryLogin = "login"
AuditCategorySecurity = "security"
AuditCategoryOperation = "operation"
AuditCategoryAccessToken = "access_token"
)
// AuditLog is retained independently of usage logs and their cleanup/TTL policy.
// TokenRef identifies a PAT generation without storing its bearer credential.
type AuditLog struct {
Id int `json:"id"`
EventId string `json:"event_id" gorm:"type:varchar(64);uniqueIndex"`
UserId int `json:"user_id" gorm:"index:idx_audit_user_time,priority:1"`
Username string `json:"username" gorm:"type:varchar(64);index"`
ActorRole int `json:"actor_role"` // Immutable role of the actor when the event began, not the log owner.
CreatedAt int64 `json:"created_at" gorm:"type:bigint;index:idx_audit_user_time,priority:2;index:idx_audit_token_time,priority:2;index"`
Category string `json:"category" gorm:"type:varchar(24);index"`
Action string `json:"action" gorm:"type:varchar(128)"`
TokenRef string `json:"token_ref" gorm:"type:varchar(64);index:idx_audit_token_time,priority:1"`
AuthMethod string `json:"auth_method" gorm:"type:varchar(24)"`
Ip string `json:"ip" gorm:"type:varchar(64)"`
UserAgent string `json:"user_agent" gorm:"type:varchar(512)"`
Method string `json:"method" gorm:"type:varchar(16)"`
Route string `json:"route" gorm:"type:varchar(255)"`
Status int `json:"status"`
Success bool `json:"success"`
RequestId string `json:"request_id" gorm:"type:varchar(64);index"`
Content string `json:"content" gorm:"type:text"`
Other AuditOther `json:"other" gorm:"type:json"`
}
type AuditLogFilter struct {
SelfView bool // Server-selected metadata projection, independent of the viewer's actual role.
UserId int
Username string
Category string
TokenRef string
ExcludeTokenRef string
RequestId string
StartTimestamp int64
EndTimestamp int64
Success *bool
}
func AccessTokenFingerprint(token string) string {
// PostgreSQL returns CHAR(32) tokens padded with spaces. Normalize that
// storage padding so persisted tokens and incoming credentials share a ref.
token = strings.TrimRight(token, " ")
if token == "" {
return ""
}
digest := sha256.Sum256([]byte(token))
return fmt.Sprintf("%x", digest)
}
// RecordAuditLog captures safe request metadata only; raw URLs, query strings,
// credentials and response/request bodies must never enter this table.
func RecordAuditLog(c *gin.Context, entry AuditLog) {
ctx := context.Background()
if c != nil && c.Request != nil {
ctx = c.Request.Context()
entry.RequestId = c.GetString(common.RequestIdKey)
entry.Ip = c.ClientIP()
entry.UserAgent = c.Request.UserAgent()
entry.Method = c.Request.Method
entry.Route = c.FullPath()
if entry.Status == 0 {
entry.Status = c.Writer.Status()
}
if entry.AuthMethod == "" {
entry.AuthMethod = "session"
if c.GetBool("use_access_token") {
entry.AuthMethod = "access_token"
}
}
}
if entry.CreatedAt == 0 {
entry.CreatedAt = common.GetTimestamp()
}
if entry.RequestId == "" {
entry.RequestId = common.NewRequestId()
}
if entry.EventId == "" {
entry.EventId = common.NewRequestId()
}
switch entry.ActorRole {
case common.RoleCommonUser, common.RoleAdminUser, common.RoleRootUser:
default:
logger.LogError(ctx, fmt.Sprintf("audit actor role unavailable (request_id=%s, actor_role=%d)", entry.RequestId, entry.ActorRole))
entry.ActorRole = 0 // Unknown actors remain visible to root only.
}
if entry.Username == "" {
entry.Username, _ = GetUsernameById(entry.UserId, false)
}
ua := []rune(entry.UserAgent)
if len(ua) > 512 {
entry.UserAgent = string(ua[:512])
}
if LOG_DB == nil {
logger.LogError(ctx, fmt.Sprintf("audit log write failed (request_id=%s): log database unavailable", entry.RequestId))
return
}
var row interface{} = &entry
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
encoded, err := common.Marshal(entry.Other)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("audit log write failed (request_id=%s): %v", entry.RequestId, err))
return
}
// The ClickHouse GORM insert callback passes structs to the native
// driver without resolving their Valuer. Bind this column's JSON
// encoding while retaining AuditOther in the domain and API models.
row = &struct {
AuditLog `gorm:"embedded"`
EncodedOther string `gorm:"column:other;type:json"`
}{AuditLog: entry, EncodedOther: string(encoded)}
}
if err := LOG_DB.Table("audit_logs").Create(row).Error; err != nil {
logger.LogError(ctx, fmt.Sprintf("audit log write failed (request_id=%s): %v", entry.RequestId, err))
}
}
func GetAuditLogs(filter AuditLogFilter, start, limit, viewerRole int) ([]*AuditLog, int64, error) {
query := LOG_DB.Model(&AuditLog{})
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
// Decode native JSON through database/sql as text for AuditOther.Scan.
// Preserve numeric metadata instead of returning quoted Int64 values.
query = query.WithContext(clickhouse.Context(query.Statement.Context, clickhouse.WithSettings(clickhouse.Settings{
"output_format_native_write_json_as_string": 1,
"output_format_json_quote_64bit_integers": 0,
})))
}
if viewerRole < common.RoleRootUser {
query = query.Where("actor_role IN ?", []int{common.RoleCommonUser, common.RoleAdminUser})
}
if filter.UserId > 0 {
query = query.Where("user_id = ?", filter.UserId)
}
if filter.Username != "" {
query = query.Where("username = ?", filter.Username)
}
if filter.Category != "" {
query = query.Where("category = ?", filter.Category)
}
if filter.TokenRef != "" {
query = query.Where("token_ref = ?", filter.TokenRef)
}
if filter.ExcludeTokenRef != "" {
query = query.Where("token_ref <> ?", filter.ExcludeTokenRef)
}
if filter.RequestId != "" {
query = query.Where("request_id = ?", filter.RequestId)
}
if filter.StartTimestamp > 0 {
query = query.Where("created_at >= ?", filter.StartTimestamp)
}
if filter.EndTimestamp > 0 {
query = query.Where("created_at <= ?", filter.EndTimestamp)
}
if filter.Success != nil {
query = query.Where("success = ?", *filter.Success)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
logs := make([]*AuditLog, 0)
if err := query.Order("created_at DESC").Order("event_id DESC").Offset(start).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
visibility := logOtherVisibilityUser
if !filter.SelfView && viewerRole >= common.RoleRootUser {
visibility = logOtherVisibilityRoot
} else if !filter.SelfView && viewerRole >= common.RoleAdminUser {
visibility = logOtherVisibilityAdmin
}
for _, entry := range logs {
if visibility != logOtherVisibilityRoot {
entry.Other.RootInfo = nil
}
if visibility == logOtherVisibilityUser {
entry.Other.AdminInfo = nil
entry.Other.AuditInfo = nil
}
}
return logs, total, nil
}
type UserAccessTokenStatus struct {
Exists bool `json:"exists"`
TokenRef string `json:"token_ref"`
CreatedAt *int64 `json:"created_at"`
LastUsedAt *int64 `json:"last_used_at"`
LastUsedIp string `json:"last_used_ip"`
}
func GetUserAccessTokenStatus(userId int) (*UserAccessTokenStatus, error) {
var user User
if err := DB.Select("id", "role", "access_token", "access_token_created_at").First(&user, userId).Error; err != nil {
return nil, err
}
status := &UserAccessTokenStatus{Exists: user.GetAccessToken() != ""}
if !status.Exists {
return status, nil
}
status.TokenRef = AccessTokenFingerprint(user.GetAccessToken())
status.CreatedAt = user.AccessTokenCreatedAt
var latest AuditLog
query := LOG_DB.Select("created_at", "ip").Where("user_id = ? AND token_ref = ? AND category = ?", userId, status.TokenRef, AuditCategoryAccessToken)
if user.Role < common.RoleRootUser {
query = query.Where("actor_role IN ?", []int{common.RoleCommonUser, common.RoleAdminUser})
}
err := query.Order("created_at DESC").Order("event_id DESC").Take(&latest).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return status, nil
}
if err != nil {
return nil, err
}
status.LastUsedAt = &latest.CreatedAt
status.LastUsedIp = latest.Ip
return status, nil
}
// MigrateAuditLogs also supports independently configured ClickHouse log stores.
// No TTL clause or usage-log cleanup integration is intentional.
func MigrateAuditLogs() error {
if !common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
return LOG_DB.AutoMigrate(&AuditLog{})
}
return LOG_DB.Exec(`CREATE TABLE IF NOT EXISTS audit_logs (
id Int64 DEFAULT 0, event_id String, user_id Int64, username String, actor_role Int32,
created_at Int64, category String, action String, token_ref String,
auth_method String, ip String, user_agent String, method String, route String,
status Int32, success UInt8, request_id String, content String, other JSON
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(toDateTime(created_at))
ORDER BY (created_at, event_id)`).Error
}
func ValidAuditCategory(category string) bool {
return category == "" || category == AuditCategoryLogin || category == AuditCategorySecurity || category == AuditCategoryOperation || category == AuditCategoryAccessToken
}
func ValidTokenFingerprint(value string) bool {
if value == "" {
return true
}
if len(value) != 64 {
return false
}
return strings.Trim(value, "0123456789abcdef") == ""
}
package model
import (
"database/sql/driver"
"encoding/json"
"fmt"
"github.com/QuantumNous/new-api/common"
)
// AuditOther is the structured metadata stored with an audit event. Privileged
// fields are separate so API projections can remove them without re-encoding JSON.
type AuditOther struct {
Op *AuditOperation `json:"op,omitempty"`
AdminInfo *AuditAdminInfo `json:"admin_info,omitempty"`
AuditInfo *AuditRequestInfo `json:"audit_info,omitempty"`
RootInfo AuditFields `json:"root_info,omitempty"`
LoginMethod string `json:"login_method,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
}
type AuditOperation struct {
Action string `json:"action"`
Params AuditFields `json:"params,omitempty"`
}
type AuditAdminInfo struct {
AdminID int `json:"admin_id,omitempty"`
AdminUsername string `json:"admin_username,omitempty"`
AdminRole int `json:"admin_role,omitempty"`
AuthMethod string `json:"auth_method,omitempty"`
}
type AuditRequestInfo struct {
Method string `json:"method"`
Route string `json:"route"`
Path string `json:"path"`
Status int `json:"status"`
Success bool `json:"success"`
Params map[string]string `json:"params,omitempty"`
}
// AuditFields holds action-specific parameters and root-only extensions.
// Retain their encoded values when reading so arbitrary nested integers do not
// round-trip through float64 and lose precision before the API returns them.
type AuditFields map[string]any
func (fields *AuditFields) UnmarshalJSON(data []byte) error {
var values map[string]json.RawMessage
if err := common.Unmarshal(data, &values); err != nil {
return err
}
if values == nil {
*fields = nil
return nil
}
decoded := make(AuditFields, len(values))
for key, value := range values {
decoded[key] = value
}
*fields = decoded
return nil
}
func (other AuditOther) Value() (driver.Value, error) {
data, err := common.Marshal(other)
if err != nil {
return nil, err
}
// PostgreSQL's simple protocol requires string, not a bytea parameter.
return string(data), nil
}
func (other *AuditOther) Scan(value any) error {
var data []byte
switch value := value.(type) {
case nil:
*other = AuditOther{}
return nil
case string:
data = []byte(value)
case []byte:
data = value
default:
return fmt.Errorf("unsupported audit metadata database type %T", value)
}
var decoded AuditOther
if len(data) > 0 {
if err := common.Unmarshal(data, &decoded); err != nil {
return err
}
}
*other = decoded
return nil
}
...@@ -166,7 +166,7 @@ func RecordLog(userId int, logType int, content string) { ...@@ -166,7 +166,7 @@ func RecordLog(userId int, logType int, content string) {
} }
// RecordLogWithAdminInfo 记录操作日志,并将管理员相关信息存入 Other.admin_info, // RecordLogWithAdminInfo 记录操作日志,并将管理员相关信息存入 Other.admin_info,
func RecordLogWithAdminInfo(userId int, logType int, content string, adminInfo map[string]interface{}) { func RecordLogWithAdminInfo(userId int, logType int, content string, adminInfo *AuditAdminInfo, request ...*gin.Context) {
if logType == LogTypeConsume && !common.LogConsumeEnabled { if logType == LogTypeConsume && !common.LogConsumeEnabled {
return return
} }
...@@ -178,75 +178,71 @@ func RecordLogWithAdminInfo(userId int, logType int, content string, adminInfo m ...@@ -178,75 +178,71 @@ func RecordLogWithAdminInfo(userId int, logType int, content string, adminInfo m
Type: logType, Type: logType,
Content: content, Content: content,
} }
if len(adminInfo) > 0 { if logType == LogTypeManage {
other := NewLogOther() var c *gin.Context
other.MergeAdmin(adminInfo) if len(request) > 0 {
log.Other = other.JSONString() c = request[0]
} }
if err := createLog(log); err != nil { actorRole := 0
common.SysLog("failed to record log: " + err.Error()) 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})
return
// buildOpField 构建语言无关的操作描述(写入 Other.op)。 }
// 前端依据 action(稳定操作标识) + params(结构化参数) 在渲染期用 i18n 本地化展示, if adminInfo != nil {
// 因此不在数据库中存储自然语言句子。 data, err := common.Marshal(AuditOther{AdminInfo: adminInfo})
func buildOpField(action string, params map[string]interface{}) map[string]interface{} { if err != nil {
op := map[string]interface{}{ common.SysError("failed to encode log admin info: " + err.Error())
"action": action, return
}
log.Other = string(data)
} }
if len(params) > 0 { if err := createLog(log); err != nil {
op["params"] = params common.SysLog("failed to record log: " + err.Error())
} }
return op
} }
// RecordLoginLog 记录用户登录成功的审计日志(type=LogTypeLogin)。 // RecordLoginLog writes new login events to the independent audit table.
// username 由调用方传入(登录流程已持有用户对象),避免额外的数据库查询。 // username 由调用方传入(登录流程已持有用户对象),避免额外的数据库查询。
// content 为英文兜底文本(用于导出);action+params 供前端本地化渲染。 // content 为英文兜底文本(用于导出);action+params 供前端本地化渲染。
// extra 可携带 login_method、user_agent 等附加信息(普通用户可见)。 // other 包含 login_method、user_agent 等结构化信息。
func RecordLoginLog(userId int, username string, content string, ip string, action string, params map[string]interface{}, extra map[string]interface{}) { func RecordLoginLog(userId, actorRole int, username string, content string, ip string, action string, params map[string]interface{}, other AuditOther, request ...*gin.Context) {
other := NewLogOther() other.Op = &AuditOperation{Action: action, Params: params}
other.MergePublic(extra) var c *gin.Context
other.SetPublic("op", buildOpField(action, params)) if len(request) > 0 {
log := &Log{ c = request[0]
UserId: userId, }
Username: username, RecordAuditLog(c, AuditLog{UserId: userId, Username: username, ActorRole: actorRole, Category: AuditCategoryLogin, Action: action, Content: content, Ip: ip, Other: other, Success: true})
CreatedAt: common.GetTimestamp(),
Type: LogTypeLogin,
Content: content,
Ip: ip,
Other: other.JSONString(),
}
if err := createLog(log); err != nil {
common.SysLog("failed to record login log: " + err.Error())
}
} }
// RecordOperationAuditLog 记录管理/高危操作审计日志(type=LogTypeManage)。 // RecordOperationAuditLog writes new operation/security events to the audit table.
// logUserId 为日志归属者,管理审计日志应归属实际操作者;目标资源/用户放入 // logUserId 为日志归属者,管理审计日志应归属实际操作者;目标资源/用户放入
// action params。username 内部按 logUserId 查询。content 为英文兜底文本(供导出使用)。 // action params。username 内部按 logUserId 查询。content 为英文兜底文本(供导出使用)。
// action+params 写入 Other.op,供前端本地化渲染(普通用户可见,不含敏感信息)。 // action+params 写入 Other.op,供前端本地化渲染(普通用户可见,不含敏感信息)。
// adminInfo 存放操作者身份(写入 Other.admin_info,普通用户查询时剥离); // adminInfo 存放操作者身份(写入 Other.admin_info,普通用户查询时剥离);
// auditInfo 存放路由/方法/结果等中间件兜底信息(写入 Other.audit_info,普通用户查询时剥离)。 // auditInfo 存放路由/方法/结果等中间件兜底信息(写入 Other.audit_info,普通用户查询时剥离)。
func RecordOperationAuditLog(logUserId int, content string, ip string, action string, params map[string]interface{}, adminInfo map[string]interface{}, auditInfo map[string]interface{}) { func RecordOperationAuditLog(logUserId, actorRole int, content string, ip string, action string, params map[string]interface{}, adminInfo *AuditAdminInfo, auditInfo *AuditRequestInfo, request ...*gin.Context) {
username, _ := GetUsernameById(logUserId, false) username, _ := GetUsernameById(logUserId, false)
other := NewLogOther() other := AuditOther{
other.SetPublic("op", buildOpField(action, params)) Op: &AuditOperation{Action: action, Params: params},
other.MergeAdmin(adminInfo) AdminInfo: adminInfo,
other.MergeAudit(auditInfo) AuditInfo: auditInfo,
log := &Log{
UserId: logUserId,
Username: username,
CreatedAt: common.GetTimestamp(),
Type: LogTypeManage,
Content: content,
Ip: ip,
Other: other.JSONString(),
} }
if err := createLog(log); err != nil { var c *gin.Context
common.SysLog("failed to record operation audit log: " + err.Error()) if len(request) > 0 {
c = request[0]
}
category := AuditCategoryOperation
if adminInfo == nil {
category = AuditCategorySecurity
}
status, success := 200, true
if auditInfo != nil {
status = auditInfo.Status
success = auditInfo.Success
} }
RecordAuditLog(c, AuditLog{UserId: logUserId, Username: username, ActorRole: actorRole, Category: category, Action: action, Content: content, Ip: ip, Status: status, Success: success, Other: other})
} }
func RecordTopupLog(userId int, content string, callerIp string, paymentMethod string, callbackPaymentMethod string) { func RecordTopupLog(userId int, content string, callerIp string, paymentMethod string, callbackPaymentMethod string) {
......
...@@ -232,6 +232,9 @@ func InitLogDB() (err error) { ...@@ -232,6 +232,9 @@ func InitLogDB() (err error) {
LOG_DB = DB LOG_DB = DB
common.SetLogDatabaseType(common.MainDatabaseType()) common.SetLogDatabaseType(common.MainDatabaseType())
initCol() initCol()
if common.IsMasterNode {
return MigrateAuditLogs()
}
return return
} }
db, dbType, err := chooseDB("LOG_SQL_DSN", true) db, dbType, err := chooseDB("LOG_SQL_DSN", true)
...@@ -387,6 +390,9 @@ func migrateDB() error { ...@@ -387,6 +390,9 @@ func migrateDB() error {
} }
func migrateLOGDB() error { func migrateLOGDB() error {
if err := MigrateAuditLogs(); err != nil {
return err
}
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) { if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
return migrateClickHouseLogDB() return migrateClickHouseLogDB()
} }
......
...@@ -92,6 +92,7 @@ type User struct { ...@@ -92,6 +92,7 @@ type User struct {
TelegramId string `json:"telegram_id" gorm:"column:telegram_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! 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 AccessToken *string `json:"-" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
AccessTokenCreatedAt *int64 `json:"-" gorm:"type:bigint;column:access_token_created_at"`
Quota int `json:"quota" gorm:"type:int;default:0"` Quota int `json:"quota" gorm:"type:int;default:0"`
UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota 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 RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number
...@@ -145,7 +146,9 @@ func UpdateUserAccessToken(id int, token string) error { ...@@ -145,7 +146,9 @@ func UpdateUserAccessToken(id int, token string) error {
if id == 0 { if id == 0 {
return errors.New("id 为空!") return errors.New("id 为空!")
} }
result := DB.Model(&User{}).Where("id = ?", id).Update("access_token", token) result := DB.Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{
"access_token": token, "access_token_created_at": common.GetTimestamp(),
})
if result.Error != nil { if result.Error != nil {
return result.Error return result.Error
} }
...@@ -155,6 +158,23 @@ func UpdateUserAccessToken(id int, token string) error { ...@@ -155,6 +158,23 @@ func UpdateUserAccessToken(id int, token string) error {
return nil return nil
} }
// RevokeUserAccessToken returns the generation actually revoked under the row lock.
func RevokeUserAccessToken(id int) (string, error) {
var tokenRef string
err := DB.Transaction(func(tx *gorm.DB) error {
var user User
if err := lockForUpdate(tx).Select("id", "access_token").First(&user, id).Error; err != nil {
return err
}
tokenRef = AccessTokenFingerprint(user.GetAccessToken())
if tokenRef == "" {
return nil
}
return tx.Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{"access_token": nil, "access_token_created_at": nil}).Error
})
return tokenRef, err
}
func (user *User) GetSetting() dto.UserSetting { func (user *User) GetSetting() dto.UserSetting {
setting := dto.UserSetting{} setting := dto.UserSetting{}
if user.Setting != "" { if user.Setting != "" {
......
...@@ -16,6 +16,7 @@ func SetApiRouter(router *gin.Engine) { ...@@ -16,6 +16,7 @@ func SetApiRouter(router *gin.Engine) {
apiRouter := router.Group("/api") apiRouter := router.Group("/api")
apiRouter.Use(middleware.RouteTag("api")) apiRouter.Use(middleware.RouteTag("api"))
apiRouter.Use(gzip.Gzip(gzip.DefaultCompression)) apiRouter.Use(gzip.Gzip(gzip.DefaultCompression))
apiRouter.Use(middleware.AccessTokenAudit())
apiRouter.Use(middleware.BodyStorageCleanup()) // 清理请求体存储 apiRouter.Use(middleware.BodyStorageCleanup()) // 清理请求体存储
apiRouter.Use(middleware.GlobalAPIRateLimit()) apiRouter.Use(middleware.GlobalAPIRateLimit())
anonymousRequestBodyLimit := middleware.AnonymousRequestBodyLimit() anonymousRequestBodyLimit := middleware.AnonymousRequestBodyLimit()
...@@ -82,7 +83,7 @@ func SetApiRouter(router *gin.Engine) { ...@@ -82,7 +83,7 @@ func SetApiRouter(router *gin.Engine) {
userRoute.GET("/groups", controller.GetUserGroups) userRoute.GET("/groups", controller.GetUserGroups)
selfRoute := userRoute.Group("/") selfRoute := userRoute.Group("/")
selfRoute.Use(middleware.UserAuth()) selfRoute.Use(middleware.DisableCache(), middleware.UserAuth())
{ {
selfRoute.GET("/sessions", middleware.DisableCache(), controller.GetLoginSessions) selfRoute.GET("/sessions", middleware.DisableCache(), controller.GetLoginSessions)
selfRoute.DELETE("/sessions/:sid", middleware.DisableCache(), controller.DeleteLoginSession) selfRoute.DELETE("/sessions/:sid", middleware.DisableCache(), controller.DeleteLoginSession)
...@@ -93,6 +94,9 @@ func SetApiRouter(router *gin.Engine) { ...@@ -93,6 +94,9 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.PUT("/self", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.UpdateSelf) selfRoute.PUT("/self", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.UpdateSelf)
selfRoute.DELETE("/self", controller.DeleteSelf) selfRoute.DELETE("/self", controller.DeleteSelf)
selfRoute.GET("/token", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("access-token"), middleware.DisableCache(), controller.GenerateAccessToken) selfRoute.GET("/token", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("access-token"), middleware.DisableCache(), controller.GenerateAccessToken)
selfRoute.GET("/token/status", middleware.DisableCache(), controller.GetAccessTokenStatus)
selfRoute.POST("/token", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("access-token"), middleware.DisableCache(), controller.GenerateAccessToken)
selfRoute.DELETE("/token", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.RevokeAccessToken)
selfRoute.GET("/passkey", controller.PasskeyStatus) selfRoute.GET("/passkey", controller.PasskeyStatus)
selfRoute.POST("/passkey/register/begin", middleware.DisableCache(), controller.PasskeyRegisterBegin) selfRoute.POST("/passkey/register/begin", middleware.DisableCache(), controller.PasskeyRegisterBegin)
selfRoute.POST("/passkey/register/finish", middleware.DisableCache(), controller.PasskeyRegisterFinish) selfRoute.POST("/passkey/register/finish", middleware.DisableCache(), controller.PasskeyRegisterFinish)
...@@ -288,6 +292,8 @@ func SetApiRouter(router *gin.Engine) { ...@@ -288,6 +292,8 @@ func SetApiRouter(router *gin.Engine) {
redemptionRoute.DELETE("/invalid", controller.DeleteInvalidRedemption) redemptionRoute.DELETE("/invalid", controller.DeleteInvalidRedemption)
redemptionRoute.DELETE("/:id", controller.DeleteRedemption) redemptionRoute.DELETE("/:id", controller.DeleteRedemption)
} }
apiRouter.GET("/audit", middleware.DisableCache(), middleware.AdminAuth(), middleware.RequirePermission(authz.AuditRead), controller.GetAuditLogs)
apiRouter.GET("/audit/self", middleware.DisableCache(), middleware.UserAuth(), controller.GetAuditLogs)
logRoute := apiRouter.Group("/log") logRoute := apiRouter.Group("/log")
logRoute.GET("/", middleware.AdminAuth(), controller.GetAllLogs) logRoute.GET("/", middleware.AdminAuth(), controller.GetAllLogs)
logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat) logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat)
......
...@@ -32,6 +32,7 @@ func SetRouter(router *gin.Engine, assets WebAssets) { ...@@ -32,6 +32,7 @@ func SetRouter(router *gin.Engine, assets WebAssets) {
router.NoRoute( router.NoRoute(
pluginDispatcher, pluginDispatcher,
middleware.RouteTag("web"), middleware.RouteTag("web"),
middleware.AccessTokenAudit(),
func(c *gin.Context) { func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, fmt.Sprintf("%s%s", frontendBaseUrl, c.Request.RequestURI)) c.Redirect(http.StatusMovedPermanently, fmt.Sprintf("%s%s", frontendBaseUrl, c.Request.RequestURI))
}, },
......
...@@ -882,3 +882,33 @@ func performPluginRequest(handler http.Handler, method, path string) *httptest.R ...@@ -882,3 +882,33 @@ func performPluginRequest(handler http.Handler, method, path string) *httptest.R
handler.ServeHTTP(recorder, request) handler.ServeHTTP(recorder, request)
return recorder return recorder
} }
func TestWebFallbackDoesNotCacheMissingAPIOrAssets(t *testing.T) {
outer := gin.New()
SetWebRouter(outer, WebAssets{IndexPage: []byte("dashboard")}, func(c *gin.Context) { c.Next() })
for _, path := range []string{"/api/user/token/status", "/api/audit/self?p=1", "/v1/missing", "/assets/missing.js"} {
t.Run(path, func(t *testing.T) {
response := performPluginRequest(outer, http.MethodGet, path)
assert.Equal(t, http.StatusNotFound, response.Code)
assert.Contains(t, response.Body.String(), "Invalid URL")
assert.Contains(t, response.Header().Get("Cache-Control"), "no-store")
assert.NotContains(t, response.Header().Get("Cache-Control"), "604800")
})
}
page := performPluginRequest(outer, http.MethodGet, "/security")
assert.Equal(t, http.StatusOK, page.Code)
assert.Equal(t, "dashboard", page.Body.String())
assert.Equal(t, "no-cache", page.Header().Get("Cache-Control"))
}
func TestSecurityRoutesDisableCachingBeforeAuthentication(t *testing.T) {
outer := gin.New()
SetApiRouter(outer)
for _, path := range []string{"/api/user/token/status", "/api/user/token", "/api/audit/self", "/api/audit"} {
t.Run(path, func(t *testing.T) {
response := performPluginRequest(outer, http.MethodGet, path)
assert.Equal(t, http.StatusUnauthorized, response.Code)
assert.Contains(t, response.Header().Get("Cache-Control"), "no-store")
})
}
}
...@@ -26,6 +26,7 @@ func SetWebRouter(router *gin.Engine, assets WebAssets, pluginDispatcher gin.Han ...@@ -26,6 +26,7 @@ func SetWebRouter(router *gin.Engine, assets WebAssets, pluginDispatcher gin.Han
pluginDispatcher, pluginDispatcher,
middleware.RouteTag("web"), middleware.RouteTag("web"),
gzip.Gzip(gzip.DefaultCompression), gzip.Gzip(gzip.DefaultCompression),
middleware.AccessTokenAudit(),
middleware.GlobalWebRateLimit(), middleware.GlobalWebRateLimit(),
middleware.Cache(), middleware.Cache(),
static.Serve("/", frontendFS), static.Serve("/", frontendFS),
......
...@@ -108,6 +108,7 @@ func TestSetUserPermissionsStoresOnlyOverrides(t *testing.T) { ...@@ -108,6 +108,7 @@ func TestSetUserPermissionsStoresOnlyOverrides(t *testing.T) {
ResourceTaskPlugin: { ResourceTaskPlugin: {
ActionBind: false, ActionBind: false,
}, },
ResourceAudit: {ActionRead: false},
}, ExplicitUserPermissions(42)) }, ExplicitUserPermissions(42))
assert.Equal(t, PermissionsMap{ assert.Equal(t, PermissionsMap{
ResourceChannel: { ResourceChannel: {
...@@ -139,6 +140,7 @@ func TestSetUserPermissionsStoresOnlyOverrides(t *testing.T) { ...@@ -139,6 +140,7 @@ func TestSetUserPermissionsStoresOnlyOverrides(t *testing.T) {
ResourceTaskPlugin: { ResourceTaskPlugin: {
ActionBind: false, ActionBind: false,
}, },
ResourceAudit: {ActionRead: false},
}, ExplicitUserPermissions(42)) }, ExplicitUserPermissions(42))
assert.Empty(t, ExplicitUserOverrides(42)) assert.Empty(t, ExplicitUserOverrides(42))
} }
......
package authz
const ResourceAudit = "audit"
var AuditRead = Permission{Resource: ResourceAudit, Action: ActionRead}
func init() {
RegisterResource(ResourceDefinition{
Resource: ResourceAudit,
LabelKey: "Audit Logs",
Actions: []ActionDefinition{{
Action: ActionRead,
LabelKey: "View other accounts' audit logs",
DescriptionKey: "View audit records from user and admin roles. Root records are always excluded.",
}},
})
}
...@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { X, User, Wallet, LogOut } from 'lucide-react' import { X, User, Wallet, LogOut, ShieldCheck } from 'lucide-react'
import { AnimatePresence, motion, type Variants } from 'motion/react' import { AnimatePresence, motion, type Variants } from 'motion/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
...@@ -26,6 +26,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' ...@@ -26,6 +26,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import useDialogState from '@/hooks/use-dialog' import useDialogState from '@/hooks/use-dialog'
import { useIsSidebarModuleVisible } from '@/hooks/use-sidebar-config'
import { useUserDisplay } from '@/hooks/use-user-display' import { useUserDisplay } from '@/hooks/use-user-display'
import type { AuthUser } from '@/stores/auth-store' import type { AuthUser } from '@/stores/auth-store'
...@@ -81,6 +82,7 @@ function MobileUserProfile({ user, onNavigate }: MobileUserProfileProps) { ...@@ -81,6 +82,7 @@ function MobileUserProfile({ user, onNavigate }: MobileUserProfileProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [signOutOpen, setSignOutOpen] = useDialogState() const [signOutOpen, setSignOutOpen] = useDialogState()
const { displayName, initials, roleLabel } = useUserDisplay(user) const { displayName, initials, roleLabel } = useUserDisplay(user)
const isSecurityVisible = useIsSidebarModuleVisible('/security')
if (!user) return null if (!user) return null
...@@ -122,6 +124,17 @@ function MobileUserProfile({ user, onNavigate }: MobileUserProfileProps) { ...@@ -122,6 +124,17 @@ function MobileUserProfile({ user, onNavigate }: MobileUserProfileProps) {
{t('Profile')} {t('Profile')}
</Link> </Link>
{isSecurityVisible && (
<Link
to='/security'
onClick={onNavigate}
className='text-primary/60 hover:text-primary/80 border-border flex items-center gap-2.5 border-b p-2.5 transition-colors'
>
<ShieldCheck className='size-4' />
{t('Security & Access')}
</Link>
)}
<Link <Link
to='/wallet' to='/wallet'
onClick={onNavigate} onClick={onNavigate}
...@@ -261,9 +274,9 @@ export function MobileDrawer({ ...@@ -261,9 +274,9 @@ export function MobileDrawer({
</div> </div>
) : ( ) : (
<AnimatePresence> <AnimatePresence>
{mobileLinksList.map((link, index) => ( {mobileLinksList.map((link) => (
<motion.div <motion.div
key={`${link.href}-${index}`} key={link.href}
className='border-border border-b p-2.5 last:border-b-0' className='border-border border-b p-2.5 last:border-b-0'
variants={MOBILE_DRAWER_ANIMATION.menuItem as Variants} variants={MOBILE_DRAWER_ANIMATION.menuItem as Variants}
> >
......
...@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { User, Wallet, LogOut, Settings } from 'lucide-react' import { User, Wallet, LogOut, Settings, ShieldCheck } from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
...@@ -48,6 +48,7 @@ export function ProfileDropdown() { ...@@ -48,6 +48,7 @@ export function ProfileDropdown() {
const { displayName, roleLabel } = useUserDisplay(user) const { displayName, roleLabel } = useUserDisplay(user)
const isSuperAdmin = user?.role === ROLE.SUPER_ADMIN const isSuperAdmin = user?.role === ROLE.SUPER_ADMIN
const isWalletVisible = useIsSidebarModuleVisible('/wallet') const isWalletVisible = useIsSidebarModuleVisible('/wallet')
const isSecurityVisible = useIsSidebarModuleVisible('/security')
const avatarName = user?.username || displayName const avatarName = user?.username || displayName
const avatarFallback = getUserAvatarFallback(avatarName) const avatarFallback = getUserAvatarFallback(avatarName)
const avatarFallbackStyle = useMemo( const avatarFallbackStyle = useMemo(
...@@ -107,6 +108,13 @@ export function ProfileDropdown() { ...@@ -107,6 +108,13 @@ export function ProfileDropdown() {
{t('Profile')} {t('Profile')}
</DropdownMenuItem> </DropdownMenuItem>
{isSecurityVisible && (
<DropdownMenuItem onClick={() => navigate({ to: '/security' })}>
<ShieldCheck className='size-4' />
{t('Security & Access')}
</DropdownMenuItem>
)}
{isWalletVisible && ( {isWalletVisible && (
<DropdownMenuItem onClick={() => navigate({ to: '/wallet' })}> <DropdownMenuItem onClick={() => navigate({ to: '/wallet' })}>
<Wallet className='size-4' /> <Wallet className='size-4' />
......
...@@ -73,7 +73,9 @@ export function SecureVerificationDialog({ ...@@ -73,7 +73,9 @@ export function SecureVerificationDialog({
state.description ?? state.description ??
(availableTabs.length (availableTabs.length
? 'Confirm your identity before accessing this sensitive action.' ? 'Confirm your identity before accessing this sensitive action.'
: 'Enable Two-factor Authentication or Passkey in your profile settings to continue.') : t(
'Enable Two-factor Authentication or Passkey in Security & Access to continue.'
))
const handleVerify = () => { const handleVerify = () => {
if (!activeMethod) return if (!activeMethod) return
...@@ -132,7 +134,7 @@ export function SecureVerificationDialog({ ...@@ -132,7 +134,7 @@ export function SecureVerificationDialog({
</div> </div>
<p className='text-muted-foreground text-sm'> <p className='text-muted-foreground text-sm'>
{t( {t(
'Enable Two-factor Authentication or Passkey in your profile to unlock sensitive operations.' 'Enable Two-factor Authentication or Passkey in Security & Access to unlock sensitive operations.'
)} )}
</p> </p>
</div> </div>
......
/*
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 { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import { updateUserSettings } from '../api'
import { NotificationTab } from '../components/tabs/notification-tab'
import type { UserProfile } from '../types'
const profile: UserProfile = {
id: 1,
username: 'alice',
display_name: 'Alice',
role: 1,
group: 'default',
quota: 1000000,
used_quota: 0,
request_count: 0,
status: 1,
aff_count: 0,
aff_quota: 0,
aff_history_quota: 0,
created_time: 0,
}
const settings = {
notify_type: 'webhook',
quota_warning_threshold: 1200,
notification_email: '',
webhook_url: 'https://example.com/notify',
webhook_secret: 'webhook-secret',
bark_url: '',
gotify_url: '',
gotify_token: '',
gotify_priority: 5,
accept_unset_model_ratio_model: true,
record_ip_log: true,
upstream_model_update_notify_enabled: true,
}
afterEach(() => vi.restoreAllMocks())
describe('user settings saves across profile and security', () => {
it('disabling IP recording sends the latest complete notification settings', async () => {
const get = vi.spyOn(api, 'get').mockResolvedValue({
data: {
success: true,
data: { ...profile, setting: JSON.stringify(settings) },
},
})
const put = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
await updateUserSettings({ record_ip_log: false })
expect(get).toHaveBeenCalledWith('/api/user/self')
expect(put).toHaveBeenCalledWith('/api/user/setting', {
...settings,
record_ip_log: false,
})
})
it('saving IP recording for a user with no settings supplies the existing defaults', async () => {
vi.spyOn(api, 'get').mockResolvedValue({
data: { success: true, data: profile },
})
const put = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
await updateUserSettings({ record_ip_log: true })
expect(put).toHaveBeenCalledWith('/api/user/setting', {
notify_type: 'email',
quota_warning_threshold: 500000,
notification_email: '',
webhook_url: '',
webhook_secret: '',
bark_url: '',
gotify_url: '',
gotify_token: '',
gotify_priority: 5,
accept_unset_model_ratio_model: false,
record_ip_log: true,
upstream_model_update_notify_enabled: false,
})
})
it('alternating notification and IP saves retains the latest values from each page', async () => {
let saved = { ...settings }
vi.spyOn(api, 'get').mockImplementation(async () => ({
data: {
success: true,
data: { ...profile, setting: JSON.stringify(saved) },
},
}))
vi.spyOn(api, 'put').mockImplementation(async (_url, body) => {
saved = body as typeof settings
return { data: { success: true } }
})
await updateUserSettings({ record_ip_log: false })
await updateUserSettings({ quota_warning_threshold: 2500 })
await updateUserSettings({ record_ip_log: true })
expect(saved).toEqual({
...settings,
quota_warning_threshold: 2500,
record_ip_log: true,
})
})
it('a rejected profile read prevents the settings write', async () => {
vi.spyOn(api, 'get').mockRejectedValue(new Error('offline'))
const put = vi.spyOn(api, 'put')
await expect(updateUserSettings({ record_ip_log: false })).rejects.toThrow(
'offline'
)
expect(put).not.toHaveBeenCalled()
})
it('an unsuccessful profile response prevents the settings write', async () => {
vi.spyOn(api, 'get').mockResolvedValue({
data: { success: false, message: 'Unavailable' },
})
const put = vi.spyOn(api, 'put')
expect(await updateUserSettings({ record_ip_log: false })).toEqual({
success: false,
message: 'Unavailable',
})
expect(put).not.toHaveBeenCalled()
})
it('an unsuccessful settings write is returned to the caller', async () => {
vi.spyOn(api, 'get').mockResolvedValue({
data: { success: true, data: profile },
})
vi.spyOn(api, 'put').mockResolvedValue({
data: { success: false, message: 'Save failed' },
})
expect(await updateUserSettings({ record_ip_log: true })).toEqual({
success: false,
message: 'Save failed',
})
})
it('saving a stale notification form keeps the current IP setting and the notification edits', async () => {
const onUpdate = vi.fn()
vi.spyOn(api, 'get').mockResolvedValue({
data: {
success: true,
data: {
...profile,
setting: JSON.stringify({ ...settings, record_ip_log: false }),
},
},
})
const put = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
render(
<NotificationTab
profile={{ ...profile, setting: JSON.stringify(settings) }}
onUpdate={onUpdate}
/>
)
expect(
screen.queryByRole('switch', { name: 'Record IP Address' })
).not.toBeInTheDocument()
fireEvent.change(
screen.getByRole('spinbutton', { name: 'Quota Warning Threshold' }),
{ target: { value: '2700' } }
)
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }))
await waitFor(() => expect(onUpdate).toHaveBeenCalled())
expect(put).toHaveBeenCalledWith('/api/user/setting', {
...settings,
quota_warning_threshold: 2700,
record_ip_log: false,
})
})
})
...@@ -20,6 +20,7 @@ import { api } from '@/lib/api' ...@@ -20,6 +20,7 @@ import { api } from '@/lib/api'
import type { CustomOAuthBinding } from '@/lib/oauth' import type { CustomOAuthBinding } from '@/lib/oauth'
import type { LoginSession } from '@/stores/auth-store' import type { LoginSession } from '@/stores/auth-store'
import { normalizeUserSettings } from './lib/user-settings'
import type { import type {
ApiResponse, ApiResponse,
UserProfile, UserProfile,
...@@ -60,7 +61,12 @@ export async function updateUserProfile( ...@@ -60,7 +61,12 @@ export async function updateUserProfile(
export async function updateUserSettings( export async function updateUserSettings(
data: UpdateUserSettingsRequest data: UpdateUserSettingsRequest
): Promise<ApiResponse> { ): Promise<ApiResponse> {
const res = await api.put('/api/user/setting', data) const profile = await getUserProfile()
if (!profile.success || !profile.data) {
return { success: false, message: profile.message }
}
const settings = normalizeUserSettings(profile.data.setting)
const res = await api.put('/api/user/setting', { ...settings, ...data })
return res.data return res.data
} }
...@@ -84,14 +90,6 @@ export async function deleteUserAccount( ...@@ -84,14 +90,6 @@ export async function deleteUserAccount(
return res.data return res.data
} }
/**
* Generate/regenerate system access token
*/
export async function generateAccessToken(): Promise<ApiResponse<string>> {
const res = await api.get('/api/user/token')
return res.data
}
// ============================================================================ // ============================================================================
// Account Binding APIs // Account Binding APIs
// ============================================================================ // ============================================================================
......
/*
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 { KeyRound, Loader2, RefreshCw } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { CopyButton } from '@/components/copy-button'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useAccessToken } from '../../hooks'
// ============================================================================
// Access Token Dialog Component
// ============================================================================
interface AccessTokenDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function AccessTokenDialog({
open,
onOpenChange,
}: AccessTokenDialogProps) {
const { t } = useTranslation()
const { token, generating, generate, clearToken } = useAccessToken()
const [confirmOpen, setConfirmOpen] = useState(false)
const handleOpenChange = (nextOpen: boolean) => {
if (generating) return
if (!nextOpen) {
setConfirmOpen(false)
clearToken()
}
onOpenChange(nextOpen)
}
const handleGenerate = async () => {
if (await generate()) {
setConfirmOpen(false)
}
}
return (
<>
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={t('Access Token')}
description={t(
"Your system access token for API authentication. Keep it secure and don't share it with others."
)}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => handleOpenChange(false)}
disabled={generating}
>
{t('Close')}
</Button>
<Button
type='button'
onClick={() => setConfirmOpen(true)}
disabled={generating}
className='gap-2'
>
{generating ? (
<Loader2 className='h-4 w-4 animate-spin' aria-hidden='true' />
) : (
<RefreshCw className='h-4 w-4' aria-hidden='true' />
)}
{generating ? t('Generating...') : t('Regenerate')}
</Button>
</>
}
>
<div className='my-6'>
{token ? (
<div className='space-y-2'>
<Label htmlFor='token'>{t('Token')}</Label>
<div className='flex gap-2'>
<Input
id='token'
type='text'
value={token}
readOnly
className='font-mono text-xs'
/>
<CopyButton
value={token}
variant='outline'
className='size-9'
iconClassName='size-4'
tooltip={t('Copy token')}
aria-label={t('Copy token')}
/>
</div>
<p className='text-muted-foreground text-xs'>
{t(
"Save this token now. You won't be able to view it again after closing this dialog."
)}
</p>
</div>
) : (
<Empty className='border py-8'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<KeyRound aria-hidden='true' />
</EmptyMedia>
<EmptyTitle>
{t('Access tokens are shown only once')}
</EmptyTitle>
<EmptyDescription>
{t(
'For security, existing access tokens cannot be displayed. Regenerate only when you need a new token.'
)}
</EmptyDescription>
<EmptyDescription>
{t(
'Regenerating immediately invalidates any existing token.'
)}
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div>
</Dialog>
<ConfirmDialog
open={confirmOpen}
onOpenChange={(nextOpen) => {
if (!generating) setConfirmOpen(nextOpen)
}}
title={t('Regenerate access token?')}
desc={
<div className='space-y-2'>
<p>
{t(
'This will immediately invalidate your existing access token. Any applications or scripts using it will stop working.'
)}
</p>
<p>
{t(
'The new token will only be shown once. Copy it and store it securely.'
)}
</p>
</div>
}
confirmText={
generating ? (
<>
<Loader2 className='h-4 w-4 animate-spin' aria-hidden='true' />
{t('Generating...')}
</>
) : (
t('Regenerate token')
)
}
destructive
isLoading={generating}
handleConfirm={handleGenerate}
/>
</>
)
}
/*
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 { Shield, Key, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { IconBadge } from '@/components/ui/icon-badge'
import { Skeleton } from '@/components/ui/skeleton'
import { TitledCard } from '@/components/ui/titled-card'
import { useDialogs } from '@/hooks/use-dialog'
import type { UserProfile } from '../types'
import { AccessTokenDialog } from './dialogs/access-token-dialog'
import { ChangePasswordDialog } from './dialogs/change-password-dialog'
import { DeleteAccountDialog } from './dialogs/delete-account-dialog'
// ============================================================================
// Profile Security Card Component
// ============================================================================
interface ProfileSecurityCardProps {
profile: UserProfile | null
loading: boolean
}
type DialogKey = 'password' | 'token' | 'delete'
export function ProfileSecurityCard({
profile,
loading,
}: ProfileSecurityCardProps) {
const { t } = useTranslation()
const dialogs = useDialogs<DialogKey>()
if (loading) {
return (
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='border-b p-3 !pb-3 sm:p-5 sm:!pb-5'>
<Skeleton className='h-6 w-32' />
<Skeleton className='mt-2 h-4 w-48' />
</CardHeader>
<CardContent className='space-y-3 p-3 sm:p-5'>
{['password', 'token', 'delete'].map((key) => (
<Skeleton key={key} className='h-16 w-full' />
))}
</CardContent>
</Card>
)
}
if (!profile) return null
const securityActions = [
{
icon: Shield,
title: t('Change Password'),
description: t('Update your password to keep your account secure'),
action: () => dialogs.open('password'),
variant: 'default' as const,
},
{
icon: Key,
title: t('Access Token'),
description: t('Generate and manage your API access token'),
action: () => dialogs.open('token'),
variant: 'default' as const,
},
{
icon: Trash2,
title: t('Delete Account'),
description: t('Permanently delete your account and all data'),
action: () => dialogs.open('delete'),
variant: 'destructive' as const,
},
]
return (
<>
<TitledCard
title={t('Security')}
description={t('Manage your security settings and account access')}
icon={<Shield className='h-4 w-4' />}
iconTone='success'
disableHoverEffect
>
<div className='grid grid-cols-1 gap-2.5 sm:gap-3 md:grid-cols-3'>
{securityActions.map((item) => (
<button
key={item.title}
type='button'
onClick={item.action}
className={`flex items-center gap-3 rounded-lg border p-3 text-left md:flex-col md:gap-2 md:p-4 md:text-center ${
item.variant === 'destructive' ? 'border-destructive/30' : ''
}`}
>
<IconBadge tone='neutral' size='sm'>
<item.icon />
</IconBadge>
<div className='min-w-0 md:contents'>
<p className='text-sm font-medium'>{item.title}</p>
<p className='text-muted-foreground line-clamp-1 text-xs md:line-clamp-none'>
{item.description}
</p>
</div>
</button>
))}
</div>
</TitledCard>
{/* Dialogs */}
<ChangePasswordDialog
open={dialogs.isOpen('password')}
onOpenChange={(open) =>
open ? dialogs.open('password') : dialogs.close('password')
}
username={profile.username}
/>
<AccessTokenDialog
open={dialogs.isOpen('token')}
onOpenChange={(open) =>
open ? dialogs.open('token') : dialogs.close('token')
}
/>
<DeleteAccountDialog
open={dialogs.isOpen('delete')}
onOpenChange={(open) =>
open ? dialogs.open('delete') : dialogs.close('delete')
}
username={profile.username}
/>
</>
)
}
...@@ -16,17 +16,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,17 +16,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { Link2, Settings } from 'lucide-react' import { Settings } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Card, CardContent, CardHeader } from '@/components/ui/card' import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { TitledCard } from '@/components/ui/titled-card' import { TitledCard } from '@/components/ui/titled-card'
import type { UserProfile } from '../types' import type { UserProfile } from '../types'
import { AccountBindingsTab } from './tabs/account-bindings-tab'
import { NotificationTab } from './tabs/notification-tab' import { NotificationTab } from './tabs/notification-tab'
// ============================================================================ // ============================================================================
...@@ -45,7 +42,6 @@ export function ProfileSettingsCard({ ...@@ -45,7 +42,6 @@ export function ProfileSettingsCard({
onProfileUpdate, onProfileUpdate,
}: ProfileSettingsCardProps) { }: ProfileSettingsCardProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [activeTab, setActiveTab] = useState('bindings')
if (loading) { if (loading) {
return ( return (
...@@ -55,8 +51,7 @@ export function ProfileSettingsCard({ ...@@ -55,8 +51,7 @@ export function ProfileSettingsCard({
<Skeleton className='mt-2 h-4 w-48' /> <Skeleton className='mt-2 h-4 w-48' />
</CardHeader> </CardHeader>
<CardContent className='space-y-4 p-3 sm:p-5'> <CardContent className='space-y-4 p-3 sm:p-5'>
<Skeleton className='h-10 w-full' /> {['notifications', 'threshold', 'preferences'].map((key) => (
{['bindings', 'preferences', 'notifications'].map((key) => (
<Skeleton key={key} className='h-20 w-full' /> <Skeleton key={key} className='h-20 w-full' />
))} ))}
</CardContent> </CardContent>
...@@ -67,41 +62,12 @@ export function ProfileSettingsCard({ ...@@ -67,41 +62,12 @@ export function ProfileSettingsCard({
return ( return (
<TitledCard <TitledCard
title={t('Settings')} title={t('Settings')}
description={t('Configure your account preferences and integrations')} description={t('Settings & Preferences')}
icon={<Settings className='h-4 w-4' />} icon={<Settings className='h-4 w-4' />}
iconTone='info' iconTone='info'
disableHoverEffect disableHoverEffect
> >
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className='grid w-full grid-cols-2 items-stretch gap-1 rounded-xl p-1 group-data-horizontal/tabs:h-10'>
<TabsTrigger
value='bindings'
className='h-full gap-2 rounded-lg px-3 py-0 leading-none'
>
<Link2 className='h-4 w-4' />
<span className='hidden sm:inline'>{t('Account Bindings')}</span>
<span className='sm:hidden'>{t('Bindings')}</span>
</TabsTrigger>
<TabsTrigger
value='settings'
className='h-full gap-2 rounded-lg px-3 py-0 leading-none'
>
<Settings className='h-4 w-4' />
<span className='hidden sm:inline'>
{t('Settings & Preferences')}
</span>
<span className='sm:hidden'>{t('Settings')}</span>
</TabsTrigger>
</TabsList>
<TabsContent value='bindings' className='mt-4 sm:mt-6'>
<AccountBindingsTab profile={profile} onUpdate={onProfileUpdate} />
</TabsContent>
<TabsContent value='settings' className='mt-4 sm:mt-6'>
<NotificationTab profile={profile} onUpdate={onProfileUpdate} /> <NotificationTab profile={profile} onUpdate={onProfileUpdate} />
</TabsContent>
</Tabs>
</TitledCard> </TitledCard>
) )
} }
...@@ -94,6 +94,11 @@ export function SidebarModulesCard() { ...@@ -94,6 +94,11 @@ export function SidebarModulesCard() {
description: t('API usage records'), description: t('API usage records'),
}, },
{ {
key: 'audit',
title: t('Audit Logs'),
description: t('Login, security and access records'),
},
{
key: 'midjourney', key: 'midjourney',
title: t('Drawing Logs'), title: t('Drawing Logs'),
description: t('Drawing task records'), description: t('Drawing task records'),
...@@ -120,6 +125,11 @@ export function SidebarModulesCard() { ...@@ -120,6 +125,11 @@ export function SidebarModulesCard() {
title: t('Personal Settings'), title: t('Personal Settings'),
description: t('Personal info settings'), description: t('Personal info settings'),
}, },
{
key: 'security',
title: t('Security & Access'),
description: t('Manage your security settings and account access'),
},
], ],
}, },
] ]
......
...@@ -30,12 +30,9 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' ...@@ -30,12 +30,9 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { ROLE } from '@/lib/roles' import { ROLE } from '@/lib/roles'
import { updateUserSettings } from '../../api' import { updateUserSettings } from '../../api'
import { import { NOTIFICATION_METHODS } from '../../constants'
DEFAULT_QUOTA_WARNING_THRESHOLD, import { normalizeUserSettings } from '../../lib/user-settings'
NOTIFICATION_METHODS, import type { UserProfile, NotifyType } from '../../types'
} from '../../constants'
import { parseUserSettings } from '../../lib'
import type { UserProfile, UserSettings, NotifyType } from '../../types'
const NOTIFICATION_ICONS: Record<NotifyType, typeof Mail> = { const NOTIFICATION_ICONS: Record<NotifyType, typeof Mail> = {
email: Mail, email: Mail,
...@@ -44,17 +41,6 @@ const NOTIFICATION_ICONS: Record<NotifyType, typeof Mail> = { ...@@ -44,17 +41,6 @@ const NOTIFICATION_ICONS: Record<NotifyType, typeof Mail> = {
gotify: Server, gotify: Server,
} }
const NOTIFICATION_VALUES = new Set<NotifyType>(
NOTIFICATION_METHODS.map((method) => method.value)
)
function normalizeNotifyType(value: unknown): NotifyType {
return typeof value === 'string' &&
NOTIFICATION_VALUES.has(value as NotifyType)
? (value as NotifyType)
: 'email'
}
// ============================================================================ // ============================================================================
// Settings Tab Component // Settings Tab Component
// ============================================================================ // ============================================================================
...@@ -68,24 +54,14 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) { ...@@ -68,24 +54,14 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
const { t } = useTranslation() const { t } = useTranslation()
const isAdmin = (profile?.role ?? 0) >= ROLE.ADMIN const isAdmin = (profile?.role ?? 0) >= ROLE.ADMIN
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [settings, setSettings] = useState<UserSettings>({ const [settings, setSettings] = useState(() => normalizeUserSettings())
notify_type: 'email',
quota_warning_threshold: DEFAULT_QUOTA_WARNING_THRESHOLD,
notification_email: '',
webhook_url: '',
webhook_secret: '',
bark_url: '',
gotify_url: '',
gotify_token: '',
gotify_priority: 5,
accept_unset_model_ratio_model: false,
record_ip_log: false,
upstream_model_update_notify_enabled: false,
})
// Update form field helper // Update form field helper
const updateField = useCallback( const updateField = useCallback(
<K extends keyof UserSettings>(field: K, value: UserSettings[K]) => { <K extends keyof typeof settings>(
field: K,
value: (typeof settings)[K]
) => {
setSettings((prev) => ({ ...prev, [field]: value })) setSettings((prev) => ({ ...prev, [field]: value }))
}, },
[] []
...@@ -93,31 +69,15 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) { ...@@ -93,31 +69,15 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
useEffect(() => { useEffect(() => {
if (profile?.setting) { if (profile?.setting) {
const parsed = parseUserSettings(profile.setting) setSettings(normalizeUserSettings(profile.setting))
setSettings({
notify_type: normalizeNotifyType(parsed.notify_type),
quota_warning_threshold:
parsed.quota_warning_threshold ?? DEFAULT_QUOTA_WARNING_THRESHOLD,
notification_email: parsed.notification_email ?? '',
webhook_url: parsed.webhook_url ?? '',
webhook_secret: parsed.webhook_secret ?? '',
bark_url: parsed.bark_url ?? '',
gotify_url: parsed.gotify_url ?? '',
gotify_token: parsed.gotify_token ?? '',
gotify_priority: parsed.gotify_priority ?? 5,
accept_unset_model_ratio_model:
parsed.accept_unset_model_ratio_model || false,
record_ip_log: parsed.record_ip_log || false,
upstream_model_update_notify_enabled:
parsed.upstream_model_update_notify_enabled || false,
})
} }
}, [profile]) }, [profile])
const handleSave = async () => { const handleSave = async () => {
try { try {
setLoading(true) setLoading(true)
const response = await updateUserSettings(settings) const { record_ip_log: _recordIpLog, ...notificationSettings } = settings
const response = await updateUserSettings(notificationSettings)
if (response.success) { if (response.success) {
toast.success(t('Settings updated successfully')) toast.success(t('Settings updated successfully'))
...@@ -125,14 +85,14 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) { ...@@ -125,14 +85,14 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
} else { } else {
toast.error(response.message || t('Failed to update settings')) toast.error(response.message || t('Failed to update settings'))
} }
} catch (_error) { } catch {
toast.error(t('Failed to update settings')) toast.error(t('Failed to update settings'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
const notifyType = normalizeNotifyType(settings.notify_type) const notifyType = settings.notify_type
return ( return (
<div className='space-y-4 sm:space-y-6'> <div className='space-y-4 sm:space-y-6'>
...@@ -143,8 +103,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) { ...@@ -143,8 +103,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
value={[notifyType]} value={[notifyType]}
onValueChange={(value) => { onValueChange={(value) => {
const nextValue = value.find((item) => item !== notifyType) const nextValue = value.find((item) => item !== notifyType)
if (nextValue) if (nextValue) updateField('notify_type', nextValue as NotifyType)
updateField('notify_type', normalizeNotifyType(nextValue))
}} }}
aria-label={t('Notification Method')} aria-label={t('Notification Method')}
variant='outline' variant='outline'
...@@ -375,22 +334,6 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) { ...@@ -375,22 +334,6 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
} }
/> />
</div> </div>
{/* Record IP Log */}
<div className='flex items-start justify-between gap-3 rounded-lg border p-3 sm:items-center sm:p-4'>
<div className='space-y-0.5'>
<Label htmlFor='recordIp'>{t('Record IP Address')}</Label>
<p className='text-muted-foreground text-xs sm:text-sm'>
{t('Log IP address for usage and error logs')}
</p>
</div>
<Switch
id='recordIp'
className='shrink-0'
checked={settings.record_ip_log}
onCheckedChange={(checked) => updateField('record_ip_log', checked)}
/>
</div>
</div> </div>
{/* Save Button */} {/* Save Button */}
......
...@@ -17,5 +17,3 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,5 +17,3 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
export * from './use-profile' export * from './use-profile'
export * from './use-access-token'
export * from './use-two-fa'
/*
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 i18next from 'i18next'
import { useState, useCallback } from 'react'
import { toast } from 'sonner'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { generateAccessToken } from '../api'
// ============================================================================
// Access Token Hook
// ============================================================================
export function useAccessToken() {
const [token, setToken] = useState<string>('')
const [generating, setGenerating] = useState(false)
const { copyToClipboard } = useCopyToClipboard({ notify: false })
// Generate new access token
const generate = useCallback(async (): Promise<boolean> => {
try {
setGenerating(true)
const response = await generateAccessToken()
if (response.success && response.data) {
setToken(response.data)
copyToClipboard(response.data)
toast.success(i18next.t('Token regenerated and copied to clipboard'))
return true
}
toast.error(response.message || i18next.t('Failed to generate token'))
return false
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to generate token:', error)
toast.error(i18next.t('Failed to generate token'))
return false
} finally {
setGenerating(false)
}
}, [copyToClipboard])
const clearToken = useCallback(() => {
setToken('')
}, [])
return {
token,
generating,
generate,
clearToken,
}
}
...@@ -26,13 +26,9 @@ import { useAuthStore } from '@/stores/auth-store' ...@@ -26,13 +26,9 @@ import { useAuthStore } from '@/stores/auth-store'
import { CheckinCalendarCard } from './components/checkin-calendar-card' import { CheckinCalendarCard } from './components/checkin-calendar-card'
import { LanguagePreferencesCard } from './components/language-preferences-card' import { LanguagePreferencesCard } from './components/language-preferences-card'
import { LoginSessionsCard } from './components/login-sessions-card'
import { PasskeyCard } from './components/passkey-card'
import { ProfileHeader } from './components/profile-header' import { ProfileHeader } from './components/profile-header'
import { ProfileSecurityCard } from './components/profile-security-card'
import { ProfileSettingsCard } from './components/profile-settings-card' import { ProfileSettingsCard } from './components/profile-settings-card'
import { SidebarModulesCard } from './components/sidebar-modules-card' import { SidebarModulesCard } from './components/sidebar-modules-card'
import { TwoFACard } from './components/two-fa-card'
import { useProfile } from './hooks' import { useProfile } from './hooks'
export function Profile() { export function Profile() {
...@@ -67,8 +63,6 @@ export function Profile() { ...@@ -67,8 +63,6 @@ export function Profile() {
profile={profile} profile={profile}
onProfileUpdate={refreshProfile} onProfileUpdate={refreshProfile}
/> />
<ProfileSecurityCard profile={profile} loading={loading} />
<LoginSessionsCard />
</div> </div>
<div className='space-y-4 sm:space-y-6 xl:sticky xl:top-6'> <div className='space-y-4 sm:space-y-6 xl:sticky xl:top-6'>
...@@ -80,8 +74,6 @@ export function Profile() { ...@@ -80,8 +74,6 @@ export function Profile() {
/> />
)} )}
{canConfigureSidebar && <SidebarModulesCard />} {canConfigureSidebar && <SidebarModulesCard />}
<PasskeyCard loading={loading} />
<TwoFACard loading={loading} />
</div> </div>
</div> </div>
</CardStaggerItem> </CardStaggerItem>
......
/*
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 {
DEFAULT_QUOTA_WARNING_THRESHOLD,
NOTIFICATION_METHODS,
} from '../constants'
import type { NotifyType, UpdateUserSettingsRequest } from '../types'
import { parseUserSettings } from './format'
export function normalizeUserSettings(
setting?: string
): Required<UpdateUserSettingsRequest> & { notify_type: NotifyType } {
const parsed = parseUserSettings(setting)
const notifyType =
NOTIFICATION_METHODS.find((method) => method.value === parsed.notify_type)
?.value ?? 'email'
return {
notify_type: notifyType,
quota_warning_threshold:
parsed.quota_warning_threshold ?? DEFAULT_QUOTA_WARNING_THRESHOLD,
notification_email: parsed.notification_email ?? '',
webhook_url: parsed.webhook_url ?? '',
webhook_secret: parsed.webhook_secret ?? '',
bark_url: parsed.bark_url ?? '',
gotify_url: parsed.gotify_url ?? '',
gotify_token: parsed.gotify_token ?? '',
gotify_priority: parsed.gotify_priority ?? 5,
accept_unset_model_ratio_model:
parsed.accept_unset_model_ratio_model || false,
record_ip_log: parsed.record_ip_log || false,
upstream_model_update_notify_enabled:
parsed.upstream_model_update_notify_enabled || false,
}
}
...@@ -16,6 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,6 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import type { UserPermissions } from '@/stores/auth-store'
// ============================================================================ // ============================================================================
// Profile Type Definitions // Profile Type Definitions
// ============================================================================ // ============================================================================
...@@ -33,6 +35,7 @@ export interface ApiResponse<T = unknown> { ...@@ -33,6 +35,7 @@ export interface ApiResponse<T = unknown> {
* User profile data * User profile data
*/ */
export interface UserProfile { export interface UserProfile {
permissions?: UserPermissions
/** User ID */ /** User ID */
id: number id: number
/** Username */ /** Username */
......
/*
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 {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
RouterProvider,
} from '@tanstack/react-router'
import {
cleanup,
render,
screen,
waitFor,
within,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profile } from '@/features/profile'
import type { UserProfile } from '@/features/profile/types'
import { api } from '@/lib/api'
import { useAuthStore } from '@/stores/auth-store'
import { Security } from '../index'
const profile: UserProfile = {
id: 1,
username: 'alice',
display_name: 'Alice',
role: 1,
group: 'default',
quota: 1000000,
used_quota: 0,
request_count: 0,
status: 1,
aff_count: 0,
aff_quota: 0,
aff_history_quota: 0,
created_time: 0,
setting: JSON.stringify({
notify_type: 'email',
quota_warning_threshold: 500000,
}),
}
beforeEach(() => {
vi.stubGlobal('localStorage', {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
})
vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
useAuthStore
.getState()
.auth.setUser({ ...profile, permissions: { sidebar_settings: false } })
vi.spyOn(api, 'get').mockImplementation(async (url) => {
if (url === '/api/user/token/status') {
return {
data: {
success: true,
data: {
exists: false,
token_ref: '',
created_at: null,
last_used_at: null,
last_used_ip: '',
},
},
}
}
if (url === '/api/user/self') {
return { data: { success: true, data: profile } }
}
if (url === '/api/user/passkey') {
return { data: { success: true, data: { enabled: false } } }
}
if (url === '/api/user/2fa/status') {
return {
data: {
success: true,
data: { enabled: false, locked: false, backup_codes_remaining: 0 },
},
}
}
if (url === '/api/user/sessions') {
return { data: { success: true, data: [] } }
}
throw new Error(`Unexpected GET ${url}`)
})
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
useAuthStore.getState().auth.reset()
vi.restoreAllMocks()
})
async function renderPage(path = '/security') {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
client.setQueryData(['status'], {
checkin_enabled: false,
wechat_login: true,
github_oauth: true,
oidc_enabled: true,
custom_oauth_providers: [
{
id: 1,
name: 'Gitea',
slug: 'gitea',
client_id: 'test-client',
authorization_endpoint: 'https://example.com/oauth/authorize',
scopes: 'openid',
},
],
})
const root = createRootRoute()
const security = createRoute({
getParentRoute: () => root,
path: '/security',
component: Security,
})
const personal = createRoute({
getParentRoute: () => root,
path: '/profile',
component: Profile,
})
const router = createRouter({
routeTree: root.addChildren([security, personal]),
history: createMemoryHistory({ initialEntries: [path] }),
})
await router.load()
const rendered = render(
<QueryClientProvider client={client}>
<RouterProvider router={router} />
</QueryClientProvider>
)
return { ...rendered, router }
}
describe('security page migration', () => {
it('places account management on the left and verification and privacy on the right', async () => {
await renderPage()
const login = await screen.findByRole('region', {
name: 'Login & Authentication',
})
expect(
screen
.getAllByRole('region')
.map((region) => within(region).getAllByRole('heading')[0].textContent)
).toEqual([
'Login & Authentication',
'Sessions & Access',
'Account Actions',
'Privacy',
])
expect(
within(login).getByRole('button', { name: 'Change Password' })
).toBeVisible()
expect(within(login).getByText('Account Bindings')).toBeVisible()
const verification = screen.getByRole('complementary', {
name: 'Security verification',
})
expect(await within(verification).findByText('Passkey Login')).toBeVisible()
expect(
await within(verification).findByText('Two-Factor Authentication')
).toBeVisible()
expect(
within(verification).getByRole('switch', { name: 'Record IP Address' })
).toBeVisible()
expect(verification).toHaveClass('xl:sticky', 'xl:top-0')
expect(verification.parentElement).toHaveClass(
'grid',
'xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.46fr)]'
)
const access = screen.getByRole('region', { name: 'Sessions & Access' })
expect(
within(access).getByRole('heading', { name: 'Access Token' })
).toBeVisible()
expect(
await within(access).findByText('No active login sessions')
).toBeVisible()
expect(
screen.getByRole('switch', { name: 'Record IP Address' })
).toBeVisible()
expect(
within(screen.getByRole('region', { name: 'Account Actions' })).getByRole(
'button',
{ name: 'Delete Account' }
)
).toBeVisible()
})
it('built-in and custom bindings share one compact responsive grid', async () => {
await renderPage()
const bindings = await screen.findByRole('list', {
name: 'Account Bindings',
})
expect(within(bindings).getAllByRole('listitem')).toHaveLength(5)
expect(within(bindings).getByText('Gitea')).toBeVisible()
expect(bindings).toHaveClass(
'grid-cols-1',
'sm:grid-cols-2',
'lg:grid-cols-3',
'gap-2'
)
expect(screen.queryByText('Custom OAuth')).not.toBeInTheDocument()
})
it('the password action opens the existing dialog by keyboard and Escape closes it', async () => {
const user = userEvent.setup()
await renderPage()
const action = await screen.findByRole('button', {
name: 'Change Password',
})
action.focus()
await user.keyboard('{Enter}')
const dialog = await screen.findByRole('dialog', {
name: 'Change Password',
})
expect(within(dialog).getByLabelText('Current Password')).toBeVisible()
expect(
within(dialog).getByLabelText('New Password', { exact: true })
).toBeVisible()
await user.keyboard('{Escape}')
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
})
it('Profile retains preferences and no longer mounts security controls or requests', async () => {
await renderPage('/profile')
await waitFor(() =>
expect(
screen.getByRole('button', { name: 'Save Settings' })
).toBeVisible()
)
expect(
screen.queryByRole('button', { name: 'Change Password' })
).not.toBeInTheDocument()
expect(screen.queryByText('Account Bindings')).not.toBeInTheDocument()
expect(
screen.queryByRole('switch', { name: 'Record IP Address' })
).not.toBeInTheDocument()
expect(api.get).not.toHaveBeenCalledWith('/api/user/passkey')
expect(api.get).not.toHaveBeenCalledWith('/api/user/2fa/status')
expect(api.get).not.toHaveBeenCalledWith('/api/user/sessions')
})
it('a failed profile load offers retry before exposing account actions', async () => {
vi.mocked(api.get).mockResolvedValueOnce({ data: { success: false } })
const user = userEvent.setup()
await renderPage()
expect(await screen.findByText('Failed to load profile')).toBeVisible()
expect(
screen.queryByRole('button', { name: 'Delete Account' })
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Retry' }))
expect(
await screen.findByRole('button', { name: 'Delete Account' })
).toBeVisible()
})
})
/*
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 type { ApiResponse } from '@/features/profile/types'
import { api } from '@/lib/api'
export interface AccessTokenStatus {
exists: boolean
token_ref: string
created_at: number | null
last_used_at: number | null
last_used_ip: string
}
export async function getAccessTokenStatus(): Promise<AccessTokenStatus> {
const response = await api.get<ApiResponse<AccessTokenStatus>>(
'/api/user/token/status'
)
if (!response.data.success || !response.data.data) {
throw new Error(response.data.message || 'Failed to load token status')
}
return response.data.data
}
export async function createAccessToken(): Promise<string> {
const response = await api.post<ApiResponse<string>>('/api/user/token')
if (!response.data.success || !response.data.data) {
throw new Error(response.data.message || 'Failed to generate token')
}
return response.data.data
}
export async function revokeAccessToken(): Promise<void> {
const response = await api.delete<ApiResponse>('/api/user/token')
if (!response.data.success) {
throw new Error(response.data.message || 'Failed to revoke token')
}
}
/*
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 {
cleanup,
render,
screen,
waitFor,
within,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Toaster, toast } from 'sonner'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import { useAuthStore } from '@/stores/auth-store'
import type { AccessTokenStatus } from '../../api'
import { AccessTokenCard } from '../access-token-card'
let status: AccessTokenStatus
beforeEach(() => {
vi.stubGlobal('localStorage', {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
})
status = {
exists: false,
token_ref: '',
created_at: null,
last_used_at: null,
last_used_ip: '',
}
vi.spyOn(api, 'get').mockImplementation(async (url) => {
if (url === '/api/audit/self') {
return { data: { success: true, data: { items: [], total: 0 } } }
}
return { data: { success: true, data: status } }
})
})
afterEach(() => {
cleanup()
useAuthStore.getState().auth.reset()
toast.dismiss()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function renderCard() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
render(
<QueryClientProvider client={client}>
<AccessTokenCard />
<Toaster />
</QueryClientProvider>
)
return client
}
describe('system access token management', () => {
it('generates a missing token and clears the one-time plaintext on Escape', async () => {
const token = 'one-time-private-token'
vi.spyOn(api, 'post').mockImplementation(async () => {
status = {
...status,
exists: true,
token_ref: 'a'.repeat(64),
created_at: 1700000000,
}
return { data: { success: true, data: token } }
})
const client = renderCard()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: 'Generate' }))
const dialog = await screen.findByRole('dialog', { name: 'Access Token' })
expect(within(dialog).getByLabelText('Token')).toHaveValue(token)
expect(api.post).toHaveBeenCalledWith('/api/user/token')
await user.keyboard('{Escape}')
await waitFor(() =>
expect(screen.queryByDisplayValue(token)).not.toBeInTheDocument()
)
expect(
JSON.stringify(
client
.getQueryCache()
.getAll()
.map((entry) => entry.state.data)
)
).not.toContain(token)
expect(
JSON.stringify(
client
.getMutationCache()
.getAll()
.map((entry) => entry.state.data)
)
).not.toContain(token)
expect(await screen.findByText('Not used yet')).toBeVisible()
})
it('legacy tokens show unknown creation and usage until records exist', async () => {
status = { ...status, exists: true, token_ref: 'a'.repeat(64) }
renderCard()
expect(await screen.findAllByText('Unknown')).toHaveLength(2)
expect(screen.queryByText('Not used yet')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Generate' })
).not.toBeInTheDocument()
})
it('status failures offer retry without asserting that a token is missing or unused', async () => {
vi.mocked(api.get).mockRejectedValueOnce(new Error('offline'))
renderCard()
expect(await screen.findByRole('alert')).toHaveTextContent(
'Failed to load token status'
)
expect(screen.queryByText('Not generated')).not.toBeInTheDocument()
expect(screen.queryByText('Not used yet')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Generate' })
).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Not generated')).toBeVisible()
})
it('rotation requires confirmation and a failed rotation keeps the existing token state', async () => {
status = { ...status, exists: true, token_ref: 'a'.repeat(64) }
const post = vi
.spyOn(api, 'post')
.mockResolvedValue({ data: { success: false } })
renderCard()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: 'Regenerate' }))
const confirmation = await screen.findByRole('alertdialog')
expect(post).not.toHaveBeenCalled()
await user.click(
within(confirmation).getByRole('button', { name: 'Regenerate token' })
)
expect(await screen.findByText('Failed to generate token')).toBeVisible()
expect(
screen.queryByRole('dialog', { name: 'Access Token' })
).not.toBeInTheDocument()
expect(screen.getByText('Generated')).toBeVisible()
})
it('revocation failures can be retried and success restores the generate action', async () => {
status = { ...status, exists: true, token_ref: 'a'.repeat(64) }
vi.spyOn(api, 'delete')
.mockRejectedValueOnce(new Error('offline'))
.mockImplementation(async () => {
status = { ...status, exists: false, token_ref: '' }
return { data: { success: true } }
})
renderCard()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: 'Revoke' }))
const confirm = within(await screen.findByRole('alertdialog')).getByRole(
'button',
{ name: 'Revoke' }
)
await user.click(confirm)
expect(await screen.findByText('Failed to revoke token')).toBeVisible()
await waitFor(() => expect(confirm).toBeEnabled())
await user.click(confirm)
expect(
await screen.findByRole('button', { name: 'Generate' })
).toBeVisible()
await waitFor(() =>
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
)
})
it('access history opens by keyboard in a full-width mobile sheet and Escape closes it', async () => {
useAuthStore
.getState()
.auth.setUser({ id: 1, username: 'admin', role: 100 })
renderCard()
const user = userEvent.setup()
const trigger = screen.getByRole('button', { name: 'Access records' })
trigger.focus()
await user.keyboard('{Enter}')
const sheet = await screen.findByRole('dialog', { name: 'Access records' })
expect(sheet).toHaveClass('w-full', 'sm:max-w-5xl')
await waitFor(() =>
expect(api.get).toHaveBeenCalledWith('/api/audit/self', {
params: expect.objectContaining({ category: 'access_token' }),
})
)
expect(api.get).not.toHaveBeenCalledWith('/api/audit', expect.anything())
expect(
within(sheet).queryByRole('tablist', { name: 'View scope' })
).not.toBeInTheDocument()
expect(
within(sheet).getByRole('combobox', { name: 'Token scope' })
).toBeVisible()
await user.keyboard('{Escape}')
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
expect(trigger).toHaveFocus()
})
})
/*
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 { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Toaster, toast } from 'sonner'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { UserProfile } from '@/features/profile/types'
import { api } from '@/lib/api'
import { PrivacyCard } from '../privacy-card'
const profile: UserProfile = {
id: 1,
username: 'alice',
display_name: 'Alice',
role: 1,
group: 'default',
quota: 1000000,
used_quota: 0,
request_count: 0,
status: 1,
aff_count: 0,
aff_quota: 0,
aff_history_quota: 0,
created_time: 0,
setting: JSON.stringify({ record_ip_log: true }),
}
afterEach(() => {
toast.dismiss()
vi.restoreAllMocks()
})
function renderPrivacy() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
const onUpdate = vi.fn()
const rendered = render(
<QueryClientProvider client={client}>
<PrivacyCard profile={profile} onUpdate={onUpdate} />
<Toaster />
</QueryClientProvider>
)
return { ...rendered, onUpdate }
}
describe('privacy settings', () => {
it('keyboard toggling off saves false and refreshes the displayed profile', async () => {
vi.spyOn(api, 'get').mockResolvedValue({
data: { success: true, data: profile },
})
const put = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
const user = userEvent.setup()
const { onUpdate } = renderPrivacy()
const toggle = screen.getByRole('switch', { name: 'Record IP Address' })
expect(toggle).toBeChecked()
toggle.focus()
await user.keyboard(' ')
expect(toggle).not.toBeChecked()
await user.click(screen.getByRole('button', { name: 'Save Settings' }))
await waitFor(() => expect(onUpdate).toHaveBeenCalled())
expect(put).toHaveBeenCalledWith(
'/api/user/setting',
expect.objectContaining({ record_ip_log: false })
)
})
it('failed configuration reads preserve the edited toggle and do not submit or refresh', async () => {
vi.spyOn(api, 'get').mockRejectedValue(new Error('offline'))
const put = vi.spyOn(api, 'put')
const user = userEvent.setup()
const { onUpdate } = renderPrivacy()
await user.click(screen.getByRole('switch', { name: 'Record IP Address' }))
await user.click(screen.getByRole('button', { name: 'Save Settings' }))
expect(await screen.findByText('Failed to update settings')).toBeVisible()
expect(put).not.toHaveBeenCalled()
expect(onUpdate).not.toHaveBeenCalled()
expect(
screen.getByRole('switch', { name: 'Record IP Address' })
).not.toBeChecked()
expect(screen.getByRole('button', { name: 'Save Settings' })).toBeEnabled()
})
it('failed saves keep the draft available for retry without refreshing the profile', async () => {
vi.spyOn(api, 'get').mockResolvedValue({
data: { success: true, data: profile },
})
vi.spyOn(api, 'put').mockResolvedValue({ data: { success: false } })
const user = userEvent.setup()
const { onUpdate } = renderPrivacy()
await user.click(screen.getByRole('switch', { name: 'Record IP Address' }))
await user.click(screen.getByRole('button', { name: 'Save Settings' }))
expect(await screen.findByText('Failed to update settings')).toBeVisible()
expect(onUpdate).not.toHaveBeenCalled()
expect(
screen.getByRole('switch', { name: 'Record IP Address' })
).not.toBeChecked()
})
})
/*
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 { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { AuditLogViewer } from '@/features/usage-logs/audit/components/audit-log-viewer'
import dayjs from '@/lib/dayjs'
import { useAccessToken } from '../hooks/use-access-token'
import { AccessTokenDialog } from './dialogs/access-token-dialog'
export function AccessTokenCard() {
const { t } = useTranslation()
const access = useAccessToken()
const [confirmation, setConfirmation] = useState<'rotate' | 'revoke' | null>(
null
)
const [historyOpen, setHistoryOpen] = useState(false)
const pending = access.generate.isPending || access.revoke.isPending
const status = access.status.data
const ready = !access.status.isError && !access.status.isPending && !!status
let lastUsed = t('Unknown')
if (status?.last_used_at) {
lastUsed = dayjs.unix(status.last_used_at).format('YYYY-MM-DD HH:mm:ss')
} else if (status?.created_at) lastUsed = t('Not used yet')
const confirm = async () => {
try {
if (confirmation === 'revoke') await access.revoke.mutateAsync()
else await access.generate.mutateAsync()
setConfirmation(null)
} catch {
/* The mutation displays the error and preserves the confirmation. */
}
}
return (
<>
<Card data-card-hover='false' className='gap-3 p-3 sm:p-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<h4 className='text-sm font-semibold'>{t('Access Token')}</h4>
<Button
size='sm'
variant='outline'
onClick={() => setHistoryOpen(true)}
>
{t('Access records')}
</Button>
</div>
{access.status.isPending && (
<p role='status' className='text-muted-foreground text-xs'>
{t('Loading...')}
</p>
)}
{access.status.isError && (
<div role='alert' className='flex flex-wrap items-center gap-2'>
<span className='text-destructive text-sm'>
{t('Failed to load token status')}
</span>
<Button
size='sm'
variant='outline'
disabled={access.status.isFetching}
onClick={() => void access.status.refetch()}
>
{t('Retry')}
</Button>
</div>
)}
{ready && (
<>
<dl className='grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-4'>
<div>
<dt className='text-muted-foreground'>{t('Status')}</dt>
<dd className='mt-1 font-medium'>
{status.exists ? t('Generated') : t('Not generated')}
</dd>
</div>
{status.exists && (
<>
<div>
<dt className='text-muted-foreground'>{t('Created At')}</dt>
<dd className='mt-1'>
{status.created_at
? dayjs
.unix(status.created_at)
.format('YYYY-MM-DD HH:mm:ss')
: t('Unknown')}
</dd>
</div>
<div>
<dt className='text-muted-foreground'>{t('Last used')}</dt>
<dd className='mt-1'>{lastUsed}</dd>
</div>
<div>
<dt className='text-muted-foreground'>
{t('Last used IP')}
</dt>
<dd className='mt-1 break-all'>
{status.last_used_ip || '—'}
</dd>
</div>
</>
)}
</dl>
<div className='flex flex-wrap justify-end gap-2'>
{status.exists ? (
<>
<Button
size='sm'
variant='outline'
disabled={pending}
onClick={() => setConfirmation('rotate')}
>
{t('Regenerate')}
</Button>
<Button
size='sm'
variant='destructive'
disabled={pending}
onClick={() => setConfirmation('revoke')}
>
{t('Revoke')}
</Button>
</>
) : (
<Button
size='sm'
disabled={pending}
onClick={() => access.generate.mutate()}
>
{t('Generate')}
</Button>
)}
</div>
</>
)}
</Card>
{access.token && (
<AccessTokenDialog token={access.token} onClose={access.clearToken} />
)}
<ConfirmDialog
open={confirmation !== null}
onOpenChange={(open) => {
if (!open && !pending) setConfirmation(null)
}}
title={
confirmation === 'revoke'
? t('Revoke access token?')
: t('Regenerate access token?')
}
desc={t(
'This will immediately invalidate your existing access token. Any applications or scripts using it will stop working.'
)}
confirmText={
confirmation === 'revoke' ? t('Revoke') : t('Regenerate token')
}
destructive
isLoading={pending}
handleConfirm={() => void confirm()}
/>
<Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
<SheetContent className='w-full sm:max-w-5xl' showCloseButton={false}>
<SheetHeader className='border-b pr-20'>
<SheetTitle>{t('Access records')}</SheetTitle>
<SheetDescription>
{t(
'Audit records start after this feature was enabled. Earlier records remain in Common Logs.'
)}
</SheetDescription>
</SheetHeader>
<SheetClose
render={
<Button
size='sm'
variant='ghost'
className='absolute top-3 right-3'
/>
}
>
{t('Close')}
</SheetClose>
<div className='min-h-0 flex-1 px-4 pb-4'>
{historyOpen && (
<AuditLogViewer
scope='self'
accessOnly
currentTokenRef={ready ? status.token_ref : undefined}
/>
)}
</div>
</SheetContent>
</Sheet>
</>
)
}
/*
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 { Shield, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { IconBadge } from '@/components/ui/icon-badge'
import { ChangePasswordDialog } from './dialogs/change-password-dialog'
import { DeleteAccountDialog } from './dialogs/delete-account-dialog'
type AccountActionCardProps = {
action: 'password' | 'delete'
username: string
}
export function AccountActionCard(props: AccountActionCardProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const actions = {
password: {
title: t('Change Password'),
description: t('Update your password to keep your account secure'),
icon: Shield,
},
delete: {
title: t('Delete Account'),
description: t('Permanently delete your account and all data'),
icon: Trash2,
},
}
const action = actions[props.action]
return (
<>
<Card
data-card-hover='false'
className={`gap-0 py-0 ${props.action === 'delete' ? 'ring-destructive/30' : ''}`}
>
<div className='flex items-center gap-3 px-3 py-2.5 sm:px-4'>
<IconBadge tone='neutral' size='sm'>
<action.icon />
</IconBadge>
<div className='min-w-0 flex-1 space-y-0.5'>
<p className='text-sm font-medium'>{action.title}</p>
<p className='text-muted-foreground text-xs'>
{action.description}
</p>
</div>
<Button
type='button'
size='sm'
variant={props.action === 'delete' ? 'destructive' : 'outline'}
onClick={() => setOpen(true)}
>
{action.title}
</Button>
</div>
</Card>
{props.action === 'password' && (
<ChangePasswordDialog
open={open}
onOpenChange={setOpen}
username={props.username}
/>
)}
{props.action === 'delete' && (
<DeleteAccountDialog
open={open}
onOpenChange={setOpen}
username={props.username}
/>
)}
</>
)
}
...@@ -26,7 +26,6 @@ import { IconDiscord } from '@/assets/brand-icons' ...@@ -26,7 +26,6 @@ import { IconDiscord } from '@/assets/brand-icons'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { createOAuthFlow } from '@/features/auth/api' import { createOAuthFlow } from '@/features/auth/api'
import { import {
OAUTH_BIND_CALLBACK_MESSAGE, OAUTH_BIND_CALLBACK_MESSAGE,
...@@ -38,6 +37,8 @@ import { ...@@ -38,6 +37,8 @@ import {
markOAuthBindPopup, markOAuthBindPopup,
} from '@/features/auth/lib/oauth-callback-mode' } from '@/features/auth/lib/oauth-callback-mode'
import type { CustomOAuthProviderInfo } from '@/features/auth/types' import type { CustomOAuthProviderInfo } from '@/features/auth/types'
import { getSelfOAuthBindings, unbindCustomOAuth } from '@/features/profile/api'
import type { UserProfile, BindingItem } from '@/features/profile/types'
import { useDialogs } from '@/hooks/use-dialog' import { useDialogs } from '@/hooks/use-dialog'
import { useStatus } from '@/hooks/use-status' import { useStatus } from '@/hooks/use-status'
import { api } from '@/lib/api' import { api } from '@/lib/api'
...@@ -50,17 +51,15 @@ import { ...@@ -50,17 +51,15 @@ import {
type CustomOAuthBinding, type CustomOAuthBinding,
} from '@/lib/oauth' } from '@/lib/oauth'
import { getSelfOAuthBindings, unbindCustomOAuth } from '../../api' import { EmailBindDialog } from './dialogs/email-bind-dialog'
import type { UserProfile, BindingItem } from '../../types' import { TelegramBindDialog } from './dialogs/telegram-bind-dialog'
import { EmailBindDialog } from '../dialogs/email-bind-dialog' import { WeChatBindDialog } from './dialogs/wechat-bind-dialog'
import { TelegramBindDialog } from '../dialogs/telegram-bind-dialog'
import { WeChatBindDialog } from '../dialogs/wechat-bind-dialog'
// ============================================================================ // ============================================================================
// Account Bindings Tab Component // Account Bindings Tab Component
// ============================================================================ // ============================================================================
interface AccountBindingsTabProps { interface AccountBindingsProps {
profile: UserProfile | null profile: UserProfile | null
onUpdate: () => void onUpdate: () => void
} }
...@@ -83,10 +82,7 @@ interface OAuthBindingCallback { ...@@ -83,10 +82,7 @@ interface OAuthBindingCallback {
errorDescription?: string errorDescription?: string
} }
export function AccountBindingsTab({ export function AccountBindings({ profile, onUpdate }: AccountBindingsProps) {
profile,
onUpdate,
}: AccountBindingsTabProps) {
const { t } = useTranslation() const { t } = useTranslation()
const dialogs = useDialogs<DialogKey>() const dialogs = useDialogs<DialogKey>()
const { status, loading } = useStatus() const { status, loading } = useStatus()
...@@ -419,7 +415,10 @@ export function AccountBindingsTab({ ...@@ -419,7 +415,10 @@ export function AccountBindingsTab({
return ( return (
<> <>
<div className='grid grid-cols-1 gap-2.5 sm:grid-cols-2 sm:gap-3'> <ul
aria-label={t('Account Bindings')}
className='grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3'
>
{bindings.map((binding) => { {bindings.map((binding) => {
let actionLabel = t('Bind') let actionLabel = t('Bind')
if (binding.isBound && binding.id === 'email') { if (binding.isBound && binding.id === 'email') {
...@@ -429,17 +428,22 @@ export function AccountBindingsTab({ ...@@ -429,17 +428,22 @@ export function AccountBindingsTab({
} }
return ( return (
<div <li
key={binding.id} key={binding.id}
className='flex items-center justify-between gap-2.5 rounded-lg border p-2.5 sm:gap-3 sm:p-3' className='flex min-w-0 items-center justify-between gap-2 rounded-lg border px-2.5 py-2'
> >
<div className='flex min-w-0 items-center gap-2.5 sm:gap-3'> <div className='flex min-w-0 items-center gap-2'>
<div className='bg-muted shrink-0 rounded-md p-1.5 sm:p-2'> <div className='bg-muted shrink-0 rounded-md p-1.5'>
<binding.icon className='h-4 w-4' /> <binding.icon className='h-4 w-4' />
</div> </div>
<div className='min-w-0'> <div className='min-w-0'>
<div className='flex items-center gap-1.5'> <div className='flex items-center gap-1.5'>
<p className='text-sm font-medium'>{binding.label}</p> <p
className='truncate text-sm font-medium'
title={binding.label}
>
{binding.label}
</p>
{binding.isBound && ( {binding.isBound && (
<StatusBadge <StatusBadge
label={t('Bound')} label={t('Bound')}
...@@ -462,34 +466,29 @@ export function AccountBindingsTab({ ...@@ -462,34 +466,29 @@ export function AccountBindingsTab({
> >
{actionLabel} {actionLabel}
</Button> </Button>
</div> </li>
) )
})} })}
</div> {customProviders?.map((provider) => {
{/* Custom OAuth Bindings */}
{customProviders && customProviders.length > 0 && (
<>
<Separator className='my-4' />
<p className='text-muted-foreground mb-3 text-sm font-medium'>
{t('Custom OAuth')}
</p>
<div className='grid grid-cols-1 gap-2.5 sm:grid-cols-2 sm:gap-3'>
{customProviders.map((provider) => {
const binding = customBindingsByProviderId.get(provider.id) const binding = customBindingsByProviderId.get(provider.id)
const isBound = !!binding const isBound = !!binding
return ( return (
<div <li
key={provider.id} key={provider.id}
className='flex items-center justify-between gap-2.5 rounded-lg border p-2.5 sm:gap-3 sm:p-3' className='flex min-w-0 items-center justify-between gap-2 rounded-lg border px-2.5 py-2'
> >
<div className='flex min-w-0 items-center gap-2.5 sm:gap-3'> <div className='flex min-w-0 items-center gap-2'>
<div className='bg-muted shrink-0 rounded-md p-1.5 sm:p-2'> <div className='bg-muted shrink-0 rounded-md p-1.5'>
<Link2 className='h-4 w-4' /> <Link2 className='h-4 w-4' />
</div> </div>
<div className='min-w-0'> <div className='min-w-0'>
<div className='flex items-center gap-1.5'> <div className='flex items-center gap-1.5'>
<p className='text-sm font-medium'>{provider.name}</p> <p
className='truncate text-sm font-medium'
title={provider.name}
>
{provider.name}
</p>
{isBound && ( {isBound && (
<StatusBadge <StatusBadge
label={t('Bound')} label={t('Bound')}
...@@ -525,12 +524,10 @@ export function AccountBindingsTab({ ...@@ -525,12 +524,10 @@ export function AccountBindingsTab({
{t('Bind')} {t('Bind')}
</Button> </Button>
)} )}
</div> </li>
) )
})} })}
</div> </ul>
</>
)}
{/* Custom OAuth Unbind Confirmation */} {/* Custom OAuth Unbind Confirmation */}
<ConfirmDialog <ConfirmDialog
......
/*
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 { useTranslation } from 'react-i18next'
import { CopyButton } from '@/components/copy-button'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
export function AccessTokenDialog(props: {
token: string
onClose: () => void
}) {
const { t } = useTranslation()
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) props.onClose()
}}
title={t('Access Token')}
description={t(
"Save this token now. You won't be able to view it again after closing this dialog."
)}
contentClassName='sm:max-w-md'
contentHeight='auto'
footer={<Button onClick={props.onClose}>{t('Close')}</Button>}
>
<div className='space-y-2 py-2'>
<Label htmlFor='generated-access-token'>{t('Token')}</Label>
<div className='flex gap-2'>
<Input
id='generated-access-token'
value={props.token}
readOnly
autoComplete='off'
className='font-mono text-xs'
/>
<CopyButton
value={props.token}
variant='outline'
tooltip={t('Copy token')}
aria-label={t('Copy token')}
/>
</div>
</div>
</Dialog>
)
}
...@@ -25,8 +25,7 @@ import { Dialog } from '@/components/dialog' ...@@ -25,8 +25,7 @@ import { Dialog } from '@/components/dialog'
import { PasswordInput } from '@/components/password-input' import { PasswordInput } from '@/components/password-input'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { updateUserProfile } from '@/features/profile/api'
import { updateUserProfile } from '../../api'
// ============================================================================ // ============================================================================
// Change Password Dialog Component // Change Password Dialog Component
...@@ -102,7 +101,7 @@ export function ChangePasswordDialog({ ...@@ -102,7 +101,7 @@ export function ChangePasswordDialog({
} else { } else {
toast.error(response.message || t('Failed to change password')) toast.error(response.message || t('Failed to change password'))
} }
} catch (_error) { } catch {
toast.error(t('Failed to change password')) toast.error(t('Failed to change password'))
} finally { } finally {
setLoading(false) setLoading(false)
......
...@@ -28,10 +28,9 @@ import { Button } from '@/components/ui/button' ...@@ -28,10 +28,9 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { logout } from '@/features/auth/api' import { logout } from '@/features/auth/api'
import { deleteUserAccount } from '@/features/profile/api'
import { clearAuthentication } from '@/lib/api' import { clearAuthentication } from '@/lib/api'
import { deleteUserAccount } from '../../api'
// ============================================================================ // ============================================================================
// Delete Account Dialog Component // Delete Account Dialog Component
// ============================================================================ // ============================================================================
......
...@@ -25,10 +25,9 @@ import { Dialog } from '@/components/dialog' ...@@ -25,10 +25,9 @@ import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { sendEmailVerification, bindEmail } from '@/features/profile/api'
import { useCountdown } from '@/hooks/use-countdown' import { useCountdown } from '@/hooks/use-countdown'
import { sendEmailVerification, bindEmail } from '../../api'
// ============================================================================ // ============================================================================
// Email Bind Dialog Component // Email Bind Dialog Component
// ============================================================================ // ============================================================================
...@@ -76,7 +75,7 @@ export function EmailBindDialog({ ...@@ -76,7 +75,7 @@ export function EmailBindDialog({
} else { } else {
toast.error(response.message || t('Failed to send verification code')) toast.error(response.message || t('Failed to send verification code'))
} }
} catch (_error) { } catch {
toast.error(t('Failed to send verification code')) toast.error(t('Failed to send verification code'))
} finally { } finally {
setSendingCode(false) setSendingCode(false)
...@@ -104,7 +103,7 @@ export function EmailBindDialog({ ...@@ -104,7 +103,7 @@ export function EmailBindDialog({
} else { } else {
toast.error(response.message || t('Failed to bind email')) toast.error(response.message || t('Failed to bind email'))
} }
} catch (_error) { } catch {
toast.error(t('Failed to bind email')) toast.error(t('Failed to bind email'))
} finally { } finally {
setLoading(false) setLoading(false)
...@@ -123,6 +122,10 @@ export function EmailBindDialog({ ...@@ -123,6 +122,10 @@ export function EmailBindDialog({
} }
} }
let sendLabel = t('Send')
if (sendingCode) sendLabel = t('Sending...')
if (isActive) sendLabel = `${secondsLeft}s`
return ( return (
<Dialog <Dialog
open={open} open={open}
...@@ -189,11 +192,7 @@ export function EmailBindDialog({ ...@@ -189,11 +192,7 @@ export function EmailBindDialog({
onClick={handleSendCode} onClick={handleSendCode}
disabled={sendingCode || isActive || !email} disabled={sendingCode || isActive || !email}
> >
{isActive {sendLabel}
? `${secondsLeft}s`
: sendingCode
? t('Sending...')
: t('Send')}
</Button> </Button>
</div> </div>
</div> </div>
......
...@@ -26,10 +26,9 @@ import { Alert, AlertDescription } from '@/components/ui/alert' ...@@ -26,10 +26,9 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { TELEGRAM_BIND_RESULT_MESSAGE } from '@/features/auth/constants' import { TELEGRAM_BIND_RESULT_MESSAGE } from '@/features/auth/constants'
import { startTelegramBind } from '@/features/profile/api'
import { getServerErrorMessageKey } from '@/lib/server-error-message' import { getServerErrorMessageKey } from '@/lib/server-error-message'
import { startTelegramBind } from '../../api'
// ============================================================================ // ============================================================================
// Telegram Bind Dialog Component // Telegram Bind Dialog Component
// ============================================================================ // ============================================================================
......
...@@ -65,7 +65,7 @@ export function TwoFABackupDialog({ ...@@ -65,7 +65,7 @@ export function TwoFABackupDialog({
} else { } else {
toast.error(response.message || t('Failed to regenerate backup codes')) toast.error(response.message || t('Failed to regenerate backup codes'))
} }
} catch (_error) { } catch {
toast.error(t('Failed to regenerate backup codes')) toast.error(t('Failed to regenerate backup codes'))
} finally { } finally {
setLoading(false) setLoading(false)
...@@ -107,8 +107,7 @@ export function TwoFABackupDialog({ ...@@ -107,8 +107,7 @@ export function TwoFABackupDialog({
contentHeight='auto' contentHeight='auto'
bodyClassName='space-y-4' bodyClassName='space-y-4'
footer={ footer={
<> backupCodes.length === 0 ? (
{backupCodes.length === 0 ? (
<> <>
<Button <Button
variant='outline' variant='outline'
...@@ -124,8 +123,7 @@ export function TwoFABackupDialog({ ...@@ -124,8 +123,7 @@ export function TwoFABackupDialog({
</> </>
) : ( ) : (
<Button onClick={handleDone}>{t('Done')}</Button> <Button onClick={handleDone}>{t('Done')}</Button>
)} )
</>
} }
> >
<div className='space-y-4 py-4'> <div className='space-y-4 py-4'>
...@@ -163,9 +161,9 @@ export function TwoFABackupDialog({ ...@@ -163,9 +161,9 @@ export function TwoFABackupDialog({
<div className='rounded-lg border p-4'> <div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2'> <div className='grid grid-cols-2 gap-2'>
{backupCodes.map((code, index) => ( {backupCodes.map((code) => (
<div <div
key={index} key={code}
className='bg-muted rounded-md p-2 text-center font-mono text-sm' className='bg-muted rounded-md p-2 text-center font-mono text-sm'
> >
{code} {code}
......
...@@ -74,7 +74,7 @@ export function TwoFADisableDialog({ ...@@ -74,7 +74,7 @@ export function TwoFADisableDialog({
} else { } else {
toast.error(response.message || t('Failed to disable 2FA')) toast.error(response.message || t('Failed to disable 2FA'))
} }
} catch (_error) { } catch {
toast.error(t('Failed to disable 2FA')) toast.error(t('Failed to disable 2FA'))
} finally { } finally {
setLoading(false) setLoading(false)
......
...@@ -28,10 +28,9 @@ import { Alert, AlertDescription } from '@/components/ui/alert' ...@@ -28,10 +28,9 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import type { TwoFASetupData } from '@/features/profile/types'
import { setup2FA, enable2FA } from '@/lib/api' import { setup2FA, enable2FA } from '@/lib/api'
import type { TwoFASetupData } from '../../types'
// ============================================================================ // ============================================================================
// Two-FA Setup Dialog Component // Two-FA Setup Dialog Component
// ============================================================================ // ============================================================================
...@@ -102,7 +101,7 @@ export function TwoFASetupDialog({ ...@@ -102,7 +101,7 @@ export function TwoFASetupDialog({
} else { } else {
toast.error(response.message || t('Failed to enable 2FA')) toast.error(response.message || t('Failed to enable 2FA'))
} }
} catch (_error) { } catch {
toast.error(t('Failed to enable 2FA')) toast.error(t('Failed to enable 2FA'))
} finally { } finally {
setLoading(false) setLoading(false)
...@@ -177,20 +176,22 @@ export function TwoFASetupDialog({ ...@@ -177,20 +176,22 @@ export function TwoFASetupDialog({
} }
> >
<div className='space-y-4 py-4'> <div className='space-y-4 py-4'>
{initializing ? ( {initializing && (
<div className='flex flex-col items-center justify-center gap-3 py-8'> <div className='flex flex-col items-center justify-center gap-3 py-8'>
<div className='border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent' /> <div className='border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent' />
<div className='text-muted-foreground text-sm'> <div className='text-muted-foreground text-sm'>
{t('Setting up 2FA...')} {t('Setting up 2FA...')}
</div> </div>
</div> </div>
) : !setupData ? ( )}
{!initializing && !setupData && (
<div className='flex justify-center py-8'> <div className='flex justify-center py-8'>
<div className='text-muted-foreground'> <div className='text-muted-foreground'>
{t('Failed to load setup data')} {t('Failed to load setup data')}
</div> </div>
</div> </div>
) : ( )}
{!initializing && setupData && (
<> <>
{/* Step 0: QR Code */} {/* Step 0: QR Code */}
{step === 0 && ( {step === 0 && (
...@@ -236,9 +237,9 @@ export function TwoFASetupDialog({ ...@@ -236,9 +237,9 @@ export function TwoFASetupDialog({
</Alert> </Alert>
<div className='rounded-lg border p-4'> <div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2'> <div className='grid grid-cols-2 gap-2'>
{setupData.backup_codes.map((code, index) => ( {setupData.backup_codes.map((code) => (
<div <div
key={index} key={code}
className='bg-muted rounded-md p-2 text-center font-mono text-sm' className='bg-muted rounded-md p-2 text-center font-mono text-sm'
> >
{code} {code}
......
...@@ -25,8 +25,7 @@ import { Button } from '@/components/ui/button' ...@@ -25,8 +25,7 @@ import { Button } from '@/components/ui/button'
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field' import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { bindWeChat } from '@/features/profile/api'
import { bindWeChat } from '../../api'
interface WeChatBindDialogProps { interface WeChatBindDialogProps {
open: boolean open: boolean
......
...@@ -42,14 +42,14 @@ import { ...@@ -42,14 +42,14 @@ import {
} from '@/components/ui/empty' } from '@/components/ui/empty'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { clearAuthenticatedClientState } from '@/lib/api'
import type { LoginSession } from '@/stores/auth-store'
import { import {
getLoginSessions, getLoginSessions,
revokeLoginSession, revokeLoginSession,
revokeOtherLoginSessions, revokeOtherLoginSessions,
} from '../api' } from '@/features/profile/api'
import { clearAuthenticatedClientState } from '@/lib/api'
import type { LoginSession } from '@/stores/auth-store'
import { LoginSessionDialogs } from './login-session-dialogs' import { LoginSessionDialogs } from './login-session-dialogs'
import { LoginSessionItem } from './login-session-item' import { LoginSessionItem } from './login-session-item'
......
/*
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 { useMutation } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { TitledCard } from '@/components/ui/titled-card'
import { updateUserSettings } from '@/features/profile/api'
import { parseUserSettings } from '@/features/profile/lib/format'
import type { UserProfile } from '@/features/profile/types'
type PrivacyCardProps = {
profile: UserProfile
onUpdate: () => void
}
export function PrivacyCard(props: PrivacyCardProps) {
const { t } = useTranslation()
const [recordIpLog, setRecordIpLog] = useState(() =>
Boolean(parseUserSettings(props.profile.setting).record_ip_log)
)
useEffect(() => {
setRecordIpLog(
Boolean(parseUserSettings(props.profile.setting).record_ip_log)
)
}, [props.profile.setting])
const save = useMutation({
mutationFn: async () => {
const response = await updateUserSettings({ record_ip_log: recordIpLog })
if (!response.success) {
throw new Error(response.message || t('Failed to update settings'))
}
},
onSuccess: () => {
toast.success(t('Settings updated successfully'))
props.onUpdate()
},
onError: () => toast.error(t('Failed to update settings')),
})
return (
<TitledCard
title={t('Record IP Address')}
description={t('Log IP address for usage and error logs')}
disableHoverEffect
>
<div className='flex items-center justify-between gap-4'>
<Label htmlFor='security-record-ip'>{t('Record IP Address')}</Label>
<Switch
id='security-record-ip'
checked={recordIpLog}
onCheckedChange={setRecordIpLog}
disabled={save.isPending}
/>
</div>
<div className='mt-4 flex justify-end'>
<Button
type='button'
onClick={() => save.mutate()}
disabled={save.isPending}
>
{save.isPending && <Loader2 className='size-4 animate-spin' />}
{save.isPending ? t('Saving...') : t('Save Settings')}
</Button>
</div>
</TitledCard>
)
}
...@@ -32,7 +32,7 @@ import { IconBadge } from '@/components/ui/icon-badge' ...@@ -32,7 +32,7 @@ import { IconBadge } from '@/components/ui/icon-badge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useDialogs } from '@/hooks/use-dialog' import { useDialogs } from '@/hooks/use-dialog'
import { useTwoFA } from '../hooks' import { useTwoFA } from '../hooks/use-two-fa'
import { TwoFABackupDialog } from './dialogs/two-fa-backup-dialog' import { TwoFABackupDialog } from './dialogs/two-fa-backup-dialog'
import { TwoFADisableDialog } from './dialogs/two-fa-disable-dialog' import { TwoFADisableDialog } from './dialogs/two-fa-disable-dialog'
import { TwoFASetupDialog } from './dialogs/two-fa-setup-dialog' import { TwoFASetupDialog } from './dialogs/two-fa-setup-dialog'
......
/*
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth-store'
import {
createAccessToken,
getAccessTokenStatus,
revokeAccessToken,
} from '../api'
export function useAccessToken() {
const { t } = useTranslation()
const client = useQueryClient()
const userId = useAuthStore((state) => state.auth.user?.id)
const statusKey = ['security', 'access-token', 'status', userId] as const
const [token, setToken] = useState('')
const status = useQuery({
queryKey: statusKey,
queryFn: getAccessTokenStatus,
retry: false,
})
const refresh = () => client.invalidateQueries({ queryKey: statusKey })
const generate = useMutation({
// Keep plaintext out of the query/mutation cache and persistent storage.
mutationFn: async () => {
setToken(await createAccessToken())
},
onSuccess: refresh,
onError: () => {
toast.error(t('Failed to generate token'))
void refresh()
},
})
const revoke = useMutation({
mutationFn: revokeAccessToken,
onSuccess: () => {
setToken('')
toast.success(t('Access token revoked'))
return refresh()
},
onError: () => {
toast.error(t('Failed to revoke token'))
void refresh()
},
})
return { status, token, clearToken: () => setToken(''), generate, revoke }
}
...@@ -18,10 +18,9 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,10 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import type { TwoFAStatus } from '@/features/profile/types'
import { get2FAStatus } from '@/lib/api' import { get2FAStatus } from '@/lib/api'
import type { TwoFAStatus } from '../types'
// ============================================================================ // ============================================================================
// Two-FA Hook // Two-FA Hook
// ============================================================================ // ============================================================================
......
/*
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 { Link2 } from 'lucide-react'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { SectionPageLayout } from '@/components/layout/components/section-page-layout'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton'
import { TitledCard } from '@/components/ui/titled-card'
import { useProfile } from '@/features/profile/hooks/use-profile'
import { AccessTokenCard } from './components/access-token-card'
import { AccountActionCard } from './components/account-action-card'
import { AccountBindings } from './components/account-bindings'
import { LoginSessionsCard } from './components/login-sessions-card'
import { PasskeyCard } from './components/passkey-card'
import { PrivacyCard } from './components/privacy-card'
import { TwoFACard } from './components/two-fa-card'
export function Security() {
const { t } = useTranslation()
const { profile, loading, refreshProfile, fetchProfile } = useProfile()
let content: ReactNode
if (loading) {
content = (
<div role='status' aria-label={t('Loading...')} className='space-y-4'>
<Skeleton className='h-28 w-full' />
<Skeleton className='h-52 w-full' />
<Skeleton className='h-52 w-full' />
</div>
)
} else if (!profile) {
content = (
<Empty>
<EmptyHeader>
<EmptyTitle>{t('Failed to load profile')}</EmptyTitle>
<EmptyDescription>
{t('Refresh the list and try again.')}
</EmptyDescription>
</EmptyHeader>
<Button
type='button'
variant='outline'
onClick={() => void fetchProfile()}
>
{t('Retry')}
</Button>
</Empty>
)
} else {
content = (
<div className='grid gap-4 sm:gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.46fr)] xl:items-start'>
<div className='min-w-0 space-y-4 sm:space-y-6'>
<section
aria-labelledby='security-authentication'
className='space-y-3'
>
<h3 id='security-authentication' className='text-sm font-semibold'>
{t('Login & Authentication')}
</h3>
<AccountActionCard action='password' username={profile.username} />
<TitledCard
title={t('Account Bindings')}
icon={<Link2 className='size-4' />}
headerClassName='px-3 py-2.5 !pb-2.5 sm:px-4 sm:py-2.5 sm:!pb-2.5'
contentClassName='p-3 sm:p-3'
titleClassName='text-sm sm:text-sm'
iconClassName='size-7 sm:size-7'
disableHoverEffect
>
<AccountBindings profile={profile} onUpdate={refreshProfile} />
</TitledCard>
</section>
<section aria-labelledby='security-access' className='space-y-4'>
<h3 id='security-access' className='text-sm font-semibold'>
{t('Sessions & Access')}
</h3>
<LoginSessionsCard />
<AccessTokenCard />
</section>
<section aria-labelledby='security-account' className='space-y-4'>
<h3 id='security-account' className='text-sm font-semibold'>
{t('Account Actions')}
</h3>
<AccountActionCard action='delete' username={profile.username} />
</section>
</div>
<aside
aria-labelledby='security-verification'
className='min-w-0 space-y-4 sm:space-y-6 xl:sticky xl:top-0'
>
<div className='space-y-3'>
<h3 id='security-verification' className='text-sm font-semibold'>
{t('Security verification')}
</h3>
<PasskeyCard loading={loading} />
<TwoFACard loading={loading} />
</div>
<section aria-labelledby='security-privacy' className='space-y-4'>
<h3 id='security-privacy' className='text-sm font-semibold'>
{t('Privacy')}
</h3>
<PrivacyCard profile={profile} onUpdate={refreshProfile} />
</section>
</aside>
</div>
)
}
return (
<SectionPageLayout>
<SectionPageLayout.Title>
{t('Security & Access')}
</SectionPageLayout.Title>
<SectionPageLayout.Content>
<div className='mx-auto w-full max-w-7xl'>{content}</div>
</SectionPageLayout.Content>
</SectionPageLayout>
)
}
...@@ -64,6 +64,7 @@ export const SIDEBAR_MODULES_DEFAULT: SidebarModulesAdminConfig = { ...@@ -64,6 +64,7 @@ export const SIDEBAR_MODULES_DEFAULT: SidebarModulesAdminConfig = {
detail: true, detail: true,
token: true, token: true,
log: true, log: true,
audit: true,
midjourney: true, midjourney: true,
task: true, task: true,
}, },
...@@ -71,6 +72,7 @@ export const SIDEBAR_MODULES_DEFAULT: SidebarModulesAdminConfig = { ...@@ -71,6 +72,7 @@ export const SIDEBAR_MODULES_DEFAULT: SidebarModulesAdminConfig = {
enabled: true, enabled: true,
topup: true, topup: true,
personal: true, personal: true,
security: true,
}, },
admin: { admin: {
enabled: true, enabled: true,
......
...@@ -110,6 +110,10 @@ export function SidebarModulesSection({ ...@@ -110,6 +110,10 @@ export function SidebarModulesSection({
title: t('Usage logs'), title: t('Usage logs'),
description: t('Detailed request logs for investigations.'), description: t('Detailed request logs for investigations.'),
}, },
audit: {
title: t('Audit Logs'),
description: t('Login, security and access records'),
},
midjourney: { midjourney: {
title: t('Drawing logs'), title: t('Drawing logs'),
description: t('History of MjProxy-style image tasks.'), description: t('History of MjProxy-style image tasks.'),
...@@ -128,6 +132,10 @@ export function SidebarModulesSection({ ...@@ -128,6 +132,10 @@ export function SidebarModulesSection({
title: t('Profile'), title: t('Profile'),
description: t('Personal settings and profile management.'), description: t('Personal settings and profile management.'),
}, },
security: {
title: t('Security & Access'),
description: t('Manage your security settings and account access'),
},
}, },
admin: { admin: {
channel: { channel: {
......
/*
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 {
render,
screen,
within,
waitFor,
cleanup,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createInstance } from 'i18next'
import { I18nextProvider } from 'react-i18next'
import { afterEach, expect, it, vi } from 'vitest'
import zh from '@/i18n/locales/zh.json'
import type { AuditLog } from '../api'
import { AuditLogDetailsDialog } from '../components/audit-log-details-dialog'
const userAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/152.0.0.0 Safari/537.36'
const entry: AuditLog = {
event_id: 'audit-event-1',
user_id: 1,
username: 'root',
actor_role: 100,
created_at: 1788600600,
category: 'operation',
action: 'channel.update',
token_ref: '',
ip: '::1',
user_agent: userAgent,
method: 'PUT',
route: '/api/channel/',
status: 200,
success: true,
request_id: '20260905092951096890008268d9d6b7oeNCj3',
content: 'Updated channel batch (ID: 42)',
other: {
admin_info: {
admin_id: 1,
admin_username: 'root',
admin_role: 100,
auth_method: 'session',
},
op: {
action: 'channel.update',
params: { id: 42, name: 'batch', changed_fields: [] },
},
},
}
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it.each([
[100, 'root'],
[10, 'admin'],
[1, 'user'],
] as const)('keeps role %i as %s in Chinese', async (role, label) => {
const i18n = createInstance()
await i18n.init({ lng: 'zh', resources: { zh } })
const log = {
...entry,
actor_role: role,
other: {
admin_info: { admin_username: 'literal-name', admin_role: role },
op: { action: 'channel.update', params: { id: 42, name: 'batch' } },
},
}
render(
<I18nextProvider i18n={i18n}>
<AuditLogDetailsDialog entry={log} />
</I18nextProvider>
)
await userEvent.click(screen.getByRole('button', { name: '详情' }))
const dialog = await screen.findByRole('dialog', { name: '日志详情' })
expect(within(dialog).getByText(label)).toBeVisible()
expect(within(dialog).getByText('literal-name (ID: 1)')).toBeVisible()
})
it('uses the returned authentication method for personal access records', async () => {
const { dialog } = await openDetails({
...entry,
auth_method: 'access_token',
})
expect(within(dialog).getByText('Access Token')).toBeVisible()
expect(within(dialog).queryByText('Session')).not.toBeInTheDocument()
})
async function openDetails(log: AuditLog = entry) {
render(<AuditLogDetailsDialog entry={log} />)
const trigger = screen.getByRole('button', { name: 'Details' })
trigger.focus()
await userEvent.keyboard('{Enter}')
return {
trigger,
dialog: await screen.findByRole('dialog', { name: 'Log Details' }),
}
}
it('renders the channel update as a readable summary and compact operation rows without JSON or empty token fields', async () => {
const { dialog } = await openDetails()
expect(
within(dialog).getByText('Updated channel batch (ID: 42)')
).toBeVisible()
expect(
within(dialog).getByText('Field change details were not recorded')
).toBeVisible()
expect(within(dialog).getByText('root')).toBeVisible()
expect(within(dialog).getByText('Session')).toBeVisible()
expect(within(dialog).getByText(userAgent)).toBeVisible()
expect(within(dialog).queryByText('Token identifier')).not.toBeInTheDocument()
expect(
within(dialog).queryByRole('button', { name: /Expand|Collapse/ })
).not.toBeInTheDocument()
expect(dialog.querySelector('pre')).not.toBeInTheDocument()
expect(dialog).not.toHaveTextContent('changed_fields')
})
it('aligns every request field in the same label and value columns', async () => {
const { dialog } = await openDetails({
...entry,
token_ref: 'token-fingerprint',
})
for (const label of [
'Method',
'HTTP',
'IP',
'Client',
'Route',
'Request ID',
'Token identifier',
]) {
const row = within(dialog).getByText(label, { exact: true }).parentElement
expect(row).toHaveClass(
'grid',
'grid-cols-[5.25rem_minmax(0,1fr)]',
'sm:grid-cols-[7rem_minmax(0,1fr)]'
)
}
})
it('shows long values in full, copies the complete identifier, and restores focus when dismissed', async () => {
const user = userEvent.setup()
const writeText = vi
.spyOn(navigator.clipboard, 'writeText')
.mockResolvedValue()
const requestId = `request-${'x'.repeat(100)}`
const { trigger, dialog } = await openDetails({
...entry,
request_id: requestId,
})
expect(within(dialog).getByText(requestId)).toBeVisible()
expect(within(dialog).getByText(requestId)).not.toHaveClass('truncate')
expect(
within(dialog).queryByRole('button', { name: /Expand|Collapse/ })
).not.toBeInTheDocument()
await user.click(
within(dialog).getByRole('button', { name: 'Copy Request ID' })
)
expect(writeText).toHaveBeenCalledWith(requestId)
expect(within(dialog).getByText(userAgent)).toBeVisible()
await user.keyboard('{Escape}')
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
await waitFor(() => expect(trigger).toHaveFocus())
})
it.each([
[
'channel.status_update',
{ id: 42, status: 2, changed: false },
['Disabled', 'No'],
],
[
'channel.status_update_batch',
{ count: 0, total: 3, status: 1 },
['Enabled', '0 / 3'],
],
['user.create', { username: 'alice', role: 10 }, ['admin', 'alice']],
])(
'formats known parameters for %s without losing zero or false',
async (action, params, values) => {
const { dialog } = await openDetails({
...entry,
action,
other: { op: { action, params } },
})
for (const value of values) {
expect(
within(dialog)
.getAllByText(value)
.some((node) => node.textContent === value)
).toBe(true)
}
}
)
it('translates changed field names and shows unknown nested metadata as readable fields', async () => {
const { dialog } = await openDetails({
...entry,
other: {
op: {
action: 'channel.update',
params: {
id: 42,
name: 'batch',
changed_fields: ['models', 'group'],
custom: { attempts: 0, permitted: false },
},
},
},
})
expect(within(dialog).getByText('Models, Group')).toBeVisible()
expect(within(dialog).getByText('custom')).toBeVisible()
expect(
within(dialog).queryByRole('button', { name: 'custom' })
).not.toBeInTheDocument()
expect(within(dialog).getByText('attempts')).toBeVisible()
expect(within(dialog).getByText('0')).toBeVisible()
expect(within(dialog).getByText('No')).toBeVisible()
expect(dialog).not.toHaveTextContent('[object Object]')
expect(dialog.querySelector('pre')).not.toBeInTheDocument()
})
it.each([null, undefined, '', '{broken', [], 42])(
'preserves request details when metadata is missing or invalid (%s)',
async (other) => {
const { dialog } = await openDetails({
...entry,
other: other as unknown as AuditLog['other'],
})
expect(within(dialog).getByText(entry.request_id)).toBeVisible()
expect(within(dialog).getByText(entry.ip)).toBeVisible()
expect(dialog).not.toHaveTextContent('{broken')
expect(dialog.querySelector('pre')).not.toBeInTheDocument()
}
)
it.each([
[
'login',
'login',
{ method: 'password' },
'Logged in successfully via Password',
],
['security', 'user.2fa_enable', {}, 'Enabled two-factor authentication'],
['access_token', 'access_token.request', {}, 'Access Token'],
])(
'shows summary and result for %s records',
async (category, action, params, summary) => {
const { dialog } = await openDetails({
...entry,
category,
action,
actor_role: 1,
content: '',
success: false,
status: 403,
other: { op: { action, params } },
})
expect(within(dialog).getAllByText(summary).length).toBeGreaterThan(0)
expect(within(dialog).getByText('Failed')).toBeVisible()
expect(within(dialog).getByText('403')).toBeVisible()
expect(within(dialog).queryByText('root')).not.toBeInTheDocument()
expect(within(dialog).getByText('user')).toBeVisible()
}
)
/*
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 type { ApiResponse } from '@/features/profile/types'
import { api } from '@/lib/api'
export interface AuditLog {
event_id: string
user_id: number
username: string
actor_role: number
created_at: number
category: string
action: string
token_ref: string
auth_method?: string
ip: string
user_agent: string
method: string
route: string
status: number
success: boolean
request_id: string
content: string
other: Record<string, unknown> | null
}
export interface AuditFilters {
p: number
page_size: number
start_timestamp?: number
end_timestamp?: number
success?: string
category?: string
token_ref?: string
exclude_token_ref?: string
username?: string
request_id?: string
}
export async function getAuditLogs(
scope: 'all' | 'self',
params: AuditFilters
): Promise<{ items: AuditLog[]; total: number }> {
const response = await api.get<
ApiResponse<{ items: AuditLog[]; total: number }>
>(scope === 'all' ? '/api/audit' : '/api/audit/self', { params })
if (!response.data.success || !response.data.data) {
throw new Error(response.data.message || 'Failed to load audit records')
}
return response.data.data
}
/*
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 { useTranslation } from 'react-i18next'
import { DetailRow } from '../../components/dialogs/log-detail-layout'
import { auditFieldLabel, isAuditDetailObject } from '../lib/audit-details'
import { AuditDetailValue } from './audit-detail-value'
export function AuditDetailFields(props: {
fields: { label: string; value: unknown }[]
}) {
const { t } = useTranslation()
return props.fields.map((field) => {
const value = field.value
if (value === null || value === undefined || value === '') return null
if (isAuditDetailObject(value) || Array.isArray(value)) {
const entries = Array.isArray(value)
? value.map((item, i) => [String(i + 1), item] as const)
: Object.entries(value)
if (!entries.length) return null
return (
<div key={field.label} className='min-w-0 space-y-1'>
<div className='text-muted-foreground text-xs break-all'>
{field.label}
</div>
<div className='space-y-1 border-l pl-2'>
<AuditDetailFields
fields={entries.map(([key, item]) => ({
label: auditFieldLabel(key, t),
value: item,
}))}
/>
</div>
</div>
)
}
let text = String(value)
if (typeof value === 'boolean') text = value ? t('Yes') : t('No')
return (
<DetailRow
key={field.label}
label={field.label}
value={<AuditDetailValue label={field.label} value={text} />}
/>
)
})
}
/*
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 { useTranslation } from 'react-i18next'
import { CopyButton } from '@/components/copy-button'
import { cn } from '@/lib/utils'
export function AuditDetailValue(props: {
label: string
value: string
mono?: boolean
copyable?: boolean
}) {
const { t } = useTranslation()
return (
<div className='flex min-w-0 items-start gap-1 text-xs'>
<span
className={cn(
'min-w-0 flex-1 leading-5 wrap-anywhere break-normal whitespace-pre-wrap',
props.mono && 'font-mono'
)}
>
{props.value}
</span>
{(props.copyable || props.value.length > 48) && (
<CopyButton
value={props.value}
className='size-5'
iconClassName='size-3'
aria-label={t('Copy {{field}}', { field: props.label })}
/>
)}
</div>
)
}
/*
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 type { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { TruncatedCell } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
import dayjs from '@/lib/dayjs'
import type { AuditLog } from '../api'
import { buildAuditDetails } from '../lib/audit-details'
import { AuditLogDetailsDialog } from './audit-log-details-dialog'
export function useAuditLogColumns(
accessOnly?: boolean
): ColumnDef<AuditLog>[] {
const { t } = useTranslation()
return useMemo(() => {
const columns: ColumnDef<AuditLog>[] = [
{
accessorKey: 'created_at',
header: t('Time'),
size: 180,
cell: ({ row }) => (
<span className='font-mono tabular-nums'>
{dayjs.unix(row.original.created_at).format('YYYY-MM-DD HH:mm:ss')}
</span>
),
meta: { label: t('Time'), mobileTitle: true },
},
]
if (!accessOnly) {
columns.push(
{
accessorKey: 'username',
header: t('Username'),
size: 100,
meta: { label: t('Username') },
},
{
id: 'event',
header: t('Event'),
size: 260,
accessorFn: (entry) => buildAuditDetails(entry, t).summary,
cell: ({ getValue }) => (
<TruncatedCell className='max-w-64'>
{getValue<string>()}
</TruncatedCell>
),
meta: { label: t('Event') },
}
)
}
columns.push(
{
accessorKey: 'ip',
header: 'IP',
size: 120,
cell: ({ row }) => (
<span className='font-mono'>{row.original.ip || '—'}</span>
),
meta: { label: 'IP' },
},
{
accessorKey: 'user_agent',
header: t('Client'),
size: 180,
cell: ({ row }) => (
<TruncatedCell className='max-w-48'>
{row.original.user_agent || '—'}
</TruncatedCell>
),
meta: { label: t('Client'), mobileHidden: true },
},
{
accessorKey: 'method',
header: t('Method'),
size: 76,
cell: ({ row }) => (
<span className='text-muted-foreground font-mono'>
{row.original.method || '—'}
</span>
),
meta: { label: t('Method') },
},
{
accessorKey: 'route',
header: t('Route'),
size: 220,
cell: ({ row }) => (
<TruncatedCell className='max-w-60 font-mono'>
{row.original.route || '—'}
</TruncatedCell>
),
meta: { label: t('Route') },
},
{
accessorKey: 'status',
header: 'HTTP',
size: 60,
cell: ({ row }) => (
<span className='font-mono tabular-nums'>
{row.original.status || '—'}
</span>
),
meta: { label: 'HTTP' },
},
{
accessorKey: 'success',
header: t('Result'),
size: 82,
cell: ({ row }) => (
<StatusBadge
label={row.original.success ? t('Success') : t('Failed')}
variant={row.original.success ? 'success' : 'danger'}
copyable={false}
/>
),
meta: { label: t('Result'), mobileBadge: true },
},
{
id: 'details',
header: t('Details'),
size: 70,
enableHiding: false,
cell: ({ row }) => <AuditLogDetailsDialog entry={row.original} />,
meta: { label: t('Details') },
}
)
return columns
}, [accessOnly, t])
}
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