diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 703cea5..9d27c2f 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -65,6 +65,10 @@ func (s *Service) Login(ctx context.Context, emailAddr, password string) (*model return nil, fmt.Errorf("please verify your email before logging in") } + if !user.Approved { + return nil, fmt.Errorf("your account is pending admin approval") + } + return &user, nil } @@ -91,7 +95,7 @@ func (s *Service) DestroySession(r *http.Request, w http.ResponseWriter) error { return s.store.Save(r, w, session) } -func (s *Service) CreateUserWithPassword(ctx context.Context, emailAddr, password, name string, verified bool) (*models.User, error) { +func (s *Service) CreateUserWithPassword(ctx context.Context, emailAddr, password, name string, verified bool, approved bool) (*models.User, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return nil, fmt.Errorf("hash password: %w", err) @@ -103,6 +107,7 @@ func (s *Service) CreateUserWithPassword(ctx context.Context, emailAddr, passwor PasswordHash: &hashStr, Name: name, EmailVerified: verified, + Approved: approved, } if err := s.db.WithContext(ctx).Create(&user).Error; err != nil { diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index 5f92d0e..afab5ac 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -119,16 +119,19 @@ func (s *Service) FindOrCreateOAuthUser(ctx context.Context, provider string, in // Try to find existing user by email var user models.User + isNewUser := false if err := s.db.WithContext(ctx).Where("email = ?", info.Email).First(&user).Error; err != nil { - // Create new user + // Create new user — approved is false, requires admin approval user = models.User{ Email: info.Email, Name: info.Name, EmailVerified: true, + Approved: false, } if err := s.db.WithContext(ctx).Create(&user).Error; err != nil { return nil, fmt.Errorf("create user: %w", err) } + isNewUser = true } // Link OAuth account @@ -148,5 +151,14 @@ func (s *Service) FindOrCreateOAuthUser(ctx context.Context, provider string, in user.EmailVerified = true } + // New OAuth users need admin approval + if isNewUser { + return nil, fmt.Errorf("your account has been created and is pending admin approval") + } + + if !user.Approved { + return nil, fmt.Errorf("your account is pending admin approval") + } + return &user, nil } diff --git a/internal/email/email.go b/internal/email/email.go index 95f5849..9864b60 100644 --- a/internal/email/email.go +++ b/internal/email/email.go @@ -85,6 +85,26 @@ func (c *Client) SendTicketClosedNotification(to, name, ticketTitle, ticketID st return err } +func (c *Client) SendAccountApprovedEmail(to, name string) error { + if c.server == nil { + return fmt.Errorf("email client not configured") + } + + loginURL := fmt.Sprintf("%s/login", c.baseURL) + htmlBody := renderAccountApprovedEmail(name, loginURL) + textBody := fmt.Sprintf("Hi %s,\n\nYour account has been approved! You can now log in at %s.", name, loginURL) + + _, err := c.server.SendEmail(context.Background(), postmark.Email{ + From: c.fromEmail, + To: to, + Subject: "Your account has been approved", + HTMLBody: htmlBody, + TextBody: textBody, + Tag: "account-approved", + }) + return err +} + func (c *Client) SendWelcomeEmail(to, name, tempPassword string) error { if c.server == nil { return fmt.Errorf("email client not configured") diff --git a/internal/email/templates.go b/internal/email/templates.go index 9b693b0..decb0d0 100644 --- a/internal/email/templates.go +++ b/internal/email/templates.go @@ -51,6 +51,16 @@ func renderTicketClosedEmail(name, ticketTitle, ticketURL string) string {

If you believe the issue is not fully resolved, you can add a comment on the ticket page.

`, name, ticketTitle, ticketURL)) } +func renderAccountApprovedEmail(name, loginURL string) string { + return emailWrapper(fmt.Sprintf(` +

Your account has been approved

+

Hi %s,

+

Your account request has been approved. You can now log in and start creating tickets.

+

+ Log In +

`, name, loginURL)) +} + func renderWelcomeEmail(name, email, tempPassword, loginURL string) string { return emailWrapper(fmt.Sprintf(`

Welcome!

diff --git a/internal/forgejo/client.go b/internal/forgejo/client.go index 53c6929..a7fa626 100644 --- a/internal/forgejo/client.go +++ b/internal/forgejo/client.go @@ -28,8 +28,14 @@ func NewClient(baseURL, apiToken string) *Client { } type CreateIssueRequest struct { - Title string `json:"title"` - Body string `json:"body"` + Title string `json:"title"` + Body string `json:"body"` + Labels []int64 `json:"labels,omitempty"` +} + +type Label struct { + ID int64 `json:"id"` + Name string `json:"name"` } type Issue struct { @@ -55,6 +61,62 @@ func GenerateWebhookSecret() (string, error) { return hex.EncodeToString(b), nil } +func (c *Client) GetOrCreateLabel(owner, repo, labelName, color string) (*Label, error) { + // Try to find existing label + listURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels", c.baseURL, owner, repo) + httpReq, err := http.NewRequest("GET", listURL, nil) + if err != nil { + return nil, err + } + httpReq.Header.Set("Authorization", "token "+c.apiToken) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("forgejo API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + var labels []Label + if err := json.NewDecoder(resp.Body).Decode(&labels); err == nil { + for _, l := range labels { + if l.Name == labelName { + return &l, nil + } + } + } + } + + // Create the label + createBody, _ := json.Marshal(map[string]string{ + "name": labelName, + "color": color, + }) + httpReq, err = http.NewRequest("POST", listURL, bytes.NewReader(createBody)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "token "+c.apiToken) + + resp2, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("forgejo API request failed: %w", err) + } + defer resp2.Body.Close() + + if resp2.StatusCode != http.StatusCreated { + respBody, _ := io.ReadAll(resp2.Body) + return nil, fmt.Errorf("forgejo API returned %d: %s", resp2.StatusCode, string(respBody)) + } + + var label Label + if err := json.NewDecoder(resp2.Body).Decode(&label); err != nil { + return nil, err + } + return &label, nil +} + func (c *Client) CreateIssue(owner, repo string, req CreateIssueRequest) (*Issue, error) { url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", c.baseURL, owner, repo) diff --git a/internal/handlers/admin/routes.go b/internal/handlers/admin/routes.go index 8b96e59..8daf838 100644 --- a/internal/handlers/admin/routes.go +++ b/internal/handlers/admin/routes.go @@ -33,9 +33,13 @@ func NewRouter(deps Dependencies) *gin.Engine { userHandler := &UserHandler{deps: deps} r.GET("/users", userHandler.List) + r.GET("/users/pending", userHandler.PendingList) r.GET("/users/new", userHandler.NewForm) r.GET("/users/:id", userHandler.Detail) r.POST("/users", userHandler.Create) + r.POST("/users/:id/approve", userHandler.Approve) + r.POST("/users/:id/reject", userHandler.Reject) + r.POST("/users/:id/repos", userHandler.UpdateRepos) ticketHandler := &TicketHandler{deps: deps} r.GET("/tickets", ticketHandler.List) diff --git a/internal/handlers/admin/users.go b/internal/handlers/admin/users.go index e3a134b..067fdd5 100644 --- a/internal/handlers/admin/users.go +++ b/internal/handlers/admin/users.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "encoding/hex" "net/http" + "net/url" "strings" "github.com/gin-gonic/gin" @@ -45,9 +46,23 @@ func (h *UserHandler) Detail(c *gin.Context) { var tickets []models.Ticket h.deps.DB.Preload("Repo").Where("user_id = ?", user.ID).Order("created_at DESC").Limit(50).Find(&tickets) + // 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": tickets, + "User": user, + "Tickets": tickets, + "AllRepos": allRepos, + "AssignedRepoIDs": assignedRepoIDs, }) } @@ -72,7 +87,7 @@ func (h *UserHandler) Create(c *gin.Context) { rand.Read(tempPassBytes) tempPassword := hex.EncodeToString(tempPassBytes)[:16] - user, err := h.deps.Auth.CreateUserWithPassword(c.Request.Context(), email, tempPassword, name, true) + 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{}{ @@ -96,3 +111,97 @@ func (h *UserHandler) Create(c *gin.Context) { 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) +} diff --git a/internal/handlers/public/auth.go b/internal/handlers/public/auth.go index ffd12c1..d61a06b 100644 --- a/internal/handlers/public/auth.go +++ b/internal/handlers/public/auth.go @@ -112,7 +112,7 @@ func (h *AuthHandler) Register(c *gin.Context) { } redirectURL := "/login?" + url.Values{ - "flash": {"Please check your email to verify your account"}, + "flash": {"Account requested! Please check your email to verify your address. After verification, an admin will review your request."}, "flash_type": {"success"}, }.Encode() c.Redirect(http.StatusSeeOther, redirectURL) @@ -139,7 +139,7 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) { } redirectURL := "/login?" + url.Values{ - "flash": {"Email verified successfully. You can now log in."}, + "flash": {"Email verified successfully. Your account is pending admin approval."}, "flash_type": {"success"}, }.Encode() c.Redirect(http.StatusSeeOther, redirectURL) diff --git a/internal/handlers/public/oauth.go b/internal/handlers/public/oauth.go index acc0186..839f4b0 100644 --- a/internal/handlers/public/oauth.go +++ b/internal/handlers/public/oauth.go @@ -4,6 +4,8 @@ import ( "crypto/rand" "encoding/hex" "net/http" + "net/url" + "strings" "github.com/gin-gonic/gin" "github.com/mattnite/forgejo-tickets/internal/auth" @@ -95,6 +97,14 @@ func (h *OAuthHandler) Callback(c *gin.Context) { user, err := h.deps.Auth.FindOrCreateOAuthUser(c.Request.Context(), provider.Name, info) if err != nil { + if strings.Contains(err.Error(), "pending admin approval") { + redirectURL := "/login?" + url.Values{ + "flash": {err.Error()}, + "flash_type": {"info"}, + }.Encode() + c.Redirect(http.StatusSeeOther, redirectURL) + return + } log.Error().Err(err).Msg("find or create oauth user error") c.String(http.StatusInternalServerError, "Authentication failed") return @@ -185,6 +195,14 @@ func (h *OAuthHandler) AppleCallback(c *gin.Context) { user, err := h.deps.Auth.FindOrCreateOAuthUser(c.Request.Context(), "apple", info) if err != nil { + if strings.Contains(err.Error(), "pending admin approval") { + redirectURL := "/login?" + url.Values{ + "flash": {err.Error()}, + "flash_type": {"info"}, + }.Encode() + c.Redirect(http.StatusSeeOther, redirectURL) + return + } log.Error().Err(err).Msg("find or create apple user error") c.String(http.StatusInternalServerError, "Authentication failed") return diff --git a/internal/handlers/public/tickets.go b/internal/handlers/public/tickets.go index 9644a69..a2fe8d9 100644 --- a/internal/handlers/public/tickets.go +++ b/internal/handlers/public/tickets.go @@ -31,8 +31,14 @@ func (h *TicketHandler) List(c *gin.Context) { } func (h *TicketHandler) NewForm(c *gin.Context) { + user := auth.CurrentUser(c) + var repos []models.Repo - if err := h.deps.DB.Where("active = ?", true).Order("name ASC").Find(&repos).Error; err != nil { + if err := h.deps.DB. + Joins("JOIN user_repos ON user_repos.repo_id = repos.id"). + Where("user_repos.user_id = ? AND repos.active = ?", user.ID, true). + Order("repos.name ASC"). + Find(&repos).Error; err != nil { log.Error().Err(err).Msg("list repos error") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to load products") return @@ -52,12 +58,23 @@ func (h *TicketHandler) Create(c *gin.Context) { return } + // Validate the user has access to this repo + var userRepo models.UserRepo + if err := h.deps.DB.Where("user_id = ? AND repo_id = ?", user.ID, repoID).First(&userRepo).Error; err != nil { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusForbidden, "You do not have access to this project") + return + } + title := c.PostForm("title") description := c.PostForm("description") if title == "" || description == "" { var repos []models.Repo - h.deps.DB.Where("active = ?", true).Order("name ASC").Find(&repos) + h.deps.DB. + Joins("JOIN user_repos ON user_repos.repo_id = repos.id"). + Where("user_repos.user_id = ? AND repos.active = ?", user.ID, true). + Order("repos.name ASC"). + Find(&repos) h.deps.Renderer.Render(c.Writer, c.Request, "tickets/new", map[string]interface{}{ "Repos": repos, "Error": "Title and description are required", @@ -85,9 +102,19 @@ func (h *TicketHandler) Create(c *gin.Context) { var repo models.Repo if err := h.deps.DB.First(&repo, "id = ?", repoID).Error; err == nil { go func() { + // Look up or create the "customer" label + var labelIDs []int64 + label, err := h.deps.ForgejoClient.GetOrCreateLabel(repo.ForgejoOwner, repo.ForgejoRepo, "customer", "#0075ca") + if err != nil { + log.Error().Err(err).Msg("forgejo get/create label error") + } else { + labelIDs = append(labelIDs, label.ID) + } + issue, err := h.deps.ForgejoClient.CreateIssue(repo.ForgejoOwner, repo.ForgejoRepo, forgejo.CreateIssueRequest{ - Title: title, - Body: description + "\n\n---\n*Submitted by: " + user.Email + "*", + Title: title, + Body: description + "\n\n---\n*Submitted by: " + user.Email + "*", + Labels: labelIDs, }) if err != nil { log.Error().Err(err).Msgf("forgejo create issue error for ticket %s", ticket.ID) diff --git a/internal/models/models.go b/internal/models/models.go index 53735e4..98a2788 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -23,13 +23,21 @@ const ( ) type User struct { - ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` - Email string `gorm:"uniqueIndex;not null" json:"email"` - PasswordHash *string `json:"-"` - Name string `gorm:"not null" json:"name"` - EmailVerified bool `gorm:"not null;default:false" json:"email_verified"` - CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"` - UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"` + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` + Email string `gorm:"uniqueIndex;not null" json:"email"` + PasswordHash *string `json:"-"` + Name string `gorm:"not null" json:"name"` + EmailVerified bool `gorm:"not null;default:false" json:"email_verified"` + Approved bool `gorm:"not null;default:false" json:"approved"` + CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"` +} + +type UserRepo struct { + UserID uuid.UUID `gorm:"type:uuid;not null;primaryKey"` + RepoID uuid.UUID `gorm:"type:uuid;not null;primaryKey"` + User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"` + Repo Repo `gorm:"foreignKey:RepoID;constraint:OnDelete:CASCADE"` } type OAuthAccount struct { @@ -115,6 +123,7 @@ func AutoMigrate(db *gorm.DB) error { &Ticket{}, &TicketComment{}, &EmailToken{}, + &UserRepo{}, ); err != nil { return err } @@ -125,5 +134,8 @@ func AutoMigrate(db *gorm.DB) error { // Create partial unique index for ticket forgejo issue lookup db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_repo_forgejo_issue ON tickets(repo_id, forgejo_issue_number) WHERE forgejo_issue_number IS NOT NULL") + // Approve all existing verified users so they aren't locked out + db.Exec("UPDATE users SET approved = true WHERE approved = false AND email_verified = true") + return nil } diff --git a/web/templates/layouts/admin.html b/web/templates/layouts/admin.html index 041e073..c153dcb 100644 --- a/web/templates/layouts/admin.html +++ b/web/templates/layouts/admin.html @@ -8,21 +8,25 @@
-
+
+
Approved
+
{{if .User.Approved}}Yes{{else}}Pending{{end}}
+
Created
{{formatDate .User.CreatedAt}}
@@ -24,6 +28,27 @@
+

Project Access

+{{if .AllRepos}} +
+
+ {{range .AllRepos}} + + {{end}} +
+
+ +
+
+{{else}} +

No active projects available.

+{{end}} +

Tickets

{{if .Tickets}}
diff --git a/web/templates/pages/admin/users/pending.html b/web/templates/pages/admin/users/pending.html new file mode 100644 index 0000000..0d9547d --- /dev/null +++ b/web/templates/pages/admin/users/pending.html @@ -0,0 +1,46 @@ +{{define "title"}}Pending Account Requests{{end}} + +{{define "content"}} +
+

Pending Account Requests

+ ← Back to all users +
+ +{{with .Data}} +{{if .Users}} +
+ + + + + + + + + + + {{range .Users}} + + + + + + + {{end}} + +
NameEmailRequestedActions
{{.Name}}{{.Email}}{{formatDate .CreatedAt}} +
+
+ +
+
+ +
+
+
+
+{{else}} +

No pending account requests.

+{{end}} +{{end}} +{{end}} diff --git a/web/templates/pages/register.html b/web/templates/pages/register.html index f83e463..5ea3f9e 100644 --- a/web/templates/pages/register.html +++ b/web/templates/pages/register.html @@ -1,8 +1,8 @@ -{{define "title"}}Register{{end}} +{{define "title"}}Request Account{{end}} {{define "content"}}
-

Create your account

+

Request an Account

{{with .Data}} {{if .Error}} @@ -42,7 +42,7 @@ class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
- +

diff --git a/web/templates/pages/tickets/new.html b/web/templates/pages/tickets/new.html index 5b88676..a1c69da 100644 --- a/web/templates/pages/tickets/new.html +++ b/web/templates/pages/tickets/new.html @@ -22,7 +22,7 @@ {{with .Data}} {{range .Repos}} - + {{end}} {{end}} diff --git a/web/templates/partials/flash.html b/web/templates/partials/flash.html index b07e473..cd530a4 100644 --- a/web/templates/partials/flash.html +++ b/web/templates/partials/flash.html @@ -1,6 +1,6 @@ {{define "flash"}} {{if .Flash}} -

+
{{if eq .Flash.Type "success"}}

{{.Flash.Message}}

diff --git a/web/templates/partials/nav.html b/web/templates/partials/nav.html index 4b056aa..89fcc11 100644 --- a/web/templates/partials/nav.html +++ b/web/templates/partials/nav.html @@ -14,7 +14,7 @@ {{else}} Login - Register + Request Account {{end}}