package controllers import ( "log" "net/http" "strconv" "strings" "time" "fotbal-club/internal/models" "fotbal-club/internal/services" "fotbal-club/pkg/utils" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // RegisterRequest represents the request body for user registration type RegisterRequest struct { Email string `json:"email" binding:"required"` Password string `json:"password" binding:"required,min=8"` FirstName string `json:"first_name"` LastName string `json:"last_name"` Name string `json:"name"` } // LoginRequest represents the request body for user login type LoginRequest struct { Email string `json:"email" binding:"required"` Password string `json:"password" binding:"required"` } // AuthResponse represents the response for authentication endpoints type AuthResponse struct { Token string `json:"token"` User *UserModel `json:"user"` } // UserModel represents the user data in the response type UserModel struct { ID uint `json:"id"` Email string `json:"email"` FirstName string `json:"first_name"` LastName string `json:"last_name"` Role string `json:"role"` CreatedAt time.Time `json:"created_at"` } // AuthController handles authentication related requests type AuthController struct { DB *gorm.DB setupService *services.SetupService } // NewAuthController creates a new AuthController func NewAuthController(db *gorm.DB) *AuthController { return &AuthController{ DB: db, setupService: services.NewSetupService(db), } } // Register handles user registration func (ac *AuthController) Register(c *gin.Context) { var req RegisterRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Backward compatibility: allow a single 'name' and split to first/last if (req.FirstName == "" || req.LastName == "") && req.Name != "" { // Split by whitespace, first token = first name, rest joined as last name parts := strings.Fields(req.Name) if len(parts) > 0 { req.FirstName = parts[0] } if len(parts) > 1 { req.LastName = strings.Join(parts[1:], " ") } } if req.FirstName == "" || req.LastName == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "first_name and last_name are required (you can also provide 'name' with both)"}) return } // Normalize email to lowercase and trim spaces (supports unicode) req.Email = strings.TrimSpace(strings.ToLower(req.Email)) if req.Email == "" || !strings.Contains(req.Email, "@") { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid email address"}) return } // Check if email already exists (case-insensitive) var existingUser models.User if err := ac.DB.Where("LOWER(email) = LOWER(?)", req.Email).First(&existingUser).Error; err == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Email already registered"}) return } // Hash password hashedPassword, err := utils.HashPassword(req.Password) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) return } // Check if this is the first user (admin) var userCount int64 ac.DB.Model(&models.User{}).Count(&userCount) role := "fan" isFirstUser := userCount == 0 if isFirstUser { role = "admin" } // Create user user := models.User{ Email: req.Email, Password: hashedPassword, FirstName: req.FirstName, LastName: req.LastName, Role: role, } if err := ac.DB.Create(&user).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) return } // Auto-subscribe newly registered fans to the newsletter _ = models.SubscribeToNewsletter(ac.DB, user.Email) // For first user, ensure setup info exists if isFirstUser { _, err := ac.setupService.GetSetupStatus() if err != nil { // If there's an error getting setup status, log it but continue log.Printf("Warning: Failed to initialize setup status: %v", err) } } // Generate JWT token token, err := utils.GenerateJWT(user.ID, user.Email, user.Role) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) return } // Also set secure HttpOnly cookie for browser-based auth (same behavior as Login) secure := c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") http.SetCookie(c.Writer, &http.Cookie{ Name: "auth_token", Value: token, Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, Expires: time.Now().Add(24 * time.Hour), }) // Prepare response response := gin.H{ "token": token, "user": ac.toUserModel(&user), } // If this is the first user, include setup status if isFirstUser { setupInfo, _ := ac.setupService.GetSetupStatus() if setupInfo != nil { response["requires_initial_setup"] = true response["setup_status"] = setupInfo.Status } } c.JSON(http.StatusCreated, response) } // Login handles user login func (ac *AuthController) Login(c *gin.Context) { var req LoginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Normalize and find user by email (case-insensitive) req.Email = strings.TrimSpace(strings.ToLower(req.Email)) if req.Email == "" || !strings.Contains(req.Email, "@") { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid email address"}) return } // Find user by email (case-insensitive) var user models.User if err := ac.DB.Where("LOWER(email) = LOWER(?)", req.Email).First(&user).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) } return } // Verify password if err := utils.CheckPassword(req.Password, user.Password); err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) return } // Update last login time now := time.Now() user.LastLogin = &now ac.DB.Save(&user) // Generate JWT token token, err := utils.GenerateJWT(user.ID, user.Email, user.Role) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) return } // Also set secure HttpOnly cookie for browser-based auth (optional to use) // Determine secure flag: HTTPS or forwarded proto secure := c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") http.SetCookie(c.Writer, &http.Cookie{ Name: "auth_token", Value: token, Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, Expires: time.Now().Add(24 * time.Hour), }) // Return user data and token (backward compatibility for clients using Authorization header) c.JSON(http.StatusOK, AuthResponse{ Token: token, User: ac.toUserModel(&user), }) } // Logout clears the auth cookie (stateless JWT) func (ac *AuthController) Logout(c *gin.Context) { // Expire the cookie in the past secure := c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") http.SetCookie(c.Writer, &http.Cookie{ Name: "auth_token", Value: "", Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, Expires: time.Unix(0, 0), MaxAge: -1, }) c.JSON(http.StatusOK, gin.H{"success": true}) } // CheckEmail checks if an email is already registered func (ac *AuthController) CheckEmail(c *gin.Context) { email := c.Query("email") if email == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"}) return } var user models.User if err := ac.DB.Where("email = ?", email).First(&user).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusOK, gin.H{"available": true}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) return } c.JSON(http.StatusOK, gin.H{"available": false}) } // GetCurrentUser returns the currently authenticated user func (ac *AuthController) GetCurrentUser(c *gin.Context) { user, exists := c.Get("user") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } c.JSON(http.StatusOK, gin.H{"user": ac.toUserModel(user.(*models.User))}) } // UpdateCurrentUser allows the authenticated user to update their personal information func (ac *AuthController) UpdateCurrentUser(c *gin.Context) { u, exists := c.Get("user") if !exists || u == nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } current := u.(*models.User) var req struct { FirstName string `json:"first_name"` LastName string `json:"last_name"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } fn := strings.TrimSpace(req.FirstName) ln := strings.TrimSpace(req.LastName) if fn != "" { current.FirstName = fn } if ln != "" { current.LastName = ln } if err := ac.DB.Save(current).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"}) return } c.JSON(http.StatusOK, gin.H{"user": ac.toUserModel(current)}) } // AdminExists returns whether any admin user exists func (ac *AuthController) AdminExists(c *gin.Context) { var count int64 if err := ac.DB.Model(&models.User{}).Where("role = ?", "admin").Count(&count).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) return } c.JSON(http.StatusOK, gin.H{"hasAdmin": count > 0}) } // MakeAdmin promotes the current authenticated user to admin if no admin exists yet func (ac *AuthController) MakeAdmin(c *gin.Context) { // Ensure an admin doesn't already exist var count int64 if err := ac.DB.Model(&models.User{}).Where("role = ?", "admin").Count(&count).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) return } if count > 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "Admin already exists"}) return } // Get current user from context u, exists := c.Get("user") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } user := u.(*models.User) // Promote to admin user.Role = "admin" if err := ac.DB.Save(user).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to promote user"}) return } c.JSON(http.StatusOK, gin.H{"user": ac.toUserModel(user)}) } // toUserModel converts a User model to a UserModel for JSON response func (ac *AuthController) toUserModel(user *models.User) *UserModel { return &UserModel{ ID: user.ID, Email: user.Email, FirstName: user.FirstName, LastName: user.LastName, Role: user.Role, CreatedAt: user.CreatedAt, } } // ListUsers returns a list of users (admin only) // Admin list item matching frontend expectations type AdminUserListItem struct { ID uint `json:"id"` Email string `json:"email"` Name string `json:"name"` Role string `json:"role"` IsActive bool `json:"isActive"` CreatedAt time.Time `json:"createdAt"` } func (ac *AuthController) ListUsers(c *gin.Context) { // Ensure admin if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } var users []models.User if err := ac.DB.Order("created_at DESC").Find(&users).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) return } out := make([]AdminUserListItem, 0, len(users)) for _, u := range users { name := strings.TrimSpace(strings.TrimSpace(u.FirstName + " " + u.LastName)) out = append(out, AdminUserListItem{ ID: u.ID, Email: u.Email, Name: name, Role: u.Role, IsActive: u.IsActive, CreatedAt: u.CreatedAt, }) } c.JSON(http.StatusOK, out) } // AdminCreateUserRequest request body type AdminCreateUserRequest struct { Name string `json:"name" binding:"required"` Email string `json:"email" binding:"required"` Password string `json:"password" binding:"required,min=8"` Role string `json:"role"` IsActive *bool `json:"isActive"` } // AdminUpdateUserRequest request body type AdminUpdateUserRequest struct { Name string `json:"name"` Email string `json:"email"` Role string `json:"role"` IsActive *bool `json:"isActive"` CurrentPassword string `json:"current_password"` } func splitName(name string) (string, string) { parts := strings.Fields(strings.TrimSpace(name)) if len(parts) == 0 { return "", "" } if len(parts) == 1 { return parts[0], "" } return parts[0], strings.Join(parts[1:], " ") } // AdminCreateUser creates a user (admin only) func (ac *AuthController) AdminCreateUser(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } var req AdminCreateUserRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } email := strings.TrimSpace(strings.ToLower(req.Email)) if email == "" || !strings.Contains(email, "@") { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid email address"}) return } // check duplicate var existing models.User if err := ac.DB.Where("LOWER(email) = LOWER(?)", email).First(&existing).Error; err == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Email already registered"}) return } // hash password hashed, err := utils.HashPassword(req.Password) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) return } // role role := strings.TrimSpace(req.Role) if role != "admin" && role != "editor" && role != "fan" { role = "editor" } // active isActive := true if req.IsActive != nil { isActive = *req.IsActive } fn, ln := splitName(req.Name) u := models.User{ Email: email, Password: hashed, FirstName: fn, LastName: ln, Role: role, IsActive: isActive, } if err := ac.DB.Create(&u).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) return } c.JSON(http.StatusCreated, gin.H{"id": u.ID}) } // AdminUpdateUser updates a user (admin only) func (ac *AuthController) AdminUpdateUser(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var user models.User if err := ac.DB.First(&user, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) } return } var req AdminUpdateUserRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // If editing an admin account, require current password confirmation from the requester if user.Role == "admin" { if strings.TrimSpace(req.CurrentPassword) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "current_password is required to modify an admin account"}) return } // verify current user's password cu, ok := c.Get("user") if !ok { c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) return } currentUser := cu.(*models.User) if err := utils.CheckPassword(req.CurrentPassword, currentUser.Password); err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid current password"}) return } } // Apply updates if req.Name != "" { fn, ln := splitName(req.Name) user.FirstName, user.LastName = fn, ln } if req.Email != "" { email := strings.TrimSpace(strings.ToLower(req.Email)) if email == "" || !strings.Contains(email, "@") { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid email address"}) return } // ensure uniqueness var cnt int64 ac.DB.Model(&models.User{}).Where("LOWER(email) = LOWER(?) AND id <> ?", email, user.ID).Count(&cnt) if cnt > 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "Email already in use"}) return } user.Email = email } if req.Role != "" { if req.Role != "admin" && req.Role != "editor" && req.Role != "fan" { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role"}) return } user.Role = req.Role } if req.IsActive != nil { user.IsActive = *req.IsActive } if err := ac.DB.Save(&user).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } // AdminDeleteUser deletes a user (admin only) func (ac *AuthController) AdminDeleteUser(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var user models.User if err := ac.DB.First(&user, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) } return } // Disallow deleting admins and self-delete safety if user.Role == "admin" { c.JSON(http.StatusBadRequest, gin.H{"error": "cannot delete admin user"}) return } if cu, ok := c.Get("user"); ok { currentUser := cu.(*models.User) if currentUser.ID == user.ID { c.JSON(http.StatusBadRequest, gin.H{"error": "cannot delete yourself"}) return } } if err := ac.DB.Delete(&user).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"}) return } c.JSON(http.StatusOK, gin.H{"success": true}) }