mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-08-31 01:13:06 +08:00
feat(security): gate admin role promotion behind step-up 2FA and harden admin TOTP verification
- 提升用户为管理员 / 创建管理员账号纳入敏感操作:handler 级 EnforceStepUp 门控 (admin API key 拒绝、未启用 TOTP 拒绝、无 grant 返回 STEP_UP_REQUIRED), 目标已是管理员的日常编辑不触发 - 管理员启用/停用 2FA 一律使用密码验证(默认通知邮箱常收不到验证码), verification-method 按用户角色返回;普通用户行为不变 - 用户编辑/创建弹窗接入 useStepUp:命中 STEP_UP_REQUIRED 弹 TOTP 验证并自动重试 - 审计日志清理入口与其他敏感操作对齐:未启用 2FA 时直接提示先启用 TOTP, 不再弹出无法完成的验证码输入框(后端强制现场 TOTP 语义不变) - 审计日志页重构:DataTable 布局、详情弹窗分区展示、时间范围改为 ops 同款 下拉(预设窗口 + 自定义起止支持时分)
This commit is contained in:
@@ -177,7 +177,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
|
||||
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
|
||||
adminService := service.NewAdminService(userRepository, groupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService)
|
||||
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache)
|
||||
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService)
|
||||
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
|
||||
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
|
||||
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
|
||||
|
||||
@@ -16,7 +16,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) {
|
||||
router := gin.New()
|
||||
adminSvc := newStubAdminService()
|
||||
|
||||
userHandler := NewUserHandler(adminSvc, nil, nil, nil)
|
||||
userHandler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil)
|
||||
groupHandler := NewGroupHandler(adminSvc, nil, nil)
|
||||
proxyHandler := NewProxyHandler(adminSvc)
|
||||
redeemHandler := NewRedeemHandler(adminSvc, nil)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/quotaview"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -30,6 +31,8 @@ type UserHandler struct {
|
||||
concurrencyService *service.ConcurrencyService
|
||||
userPlatformQuotaRepo service.UserPlatformQuotaRepository // T13 admin quota view
|
||||
billingCache service.BillingCache // T17/T18 缓存失效(PUT/POST 路径)
|
||||
totpService *service.TotpService // 角色提升为管理员的 step-up 门控
|
||||
userService *service.UserService
|
||||
}
|
||||
|
||||
// NewUserHandler creates a new admin user handler
|
||||
@@ -38,12 +41,16 @@ func NewUserHandler(
|
||||
concurrencyService *service.ConcurrencyService,
|
||||
userPlatformQuotaRepo service.UserPlatformQuotaRepository,
|
||||
billingCache service.BillingCache,
|
||||
totpService *service.TotpService,
|
||||
userService *service.UserService,
|
||||
) *UserHandler {
|
||||
return &UserHandler{
|
||||
adminService: adminService,
|
||||
concurrencyService: concurrencyService,
|
||||
userPlatformQuotaRepo: userPlatformQuotaRepo,
|
||||
billingCache: billingCache,
|
||||
totpService: totpService,
|
||||
userService: userService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +273,13 @@ func (h *UserHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 创建管理员账号属权限敏感操作:需最近完成 step-up 2FA 验证。
|
||||
if req.Role == service.RoleAdmin {
|
||||
if !middleware.EnforceStepUp(c, h.totpService, h.userService) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
user, err := h.adminService.CreateUser(c.Request.Context(), &service.CreateUserInput{
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
@@ -308,6 +322,21 @@ func (h *UserHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 把普通用户提升为管理员属权限敏感操作:需最近完成 step-up 2FA 验证。
|
||||
// 目标已是管理员时(前端编辑表单总是携带 role)不触发,避免日常编辑被打断。
|
||||
if req.Role == service.RoleAdmin {
|
||||
target, err := h.adminService.GetUser(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if target.Role != service.RoleAdmin {
|
||||
if !middleware.EnforceStepUp(c, h.totpService, h.userService) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用指针类型直接传递,nil 表示未提供该字段
|
||||
user, err := h.adminService.UpdateUser(c.Request.Context(), userID, &service.UpdateUserInput{
|
||||
Email: req.Email,
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestUserHandlerListIncludesActivityFieldsAndSortParams(t *testing.T) {
|
||||
UpdatedAt: lastLoginAt,
|
||||
},
|
||||
}
|
||||
handler := NewUserHandler(adminSvc, nil, nil, nil)
|
||||
handler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
@@ -89,7 +89,7 @@ func TestUserHandlerGetByIDIncludesActivityFields(t *testing.T) {
|
||||
UpdatedAt: lastLoginAt,
|
||||
},
|
||||
}
|
||||
handler := NewUserHandler(adminSvc, nil, nil, nil)
|
||||
handler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
|
||||
@@ -26,7 +26,7 @@ func (s *getByIDAdminStub) GetUserIncludeDeleted(_ context.Context, id int64) (*
|
||||
func setupGetByIDRouter(svc service.AdminService) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
h := NewUserHandler(svc, nil, nil, nil)
|
||||
h := NewUserHandler(svc, nil, nil, nil, nil, nil)
|
||||
r.GET("/admin/users/:id", h.GetByID)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestAdminUserList_ParsesAPIKeyGroupID(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
stub := &listUsersFilterStub{AdminService: newStubAdminService()}
|
||||
r := gin.New()
|
||||
h := NewUserHandler(stub, nil, nil, nil)
|
||||
h := NewUserHandler(stub, nil, nil, nil, nil, nil)
|
||||
r.GET("/admin/users", h.List)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 角色提升为管理员的 step-up 门控条件测试。
|
||||
// 测试环境不注入认证上下文,因此门控一旦触发会以 401 中止;
|
||||
// 借此区分「触发了 step-up 校验」与「直接放行到业务层(200)」。
|
||||
func setupRoleStepUpRouter(t *testing.T) (*gin.Engine, *stubAdminService) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
adminSvc := newStubAdminService()
|
||||
// 追加一个已是管理员的目标用户,验证「目标已是 admin 不触发门控」。
|
||||
adminSvc.users = append(adminSvc.users, service.User{
|
||||
ID: 2,
|
||||
Email: "admin@example.com",
|
||||
Role: service.RoleAdmin,
|
||||
Status: service.StatusActive,
|
||||
})
|
||||
|
||||
h := NewUserHandler(adminSvc, nil, nil, nil, nil, nil)
|
||||
router.POST("/api/v1/admin/users", h.Create)
|
||||
router.PUT("/api/v1/admin/users/:id", h.Update)
|
||||
return router, adminSvc
|
||||
}
|
||||
|
||||
func doJSON(t *testing.T, router *gin.Engine, method, path string, payload map[string]any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestUpdateUserPromoteToAdminRequiresStepUp(t *testing.T) {
|
||||
router, _ := setupRoleStepUpRouter(t)
|
||||
|
||||
rec := doJSON(t, router, http.MethodPut, "/api/v1/admin/users/1", map[string]any{"role": "admin"})
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
}
|
||||
|
||||
func TestUpdateUserKeepAdminRoleSkipsStepUp(t *testing.T) {
|
||||
router, _ := setupRoleStepUpRouter(t)
|
||||
|
||||
rec := doJSON(t, router, http.MethodPut, "/api/v1/admin/users/2", map[string]any{"role": "admin"})
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
func TestUpdateUserRegularRoleSkipsStepUp(t *testing.T) {
|
||||
router, _ := setupRoleStepUpRouter(t)
|
||||
|
||||
rec := doJSON(t, router, http.MethodPut, "/api/v1/admin/users/1", map[string]any{"role": "user", "email": "u@example.com"})
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
func TestCreateAdminUserRequiresStepUp(t *testing.T) {
|
||||
router, _ := setupRoleStepUpRouter(t)
|
||||
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/v1/admin/users", map[string]any{
|
||||
"email": "new-admin@example.com", "password": "pass123", "role": "admin",
|
||||
})
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
}
|
||||
|
||||
func TestCreateRegularUserSkipsStepUp(t *testing.T) {
|
||||
router, _ := setupRoleStepUpRouter(t)
|
||||
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/v1/admin/users", map[string]any{
|
||||
"email": "new-user@example.com", "password": "pass123", "role": "user",
|
||||
})
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
@@ -159,7 +159,17 @@ func (h *TotpHandler) Disable(c *gin.Context) {
|
||||
// GetVerificationMethod returns the verification method for TOTP operations
|
||||
// GET /api/v1/user/totp/verification-method
|
||||
func (h *TotpHandler) GetVerificationMethod(c *gin.Context) {
|
||||
method := h.totpService.GetVerificationMethod(c.Request.Context())
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "User not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
method, err := h.totpService.GetVerificationMethod(c.Request.Context(), subject.UserID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, method)
|
||||
}
|
||||
|
||||
|
||||
@@ -45,42 +45,56 @@ func NewStepUpAuthMiddleware(totpService *service.TotpService, userService *serv
|
||||
|
||||
func stepUpAuth(grantChecker stepUpGrantChecker, userReader stepUpUserReader) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey {
|
||||
AbortWithError(c, 403, "STEP_UP_ADMIN_API_KEY_FORBIDDEN",
|
||||
"Admin API key cannot access this endpoint; a two-factor verified admin session is required")
|
||||
if !enforceStepUp(c, grantChecker, userReader) {
|
||||
return
|
||||
}
|
||||
|
||||
subject, ok := GetAuthSubjectFromContext(c)
|
||||
if !ok || subject.UserID <= 0 {
|
||||
AbortWithError(c, 401, "UNAUTHORIZED", "Authorization required")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := userReader.GetByID(c.Request.Context(), subject.UserID)
|
||||
if err != nil {
|
||||
AbortWithError(c, 500, "INTERNAL_ERROR", "Failed to load user")
|
||||
return
|
||||
}
|
||||
if !user.TotpEnabled {
|
||||
AbortWithError(c, 403, "STEP_UP_TOTP_NOT_ENABLED",
|
||||
"This operation requires two-factor authentication; please enable TOTP first")
|
||||
return
|
||||
}
|
||||
|
||||
sessionKey := StepUpSessionKey(c, subject.UserID)
|
||||
granted, err := grantChecker.HasStepUpGrant(c.Request.Context(), subject.UserID, sessionKey)
|
||||
if err != nil {
|
||||
// 安全门控故障时选择 fail-closed。
|
||||
AbortWithError(c, 503, "STEP_UP_UNAVAILABLE", "Step-up verification service unavailable")
|
||||
return
|
||||
}
|
||||
if !granted {
|
||||
AbortWithError(c, 403, "STEP_UP_REQUIRED",
|
||||
"This operation requires recent two-factor verification")
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// EnforceStepUp 对当前请求执行与 StepUpAuthMiddleware 相同语义的 step-up 门控,
|
||||
// 供 handler 在需要按请求内容条件触发时调用(如仅当把用户角色提升为管理员时)。
|
||||
// 校验失败时写入错误响应并中止请求,返回 false;通过返回 true。
|
||||
func EnforceStepUp(c *gin.Context, totpService *service.TotpService, userService *service.UserService) bool {
|
||||
return enforceStepUp(c, totpService, userService)
|
||||
}
|
||||
|
||||
func enforceStepUp(c *gin.Context, grantChecker stepUpGrantChecker, userReader stepUpUserReader) bool {
|
||||
if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey {
|
||||
AbortWithError(c, 403, "STEP_UP_ADMIN_API_KEY_FORBIDDEN",
|
||||
"Admin API key cannot access this endpoint; a two-factor verified admin session is required")
|
||||
return false
|
||||
}
|
||||
|
||||
subject, ok := GetAuthSubjectFromContext(c)
|
||||
if !ok || subject.UserID <= 0 {
|
||||
AbortWithError(c, 401, "UNAUTHORIZED", "Authorization required")
|
||||
return false
|
||||
}
|
||||
|
||||
user, err := userReader.GetByID(c.Request.Context(), subject.UserID)
|
||||
if err != nil {
|
||||
AbortWithError(c, 500, "INTERNAL_ERROR", "Failed to load user")
|
||||
return false
|
||||
}
|
||||
if !user.TotpEnabled {
|
||||
AbortWithError(c, 403, "STEP_UP_TOTP_NOT_ENABLED",
|
||||
"This operation requires two-factor authentication; please enable TOTP first")
|
||||
return false
|
||||
}
|
||||
|
||||
sessionKey := StepUpSessionKey(c, subject.UserID)
|
||||
granted, err := grantChecker.HasStepUpGrant(c.Request.Context(), subject.UserID, sessionKey)
|
||||
if err != nil {
|
||||
// 安全门控故障时选择 fail-closed。
|
||||
AbortWithError(c, 503, "STEP_UP_UNAVAILABLE", "Step-up verification service unavailable")
|
||||
return false
|
||||
}
|
||||
if !granted {
|
||||
AbortWithError(c, 403, "STEP_UP_REQUIRED",
|
||||
"This operation requires recent two-factor verification")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type stubStepUpGrantChecker struct {
|
||||
granted bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubStepUpGrantChecker) HasStepUpGrant(ctx context.Context, userID int64, sessionKey string) (bool, error) {
|
||||
return s.granted, s.err
|
||||
}
|
||||
|
||||
type stubStepUpUserReader struct {
|
||||
user *service.User
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubStepUpUserReader) GetByID(ctx context.Context, id int64) (*service.User, error) {
|
||||
return s.user, s.err
|
||||
}
|
||||
|
||||
func newStepUpTestContext(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/sensitive", nil)
|
||||
return c, rec
|
||||
}
|
||||
|
||||
func TestEnforceStepUpRejectsAdminAPIKey(t *testing.T) {
|
||||
c, rec := newStepUpTestContext(t)
|
||||
c.Set("auth_method", service.AuditAuthMethodAdminAPIKey)
|
||||
|
||||
ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{TotpEnabled: true}})
|
||||
|
||||
require.False(t, ok)
|
||||
require.True(t, c.IsAborted())
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "STEP_UP_ADMIN_API_KEY_FORBIDDEN")
|
||||
}
|
||||
|
||||
func TestEnforceStepUpRequiresAuthSubject(t *testing.T) {
|
||||
c, rec := newStepUpTestContext(t)
|
||||
|
||||
ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{TotpEnabled: true}})
|
||||
|
||||
require.False(t, ok)
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
}
|
||||
|
||||
func TestEnforceStepUpRequiresTotpEnabled(t *testing.T) {
|
||||
c, rec := newStepUpTestContext(t)
|
||||
c.Set(string(ContextKeyUser), AuthSubject{UserID: 1})
|
||||
|
||||
ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: false}})
|
||||
|
||||
require.False(t, ok)
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "STEP_UP_TOTP_NOT_ENABLED")
|
||||
}
|
||||
|
||||
func TestEnforceStepUpFailsClosedOnGrantError(t *testing.T) {
|
||||
c, rec := newStepUpTestContext(t)
|
||||
c.Set(string(ContextKeyUser), AuthSubject{UserID: 1})
|
||||
|
||||
ok := enforceStepUp(c, stubStepUpGrantChecker{err: errors.New("redis down")}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: true}})
|
||||
|
||||
require.False(t, ok)
|
||||
require.Equal(t, http.StatusServiceUnavailable, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "STEP_UP_UNAVAILABLE")
|
||||
}
|
||||
|
||||
func TestEnforceStepUpRequiresGrant(t *testing.T) {
|
||||
c, rec := newStepUpTestContext(t)
|
||||
c.Set(string(ContextKeyUser), AuthSubject{UserID: 1})
|
||||
|
||||
ok := enforceStepUp(c, stubStepUpGrantChecker{granted: false}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: true}})
|
||||
|
||||
require.False(t, ok)
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "STEP_UP_REQUIRED")
|
||||
}
|
||||
|
||||
func TestEnforceStepUpPassesWithGrant(t *testing.T) {
|
||||
c, _ := newStepUpTestContext(t)
|
||||
c.Set(string(ContextKeyUser), AuthSubject{UserID: 1})
|
||||
|
||||
ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: true}})
|
||||
|
||||
require.True(t, ok)
|
||||
require.False(t, c.IsAborted())
|
||||
}
|
||||
@@ -141,6 +141,31 @@ func (s *TotpService) GetStatus(ctx context.Context, userID int64) (*TotpStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// usesEmailVerification 判断 TOTP 启用/停用时的身份校验方式。
|
||||
// 管理员一律使用密码校验:管理员账号的邮箱常为占位地址收不到验证码,
|
||||
// 且管理员凭证失守时攻击者往往同时控制通知邮箱,邮箱验证码不构成有效防线。
|
||||
// 普通用户维持原有行为:开启邮箱验证时用邮箱验证码,否则用密码。
|
||||
func (s *TotpService) usesEmailVerification(ctx context.Context, user *User) bool {
|
||||
return user.Role != RoleAdmin && s.settingService.IsEmailVerifyEnabled(ctx)
|
||||
}
|
||||
|
||||
// verifyIdentity 按 usesEmailVerification 的结果校验邮箱验证码或密码。
|
||||
func (s *TotpService) verifyIdentity(ctx context.Context, user *User, emailCode, password string) error {
|
||||
if s.usesEmailVerification(ctx, user) {
|
||||
if emailCode == "" {
|
||||
return ErrVerifyCodeRequired
|
||||
}
|
||||
return s.emailService.VerifyCode(ctx, user.Email, emailCode)
|
||||
}
|
||||
if password == "" {
|
||||
return ErrPasswordRequired
|
||||
}
|
||||
if !user.CheckPassword(password) {
|
||||
return ErrPasswordIncorrect
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitiateSetup starts the TOTP setup process
|
||||
// If email verification is enabled, emailCode is required; otherwise password is required
|
||||
func (s *TotpService) InitiateSetup(ctx context.Context, userID int64, emailCode, password string) (*TotpSetupResponse, error) {
|
||||
@@ -159,23 +184,8 @@ func (s *TotpService) InitiateSetup(ctx context.Context, userID int64, emailCode
|
||||
return nil, ErrTotpAlreadyEnabled
|
||||
}
|
||||
|
||||
// Verify identity based on email verification setting
|
||||
if s.settingService.IsEmailVerifyEnabled(ctx) {
|
||||
// Email verification enabled - verify email code
|
||||
if emailCode == "" {
|
||||
return nil, ErrVerifyCodeRequired
|
||||
}
|
||||
if err := s.emailService.VerifyCode(ctx, user.Email, emailCode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// Email verification disabled - verify password
|
||||
if password == "" {
|
||||
return nil, ErrPasswordRequired
|
||||
}
|
||||
if !user.CheckPassword(password) {
|
||||
return nil, ErrPasswordIncorrect
|
||||
}
|
||||
if err := s.verifyIdentity(ctx, user, emailCode, password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate a new TOTP key
|
||||
@@ -306,23 +316,8 @@ func (s *TotpService) Disable(ctx context.Context, userID int64, emailCode, pass
|
||||
return ErrTotpNotSetup
|
||||
}
|
||||
|
||||
// Verify identity based on email verification setting
|
||||
if s.settingService.IsEmailVerifyEnabled(ctx) {
|
||||
// Email verification enabled - verify email code
|
||||
if emailCode == "" {
|
||||
return ErrVerifyCodeRequired
|
||||
}
|
||||
if err := s.emailService.VerifyCode(ctx, user.Email, emailCode); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Email verification disabled - verify password
|
||||
if password == "" {
|
||||
return ErrPasswordRequired
|
||||
}
|
||||
if !user.CheckPassword(password) {
|
||||
return ErrPasswordIncorrect
|
||||
}
|
||||
if err := s.verifyIdentity(ctx, user, emailCode, password); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Disable TOTP
|
||||
@@ -532,12 +527,17 @@ type VerificationMethod struct {
|
||||
Method string `json:"method"` // "email" or "password"
|
||||
}
|
||||
|
||||
// GetVerificationMethod returns the verification method for TOTP operations
|
||||
func (s *TotpService) GetVerificationMethod(ctx context.Context) *VerificationMethod {
|
||||
if s.settingService.IsEmailVerifyEnabled(ctx) {
|
||||
return &VerificationMethod{Method: "email"}
|
||||
// GetVerificationMethod returns the verification method for TOTP operations.
|
||||
// 与 verifyIdentity 保持同一判定:管理员一律返回 password。
|
||||
func (s *TotpService) GetVerificationMethod(ctx context.Context, userID int64) (*VerificationMethod, error) {
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
return &VerificationMethod{Method: "password"}
|
||||
if s.usesEmailVerification(ctx, user) {
|
||||
return &VerificationMethod{Method: "email"}, nil
|
||||
}
|
||||
return &VerificationMethod{Method: "password"}, nil
|
||||
}
|
||||
|
||||
// SendVerifyCode sends an email verification code for TOTP operations
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// totpVMUserRepoStub 仅实现 TOTP 验证方式测试所需方法;未桩方法调用即 panic(嵌入 nil 接口)。
|
||||
type totpVMUserRepoStub struct {
|
||||
UserRepository
|
||||
user *User
|
||||
totpDisabled bool
|
||||
disableCalled bool
|
||||
}
|
||||
|
||||
func (s *totpVMUserRepoStub) GetByID(ctx context.Context, id int64) (*User, error) {
|
||||
if s.user == nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
return s.user, nil
|
||||
}
|
||||
|
||||
func (s *totpVMUserRepoStub) DisableTotp(ctx context.Context, userID int64) error {
|
||||
s.disableCalled = true
|
||||
s.totpDisabled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
type totpVMSettingRepoStub struct {
|
||||
SettingRepository
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func (s *totpVMSettingRepoStub) GetValue(ctx context.Context, key string) (string, error) {
|
||||
v, ok := s.values[key]
|
||||
if !ok {
|
||||
return "", errors.New("setting not found")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func newTotpVMService(t *testing.T, user *User, emailVerifyEnabled bool) (*TotpService, *totpVMUserRepoStub) {
|
||||
t.Helper()
|
||||
userRepo := &totpVMUserRepoStub{user: user}
|
||||
values := map[string]string{}
|
||||
if emailVerifyEnabled {
|
||||
values[SettingKeyEmailVerifyEnabled] = "true"
|
||||
}
|
||||
settingSvc := NewSettingService(&totpVMSettingRepoStub{values: values}, nil)
|
||||
return NewTotpService(userRepo, nil, nil, settingSvc, nil, nil), userRepo
|
||||
}
|
||||
|
||||
func TestGetVerificationMethodAdminAlwaysPassword(t *testing.T) {
|
||||
admin := &User{ID: 1, Email: "admin@example.com", Role: RoleAdmin}
|
||||
svc, _ := newTotpVMService(t, admin, true)
|
||||
|
||||
method, err := svc.GetVerificationMethod(context.Background(), admin.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "password", method.Method)
|
||||
}
|
||||
|
||||
func TestGetVerificationMethodRegularUserFollowsEmailVerifySetting(t *testing.T) {
|
||||
user := &User{ID: 2, Email: "user@example.com", Role: RoleUser}
|
||||
|
||||
svcEmailOn, _ := newTotpVMService(t, user, true)
|
||||
method, err := svcEmailOn.GetVerificationMethod(context.Background(), user.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "email", method.Method)
|
||||
|
||||
svcEmailOff, _ := newTotpVMService(t, user, false)
|
||||
method, err = svcEmailOff.GetVerificationMethod(context.Background(), user.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "password", method.Method)
|
||||
}
|
||||
|
||||
func TestTotpDisableAdminUsesPasswordEvenWithEmailVerifyEnabled(t *testing.T) {
|
||||
admin := &User{ID: 1, Email: "admin@example.com", Role: RoleAdmin, TotpEnabled: true}
|
||||
require.NoError(t, admin.SetPassword("correct-password"))
|
||||
svc, userRepo := newTotpVMService(t, admin, true)
|
||||
|
||||
// 缺密码 → 要求密码(而非邮箱验证码)。
|
||||
err := svc.Disable(context.Background(), admin.ID, "", "")
|
||||
require.ErrorIs(t, err, ErrPasswordRequired)
|
||||
|
||||
// 密码错误 → 拒绝。
|
||||
err = svc.Disable(context.Background(), admin.ID, "", "wrong-password")
|
||||
require.ErrorIs(t, err, ErrPasswordIncorrect)
|
||||
|
||||
// 密码正确 → 成功停用;全程不需要邮箱验证码(emailService 为 nil,走到邮箱分支会 panic)。
|
||||
err = svc.Disable(context.Background(), admin.ID, "", "correct-password")
|
||||
require.NoError(t, err)
|
||||
require.True(t, userRepo.disableCalled)
|
||||
}
|
||||
|
||||
func TestTotpDisableRegularUserStillRequiresEmailCode(t *testing.T) {
|
||||
user := &User{ID: 2, Email: "user@example.com", Role: RoleUser, TotpEnabled: true}
|
||||
require.NoError(t, user.SetPassword("whatever"))
|
||||
svc, _ := newTotpVMService(t, user, true)
|
||||
|
||||
err := svc.Disable(context.Background(), user.ID, "", "whatever")
|
||||
require.ErrorIs(t, err, ErrVerifyCodeRequired)
|
||||
}
|
||||
@@ -64,34 +64,57 @@
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- 创建管理员账号时后端要求 step-up 2FA,弹出 TOTP 验证后自动重试 -->
|
||||
<TotpStepUpDialog :controller="stepUp" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'; import { adminAPI } from '@/api/admin'
|
||||
import { useForm } from '@/composables/useForm'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useStepUp, isStepUpBlocked, isStepUpCancelled, stepUpBlockReason } from '@/composables/useStepUp'
|
||||
import TotpStepUpDialog from '@/components/auth/TotpStepUpDialog.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits(['close', 'success']); const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const form = reactive({ email: '', password: '', username: '', notes: '', role: 'user' as 'user' | 'admin', balance: '', concurrency: 1, rpm_limit: 0 })
|
||||
|
||||
const { loading, submit } = useForm({
|
||||
form,
|
||||
submitFn: async (data) => {
|
||||
const { balance: rawBalance, ...rest } = data
|
||||
const stepUp = useStepUp()
|
||||
const loading = ref(false)
|
||||
|
||||
const submit = async () => {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const { balance: rawBalance, ...rest } = { ...form }
|
||||
const balance = String(rawBalance).trim()
|
||||
const payload: typeof rest & { balance?: number } = { ...rest }
|
||||
if (balance !== '') {
|
||||
payload.balance = Number(balance)
|
||||
}
|
||||
await adminAPI.users.create(payload)
|
||||
// 创建管理员属敏感操作:后端返回 STEP_UP_REQUIRED 时弹 TOTP 验证并重试
|
||||
await stepUp.run(() => adminAPI.users.create(payload))
|
||||
appStore.showSuccess(t('admin.users.userCreated'))
|
||||
emit('success'); emit('close')
|
||||
},
|
||||
successMsg: t('admin.users.userCreated')
|
||||
})
|
||||
} catch (e: any) {
|
||||
if (isStepUpCancelled(e)) {
|
||||
// 用户主动取消二次验证:静默返回,表单保持打开。
|
||||
} else if (isStepUpBlocked(e)) {
|
||||
appStore.showError(
|
||||
stepUpBlockReason(e) === 'STEP_UP_ADMIN_API_KEY_FORBIDDEN'
|
||||
? t('stepUp.adminApiKeyForbidden')
|
||||
: t('stepUp.notEnabled')
|
||||
)
|
||||
} else {
|
||||
appStore.showError(e?.message || t('admin.users.failedToCreate'))
|
||||
}
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
watch(() => props.show, (v) => { if(v) Object.assign(form, { email: '', password: '', username: '', notes: '', role: 'user', balance: '', concurrency: 1, rpm_limit: 0 }) })
|
||||
|
||||
|
||||
@@ -67,6 +67,9 @@
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- 角色提升为管理员时后端要求 step-up 2FA,弹出 TOTP 验证后自动重试 -->
|
||||
<TotpStepUpDialog :controller="stepUp" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -79,6 +82,8 @@ import type { AdminUser, UserAttributeValuesMap } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import UserAttributeForm from '@/components/user/UserAttributeForm.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useStepUp, isStepUpBlocked, isStepUpCancelled, stepUpBlockReason } from '@/composables/useStepUp'
|
||||
import TotpStepUpDialog from '@/components/auth/TotpStepUpDialog.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean, user: AdminUser | null }>()
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
@@ -104,6 +109,8 @@ const copyPassword = async () => {
|
||||
passwordCopied.value = true; setTimeout(() => passwordCopied.value = false, 2000)
|
||||
}
|
||||
}
|
||||
const stepUp = useStepUp()
|
||||
|
||||
const handleUpdateUser = async () => {
|
||||
if (!props.user) return
|
||||
if (!form.email.trim()) {
|
||||
@@ -114,16 +121,28 @@ const handleUpdateUser = async () => {
|
||||
appStore.showError(t('admin.users.concurrencyMin'))
|
||||
return
|
||||
}
|
||||
const userId = props.user.id
|
||||
submitting.value = true
|
||||
try {
|
||||
const data: any = { email: form.email, username: form.username, notes: form.notes, role: form.role, concurrency: form.concurrency, rpm_limit: form.rpm_limit }
|
||||
if (form.password.trim()) data.password = form.password.trim()
|
||||
await adminAPI.users.update(props.user.id, data)
|
||||
if (Object.keys(form.customAttributes).length > 0) await adminAPI.userAttributes.updateUserAttributeValues(props.user.id, form.customAttributes)
|
||||
// 提升为管理员属敏感操作:后端返回 STEP_UP_REQUIRED 时弹 TOTP 验证并重试
|
||||
await stepUp.run(() => adminAPI.users.update(userId, data))
|
||||
if (Object.keys(form.customAttributes).length > 0) await adminAPI.userAttributes.updateUserAttributeValues(userId, form.customAttributes)
|
||||
appStore.showSuccess(t('admin.users.userUpdated'))
|
||||
emit('success'); emit('close')
|
||||
} catch (e: any) {
|
||||
appStore.showError(e.response?.data?.detail || t('admin.users.failedToUpdate'))
|
||||
if (isStepUpCancelled(e)) {
|
||||
// 用户主动取消二次验证:静默返回,表单保持打开。
|
||||
} else if (isStepUpBlocked(e)) {
|
||||
appStore.showError(
|
||||
stepUpBlockReason(e) === 'STEP_UP_ADMIN_API_KEY_FORBIDDEN'
|
||||
? t('stepUp.adminApiKeyForbidden')
|
||||
: t('stepUp.notEnabled')
|
||||
)
|
||||
} else {
|
||||
appStore.showError(e?.message || t('admin.users.failedToUpdate'))
|
||||
}
|
||||
} finally { submitting.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,150 +1,304 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Page header -->
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('admin.audit.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.audit.description') }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-danger btn-sm" @click="openClearDialog">
|
||||
{{ t('admin.audit.clearAll') }}
|
||||
</button>
|
||||
</div>
|
||||
<AppLayout>
|
||||
<TablePageLayout>
|
||||
<!-- Filters -->
|
||||
<template #filters>
|
||||
<div class="card p-4 sm:p-6">
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<!-- Left: filter fields -->
|
||||
<div class="flex flex-1 flex-wrap items-end gap-4">
|
||||
<div class="w-full sm:w-auto sm:min-w-[240px]">
|
||||
<label class="input-label">{{ t('admin.audit.filters.q') }}</label>
|
||||
<div class="relative">
|
||||
<Icon
|
||||
name="search"
|
||||
size="md"
|
||||
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
<input
|
||||
v-model.trim="filters.q"
|
||||
type="text"
|
||||
class="input pl-10"
|
||||
:placeholder="t('admin.audit.filters.qPlaceholder')"
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card p-4">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.q') }}</span>
|
||||
<input v-model.trim="filters.q" class="input" :placeholder="t('admin.audit.filters.qPlaceholder')" @keyup.enter="search" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.actorEmail') }}</span>
|
||||
<input v-model.trim="filters.actor_email" class="input" @keyup.enter="search" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.action') }}</span>
|
||||
<input v-model.trim="filters.action" class="input" @keyup.enter="search" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.clientIp') }}</span>
|
||||
<input v-model.trim="filters.client_ip" class="input" @keyup.enter="search" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.method') }}</span>
|
||||
<Select v-model="filters.method" :options="methodOptions" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.authMethod') }}</span>
|
||||
<Select v-model="filters.auth_method" :options="authMethodOptions" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.result') }}</span>
|
||||
<Select v-model="filters.success" :options="resultOptions" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.startTime') }}</span>
|
||||
<input v-model="filters.start_time" type="datetime-local" class="input" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.endTime') }}</span>
|
||||
<input v-model="filters.end_time" type="datetime-local" class="input" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button type="button" class="btn btn-primary btn-sm" :disabled="loading" @click="search">
|
||||
{{ t('common.search') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="loading" @click="resetFilters">
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-auto sm:min-w-[200px]">
|
||||
<label class="input-label">{{ t('admin.audit.filters.actorEmail') }}</label>
|
||||
<input v-model.trim="filters.actor_email" type="text" class="input" @keyup.enter="search" />
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="bg-gray-50 dark:bg-dark-900/40">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.time') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.actor') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.action') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.method') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.result') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.clientIp') }}</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.detail') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<tr v-if="loading">
|
||||
<td colspan="7" class="px-4 py-8 text-center text-sm text-gray-500">{{ t('common.loading') }}</td>
|
||||
</tr>
|
||||
<tr v-else-if="logs.length === 0">
|
||||
<td colspan="7" class="px-4 py-8 text-center text-sm text-gray-500">{{ t('admin.audit.empty') }}</td>
|
||||
</tr>
|
||||
<tr v-for="log in logs" :key="log.id" class="hover:bg-gray-50 dark:hover:bg-dark-800/60">
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{{ formatTime(log.created_at) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div class="font-medium">{{ log.actor_email || '—' }}</div>
|
||||
<div class="text-xs text-gray-400">{{ log.actor_role }}<span v-if="log.auth_method"> · {{ log.auth_method }}</span></div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm font-mono text-gray-700 dark:text-gray-300">{{ log.action }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">{{ log.method }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<span :class="statusBadgeClass(log.status_code)">{{ log.status_code }}</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">{{ log.client_ip || '—' }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
|
||||
<button type="button" class="text-primary-600 hover:underline dark:text-primary-400" @click="openDetail(log.id)">
|
||||
{{ t('admin.audit.columns.detail') }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 px-4 py-3 dark:border-dark-700">
|
||||
<div class="w-full sm:w-auto sm:min-w-[180px]">
|
||||
<label class="input-label">{{ t('admin.audit.filters.action') }}</label>
|
||||
<input v-model.trim="filters.action" type="text" class="input" @keyup.enter="search" />
|
||||
</div>
|
||||
|
||||
<div class="w-full sm:w-auto sm:min-w-[160px]">
|
||||
<label class="input-label">{{ t('admin.audit.filters.clientIp') }}</label>
|
||||
<input v-model.trim="filters.client_ip" type="text" class="input" @keyup.enter="search" />
|
||||
</div>
|
||||
|
||||
<div class="w-full sm:w-auto sm:min-w-[140px]">
|
||||
<label class="input-label">{{ t('admin.audit.filters.method') }}</label>
|
||||
<Select v-model="filters.method" :options="methodOptions" @change="search" />
|
||||
</div>
|
||||
|
||||
<div class="w-full sm:w-auto sm:min-w-[170px]">
|
||||
<label class="input-label">{{ t('admin.audit.filters.authMethod') }}</label>
|
||||
<Select v-model="filters.auth_method" :options="authMethodOptions" @change="search" />
|
||||
</div>
|
||||
|
||||
<div class="w-full sm:w-auto sm:min-w-[140px]">
|
||||
<label class="input-label">{{ t('admin.audit.filters.result') }}</label>
|
||||
<Select v-model="filters.success" :options="resultOptions" @change="search" />
|
||||
</div>
|
||||
|
||||
<div class="w-full sm:w-auto sm:min-w-[170px]">
|
||||
<label class="input-label">{{ t('admin.dashboard.timeRange') }}</label>
|
||||
<Select
|
||||
:model-value="timeRange"
|
||||
:options="timeRangeOptions"
|
||||
@update:model-value="handleTimeRangeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: actions -->
|
||||
<div class="flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto">
|
||||
<button type="button" class="btn btn-primary" :disabled="loading" @click="search">
|
||||
{{ t('common.search') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" :disabled="loading" @click="resetFilters">
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" @click="openClearDialog">
|
||||
<Icon name="trash" size="sm" class="mr-1.5" />
|
||||
{{ t('admin.audit.clearAll') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Table -->
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="logs" :loading="loading" row-key="id">
|
||||
<template #cell-created_at="{ value }">
|
||||
<span class="whitespace-nowrap text-gray-600 dark:text-gray-300">{{ formatTime(value) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actor="{ row }">
|
||||
<div class="min-w-0 max-w-[220px]">
|
||||
<div class="truncate font-medium text-gray-900 dark:text-white" :title="row.actor_email">
|
||||
{{ row.actor_email || '—' }}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-xs text-gray-400">
|
||||
{{ row.actor_role }}<span v-if="row.auth_method"> · {{ authMethodLabel(row.auth_method) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-action="{ row }">
|
||||
<div class="min-w-0 max-w-xs">
|
||||
<div class="truncate font-mono text-sm text-gray-800 dark:text-gray-200" :title="row.action">
|
||||
{{ row.action }}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate font-mono text-xs text-gray-400" :title="`${row.method} ${row.path}`">
|
||||
{{ row.method }} {{ row.path }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-status_code="{ row }">
|
||||
<span :class="statusBadgeClass(row.status_code)">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="statusDotClass(row.status_code)"></span>
|
||||
{{ row.status_code }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-latency_ms="{ value }">
|
||||
<span class="whitespace-nowrap text-gray-500 dark:text-gray-400">{{ value }} ms</span>
|
||||
</template>
|
||||
|
||||
<template #cell-client_ip="{ value }">
|
||||
<span class="whitespace-nowrap font-mono text-gray-600 dark:text-gray-300">{{ value || '—' }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
@click="openDetail(row.id)"
|
||||
>
|
||||
<Icon name="eye" size="sm" />
|
||||
{{ t('admin.audit.columns.detail') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<div class="flex flex-col items-center py-8">
|
||||
<Icon name="shield" size="xl" class="mb-4 h-12 w-12 text-gray-300 dark:text-dark-600" />
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('admin.audit.empty') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
|
||||
<!-- Pagination -->
|
||||
<template #pagination>
|
||||
<Pagination
|
||||
v-if="total > 0"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
@update:page="onPageChange"
|
||||
@update:pageSize="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
|
||||
<!-- Detail dialog -->
|
||||
<BaseDialog :show="detailVisible" :title="t('admin.audit.detail.title')" width="wide" @close="detailVisible = false">
|
||||
<div v-if="detailLoading" class="py-8 text-center text-sm text-gray-500">{{ t('common.loading') }}</div>
|
||||
<div v-else-if="detail" class="space-y-3 text-sm">
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<DetailRow :label="t('admin.audit.columns.time')" :value="formatTime(detail.created_at)" />
|
||||
<DetailRow :label="t('admin.audit.columns.actor')" :value="detail.actor_email || '—'" />
|
||||
<DetailRow :label="t('admin.audit.detail.actorRole')" :value="detail.actor_role" />
|
||||
<DetailRow :label="t('admin.audit.filters.authMethod')" :value="detail.auth_method" />
|
||||
<DetailRow :label="t('admin.audit.columns.action')" :value="detail.action" mono />
|
||||
<DetailRow :label="t('admin.audit.detail.methodPath')" :value="`${detail.method} ${detail.path}`" mono />
|
||||
<DetailRow :label="t('admin.audit.columns.result')" :value="String(detail.status_code)" />
|
||||
<DetailRow :label="t('admin.audit.detail.latency')" :value="`${detail.latency_ms} ms`" />
|
||||
<DetailRow :label="t('admin.audit.columns.clientIp')" :value="detail.client_ip || '—'" />
|
||||
<DetailRow :label="t('admin.audit.detail.requestId')" :value="detail.request_id || '—'" mono />
|
||||
<DetailRow :label="t('admin.audit.detail.credential')" :value="detail.credential_masked || '—'" mono />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-500">{{ t('admin.audit.detail.userAgent') }}</div>
|
||||
<div class="break-all rounded bg-gray-50 p-2 font-mono text-xs text-gray-600 dark:bg-dark-900/40 dark:text-gray-400">{{ detail.user_agent || '—' }}</div>
|
||||
</div>
|
||||
<div v-if="detail.request_body">
|
||||
<div class="mb-1 text-xs font-medium text-gray-500">{{ t('admin.audit.detail.requestBody') }}</div>
|
||||
<pre class="max-h-72 overflow-auto rounded bg-gray-50 p-3 font-mono text-xs text-gray-600 dark:bg-dark-900/40 dark:text-gray-400">{{ prettyBody(detail.request_body) }}</pre>
|
||||
</div>
|
||||
<div v-if="detail.extra && Object.keys(detail.extra).length">
|
||||
<div class="mb-1 text-xs font-medium text-gray-500">{{ t('admin.audit.detail.extra') }}</div>
|
||||
<pre class="max-h-48 overflow-auto rounded bg-gray-50 p-3 font-mono text-xs text-gray-600 dark:bg-dark-900/40 dark:text-gray-400">{{ JSON.stringify(detail.extra, null, 2) }}</pre>
|
||||
<BaseDialog
|
||||
:show="detailVisible"
|
||||
:title="t('admin.audit.detail.title')"
|
||||
width="wide"
|
||||
:close-on-click-outside="true"
|
||||
@close="detailVisible = false"
|
||||
>
|
||||
<div v-if="detailLoading" class="flex items-center justify-center py-16">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('common.loading') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="detail" class="space-y-5 py-2">
|
||||
<!-- Hero: action + result at a glance -->
|
||||
<div class="rounded-2xl border border-gray-200 bg-gray-50/60 p-5 dark:border-dark-700 dark:bg-dark-900/60">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span :class="statusBadgeClass(detail.status_code)">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="statusDotClass(detail.status_code)"></span>
|
||||
{{ detail.status_code }} {{ statusText(detail.status_code) }}
|
||||
</span>
|
||||
<span class="break-all font-mono text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ detail.action }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center gap-2 rounded-lg bg-white px-3 py-2 ring-1 ring-gray-200 dark:bg-dark-800 dark:ring-dark-600">
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-[11px] font-bold text-gray-700 dark:bg-dark-700 dark:text-gray-200">
|
||||
{{ detail.method }}
|
||||
</span>
|
||||
<span class="break-all font-mono text-xs text-gray-600 dark:text-gray-300">{{ detail.path }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<Icon name="clock" size="xs" />
|
||||
{{ formatTime(detail.created_at) }}
|
||||
</span>
|
||||
<span>{{ t('admin.audit.detail.latency') }} {{ detail.latency_ms }} ms</span>
|
||||
<span v-if="detail.request_id" class="inline-flex items-center gap-1">
|
||||
{{ t('admin.audit.detail.requestId') }}
|
||||
<span class="break-all font-mono">{{ detail.request_id }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actor / auth / source -->
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">
|
||||
{{ t('admin.audit.columns.actor') }}
|
||||
</div>
|
||||
<div class="mt-1 break-all text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ detail.actor_email || '—' }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-gray-400">{{ detail.actor_role }}</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">
|
||||
{{ t('admin.audit.filters.authMethod') }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ authMethodLabel(detail.auth_method) || '—' }}
|
||||
</div>
|
||||
<div v-if="detail.credential_masked" class="mt-0.5 break-all font-mono text-xs text-gray-400">
|
||||
{{ detail.credential_masked }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">
|
||||
{{ t('admin.audit.columns.clientIp') }}
|
||||
</div>
|
||||
<div class="mt-1 break-all font-mono text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ detail.client_ip || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User-Agent -->
|
||||
<section>
|
||||
<h4 class="mb-1.5 text-xs font-bold uppercase tracking-wider text-gray-400">
|
||||
{{ t('admin.audit.detail.userAgent') }}
|
||||
</h4>
|
||||
<div class="break-all rounded-xl bg-gray-50 p-3 font-mono text-xs leading-relaxed text-gray-600 dark:bg-dark-900 dark:text-gray-400">
|
||||
{{ detail.user_agent || '—' }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Request body (redacted) -->
|
||||
<section v-if="detail.request_body">
|
||||
<h4 class="mb-1.5 text-xs font-bold uppercase tracking-wider text-gray-400">
|
||||
{{ t('admin.audit.detail.requestBody') }}
|
||||
</h4>
|
||||
<pre class="max-h-72 overflow-auto rounded-xl bg-gray-50 p-4 font-mono text-xs leading-relaxed text-gray-600 dark:bg-dark-900 dark:text-gray-400">{{ prettyBody(detail.request_body) }}</pre>
|
||||
</section>
|
||||
|
||||
<!-- Extra -->
|
||||
<section v-if="detail.extra && Object.keys(detail.extra).length">
|
||||
<h4 class="mb-1.5 text-xs font-bold uppercase tracking-wider text-gray-400">
|
||||
{{ t('admin.audit.detail.extra') }}
|
||||
</h4>
|
||||
<pre class="max-h-48 overflow-auto rounded-xl bg-gray-50 p-4 font-mono text-xs leading-relaxed text-gray-600 dark:bg-dark-900 dark:text-gray-400">{{ JSON.stringify(detail.extra, null, 2) }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Custom time range dialog (与 /admin/ops 时间下拉一致的自定义范围,支持时分) -->
|
||||
<BaseDialog
|
||||
:show="showCustomTimeRangeDialog"
|
||||
:title="t('admin.ops.timeRange.custom')"
|
||||
width="narrow"
|
||||
@close="handleCustomTimeRangeCancel"
|
||||
>
|
||||
<div class="space-y-4 py-2">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.customTimeRange.startTime') }}</label>
|
||||
<input v-model="customStartTimeInput" type="datetime-local" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.customTimeRange.endTime') }}</label>
|
||||
<input v-model="customEndTimeInput" type="datetime-local" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button type="button" class="btn btn-secondary" @click="handleCustomTimeRangeCancel">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!customStartTimeInput || !customEndTimeInput"
|
||||
@click="handleCustomTimeRangeConfirm"
|
||||
>
|
||||
{{ t('common.confirm') }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Clear confirmation → step-up TOTP -->
|
||||
@@ -159,55 +313,63 @@
|
||||
@cancel="clearConfirmVisible = false"
|
||||
/>
|
||||
|
||||
<!-- Reused: TOTP prompt for the clear operation -->
|
||||
<div v-if="clearTotpVisible" class="fixed inset-0 z-[60] overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div class="fixed inset-0 bg-black/50" @click="cancelClearTotp"></div>
|
||||
<div class="relative w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-dark-800">
|
||||
<h3 class="mb-2 text-center text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.audit.clearConfirm.totpTitle') }}</h3>
|
||||
<p class="mb-4 text-center text-sm text-gray-500 dark:text-gray-400">{{ t('admin.audit.clearConfirm.totpHint') }}</p>
|
||||
<input
|
||||
v-model.trim="clearTotpCode"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
autocomplete="one-time-code"
|
||||
class="input mb-4 text-center text-lg tracking-[0.5em]"
|
||||
placeholder="••••••"
|
||||
@keyup.enter="submitClear"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-secondary flex-1" :disabled="clearing" @click="cancelClearTotp">{{ t('common.cancel') }}</button>
|
||||
<button type="button" class="btn btn-danger flex-1" :disabled="clearing || clearTotpCode.length !== 6" @click="submitClear">
|
||||
{{ clearing ? t('common.loading') : t('admin.audit.clearAll') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- TOTP prompt for the clear operation -->
|
||||
<BaseDialog
|
||||
:show="clearTotpVisible"
|
||||
:title="t('admin.audit.clearConfirm.totpTitle')"
|
||||
width="narrow"
|
||||
:z-index="60"
|
||||
@close="cancelClearTotp"
|
||||
>
|
||||
<div class="py-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.audit.clearConfirm.totpHint') }}</p>
|
||||
<input
|
||||
v-model.trim="clearTotpCode"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
autocomplete="one-time-code"
|
||||
class="input mt-4 text-center text-lg tracking-[0.5em]"
|
||||
placeholder="••••••"
|
||||
@keyup.enter="submitClear"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button type="button" class="btn btn-secondary" :disabled="clearing" @click="cancelClearTotp">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
:disabled="clearing || clearTotpCode.length !== 6"
|
||||
@click="submitClear"
|
||||
>
|
||||
{{ clearing ? t('common.loading') : t('admin.audit.clearAll') }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI, type AuditLog } from '@/api/admin'
|
||||
import { totpAPI } from '@/api'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useAppStore } from '@/stores'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Tiny inline label/value row for the detail dialog.
|
||||
const DetailRow = (props: { label: string; value: string; mono?: boolean }) =>
|
||||
h('div', { class: 'flex flex-col gap-0.5' }, [
|
||||
h('span', { class: 'text-xs text-gray-400' }, props.label),
|
||||
h('span', { class: ['text-gray-700 dark:text-gray-200 break-all', props.mono ? 'font-mono text-xs' : ''] }, props.value)
|
||||
])
|
||||
|
||||
const loading = ref(false)
|
||||
const logs = ref<AuditLog[]>([])
|
||||
const total = ref(0)
|
||||
@@ -221,11 +383,96 @@ const filters = reactive({
|
||||
client_ip: '',
|
||||
method: '',
|
||||
auth_method: '',
|
||||
success: '',
|
||||
start_time: '',
|
||||
end_time: ''
|
||||
success: ''
|
||||
})
|
||||
|
||||
// 时间范围:预设窗口(同 /admin/ops 时间下拉)+ 自定义起止(datetime-local,支持时分)
|
||||
const timeRange = ref('')
|
||||
const customStartTime = ref('')
|
||||
const customEndTime = ref('')
|
||||
const showCustomTimeRangeDialog = ref(false)
|
||||
const customStartTimeInput = ref('')
|
||||
const customEndTimeInput = ref('')
|
||||
|
||||
const TIME_RANGE_MINUTES: Record<string, number> = {
|
||||
'30m': 30,
|
||||
'1h': 60,
|
||||
'6h': 6 * 60,
|
||||
'24h': 24 * 60,
|
||||
'7d': 7 * 24 * 60,
|
||||
'30d': 30 * 24 * 60
|
||||
}
|
||||
|
||||
const timeRangeOptions = computed(() => [
|
||||
{ value: '', label: t('admin.audit.filters.all') },
|
||||
{ value: '30m', label: t('admin.ops.timeRange.30m') },
|
||||
{ value: '1h', label: t('admin.ops.timeRange.1h') },
|
||||
{ value: '6h', label: t('admin.ops.timeRange.6h') },
|
||||
{ value: '24h', label: t('admin.ops.timeRange.24h') },
|
||||
{ value: '7d', label: t('admin.ops.timeRange.7d') },
|
||||
{ value: '30d', label: t('admin.ops.timeRange.30d') },
|
||||
{
|
||||
value: 'custom',
|
||||
label:
|
||||
timeRange.value === 'custom' && customStartTime.value && customEndTime.value
|
||||
? `${t('admin.ops.timeRange.custom')} (${formatCustomTimeRangeLabel(customStartTime.value, customEndTime.value)})`
|
||||
: t('admin.ops.timeRange.custom')
|
||||
}
|
||||
])
|
||||
|
||||
function formatCustomTimeRangeLabel(startTime: string, endTime: string): string {
|
||||
const fmt = (raw: string) => {
|
||||
const d = new Date(raw)
|
||||
if (Number.isNaN(d.getTime())) return raw
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
return `${fmt(startTime)} ~ ${fmt(endTime)}`
|
||||
}
|
||||
|
||||
function toDatetimeLocal(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
function handleTimeRangeChange(val: string | number | boolean | null) {
|
||||
const value = String(val ?? '')
|
||||
if (value === 'custom') {
|
||||
// 预填:已有自定义值沿用,否则默认最近1小时(本地时区)
|
||||
const now = new Date()
|
||||
customStartTimeInput.value = customStartTime.value || toDatetimeLocal(new Date(now.getTime() - 60 * 60 * 1000))
|
||||
customEndTimeInput.value = customEndTime.value || toDatetimeLocal(now)
|
||||
showCustomTimeRangeDialog.value = true
|
||||
return
|
||||
}
|
||||
timeRange.value = value
|
||||
search()
|
||||
}
|
||||
|
||||
function handleCustomTimeRangeConfirm() {
|
||||
if (!customStartTimeInput.value || !customEndTimeInput.value) return
|
||||
customStartTime.value = customStartTimeInput.value
|
||||
customEndTime.value = customEndTimeInput.value
|
||||
timeRange.value = 'custom'
|
||||
showCustomTimeRangeDialog.value = false
|
||||
search()
|
||||
}
|
||||
|
||||
function handleCustomTimeRangeCancel() {
|
||||
// 未确认不改变当前时间范围;Select 是受控组件,展示值保持不变。
|
||||
showCustomTimeRangeDialog.value = false
|
||||
}
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'created_at', label: t('admin.audit.columns.time') },
|
||||
{ key: 'actor', label: t('admin.audit.columns.actor') },
|
||||
{ key: 'action', label: t('admin.audit.columns.action') },
|
||||
{ key: 'status_code', label: t('admin.audit.columns.result') },
|
||||
{ key: 'latency_ms', label: t('admin.audit.detail.latency') },
|
||||
{ key: 'client_ip', label: t('admin.audit.columns.clientIp') },
|
||||
{ key: 'actions', label: t('common.actions') }
|
||||
])
|
||||
|
||||
const methodOptions = computed(() => [
|
||||
{ value: '', label: t('admin.audit.filters.all') },
|
||||
{ value: 'POST', label: 'POST' },
|
||||
@@ -247,6 +494,11 @@ const resultOptions = computed(() => [
|
||||
{ value: 'false', label: t('admin.audit.filters.resultFailure') }
|
||||
])
|
||||
|
||||
function authMethodLabel(method: string): string {
|
||||
const found = authMethodOptions.value.find((o) => o.value === method)
|
||||
return found && found.value ? found.label : method
|
||||
}
|
||||
|
||||
function toRFC3339(local: string): string | undefined {
|
||||
if (!local) return undefined
|
||||
const d = new Date(local)
|
||||
@@ -254,6 +506,18 @@ function toRFC3339(local: string): string | undefined {
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
function buildTimeRangeQuery(): { start_time?: string; end_time?: string } {
|
||||
if (timeRange.value === 'custom') {
|
||||
return {
|
||||
start_time: toRFC3339(customStartTime.value),
|
||||
end_time: toRFC3339(customEndTime.value)
|
||||
}
|
||||
}
|
||||
const minutes = TIME_RANGE_MINUTES[timeRange.value]
|
||||
if (!minutes) return {}
|
||||
return { start_time: new Date(Date.now() - minutes * 60 * 1000).toISOString() }
|
||||
}
|
||||
|
||||
function buildQuery() {
|
||||
return {
|
||||
page: page.value,
|
||||
@@ -265,8 +529,7 @@ function buildQuery() {
|
||||
method: filters.method || undefined,
|
||||
auth_method: filters.auth_method || undefined,
|
||||
success: filters.success || undefined,
|
||||
start_time: toRFC3339(filters.start_time),
|
||||
end_time: toRFC3339(filters.end_time)
|
||||
...buildTimeRangeQuery()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,8 +559,9 @@ function resetFilters() {
|
||||
filters.method = ''
|
||||
filters.auth_method = ''
|
||||
filters.success = ''
|
||||
filters.start_time = ''
|
||||
filters.end_time = ''
|
||||
timeRange.value = ''
|
||||
customStartTime.value = ''
|
||||
customEndTime.value = ''
|
||||
search()
|
||||
}
|
||||
|
||||
@@ -344,8 +608,25 @@ const clearConfirmVisible = ref(false)
|
||||
const clearTotpVisible = ref(false)
|
||||
const clearTotpCode = ref('')
|
||||
const clearing = ref(false)
|
||||
const checkingTotpStatus = ref(false)
|
||||
|
||||
function openClearDialog() {
|
||||
// 与其他敏感操作一致:未启用 2FA 时直接提示去个人资料启用 TOTP,
|
||||
// 而不是弹出一个无法完成的验证码输入框(后端会以 TOTP_NOT_SETUP 拒绝)。
|
||||
async function openClearDialog() {
|
||||
if (checkingTotpStatus.value) return
|
||||
checkingTotpStatus.value = true
|
||||
try {
|
||||
const status = await totpAPI.getStatus()
|
||||
if (!status.enabled) {
|
||||
appStore.showError(t('stepUp.notEnabled'))
|
||||
return
|
||||
}
|
||||
} catch (err: any) {
|
||||
appStore.showError(err?.message || t('admin.audit.loadFailed'))
|
||||
return
|
||||
} finally {
|
||||
checkingTotpStatus.value = false
|
||||
}
|
||||
clearConfirmVisible.value = true
|
||||
}
|
||||
|
||||
@@ -383,12 +664,22 @@ function formatTime(iso: string): string {
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
function statusText(status: number): string {
|
||||
return status < 400 ? t('admin.audit.filters.resultSuccess') : t('admin.audit.filters.resultFailure')
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: number): string {
|
||||
const base = 'inline-flex rounded-full px-2 py-0.5 text-xs font-medium '
|
||||
const base = 'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold '
|
||||
if (status >= 500) return base + 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
||||
if (status >= 400) return base + 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
|
||||
return base + 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
}
|
||||
|
||||
function statusDotClass(status: number): string {
|
||||
if (status >= 500) return 'bg-red-500'
|
||||
if (status >= 400) return 'bg-amber-500'
|
||||
return 'bg-green-500'
|
||||
}
|
||||
|
||||
onMounted(fetchLogs)
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user