package admin import ( "crypto/rand" "encoding/hex" "net/http" "net/url" "strings" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/mattnite/forgejo-tickets/internal/forgejo" "github.com/mattnite/forgejo-tickets/internal/models" "github.com/rs/zerolog/log" ) type UserHandler struct { deps Dependencies } func (h *UserHandler) List(c *gin.Context) { var users []models.User if err := h.deps.DB.Order("created_at DESC").Limit(100).Find(&users).Error; err != nil { log.Error().Err(err).Msg("list users error") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to load users") return } h.deps.Renderer.Render(c.Writer, c.Request, "admin/users/list", map[string]interface{}{ "Users": users, }) } func (h *UserHandler) Detail(c *gin.Context) { userID, err := uuid.Parse(c.Param("id")) if err != nil { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid user ID") return } var user models.User if err := h.deps.DB.First(&user, "id = ?", userID).Error; err != nil { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusNotFound, "User not found") return } // Load user's ticket mappings with repo info var tickets []models.Ticket h.deps.DB.Preload("Repo").Where("user_id = ?", user.ID).Order("created_at DESC").Limit(50).Find(&tickets) // Group by repo and fetch issue details from Forgejo type ticketView struct { ID uuid.UUID Title string Status string RepoName string CreatedAt interface{} } var ticketViews []ticketView type repoGroup struct { repo models.Repo tickets []models.Ticket } repoMap := map[uuid.UUID]*repoGroup{} for _, t := range tickets { if _, ok := repoMap[t.RepoID]; !ok { repoMap[t.RepoID] = &repoGroup{repo: t.Repo} } repoMap[t.RepoID].tickets = append(repoMap[t.RepoID].tickets, t) } for _, group := range repoMap { issues, err := h.deps.ForgejoClient.ListIssues(group.repo.ForgejoOwner, group.repo.ForgejoRepo, "all", "") if err != nil { log.Error().Err(err).Str("repo", group.repo.Name).Msg("forgejo list issues error") for _, t := range group.tickets { ticketViews = append(ticketViews, ticketView{ ID: t.ID, Title: "Unable to load", Status: "open", RepoName: group.repo.Name, CreatedAt: t.CreatedAt, }) } continue } issueByNumber := map[int64]*forgejo.Issue{} for i := range issues { issueByNumber[issues[i].Number] = &issues[i] } for _, t := range group.tickets { if issue, ok := issueByNumber[t.ForgejoIssueNumber]; ok { ticketViews = append(ticketViews, ticketView{ ID: t.ID, Title: issue.Title, Status: forgejo.DeriveStatus(issue), RepoName: group.repo.Name, CreatedAt: t.CreatedAt, }) } } } // Load all repos and user's assigned repo IDs var allRepos []models.Repo h.deps.DB.Where("active = ?", true).Order("name ASC").Find(&allRepos) var userRepos []models.UserRepo h.deps.DB.Where("user_id = ?", user.ID).Find(&userRepos) assignedRepoIDs := make(map[string]bool) for _, ur := range userRepos { assignedRepoIDs[ur.RepoID.String()] = true } h.deps.Renderer.Render(c.Writer, c.Request, "admin/users/detail", map[string]interface{}{ "User": user, "Tickets": ticketViews, "AllRepos": allRepos, "AssignedRepoIDs": assignedRepoIDs, }) } func (h *UserHandler) NewForm(c *gin.Context) { h.deps.Renderer.Render(c.Writer, c.Request, "admin/users/new", nil) } func (h *UserHandler) Create(c *gin.Context) { name := strings.TrimSpace(c.PostForm("name")) email := strings.TrimSpace(c.PostForm("email")) if name == "" || email == "" { h.deps.Renderer.Render(c.Writer, c.Request, "admin/users/new", map[string]interface{}{ "Error": "Name and email are required", "Name": name, "Email": email, }) return } tempPassBytes := make([]byte, 12) rand.Read(tempPassBytes) tempPassword := hex.EncodeToString(tempPassBytes)[:16] user, err := h.deps.Auth.CreateUserWithPassword(c.Request.Context(), email, tempPassword, name, true, true) if err != nil { if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique") { h.deps.Renderer.Render(c.Writer, c.Request, "admin/users/new", map[string]interface{}{ "Error": "A user with this email already exists", "Name": name, "Email": email, }) } else { h.deps.Renderer.Render(c.Writer, c.Request, "admin/users/new", map[string]interface{}{ "Error": "Failed to create user: " + err.Error(), "Name": name, "Email": email, }) } return } if err := h.deps.EmailClient.SendWelcomeEmail(email, name, tempPassword); err != nil { log.Error().Err(err).Msg("send welcome email error") } c.Redirect(http.StatusSeeOther, "/users/"+user.ID.String()) } func (h *UserHandler) PendingList(c *gin.Context) { var users []models.User if err := h.deps.DB.Where("email_verified = ? AND approved = ?", true, false).Order("created_at DESC").Find(&users).Error; err != nil { log.Error().Err(err).Msg("list pending users error") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to load pending users") return } h.deps.Renderer.Render(c.Writer, c.Request, "admin/users/pending", map[string]interface{}{ "Users": users, }) } func (h *UserHandler) Approve(c *gin.Context) { userID, err := uuid.Parse(c.Param("id")) if err != nil { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid user ID") return } var user models.User if err := h.deps.DB.First(&user, "id = ?", userID).Error; err != nil { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusNotFound, "User not found") return } h.deps.DB.Model(&user).Update("approved", true) if err := h.deps.EmailClient.SendAccountApprovedEmail(user.Email, user.Name); err != nil { log.Error().Err(err).Msg("send approval email error") } redirectURL := "/users/pending?" + url.Values{ "flash": {"User " + user.Email + " has been approved"}, "flash_type": {"success"}, }.Encode() c.Redirect(http.StatusSeeOther, redirectURL) } func (h *UserHandler) Reject(c *gin.Context) { userID, err := uuid.Parse(c.Param("id")) if err != nil { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid user ID") return } if err := h.deps.DB.Delete(&models.User{}, "id = ?", userID).Error; err != nil { log.Error().Err(err).Msg("delete user error") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to reject user") return } redirectURL := "/users/pending?" + url.Values{ "flash": {"User request has been rejected"}, "flash_type": {"success"}, }.Encode() c.Redirect(http.StatusSeeOther, redirectURL) } func (h *UserHandler) UpdateRepos(c *gin.Context) { userID, err := uuid.Parse(c.Param("id")) if err != nil { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid user ID") return } var user models.User if err := h.deps.DB.First(&user, "id = ?", userID).Error; err != nil { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusNotFound, "User not found") return } // Get selected repo IDs from form repoIDs := c.PostFormArray("repo_ids") // Delete existing assignments h.deps.DB.Where("user_id = ?", userID).Delete(&models.UserRepo{}) // Create new assignments for _, idStr := range repoIDs { repoID, err := uuid.Parse(idStr) if err != nil { continue } h.deps.DB.Create(&models.UserRepo{UserID: userID, RepoID: repoID}) } redirectURL := "/users/" + userID.String() + "?" + url.Values{ "flash": {"Project assignments updated"}, "flash_type": {"success"}, }.Encode() c.Redirect(http.StatusSeeOther, redirectURL) }