diff --git a/backend/internal/handler/admin/admin_basic_handlers_test.go b/backend/internal/handler/admin/admin_basic_handlers_test.go index 207931d9..ddeaab02 100644 --- a/backend/internal/handler/admin/admin_basic_handlers_test.go +++ b/backend/internal/handler/admin/admin_basic_handlers_test.go @@ -7,7 +7,6 @@ import ( "net/http/httptest" "testing" - servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -23,12 +22,6 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) { redeemHandler := NewRedeemHandler(adminSvc, nil) router.GET("/api/v1/admin/users", userHandler.List) - router.GET("/api/v1/admin/users/auth-identity-migration-reports/summary", userHandler.GetAuthIdentityMigrationReportSummary) - router.GET("/api/v1/admin/users/auth-identity-migration-reports", userHandler.ListAuthIdentityMigrationReports) - router.POST("/api/v1/admin/users/auth-identity-migration-reports/:id/resolve", func(c *gin.Context) { - c.Set(string(servermiddleware.ContextKeyUser), servermiddleware.AuthSubject{UserID: 99}) - userHandler.ResolveAuthIdentityMigrationReport(c) - }) router.GET("/api/v1/admin/users/:id", userHandler.GetByID) router.POST("/api/v1/admin/users/:id/auth-identities", userHandler.BindAuthIdentity) router.POST("/api/v1/admin/users", userHandler.Create) @@ -78,23 +71,6 @@ func TestUserHandlerEndpoints(t *testing.T) { router.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) - rec = httptest.NewRecorder() - req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/auth-identity-migration-reports/summary", nil) - router.ServeHTTP(rec, req) - require.Equal(t, http.StatusOK, rec.Code) - - rec = httptest.NewRecorder() - req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/auth-identity-migration-reports?report_type=oidc_synthetic_email_requires_manual_recovery", nil) - router.ServeHTTP(rec, req) - require.Equal(t, http.StatusOK, rec.Code) - - body, _ := json.Marshal(map[string]any{"resolution_note": "resolved by manual bind"}) - rec = httptest.NewRecorder() - req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/users/auth-identity-migration-reports/1/resolve", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(rec, req) - require.Equal(t, http.StatusOK, rec.Code) - rec = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/1", nil) router.ServeHTTP(rec, req) @@ -111,7 +87,7 @@ func TestUserHandlerEndpoints(t *testing.T) { "channel_subject": "openid-123", }, } - body, _ = json.Marshal(bindBody) + body, _ := json.Marshal(bindBody) rec = httptest.NewRecorder() req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/users/1/auth-identities", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index dfdf327b..3a395342 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -17,7 +17,6 @@ type stubAdminService struct { proxies []service.Proxy proxyCounts []service.ProxyWithAccountCount redeems []service.RedeemCode - migrationReports []service.AuthIdentityMigrationReport boundAuthIdentity *service.AdminBindAuthIdentityInput boundAuthIdentityFor int64 createdAccounts []*service.CreateAccountInput @@ -134,15 +133,6 @@ func newStubAdminService() *stubAdminService { proxies: []service.Proxy{proxy}, proxyCounts: []service.ProxyWithAccountCount{{Proxy: proxy, AccountCount: 1}}, redeems: []service.RedeemCode{redeem}, - migrationReports: []service.AuthIdentityMigrationReport{ - { - ID: 1, - ReportType: "oidc_synthetic_email_requires_manual_recovery", - ReportKey: "u-1", - Details: map[string]any{"user_id": 1}, - CreatedAt: now, - }, - }, } } @@ -193,30 +183,6 @@ func (s *stubAdminService) GetUserUsageStats(ctx context.Context, userID int64, return map[string]any{"user_id": userID}, nil } -func (s *stubAdminService) ListAuthIdentityMigrationReports(ctx context.Context, reportType string, page, pageSize int) ([]service.AuthIdentityMigrationReport, int64, error) { - if reportType == "" { - return s.migrationReports, int64(len(s.migrationReports)), nil - } - filtered := make([]service.AuthIdentityMigrationReport, 0, len(s.migrationReports)) - for _, report := range s.migrationReports { - if strings.EqualFold(report.ReportType, reportType) { - filtered = append(filtered, report) - } - } - return filtered, int64(len(filtered)), nil -} - -func (s *stubAdminService) GetAuthIdentityMigrationReportSummary(ctx context.Context) (*service.AuthIdentityMigrationReportSummary, error) { - summary := &service.AuthIdentityMigrationReportSummary{ - ByType: map[string]int64{}, - } - for _, report := range s.migrationReports { - summary.Total++ - summary.ByType[report.ReportType]++ - } - return summary, nil -} - func (s *stubAdminService) BindUserAuthIdentity(ctx context.Context, userID int64, input service.AdminBindAuthIdentityInput) (*service.AdminBoundAuthIdentity, error) { s.boundAuthIdentityFor = userID copied := input @@ -263,20 +229,6 @@ func (s *stubAdminService) BindUserAuthIdentity(ctx context.Context, userID int6 return result, nil } -func (s *stubAdminService) ResolveAuthIdentityMigrationReport(ctx context.Context, reportID, resolvedByUserID int64, resolutionNote string) (*service.AuthIdentityMigrationReport, error) { - now := time.Now().UTC() - for i := range s.migrationReports { - if s.migrationReports[i].ID != reportID { - continue - } - s.migrationReports[i].ResolvedAt = &now - s.migrationReports[i].ResolvedByUserID = &resolvedByUserID - s.migrationReports[i].ResolutionNote = resolutionNote - return &s.migrationReports[i], nil - } - return nil, nil -} - func (s *stubAdminService) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]service.Group, int64, error) { return s.groups, int64(len(s.groups)), nil } diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index e582e322..b2ed9d18 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -7,7 +7,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/response" - servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -83,10 +82,6 @@ type BindUserAuthIdentityChannelRequest struct { Metadata map[string]any `json:"metadata"` } -type ResolveAuthIdentityMigrationReportRequest struct { - ResolutionNote string `json:"resolution_note"` -} - // List handles listing all users with pagination // GET /api/v1/admin/users // Query params: @@ -193,31 +188,6 @@ func (h *UserHandler) GetByID(c *gin.Context) { response.Success(c, dto.UserFromServiceAdmin(user)) } -// GetAuthIdentityMigrationReportSummary returns aggregate migration report counts. -// GET /api/v1/admin/users/auth-identity-migration-reports/summary -func (h *UserHandler) GetAuthIdentityMigrationReportSummary(c *gin.Context) { - summary, err := h.adminService.GetAuthIdentityMigrationReportSummary(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - response.Success(c, summary) -} - -// ListAuthIdentityMigrationReports returns paginated auth identity migration reports. -// GET /api/v1/admin/users/auth-identity-migration-reports -func (h *UserHandler) ListAuthIdentityMigrationReports(c *gin.Context) { - page, pageSize := response.ParsePagination(c) - reportType := strings.TrimSpace(c.Query("report_type")) - - reports, total, err := h.adminService.ListAuthIdentityMigrationReports(c.Request.Context(), reportType, page, pageSize) - if err != nil { - response.ErrorFrom(c, err) - return - } - response.Paginated(c, reports, total, page, pageSize) -} - // BindAuthIdentity manually binds a canonical auth identity to a user. // POST /api/v1/admin/users/:id/auth-identities func (h *UserHandler) BindAuthIdentity(c *gin.Context) { @@ -257,40 +227,6 @@ func (h *UserHandler) BindAuthIdentity(c *gin.Context) { response.Success(c, result) } -// ResolveAuthIdentityMigrationReport marks a migration report as resolved. -// POST /api/v1/admin/users/auth-identity-migration-reports/:id/resolve -func (h *UserHandler) ResolveAuthIdentityMigrationReport(c *gin.Context) { - reportID, err := strconv.ParseInt(c.Param("id"), 10, 64) - if err != nil { - response.BadRequest(c, "Invalid report ID") - return - } - - subject, ok := servermiddleware.GetAuthSubjectFromContext(c) - if !ok || subject.UserID <= 0 { - response.Unauthorized(c, "Authentication required") - return - } - - var req ResolveAuthIdentityMigrationReportRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - - report, err := h.adminService.ResolveAuthIdentityMigrationReport( - c.Request.Context(), - reportID, - subject.UserID, - req.ResolutionNote, - ) - if err != nil { - response.ErrorFrom(c, err) - return - } - response.Success(c, report) -} - // Create handles creating a new user // POST /api/v1/admin/users func (h *UserHandler) Create(c *gin.Context) { diff --git a/backend/internal/handler/admin/user_handler_activity_test.go b/backend/internal/handler/admin/user_handler_activity_test.go index ac29086d..bfba2408 100644 --- a/backend/internal/handler/admin/user_handler_activity_test.go +++ b/backend/internal/handler/admin/user_handler_activity_test.go @@ -29,7 +29,6 @@ func TestUserHandlerListIncludesActivityFieldsAndSortParams(t *testing.T) { Username: "activity-user", Role: service.RoleUser, Status: service.StatusActive, - LastLoginAt: &lastLoginAt, LastActiveAt: &lastActiveAt, LastUsedAt: &lastUsedAt, CreatedAt: lastLoginAt.Add(-24 * time.Hour), @@ -57,7 +56,6 @@ func TestUserHandlerListIncludesActivityFieldsAndSortParams(t *testing.T) { Code int `json:"code"` Data struct { Items []struct { - LastLoginAt *time.Time `json:"last_login_at"` LastActiveAt *time.Time `json:"last_active_at"` LastUsedAt *time.Time `json:"last_used_at"` } `json:"items"` @@ -66,7 +64,6 @@ func TestUserHandlerListIncludesActivityFieldsAndSortParams(t *testing.T) { require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) require.Equal(t, 0, resp.Code) require.Len(t, resp.Data.Items, 1) - require.WithinDuration(t, lastLoginAt, *resp.Data.Items[0].LastLoginAt, time.Second) require.WithinDuration(t, lastActiveAt, *resp.Data.Items[0].LastActiveAt, time.Second) require.WithinDuration(t, lastUsedAt, *resp.Data.Items[0].LastUsedAt, time.Second) } @@ -86,7 +83,6 @@ func TestUserHandlerGetByIDIncludesActivityFields(t *testing.T) { Username: "detail-user", Role: service.RoleUser, Status: service.StatusActive, - LastLoginAt: &lastLoginAt, LastActiveAt: &lastActiveAt, LastUsedAt: &lastUsedAt, CreatedAt: lastLoginAt.Add(-24 * time.Hour), @@ -107,14 +103,12 @@ func TestUserHandlerGetByIDIncludesActivityFields(t *testing.T) { var resp struct { Code int `json:"code"` Data struct { - LastLoginAt *time.Time `json:"last_login_at"` LastActiveAt *time.Time `json:"last_active_at"` LastUsedAt *time.Time `json:"last_used_at"` } `json:"data"` } require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) require.Equal(t, 0, resp.Code) - require.WithinDuration(t, lastLoginAt, *resp.Data.LastLoginAt, time.Second) require.WithinDuration(t, lastActiveAt, *resp.Data.LastActiveAt, time.Second) require.WithinDuration(t, lastUsedAt, *resp.Data.LastUsedAt, time.Second) } diff --git a/backend/internal/handler/auth_oauth_pending_flow_test.go b/backend/internal/handler/auth_oauth_pending_flow_test.go index 008c9da2..a4a5c544 100644 --- a/backend/internal/handler/auth_oauth_pending_flow_test.go +++ b/backend/internal/handler/auth_oauth_pending_flow_test.go @@ -2293,6 +2293,10 @@ func (r *oauthPendingFlowUserRepo) Update(ctx context.Context, user *service.Use return nil } +func (r *oauthPendingFlowUserRepo) UpdateUserLastActiveAt(ctx context.Context, userID int64, activeAt time.Time) error { + return r.client.User.UpdateOneID(userID).SetLastActiveAt(activeAt).Exec(ctx) +} + func (r *oauthPendingFlowUserRepo) Delete(ctx context.Context, id int64) error { if r.options.rejectDeleteWhileAuthIdentityExists { count, err := r.client.AuthIdentity.Query().Where(authidentity.UserIDEQ(id)).Count(ctx) diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index d88c110c..9780ff79 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -21,7 +21,6 @@ func UserFromServiceShallow(u *service.User) *User { Concurrency: u.Concurrency, Status: u.Status, AllowedGroups: u.AllowedGroups, - LastLoginAt: u.LastLoginAt, LastActiveAt: u.LastActiveAt, CreatedAt: u.CreatedAt, UpdatedAt: u.UpdatedAt, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 15b8548a..c0bce40b 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -15,7 +15,6 @@ type User struct { Concurrency int `json:"concurrency"` Status string `json:"status"` AllowedGroups []int64 `json:"allowed_groups"` - LastLoginAt *time.Time `json:"last_login_at,omitempty"` LastActiveAt *time.Time `json:"last_active_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/backend/internal/handler/dto/user_mapper_activity_test.go b/backend/internal/handler/dto/user_mapper_activity_test.go index 1e362fba..a17f0ce4 100644 --- a/backend/internal/handler/dto/user_mapper_activity_test.go +++ b/backend/internal/handler/dto/user_mapper_activity_test.go @@ -21,16 +21,13 @@ func TestUserFromServiceAdmin_MapsActivityTimestamps(t *testing.T) { Username: "admin", Role: service.RoleAdmin, Status: service.StatusActive, - LastLoginAt: &lastLoginAt, LastActiveAt: &lastActiveAt, LastUsedAt: &lastUsedAt, }) require.NotNil(t, out) - require.NotNil(t, out.LastLoginAt) require.NotNil(t, out.LastActiveAt) require.NotNil(t, out.LastUsedAt) - require.WithinDuration(t, lastLoginAt, *out.LastLoginAt, time.Second) require.WithinDuration(t, lastActiveAt, *out.LastActiveAt, time.Second) require.WithinDuration(t, lastUsedAt, *out.LastUsedAt, time.Second) } diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 24f715d4..8095ed57 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -99,6 +99,12 @@ func (s *userHandlerRepoStub) GetLatestUsedAtByUserIDs(context.Context, []int64) func (s *userHandlerRepoStub) GetLatestUsedAtByUserID(context.Context, int64) (*time.Time, error) { return nil, nil } +func (s *userHandlerRepoStub) UpdateUserLastActiveAt(_ context.Context, _ int64, activeAt time.Time) error { + if s.user != nil { + s.user.LastActiveAt = &activeAt + } + return nil +} func (s *userHandlerRepoStub) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error { return nil } diff --git a/backend/internal/repository/auth_identity_migration_report.go b/backend/internal/repository/auth_identity_migration_report.go deleted file mode 100644 index 70f298c1..00000000 --- a/backend/internal/repository/auth_identity_migration_report.go +++ /dev/null @@ -1,148 +0,0 @@ -package repository - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "strings" - "time" -) - -type AuthIdentityMigrationReport struct { - ID int64 - ReportType string - ReportKey string - Details map[string]any - CreatedAt time.Time -} - -type AuthIdentityMigrationReportQuery struct { - ReportType string - Limit int - Offset int -} - -type AuthIdentityMigrationReportSummary struct { - Total int64 - ByType map[string]int64 -} - -func (r *userRepository) ListAuthIdentityMigrationReports(ctx context.Context, query AuthIdentityMigrationReportQuery) ([]AuthIdentityMigrationReport, error) { - exec := txAwareSQLExecutor(ctx, r.sql, r.client) - if exec == nil { - return nil, fmt.Errorf("sql executor is not configured") - } - - limit := query.Limit - if limit <= 0 { - limit = 100 - } - rows, err := exec.QueryContext(ctx, ` -SELECT id, report_type, report_key, details, created_at -FROM auth_identity_migration_reports -WHERE ($1 = '' OR report_type = $1) -ORDER BY created_at DESC, id DESC -LIMIT $2 OFFSET $3`, - strings.TrimSpace(query.ReportType), - limit, - query.Offset, - ) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - reports := make([]AuthIdentityMigrationReport, 0) - for rows.Next() { - report, scanErr := scanAuthIdentityMigrationReport(rows) - if scanErr != nil { - return nil, scanErr - } - reports = append(reports, report) - } - if err := rows.Err(); err != nil { - return nil, err - } - return reports, nil -} - -func (r *userRepository) GetAuthIdentityMigrationReport(ctx context.Context, reportType, reportKey string) (*AuthIdentityMigrationReport, error) { - exec := txAwareSQLExecutor(ctx, r.sql, r.client) - if exec == nil { - return nil, fmt.Errorf("sql executor is not configured") - } - - rows, err := exec.QueryContext(ctx, ` -SELECT id, report_type, report_key, details, created_at -FROM auth_identity_migration_reports -WHERE report_type = $1 AND report_key = $2 -LIMIT 1`, - strings.TrimSpace(reportType), - strings.TrimSpace(reportKey), - ) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - if !rows.Next() { - return nil, sql.ErrNoRows - } - report, err := scanAuthIdentityMigrationReport(rows) - if err != nil { - return nil, err - } - return &report, rows.Err() -} - -func (r *userRepository) SummarizeAuthIdentityMigrationReports(ctx context.Context) (*AuthIdentityMigrationReportSummary, error) { - exec := txAwareSQLExecutor(ctx, r.sql, r.client) - if exec == nil { - return nil, fmt.Errorf("sql executor is not configured") - } - - rows, err := exec.QueryContext(ctx, ` -SELECT report_type, COUNT(*) -FROM auth_identity_migration_reports -GROUP BY report_type -ORDER BY report_type ASC`) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - summary := &AuthIdentityMigrationReportSummary{ - ByType: make(map[string]int64), - } - for rows.Next() { - var reportType string - var count int64 - if err := rows.Scan(&reportType, &count); err != nil { - return nil, err - } - summary.ByType[reportType] = count - summary.Total += count - } - if err := rows.Err(); err != nil { - return nil, err - } - return summary, nil -} - -func scanAuthIdentityMigrationReport(scanner interface{ Scan(dest ...any) error }) (AuthIdentityMigrationReport, error) { - var ( - report AuthIdentityMigrationReport - details []byte - ) - if err := scanner.Scan(&report.ID, &report.ReportType, &report.ReportKey, &details, &report.CreatedAt); err != nil { - return AuthIdentityMigrationReport{}, err - } - report.Details = map[string]any{} - if len(details) > 0 { - if err := json.Unmarshal(details, &report.Details); err != nil { - return AuthIdentityMigrationReport{}, err - } - } - return report, nil -} diff --git a/backend/internal/repository/user_profile_identity_repo_contract_test.go b/backend/internal/repository/user_profile_identity_repo_contract_test.go index a02af62b..d24a7d83 100644 --- a/backend/internal/repository/user_profile_identity_repo_contract_test.go +++ b/backend/internal/repository/user_profile_identity_repo_contract_test.go @@ -36,7 +36,6 @@ TRUNCATE TABLE auth_identity_channels, auth_identities, pending_auth_sessions, - auth_identity_migration_reports, user_provider_default_grants, user_avatars RESTART IDENTITY`) @@ -393,36 +392,6 @@ func (s *UserProfileIdentityRepoSuite) TestUserAvatarCRUDAndUserLookup() { s.Require().Nil(loadedAvatar) } -func (s *UserProfileIdentityRepoSuite) TestAuthIdentityMigrationReportHelpers_ListAndSummarize() { - _, err := integrationDB.ExecContext(s.ctx, ` -INSERT INTO auth_identity_migration_reports (report_type, report_key, details, created_at) -VALUES - ('wechat_openid_only_requires_remediation', 'u-1', '{"user_id":1}'::jsonb, '2026-04-20T10:00:00Z'), - ('wechat_openid_only_requires_remediation', 'u-2', '{"user_id":2}'::jsonb, '2026-04-20T11:00:00Z'), - ('oidc_synthetic_email_requires_manual_recovery', 'u-3', '{"user_id":3}'::jsonb, '2026-04-20T12:00:00Z')`) - s.Require().NoError(err) - - summary, err := s.repo.SummarizeAuthIdentityMigrationReports(s.ctx) - s.Require().NoError(err) - s.Require().Equal(int64(3), summary.Total) - s.Require().Equal(int64(2), summary.ByType["wechat_openid_only_requires_remediation"]) - s.Require().Equal(int64(1), summary.ByType["oidc_synthetic_email_requires_manual_recovery"]) - - reports, err := s.repo.ListAuthIdentityMigrationReports(s.ctx, AuthIdentityMigrationReportQuery{ - ReportType: "wechat_openid_only_requires_remediation", - Limit: 10, - }) - s.Require().NoError(err) - s.Require().Len(reports, 2) - s.Require().Equal("u-2", reports[0].ReportKey) - s.Require().Equal(float64(2), reports[0].Details["user_id"]) - - report, err := s.repo.GetAuthIdentityMigrationReport(s.ctx, "oidc_synthetic_email_requires_manual_recovery", "u-3") - s.Require().NoError(err) - s.Require().Equal("u-3", report.ReportKey) - s.Require().Equal(float64(3), report.Details["user_id"]) -} - func (s *UserProfileIdentityRepoSuite) TestUpdateUserLastLoginAndActiveAt_UsesDedicatedColumns() { user := s.mustCreateUser("activity") loginAt := time.Date(2026, 4, 20, 8, 0, 0, 0, time.UTC) diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index 195776a3..b6f907aa 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -445,10 +445,6 @@ func userListOrder(params pagination.PaginationParams) []func(*entsql.Selector) case "created_at": field = dbuser.FieldCreatedAt defaultField = false - case "last_login_at": - field = dbuser.FieldLastLoginAt - defaultField = false - nullsLastField = true case "last_active_at": field = dbuser.FieldLastActiveAt defaultField = false diff --git a/backend/internal/repository/user_repo_sort_integration_test.go b/backend/internal/repository/user_repo_sort_integration_test.go index e2445d5b..365a0402 100644 --- a/backend/internal/repository/user_repo_sort_integration_test.go +++ b/backend/internal/repository/user_repo_sort_integration_test.go @@ -95,27 +95,6 @@ func (s *UserRepoSuite) TestUpdate_PersistsSignupSourceAndActivityTimestamps() { s.Require().True(got.LastActiveAt.Equal(lastActiveAt)) } -func (s *UserRepoSuite) TestListWithFilters_SortByLastLoginAtDesc() { - older := time.Now().Add(-4 * time.Hour).UTC().Truncate(time.Microsecond) - newer := time.Now().Add(-1 * time.Hour).UTC().Truncate(time.Microsecond) - - s.mustCreateUser(&service.User{Email: "nil-login@example.com"}) - s.mustCreateUser(&service.User{Email: "older-login@example.com", LastLoginAt: &older}) - s.mustCreateUser(&service.User{Email: "newer-login@example.com", LastLoginAt: &newer}) - - users, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{ - Page: 1, - PageSize: 10, - SortBy: "last_login_at", - SortOrder: "desc", - }, service.UserListFilters{}) - s.Require().NoError(err) - s.Require().Len(users, 3) - s.Require().Equal("newer-login@example.com", users[0].Email) - s.Require().Equal("older-login@example.com", users[1].Email) - s.Require().Equal("nil-login@example.com", users[2].Email) -} - func (s *UserRepoSuite) TestListWithFilters_SortByLastActiveAtAsc() { earlier := time.Now().Add(-3 * time.Hour).UTC().Truncate(time.Microsecond) later := time.Now().Add(-45 * time.Minute).UTC().Truncate(time.Microsecond) diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index e5c0eac1..84c963ec 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -210,9 +210,6 @@ func registerDashboardRoutes(admin *gin.RouterGroup, h *handler.Handlers) { func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) { users := admin.Group("/users") { - users.GET("/auth-identity-migration-reports/summary", h.Admin.User.GetAuthIdentityMigrationReportSummary) - users.GET("/auth-identity-migration-reports", h.Admin.User.ListAuthIdentityMigrationReports) - users.POST("/auth-identity-migration-reports/:id/resolve", h.Admin.User.ResolveAuthIdentityMigrationReport) users.GET("", h.Admin.User.List) users.GET("/:id", h.Admin.User.GetByID) users.POST("/:id/auth-identities", h.Admin.User.BindAuthIdentity) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index ce1c1a77..e0effa66 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -39,10 +39,7 @@ type AdminService interface { // codeType is optional - pass empty string to return all types. // Also returns totalRecharged (sum of all positive balance top-ups). GetUserBalanceHistory(ctx context.Context, userID int64, page, pageSize int, codeType string) ([]RedeemCode, int64, float64, error) - ListAuthIdentityMigrationReports(ctx context.Context, reportType string, page, pageSize int) ([]AuthIdentityMigrationReport, int64, error) - GetAuthIdentityMigrationReportSummary(ctx context.Context) (*AuthIdentityMigrationReportSummary, error) BindUserAuthIdentity(ctx context.Context, userID int64, input AdminBindAuthIdentityInput) (*AdminBoundAuthIdentity, error) - ResolveAuthIdentityMigrationReport(ctx context.Context, reportID, resolvedByUserID int64, resolutionNote string) (*AuthIdentityMigrationReport, error) // Group management ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]Group, int64, error) @@ -137,24 +134,6 @@ type UpdateUserInput struct { GroupRates map[int64]*float64 } -type AuthIdentityMigrationReport struct { - ID int64 `json:"id"` - ReportType string `json:"report_type"` - ReportKey string `json:"report_key"` - Details map[string]any `json:"details"` - CreatedAt time.Time `json:"created_at"` - ResolvedAt *time.Time `json:"resolved_at,omitempty"` - ResolvedByUserID *int64 `json:"resolved_by_user_id,omitempty"` - ResolutionNote string `json:"resolution_note,omitempty"` -} - -type AuthIdentityMigrationReportSummary struct { - Total int64 `json:"total"` - OpenTotal int64 `json:"open_total"` - ResolvedTotal int64 `json:"resolved_total"` - ByType map[string]int64 `json:"by_type"` -} - type AdminBindAuthIdentityInput struct { ProviderType string ProviderKey string @@ -874,152 +853,6 @@ func (s *adminServiceImpl) GetUserBalanceHistory(ctx context.Context, userID int return codes, result.Total, totalRecharged, nil } -func (s *adminServiceImpl) ListAuthIdentityMigrationReports(ctx context.Context, reportType string, page, pageSize int) ([]AuthIdentityMigrationReport, int64, error) { - db, err := s.adminSQLDB() - if err != nil { - return nil, 0, err - } - - reportType = strings.TrimSpace(reportType) - if page <= 0 { - page = 1 - } - if pageSize <= 0 { - pageSize = 20 - } - offset := (page - 1) * pageSize - - var total int64 - if err := db.QueryRowContext(ctx, ` -SELECT COUNT(*) -FROM auth_identity_migration_reports -WHERE ($1 = '' OR report_type = $1)`, - reportType, - ).Scan(&total); err != nil { - return nil, 0, err - } - - rows, err := db.QueryContext(ctx, ` -SELECT id, report_type, report_key, details, created_at, resolved_at, resolved_by_user_id, resolution_note -FROM auth_identity_migration_reports -WHERE ($1 = '' OR report_type = $1) -ORDER BY created_at DESC, id DESC -LIMIT $2 OFFSET $3`, - reportType, - pageSize, - offset, - ) - if err != nil { - return nil, 0, err - } - defer func() { _ = rows.Close() }() - - reports := make([]AuthIdentityMigrationReport, 0) - for rows.Next() { - report, scanErr := scanAuthIdentityMigrationReport(rows) - if scanErr != nil { - return nil, 0, scanErr - } - reports = append(reports, report) - } - if err := rows.Err(); err != nil { - return nil, 0, err - } - return reports, total, nil -} - -func (s *adminServiceImpl) GetAuthIdentityMigrationReportSummary(ctx context.Context) (*AuthIdentityMigrationReportSummary, error) { - db, err := s.adminSQLDB() - if err != nil { - return nil, err - } - - rows, err := db.QueryContext(ctx, ` -SELECT - report_type, - COUNT(*), - SUM(CASE WHEN resolved_at IS NULL THEN 1 ELSE 0 END), - SUM(CASE WHEN resolved_at IS NOT NULL THEN 1 ELSE 0 END) -FROM auth_identity_migration_reports -GROUP BY report_type -ORDER BY report_type ASC`) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - summary := &AuthIdentityMigrationReportSummary{ - ByType: make(map[string]int64), - } - for rows.Next() { - var reportType string - var count int64 - var openCount int64 - var resolvedCount int64 - if err := rows.Scan(&reportType, &count, &openCount, &resolvedCount); err != nil { - return nil, err - } - summary.ByType[reportType] = count - summary.Total += count - summary.OpenTotal += openCount - summary.ResolvedTotal += resolvedCount - } - if err := rows.Err(); err != nil { - return nil, err - } - return summary, nil -} - -func (s *adminServiceImpl) ResolveAuthIdentityMigrationReport(ctx context.Context, reportID, resolvedByUserID int64, resolutionNote string) (*AuthIdentityMigrationReport, error) { - if reportID <= 0 { - return nil, infraerrors.BadRequest("INVALID_INPUT", "report id must be greater than 0") - } - if resolvedByUserID <= 0 { - return nil, infraerrors.BadRequest("INVALID_INPUT", "resolved_by_user_id must be greater than 0") - } - - db, err := s.adminSQLDB() - if err != nil { - return nil, err - } - - now := time.Now().UTC() - result, err := db.ExecContext(ctx, ` -UPDATE auth_identity_migration_reports -SET - resolved_at = COALESCE(resolved_at, $2), - resolved_by_user_id = COALESCE(resolved_by_user_id, $3), - resolution_note = $4 -WHERE id = $1`, - reportID, - now, - resolvedByUserID, - strings.TrimSpace(resolutionNote), - ) - if err != nil { - return nil, err - } - affected, err := result.RowsAffected() - if err != nil { - return nil, err - } - if affected == 0 { - return nil, infraerrors.NotFound("AUTH_IDENTITY_MIGRATION_REPORT_NOT_FOUND", "auth identity migration report not found") - } - - row := db.QueryRowContext(ctx, ` -SELECT id, report_type, report_key, details, created_at, resolved_at, resolved_by_user_id, resolution_note -FROM auth_identity_migration_reports -WHERE id = $1`, - reportID, - ) - report, err := scanAuthIdentityMigrationReport(row) - if err != nil { - return nil, err - } - return &report, nil -} - func (s *adminServiceImpl) BindUserAuthIdentity(ctx context.Context, userID int64, input AdminBindAuthIdentityInput) (*AdminBoundAuthIdentity, error) { if userID <= 0 { return nil, infraerrors.BadRequest("INVALID_INPUT", "user_id must be greater than 0") @@ -1252,44 +1085,6 @@ func cloneAdminAuthIdentityMetadata(input map[string]any) map[string]any { return out } -func scanAuthIdentityMigrationReport(scanner interface{ Scan(dest ...any) error }) (AuthIdentityMigrationReport, error) { - var ( - report AuthIdentityMigrationReport - details []byte - resolvedAt sql.NullTime - resolvedByUserID sql.NullInt64 - resolutionNote sql.NullString - ) - if err := scanner.Scan( - &report.ID, - &report.ReportType, - &report.ReportKey, - &details, - &report.CreatedAt, - &resolvedAt, - &resolvedByUserID, - &resolutionNote, - ); err != nil { - return AuthIdentityMigrationReport{}, err - } - report.Details = map[string]any{} - if len(details) > 0 { - if err := json.Unmarshal(details, &report.Details); err != nil { - return AuthIdentityMigrationReport{}, err - } - } - if resolvedAt.Valid { - report.ResolvedAt = &resolvedAt.Time - } - if resolvedByUserID.Valid { - report.ResolvedByUserID = &resolvedByUserID.Int64 - } - if resolutionNote.Valid { - report.ResolutionNote = resolutionNote.String - } - return report, nil -} - // Group management implementations func (s *adminServiceImpl) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]Group, int64, error) { params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} diff --git a/backend/internal/service/admin_service_identity_migration_report_test.go b/backend/internal/service/admin_service_identity_migration_report_test.go deleted file mode 100644 index 6975604b..00000000 --- a/backend/internal/service/admin_service_identity_migration_report_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package service - -import ( - "context" - "database/sql" - "testing" - "time" - - dbent "github.com/Wei-Shaw/sub2api/ent" - "github.com/Wei-Shaw/sub2api/ent/enttest" - "github.com/stretchr/testify/require" - - "entgo.io/ent/dialect" - entsql "entgo.io/ent/dialect/sql" - _ "modernc.org/sqlite" -) - -func newAdminServiceMigrationReportTestClient(t *testing.T) *dbent.Client { - t.Helper() - - db, err := sql.Open("sqlite", "file:admin_service_migration_reports?mode=memory&cache=shared&_fk=1") - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - _, err = db.Exec("PRAGMA foreign_keys = ON") - require.NoError(t, err) - - _, err = db.Exec(`CREATE TABLE auth_identity_migration_reports ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - report_type TEXT NOT NULL, - report_key TEXT NOT NULL, - details TEXT NOT NULL DEFAULT '{}', - created_at DATETIME NOT NULL, - resolved_at DATETIME NULL, - resolved_by_user_id INTEGER NULL, - resolution_note TEXT NOT NULL DEFAULT '' - )`) - require.NoError(t, err) - - drv := entsql.OpenDB(dialect.SQLite, db) - client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv))) - t.Cleanup(func() { _ = client.Close() }) - return client -} - -func TestAdminServiceListAuthIdentityMigrationReports(t *testing.T) { - client := newAdminServiceMigrationReportTestClient(t) - driver, ok := client.Driver().(*entsql.Driver) - require.True(t, ok) - - now := time.Now().UTC() - _, err := driver.DB().ExecContext(context.Background(), ` -INSERT INTO auth_identity_migration_reports (report_type, report_key, details, created_at) -VALUES - ($1, $2, $3, $4), - ($5, $6, $7, $8)`, - "oidc_synthetic_email_requires_manual_recovery", "u-1", `{"user_id":1}`, now, - "wechat_provider_key_conflict", "u-2", `{"user_id":2}`, now.Add(-time.Minute), - ) - require.NoError(t, err) - - svc := &adminServiceImpl{entClient: client} - reports, total, err := svc.ListAuthIdentityMigrationReports(context.Background(), "oidc_synthetic_email_requires_manual_recovery", 1, 20) - require.NoError(t, err) - require.Equal(t, int64(1), total) - require.Len(t, reports, 1) - require.Equal(t, "oidc_synthetic_email_requires_manual_recovery", reports[0].ReportType) - require.Equal(t, float64(1), reports[0].Details["user_id"]) -} - -func TestAdminServiceGetAuthIdentityMigrationReportSummary(t *testing.T) { - client := newAdminServiceMigrationReportTestClient(t) - driver, ok := client.Driver().(*entsql.Driver) - require.True(t, ok) - - now := time.Now().UTC() - _, err := driver.DB().ExecContext(context.Background(), ` -INSERT INTO auth_identity_migration_reports (report_type, report_key, details, created_at) -VALUES - ($1, $2, $3, $4), - ($5, $6, $7, $8), - ($9, $10, $11, $12)`, - "oidc_synthetic_email_requires_manual_recovery", "u-1", `{"user_id":1}`, now, - "wechat_provider_key_conflict", "u-2", `{"user_id":2}`, now.Add(-time.Minute), - "wechat_provider_key_conflict", "u-3", `{"user_id":3}`, now.Add(-2*time.Minute), - ) - require.NoError(t, err) - - svc := &adminServiceImpl{entClient: client} - summary, err := svc.GetAuthIdentityMigrationReportSummary(context.Background()) - require.NoError(t, err) - require.Equal(t, int64(3), summary.Total) - require.Equal(t, int64(3), summary.OpenTotal) - require.Zero(t, summary.ResolvedTotal) - require.Equal(t, int64(1), summary.ByType["oidc_synthetic_email_requires_manual_recovery"]) - require.Equal(t, int64(2), summary.ByType["wechat_provider_key_conflict"]) -} - -func TestAdminServiceResolveAuthIdentityMigrationReport(t *testing.T) { - client := newAdminServiceMigrationReportTestClient(t) - driver, ok := client.Driver().(*entsql.Driver) - require.True(t, ok) - - now := time.Now().UTC() - _, err := driver.DB().ExecContext(context.Background(), ` -INSERT INTO auth_identity_migration_reports (report_type, report_key, details, created_at) -VALUES ($1, $2, $3, $4)`, - "oidc_synthetic_email_requires_manual_recovery", "u-1", `{"user_id":1}`, now, - ) - require.NoError(t, err) - - svc := &adminServiceImpl{entClient: client} - report, err := svc.ResolveAuthIdentityMigrationReport(context.Background(), 1, 99, "resolved by admin binding") - require.NoError(t, err) - require.NotNil(t, report.ResolvedAt) - require.NotNil(t, report.ResolvedByUserID) - require.Equal(t, int64(99), *report.ResolvedByUserID) - require.Equal(t, "resolved by admin binding", report.ResolutionNote) - - summary, err := svc.GetAuthIdentityMigrationReportSummary(context.Background()) - require.NoError(t, err) - require.Zero(t, summary.OpenTotal) - require.Equal(t, int64(1), summary.ResolvedTotal) -} diff --git a/frontend/src/api/__tests__/users.migrationReports.spec.ts b/frontend/src/api/__tests__/users.migrationReports.spec.ts deleted file mode 100644 index ca889600..00000000 --- a/frontend/src/api/__tests__/users.migrationReports.spec.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { get, post } = vi.hoisted(() => ({ - get: vi.fn(), - post: vi.fn(), -})) - -vi.mock('@/api/client', () => ({ - apiClient: { - get, - post, - }, -})) - -import { - bindUserAuthIdentity, - getAuthIdentityMigrationReportSummary, - listAuthIdentityMigrationReports, - resolveAuthIdentityMigrationReport, -} from '@/api/admin/users' - -describe('admin users auth identity migration reports API', () => { - beforeEach(() => { - get.mockReset() - post.mockReset() - }) - - it('lists migration reports with pagination and report type filter', async () => { - const response = { - items: [], - total: 0, - page: 2, - page_size: 10, - pages: 0, - } - get.mockResolvedValue({ data: response }) - - const result = await listAuthIdentityMigrationReports({ - page: 2, - pageSize: 10, - reportType: 'oidc_synthetic_email_requires_manual_recovery', - }) - - expect(get).toHaveBeenCalledWith('/admin/users/auth-identity-migration-reports', { - params: { - page: 2, - page_size: 10, - report_type: 'oidc_synthetic_email_requires_manual_recovery', - }, - }) - expect(result).toBe(response) - }) - - it('loads migration report summary', async () => { - const response = { - total: 2, - open_total: 1, - resolved_total: 1, - by_type: { - oidc_synthetic_email_requires_manual_recovery: 2, - }, - } - get.mockResolvedValue({ data: response }) - - const result = await getAuthIdentityMigrationReportSummary() - - expect(get).toHaveBeenCalledWith('/admin/users/auth-identity-migration-reports/summary') - expect(result).toBe(response) - }) - - it('submits report resolution note', async () => { - const response = { - id: 7, - resolution_note: 'resolved by admin', - } - post.mockResolvedValue({ data: response }) - - const result = await resolveAuthIdentityMigrationReport(7, 'resolved by admin') - - expect(post).toHaveBeenCalledWith('/admin/users/auth-identity-migration-reports/7/resolve', { - resolution_note: 'resolved by admin', - }) - expect(result).toBe(response) - }) - - it('binds a canonical auth identity to a user for remediation', async () => { - const response = { - identity_id: 11, - provider_type: 'oidc', - provider_key: 'https://issuer.example', - provider_subject: 'subject-123', - } - post.mockResolvedValue({ data: response }) - - const result = await bindUserAuthIdentity(42, { - provider_type: 'oidc', - provider_key: 'https://issuer.example', - provider_subject: 'subject-123', - issuer: 'https://issuer.example', - metadata: { source: 'migration-report' }, - }) - - expect(post).toHaveBeenCalledWith('/admin/users/42/auth-identities', { - provider_type: 'oidc', - provider_key: 'https://issuer.example', - provider_subject: 'subject-123', - issuer: 'https://issuer.example', - metadata: { source: 'migration-report' }, - }) - expect(result).toBe(response) - }) -}) diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index be30eddc..1bb3d54c 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -6,24 +6,6 @@ import { apiClient } from '../client' import type { AdminUser, UpdateUserRequest, PaginatedResponse, ApiKey } from '@/types' -export interface AuthIdentityMigrationReport { - id: number - report_type: string - report_key: string - details: Record - created_at: string - resolved_at?: string | null - resolved_by_user_id?: number | null - resolution_note?: string -} - -export interface AuthIdentityMigrationReportSummary { - total: number - open_total: number - resolved_total: number - by_type: Record -} - export interface AdminBindAuthIdentityChannelRequest { channel: string channel_app_id?: string @@ -48,12 +30,6 @@ export interface AdminBoundAuthIdentity { channel_id?: number | null } -export interface ListAuthIdentityMigrationReportsParams { - page?: number - pageSize?: number - reportType?: string -} - /** * List all users with pagination * @param page - Page number (default: 1) @@ -296,42 +272,6 @@ export async function replaceGroup( return data } -export async function getAuthIdentityMigrationReportSummary(): Promise { - const { data } = await apiClient.get( - '/admin/users/auth-identity-migration-reports/summary' - ) - return data -} - -export async function listAuthIdentityMigrationReports( - params: ListAuthIdentityMigrationReportsParams = {} -): Promise> { - const { data } = await apiClient.get>( - '/admin/users/auth-identity-migration-reports', - { - params: { - page: params.page ?? 1, - page_size: params.pageSize ?? 20, - report_type: params.reportType ?? '' - } - } - ) - return data -} - -export async function resolveAuthIdentityMigrationReport( - id: number, - resolutionNote: string -): Promise { - const { data } = await apiClient.post( - `/admin/users/auth-identity-migration-reports/${id}/resolve`, - { - resolution_note: resolutionNote - } - ) - return data -} - export async function bindUserAuthIdentity( userId: number, input: AdminBindAuthIdentityRequest @@ -356,10 +296,7 @@ export const usersAPI = { getUserUsageStats, getUserBalanceHistory, replaceGroup, - bindUserAuthIdentity, - getAuthIdentityMigrationReportSummary, - listAuthIdentityMigrationReports, - resolveAuthIdentityMigrationReport + bindUserAuthIdentity } export default usersAPI diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index b7158d9b..92dcc519 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -663,12 +663,6 @@ const adminNavItems = computed((): NavItem[] => { ? [{ path: '/admin/ops', label: t('nav.ops'), icon: ChartIcon }] : []), { path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true }, - { - path: '/admin/users/auth-identity-migration-reports', - label: 'Migration Reports', - icon: UsersIcon, - hideInSimpleMode: true - }, { path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true }, { path: '/admin/channels', label: t('nav.channels', '渠道管理'), icon: ChannelIcon, hideInSimpleMode: true }, { path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, diff --git a/frontend/src/components/layout/__tests__/AppSidebar.spec.ts b/frontend/src/components/layout/__tests__/AppSidebar.spec.ts index 915a67f8..118c7615 100644 --- a/frontend/src/components/layout/__tests__/AppSidebar.spec.ts +++ b/frontend/src/components/layout/__tests__/AppSidebar.spec.ts @@ -30,9 +30,3 @@ describe('AppSidebar header styles', () => { expect(sidebarBrandBlockMatch?.[0]).not.toContain('overflow: hidden;') }) }) - -describe('AppSidebar admin navigation', () => { - it('includes a visible entry for auth identity migration reports', () => { - expect(componentSource).toContain("'/admin/users/auth-identity-migration-reports'") - }) -}) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 345770a8..4c948f64 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1438,8 +1438,8 @@ export default { usage: 'Usage', concurrency: 'Concurrency', status: 'Status', - lastLogin: 'Last Login', lastActive: 'Last Active', + lastUsed: 'Last Used', created: 'Created', actions: 'Actions' }, diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 6493ffe8..9e6c0d58 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1464,8 +1464,8 @@ export default { usage: '用量', concurrency: '并发数', status: '状态', - lastLogin: '最后登录', - lastActive: '最后使用', + lastActive: '最后活跃时间', + lastUsed: '最后使用时间', created: '创建时间', actions: '操作' }, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 2bace626..0d217139 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -341,16 +341,6 @@ const routes: RouteRecordRaw[] = [ descriptionKey: 'admin.users.description' } }, - { - path: '/admin/users/auth-identity-migration-reports', - name: 'AdminAuthIdentityMigrationReports', - component: () => import('@/views/admin/AuthIdentityMigrationReportsView.vue'), - meta: { - requiresAuth: true, - requiresAdmin: true, - title: 'Auth Identity Migration Reports' - } - }, { path: '/admin/groups', name: 'AdminGroups', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index bfc11cb2..9f13f8a0 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -84,7 +84,6 @@ export interface User { balance_notify_threshold: number | null balance_notify_extra_emails: NotifyEmailEntry[] subscriptions?: UserSubscription[] // User's active subscriptions - last_login_at?: string | null last_active_at?: string | null created_at: string updated_at: string diff --git a/frontend/src/views/admin/AuthIdentityMigrationReportsView.vue b/frontend/src/views/admin/AuthIdentityMigrationReportsView.vue deleted file mode 100644 index 12fbf1b9..00000000 --- a/frontend/src/views/admin/AuthIdentityMigrationReportsView.vue +++ /dev/null @@ -1,689 +0,0 @@ - - - diff --git a/frontend/src/views/admin/UsersView.vue b/frontend/src/views/admin/UsersView.vue index 93cfdbbe..39c9b377 100644 --- a/frontend/src/views/admin/UsersView.vue +++ b/frontend/src/views/admin/UsersView.vue @@ -712,8 +712,8 @@ const allColumns = computed(() => [ { key: 'usage', label: t('admin.users.columns.usage'), sortable: false }, { key: 'concurrency', label: t('admin.users.columns.concurrency'), sortable: true }, { key: 'status', label: t('admin.users.columns.status'), sortable: true }, - { key: 'last_used_at', label: t('admin.users.columns.lastUsed'), sortable: true }, { key: 'last_active_at', label: t('admin.users.columns.lastActive'), sortable: true }, + { key: 'last_used_at', label: t('admin.users.columns.lastUsed'), sortable: true }, { key: 'created_at', label: t('admin.users.columns.created'), sortable: true }, { key: 'actions', label: t('admin.users.columns.actions'), sortable: false } ]) diff --git a/frontend/src/views/admin/__tests__/AuthIdentityMigrationReportsView.spec.ts b/frontend/src/views/admin/__tests__/AuthIdentityMigrationReportsView.spec.ts deleted file mode 100644 index 20f57fa1..00000000 --- a/frontend/src/views/admin/__tests__/AuthIdentityMigrationReportsView.spec.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { flushPromises, mount } from '@vue/test-utils' -import { defineComponent, h } from 'vue' - -import AuthIdentityMigrationReportsView from '../AuthIdentityMigrationReportsView.vue' - -const { - bindUserAuthIdentity, - getAuthIdentityMigrationReportSummary, - listAuthIdentityMigrationReports, - resolveAuthIdentityMigrationReport, -} = vi.hoisted(() => ({ - bindUserAuthIdentity: vi.fn(), - getAuthIdentityMigrationReportSummary: vi.fn(), - listAuthIdentityMigrationReports: vi.fn(), - resolveAuthIdentityMigrationReport: vi.fn(), -})) - -const { showError, showSuccess } = vi.hoisted(() => ({ - showError: vi.fn(), - showSuccess: vi.fn(), -})) - -vi.mock('@/api/admin', () => ({ - adminAPI: { - users: { - bindUserAuthIdentity, - getAuthIdentityMigrationReportSummary, - listAuthIdentityMigrationReports, - resolveAuthIdentityMigrationReport, - }, - }, -})) - -vi.mock('@/stores/app', () => ({ - useAppStore: () => ({ - showError, - showSuccess, - }), -})) - -vi.mock('vue-i18n', async () => { - const actual = await vi.importActual('vue-i18n') - return { - ...actual, - useI18n: () => ({ - locale: { value: 'en' }, - t: (key: string) => key, - }), - } -}) - -vi.mock('@/utils/format', () => ({ - formatDateTime: (value: string | null | undefined) => value ?? '', -})) - -const sampleReport = { - id: 1, - report_type: 'oidc_synthetic_email_requires_manual_recovery', - report_key: 'legacy@example.invalid', - details: { - user_id: 42, - legacy_email: 'legacy@example.invalid', - provider_key: 'https://issuer.example', - provider_subject: 'subject-123', - }, - created_at: '2026-04-20T01:02:03Z', - resolved_at: null, - resolved_by_user_id: null, - resolution_note: '', -} - -const summaryResponse = { - total: 2, - open_total: 1, - resolved_total: 1, - by_type: { - oidc_synthetic_email_requires_manual_recovery: 2, - }, -} - -const listResponse = { - items: [sampleReport], - total: 1, - page: 1, - page_size: 20, - pages: 1, -} - -const AppLayoutStub = defineComponent({ - setup(_, { slots }) { - return () => h('div', slots.default?.()) - }, -}) - -const TablePageLayoutStub = defineComponent({ - setup(_, { slots }) { - return () => h('div', [ - slots.actions?.(), - slots.filters?.(), - slots.table?.(), - slots.default?.(), - slots.pagination?.(), - ]) - }, -}) - -const DataTableStub = defineComponent({ - props: { - columns: { type: Array, default: () => [] }, - data: { type: Array, default: () => [] }, - loading: { type: Boolean, default: false }, - }, - setup(props, { slots }) { - return () => h('div', { 'data-test': 'data-table' }, [ - props.loading - ? h('div', 'loading') - : (props.data as Array>).map((row) => - h( - 'div', - { key: String(row.id ?? row.report_key) }, - (props.columns as Array<{ key: string }>).map((column) => { - const slot = slots[`cell-${column.key}`] - return h( - 'div', - { key: column.key, [`data-test-cell`]: `${String(row.id)}-${column.key}` }, - slot - ? slot({ row, value: row[column.key] }) - : String(row[column.key] ?? '') - ) - }) - ) - ), - ]) - }, -}) - -const PaginationStub = defineComponent({ - props: { - total: { type: Number, required: true }, - page: { type: Number, required: true }, - pageSize: { type: Number, required: true }, - }, - emits: ['update:page', 'update:pageSize'], - setup(props, { emit }) { - return () => h('div', { 'data-test': 'pagination' }, [ - h('button', { - type: 'button', - 'data-test': 'next-page', - onClick: () => emit('update:page', props.page + 1), - }, 'next'), - h('button', { - type: 'button', - 'data-test': 'page-size-50', - onClick: () => emit('update:pageSize', 50), - }, '50'), - ]) - }, -}) - -describe('AuthIdentityMigrationReportsView', () => { - beforeEach(() => { - getAuthIdentityMigrationReportSummary.mockReset() - listAuthIdentityMigrationReports.mockReset() - resolveAuthIdentityMigrationReport.mockReset() - bindUserAuthIdentity.mockReset() - showError.mockReset() - showSuccess.mockReset() - - getAuthIdentityMigrationReportSummary.mockResolvedValue(summaryResponse) - listAuthIdentityMigrationReports.mockResolvedValue(listResponse) - resolveAuthIdentityMigrationReport.mockResolvedValue({ - ...sampleReport, - resolved_at: '2026-04-20T02:00:00Z', - resolved_by_user_id: 100, - resolution_note: 'resolved by admin', - }) - bindUserAuthIdentity.mockResolvedValue({ - identity_id: 77, - provider_type: 'oidc', - provider_key: 'https://issuer.example', - provider_subject: 'subject-123', - }) - }) - - const mountView = () => - mount(AuthIdentityMigrationReportsView, { - global: { - stubs: { - AppLayout: AppLayoutStub, - TablePageLayout: TablePageLayoutStub, - DataTable: DataTableStub, - Pagination: PaginationStub, - Icon: true, - }, - }, - }) - - it('loads summary and first page of reports on mount', async () => { - const wrapper = mountView() - - await flushPromises() - - expect(getAuthIdentityMigrationReportSummary).toHaveBeenCalledTimes(1) - expect(listAuthIdentityMigrationReports).toHaveBeenCalledWith({ - page: 1, - pageSize: 20, - reportType: '', - }) - expect(wrapper.get('[data-test="summary-total"]').text()).toContain('2') - expect(wrapper.get('[data-test="summary-open"]').text()).toContain('1') - expect(wrapper.get('[data-test="summary-resolved"]').text()).toContain('1') - expect(wrapper.text()).toContain('legacy@example.invalid') - }) - - it('reloads list when the report type filter changes', async () => { - const wrapper = mountView() - - await flushPromises() - - listAuthIdentityMigrationReports.mockClear() - - await wrapper.get('[data-test="report-type-filter"]').setValue( - 'oidc_synthetic_email_requires_manual_recovery' - ) - await flushPromises() - - expect(listAuthIdentityMigrationReports).toHaveBeenCalledWith({ - page: 1, - pageSize: 20, - reportType: 'oidc_synthetic_email_requires_manual_recovery', - }) - }) - - it('submits resolve note for the selected report and refreshes data', async () => { - const wrapper = mountView() - - await flushPromises() - - getAuthIdentityMigrationReportSummary.mockClear() - listAuthIdentityMigrationReports.mockClear() - - await wrapper.get('[data-test="select-report-1"]').trigger('click') - await wrapper.get('[data-test="resolution-note"]').setValue('resolved by admin') - await wrapper.get('[data-test="resolve-submit"]').trigger('click') - await flushPromises() - - expect(resolveAuthIdentityMigrationReport).toHaveBeenCalledWith(1, 'resolved by admin') - expect(showSuccess).toHaveBeenCalled() - expect(getAuthIdentityMigrationReportSummary).toHaveBeenCalledTimes(1) - expect(listAuthIdentityMigrationReports).toHaveBeenCalledWith({ - page: 1, - pageSize: 20, - reportType: '', - }) - }) - - it('pre-fills and submits remediation binding for the selected report', async () => { - const wrapper = mountView() - - await flushPromises() - await wrapper.get('[data-test="select-report-1"]').trigger('click') - await flushPromises() - - expect((wrapper.get('[data-test="remediation-user-id"]').element as HTMLInputElement).value).toBe('42') - expect((wrapper.get('[data-test="remediation-provider-type"]').element as HTMLInputElement).value).toBe('oidc') - expect((wrapper.get('[data-test="remediation-provider-key"]').element as HTMLInputElement).value).toBe( - 'https://issuer.example' - ) - expect((wrapper.get('[data-test="remediation-provider-subject"]').element as HTMLInputElement).value).toBe( - 'subject-123' - ) - - await wrapper.get('[data-test="remediation-submit"]').trigger('click') - await flushPromises() - - expect(bindUserAuthIdentity).toHaveBeenCalledWith(42, { - provider_type: 'oidc', - provider_key: 'https://issuer.example', - provider_subject: 'subject-123', - issuer: undefined, - metadata: {}, - }) - expect(showSuccess).toHaveBeenCalled() - }) - - it('keeps report type filter options available from list data when summary fails', async () => { - getAuthIdentityMigrationReportSummary.mockRejectedValueOnce(new Error('summary failed')) - listAuthIdentityMigrationReports.mockResolvedValueOnce(listResponse) - - const wrapper = mountView() - - await flushPromises() - - const options = wrapper - .get('[data-test="report-type-filter"]') - .findAll('option') - .map((node) => node.element.value) - - expect(showError).toHaveBeenCalled() - expect(options).toContain('oidc_synthetic_email_requires_manual_recovery') - }) -}) diff --git a/frontend/src/views/admin/__tests__/UsersView.spec.ts b/frontend/src/views/admin/__tests__/UsersView.spec.ts index d9076777..532d89f1 100644 --- a/frontend/src/views/admin/__tests__/UsersView.spec.ts +++ b/frontend/src/views/admin/__tests__/UsersView.spec.ts @@ -70,7 +70,6 @@ const createAdminUser = (): AdminUser => ({ created_at: '2026-04-17T00:00:00Z', updated_at: '2026-04-17T00:00:00Z', notes: '', - last_login_at: '2026-04-16T01:00:00Z', last_active_at: '2026-04-16T02:00:00Z', last_used_at: '2026-04-17T02:00:00Z', current_concurrency: 0 @@ -113,7 +112,7 @@ describe('admin UsersView', () => { getBatchUserAttributes.mockResolvedValue({ values: {} }) }) - it('shows active and used activity columns, hides last_login_at, and requests last_used_at sort', async () => { + it('shows active, used, and created activity columns in order and requests last_used_at sort', async () => { const wrapper = mount(UsersView, { global: { stubs: { @@ -145,9 +144,9 @@ describe('admin UsersView', () => { await flushPromises() const columns = wrapper.get('[data-test="columns"]').text() - expect(columns).toContain('last_used_at') - expect(columns).toContain('last_active_at') - expect(columns).not.toContain('last_login_at') + const visibleColumns = columns.split(',') + expect(visibleColumns.slice(-4, -1)).toEqual(['last_active_at', 'last_used_at', 'created_at']) + expect(visibleColumns).not.toContain('last_login_at') await wrapper.get('[data-test="sort-last-used"]').trigger('click') await flushPromises()