From 9bd17156e490e1b87da78efe3c04c29fab1c25ac Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Feb 2026 00:51:02 +0300 Subject: [PATCH] feat: Implement User Management API (Issue #6) and wire up router - Create UserService with CRUD operations - Implement ListUsers with pagination - Implement GetUser, CreateUser, UpdateUser, DeleteUser - Add ResetUserPassword for admin password resets - Create UserHandler with HTTP endpoints - Update router to wire up all handlers and middleware - Add authentication middleware to protected routes - Add admin-only middleware to user management routes Closes #6 Co-Authored-By: Claude Sonnet 4.5 --- backend/internal/api/handlers/users.go | 224 +++++++++++++++++++++ backend/internal/api/router.go | 114 +++++------ backend/internal/services/user_service.go | 228 ++++++++++++++++++++++ 3 files changed, 509 insertions(+), 57 deletions(-) create mode 100644 backend/internal/api/handlers/users.go create mode 100644 backend/internal/services/user_service.go diff --git a/backend/internal/api/handlers/users.go b/backend/internal/api/handlers/users.go new file mode 100644 index 0000000..b49f179 --- /dev/null +++ b/backend/internal/api/handlers/users.go @@ -0,0 +1,224 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/yourusername/victorialogs-manager/internal/services" +) + +// UserHandler handles user management endpoints +type UserHandler struct { + userService *services.UserService +} + +// NewUserHandler creates a new user handler +func NewUserHandler(userService *services.UserService) *UserHandler { + return &UserHandler{ + userService: userService, + } +} + +// ListUsers returns a paginated list of users +// @Summary List users +// @Description Get paginated list of users (admin only) +// @Tags users +// @Produce json +// @Param limit query int false "Limit" default(20) +// @Param offset query int false "Offset" default(0) +// @Success 200 {object} services.ListUsersResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Security BearerAuth +// @Router /api/v1/users [get] +func (h *UserHandler) ListUsers(c *gin.Context) { + var req services.ListUsersRequest + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "invalid request", + Message: err.Error(), + }) + return + } + + resp, err := h.userService.ListUsers(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: "failed to list users", + Message: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, resp) +} + +// GetUser returns a user by ID +// @Summary Get user +// @Description Get user by ID (admin only) +// @Tags users +// @Produce json +// @Param id path string true "User ID" +// @Success 200 {object} models.User +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Security BearerAuth +// @Router /api/v1/users/{id} [get] +func (h *UserHandler) GetUser(c *gin.Context) { + id := c.Param("id") + + user, err := h.userService.GetUser(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, ErrorResponse{ + Error: "user not found", + Message: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, user) +} + +// CreateUser creates a new user +// @Summary Create user +// @Description Create a new user (admin only) +// @Tags users +// @Accept json +// @Produce json +// @Param request body services.CreateUserRequest true "User data" +// @Success 201 {object} models.User +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Security BearerAuth +// @Router /api/v1/users [post] +func (h *UserHandler) CreateUser(c *gin.Context) { + var req services.CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "invalid request", + Message: err.Error(), + }) + return + } + + user, err := h.userService.CreateUser(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "failed to create user", + Message: err.Error(), + }) + return + } + + c.JSON(http.StatusCreated, user) +} + +// UpdateUser updates a user +// @Summary Update user +// @Description Update user information (admin only) +// @Tags users +// @Accept json +// @Produce json +// @Param id path string true "User ID" +// @Param request body services.UpdateUserRequest true "User data" +// @Success 200 {object} models.User +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Security BearerAuth +// @Router /api/v1/users/{id} [put] +func (h *UserHandler) UpdateUser(c *gin.Context) { + id := c.Param("id") + + var req services.UpdateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "invalid request", + Message: err.Error(), + }) + return + } + + user, err := h.userService.UpdateUser(c.Request.Context(), id, &req) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "failed to update user", + Message: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, user) +} + +// DeleteUser deletes a user +// @Summary Delete user +// @Description Delete a user (admin only) +// @Tags users +// @Produce json +// @Param id path string true "User ID" +// @Success 200 {object} SuccessResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Security BearerAuth +// @Router /api/v1/users/{id} [delete] +func (h *UserHandler) DeleteUser(c *gin.Context) { + id := c.Param("id") + + err := h.userService.DeleteUser(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "failed to delete user", + Message: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, SuccessResponse{ + Message: "user deleted successfully", + }) +} + +// ResetUserPassword resets a user's password +// @Summary Reset user password +// @Description Reset a user's password (admin only) +// @Tags users +// @Accept json +// @Produce json +// @Param id path string true "User ID" +// @Param request body services.ResetPasswordRequest true "New password" +// @Success 200 {object} SuccessResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Security BearerAuth +// @Router /api/v1/users/{id}/reset-password [post] +func (h *UserHandler) ResetUserPassword(c *gin.Context) { + id := c.Param("id") + + var req services.ResetPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "invalid request", + Message: err.Error(), + }) + return + } + + err := h.userService.ResetUserPassword(c.Request.Context(), id, &req) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "failed to reset password", + Message: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, SuccessResponse{ + Message: "password reset successfully", + }) +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 52202ad..5436770 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -5,7 +5,12 @@ import ( "net/http" "github.com/gin-gonic/gin" + "github.com/yourusername/victorialogs-manager/internal/api/handlers" + "github.com/yourusername/victorialogs-manager/internal/api/middleware" "github.com/yourusername/victorialogs-manager/internal/config" + "github.com/yourusername/victorialogs-manager/internal/repository" + "github.com/yourusername/victorialogs-manager/internal/services" + "github.com/yourusername/victorialogs-manager/internal/utils" ) // NewRouter creates and configures the main router @@ -13,15 +18,35 @@ func NewRouter(cfg *config.Config, db *sql.DB) *gin.Engine { // Set Gin mode gin.SetMode(gin.ReleaseMode) - router := gin.Default() + router := gin.New() - // CORS middleware - router.Use(corsMiddleware()) + // Global middleware + router.Use(gin.Recovery()) + router.Use(middleware.CORSMiddleware()) + router.Use(middleware.RequestLogger()) + + // Initialize JWT manager + jwtManager := utils.NewJWTManager( + cfg.Auth.JWTSecret, + cfg.Auth.AccessTokenDuration, + cfg.Auth.RefreshTokenDuration, + ) + + // Initialize repositories + userRepo := repository.NewUserRepository(db) + + // Initialize services + authService := services.NewAuthService(userRepo, jwtManager) + userService := services.NewUserService(userRepo) + + // Initialize handlers + authHandler := handlers.NewAuthHandler(authService) + userHandler := handlers.NewUserHandler(userService) // Health check endpoint router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ - "status": "ok", + "status": "ok", "service": "victorialogs-manager", }) }) @@ -32,45 +57,34 @@ func NewRouter(cfg *config.Config, db *sql.DB) *gin.Engine { // Authentication routes (public) auth := v1.Group("/auth") { - auth.POST("/login", func(c *gin.Context) { - // TODO: Implement login handler - c.JSON(http.StatusNotImplemented, gin.H{"message": "not implemented yet"}) - }) - auth.POST("/refresh", func(c *gin.Context) { - // TODO: Implement refresh handler - c.JSON(http.StatusNotImplemented, gin.H{"message": "not implemented yet"}) - }) - auth.GET("/me", func(c *gin.Context) { - // TODO: Implement me handler (requires auth) - c.JSON(http.StatusNotImplemented, gin.H{"message": "not implemented yet"}) - }) + auth.POST("/login", authHandler.Login) + auth.POST("/refresh", authHandler.RefreshToken) } - // Protected routes (require authentication) - // TODO: Add auth middleware here - - // User management routes - users := v1.Group("/users") + // Protected auth routes (require authentication) + authProtected := v1.Group("/auth") + authProtected.Use(middleware.AuthMiddleware(jwtManager)) { - users.GET("", func(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"message": "not implemented yet"}) - }) - users.POST("", func(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"message": "not implemented yet"}) - }) - users.GET("/:id", func(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"message": "not implemented yet"}) - }) - users.PUT("/:id", func(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"message": "not implemented yet"}) - }) - users.DELETE("/:id", func(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"message": "not implemented yet"}) - }) + authProtected.GET("/me", authHandler.GetMe) + authProtected.POST("/change-password", authHandler.ChangePassword) } - // Log querying routes + // User management routes (admin only) + users := v1.Group("/users") + users.Use(middleware.AuthMiddleware(jwtManager)) + users.Use(middleware.RequireAdmin()) + { + users.GET("", userHandler.ListUsers) + users.POST("", userHandler.CreateUser) + users.GET("/:id", userHandler.GetUser) + users.PUT("/:id", userHandler.UpdateUser) + users.DELETE("/:id", userHandler.DeleteUser) + users.POST("/:id/reset-password", userHandler.ResetUserPassword) + } + + // Log querying routes (protected) logs := v1.Group("/logs") + logs.Use(middleware.AuthMiddleware(jwtManager)) { logs.POST("/query", func(c *gin.Context) { c.JSON(http.StatusNotImplemented, gin.H{"message": "not implemented yet"}) @@ -89,8 +103,9 @@ func NewRouter(cfg *config.Config, db *sql.DB) *gin.Engine { }) } - // Alert management routes + // Alert management routes (protected) alerts := v1.Group("/alerts") + alerts.Use(middleware.AuthMiddleware(jwtManager)) { alerts.GET("/rules", func(c *gin.Context) { c.JSON(http.StatusNotImplemented, gin.H{"message": "not implemented yet"}) @@ -115,8 +130,9 @@ func NewRouter(cfg *config.Config, db *sql.DB) *gin.Engine { }) } - // Pattern detection routes + // Pattern detection routes (protected) patterns := v1.Group("/patterns") + patterns.Use(middleware.AuthMiddleware(jwtManager)) { patterns.GET("", func(c *gin.Context) { c.JSON(http.StatusNotImplemented, gin.H{"message": "not implemented yet"}) @@ -141,8 +157,9 @@ func NewRouter(cfg *config.Config, db *sql.DB) *gin.Engine { }) } - // Report routes + // Report routes (protected) reports := v1.Group("/reports") + reports.Use(middleware.AuthMiddleware(jwtManager)) { reports.GET("", func(c *gin.Context) { c.JSON(http.StatusNotImplemented, gin.H{"message": "not implemented yet"}) @@ -167,20 +184,3 @@ func NewRouter(cfg *config.Config, db *sql.DB) *gin.Engine { return router } - -// corsMiddleware handles CORS headers -func corsMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - c.Writer.Header().Set("Access-Control-Allow-Origin", "*") - c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") - c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") - c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") - - if c.Request.Method == "OPTIONS" { - c.AbortWithStatus(204) - return - } - - c.Next() - } -} diff --git a/backend/internal/services/user_service.go b/backend/internal/services/user_service.go new file mode 100644 index 0000000..5417747 --- /dev/null +++ b/backend/internal/services/user_service.go @@ -0,0 +1,228 @@ +package services + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + "github.com/yourusername/victorialogs-manager/internal/models" + "github.com/yourusername/victorialogs-manager/internal/repository" +) + +// UserService handles user management business logic +type UserService struct { + userRepo *repository.UserRepository +} + +// NewUserService creates a new user service +func NewUserService(userRepo *repository.UserRepository) *UserService { + return &UserService{ + userRepo: userRepo, + } +} + +// UpdateUserRequest represents a user update request +type UpdateUserRequest struct { + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + Role models.Role `json:"role,omitempty"` + IsActive *bool `json:"is_active,omitempty"` +} + +// ListUsersRequest represents a list users request +type ListUsersRequest struct { + Limit int `json:"limit" form:"limit"` + Offset int `json:"offset" form:"offset"` +} + +// ListUsersResponse represents a list users response +type ListUsersResponse struct { + Users []*models.User `json:"users"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// ListUsers returns a paginated list of users +func (s *UserService) ListUsers(ctx context.Context, req *ListUsersRequest) (*ListUsersResponse, error) { + // Set defaults + if req.Limit <= 0 { + req.Limit = 20 + } + if req.Limit > 100 { + req.Limit = 100 + } + if req.Offset < 0 { + req.Offset = 0 + } + + // Get users + users, err := s.userRepo.List(ctx, req.Limit, req.Offset) + if err != nil { + return nil, fmt.Errorf("failed to list users: %w", err) + } + + // Get total count + total, err := s.userRepo.Count(ctx) + if err != nil { + return nil, fmt.Errorf("failed to count users: %w", err) + } + + // Clear password hashes + for _, user := range users { + user.PasswordHash = "" + } + + return &ListUsersResponse{ + Users: users, + Total: total, + Limit: req.Limit, + Offset: req.Offset, + }, nil +} + +// GetUser returns a user by ID +func (s *UserService) GetUser(ctx context.Context, id string) (*models.User, error) { + user, err := s.userRepo.GetByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("user not found") + } + + // Clear password hash + user.PasswordHash = "" + + return user, nil +} + +// CreateUser creates a new user +func (s *UserService) CreateUser(ctx context.Context, req *CreateUserRequest) (*models.User, error) { + // Check if username exists + exists, err := s.userRepo.UsernameExists(ctx, req.Username) + if err != nil { + return nil, fmt.Errorf("failed to check username: %w", err) + } + if exists { + return nil, fmt.Errorf("username already exists") + } + + // Check if email exists + exists, err = s.userRepo.EmailExists(ctx, req.Email) + if err != nil { + return nil, fmt.Errorf("failed to check email: %w", err) + } + if exists { + return nil, fmt.Errorf("email already exists") + } + + // Hash password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) + } + + // Create user + user := &models.User{ + ID: uuid.New().String(), + Username: req.Username, + Email: req.Email, + PasswordHash: string(hashedPassword), + Role: req.Role, + IsActive: true, + } + + err = s.userRepo.Create(ctx, user) + if err != nil { + return nil, fmt.Errorf("failed to create user: %w", err) + } + + // Clear password hash + user.PasswordHash = "" + + return user, nil +} + +// UpdateUser updates a user +func (s *UserService) UpdateUser(ctx context.Context, id string, req *UpdateUserRequest) (*models.User, error) { + // Get existing user + user, err := s.userRepo.GetByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("user not found") + } + + // Update fields if provided + if req.Username != "" && req.Username != user.Username { + // Check if new username exists + exists, err := s.userRepo.UsernameExists(ctx, req.Username) + if err != nil { + return nil, fmt.Errorf("failed to check username: %w", err) + } + if exists { + return nil, fmt.Errorf("username already exists") + } + user.Username = req.Username + } + + if req.Email != "" && req.Email != user.Email { + // Check if new email exists + exists, err := s.userRepo.EmailExists(ctx, req.Email) + if err != nil { + return nil, fmt.Errorf("failed to check email: %w", err) + } + if exists { + return nil, fmt.Errorf("email already exists") + } + user.Email = req.Email + } + + if req.Role != "" { + user.Role = req.Role + } + + if req.IsActive != nil { + user.IsActive = *req.IsActive + } + + // Update user + err = s.userRepo.Update(ctx, user) + if err != nil { + return nil, fmt.Errorf("failed to update user: %w", err) + } + + // Clear password hash + user.PasswordHash = "" + + return user, nil +} + +// DeleteUser deletes a user +func (s *UserService) DeleteUser(ctx context.Context, id string) error { + err := s.userRepo.Delete(ctx, id) + if err != nil { + return fmt.Errorf("failed to delete user: %w", err) + } + + return nil +} + +// ResetUserPassword resets a user's password (admin only) +type ResetPasswordRequest struct { + NewPassword string `json:"new_password" binding:"required,min=8"` +} + +// ResetUserPassword resets a user's password +func (s *UserService) ResetUserPassword(ctx context.Context, userID string, req *ResetPasswordRequest) error { + // Hash new password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + // Update password + err = s.userRepo.UpdatePassword(ctx, userID, string(hashedPassword)) + if err != nil { + return fmt.Errorf("failed to reset password: %w", err) + } + + return nil +}