Improvements
This commit is contained in:
parent
1ef523d096
commit
61e9f00b1c
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -51,6 +51,16 @@ func renderTicketClosedEmail(name, ticketTitle, ticketURL string) string {
|
|||
<p>If you believe the issue is not fully resolved, you can add a comment on the ticket page.</p>`, name, ticketTitle, ticketURL))
|
||||
}
|
||||
|
||||
func renderAccountApprovedEmail(name, loginURL string) string {
|
||||
return emailWrapper(fmt.Sprintf(`
|
||||
<h2 style="color: #111;">Your account has been approved</h2>
|
||||
<p>Hi %s,</p>
|
||||
<p>Your account request has been approved. You can now log in and start creating tickets.</p>
|
||||
<p style="margin: 30px 0;">
|
||||
<a href="%s" style="background: #2563eb; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 500;">Log In</a>
|
||||
</p>`, name, loginURL))
|
||||
}
|
||||
|
||||
func renderWelcomeEmail(name, email, tempPassword, loginURL string) string {
|
||||
return emailWrapper(fmt.Sprintf(`
|
||||
<h2 style="color: #111;">Welcome!</h2>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ func NewClient(baseURL, apiToken string) *Client {
|
|||
type CreateIssueRequest struct {
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
"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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 + "*",
|
||||
Labels: labelIDs,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("forgejo create issue error for ticket %s", ticket.ID)
|
||||
|
|
|
|||
|
|
@ -28,10 +28,18 @@ type User struct {
|
|||
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 {
|
||||
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,21 +8,25 @@
|
|||
</head>
|
||||
<body class="h-full">
|
||||
<div class="min-h-full">
|
||||
<nav class="bg-gray-900">
|
||||
<nav class="bg-white shadow">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
<div class="flex items-center gap-8">
|
||||
<span class="text-white font-bold text-lg">Admin Panel</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl font-bold text-gray-900">Support</span>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">Admin</span>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<a href="/" class="text-gray-300 hover:text-white text-sm font-medium">Dashboard</a>
|
||||
<a href="/users" class="text-gray-300 hover:text-white text-sm font-medium">Users</a>
|
||||
<a href="/tickets" class="text-gray-300 hover:text-white text-sm font-medium">Tickets</a>
|
||||
<a href="/repos" class="text-gray-300 hover:text-white text-sm font-medium">Repos</a>
|
||||
<a href="/" class="text-sm font-medium text-gray-700 hover:text-gray-900">Dashboard</a>
|
||||
<a href="/users" class="text-sm font-medium text-gray-700 hover:text-gray-900">Users</a>
|
||||
<a href="/tickets" class="text-sm font-medium text-gray-700 hover:text-gray-900">Tickets</a>
|
||||
<a href="/repos" class="text-sm font-medium text-gray-700 hover:text-gray-900">Repos</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{{template "flash" .}}
|
||||
<main class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@
|
|||
<dt class="font-medium text-gray-500">Verified</dt>
|
||||
<dd>{{if .User.EmailVerified}}<span class="text-green-600">Yes</span>{{else}}<span class="text-red-600">No</span>{{end}}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-medium text-gray-500">Approved</dt>
|
||||
<dd>{{if .User.Approved}}<span class="text-green-600">Yes</span>{{else}}<span class="text-yellow-600">Pending</span>{{end}}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-medium text-gray-500">Created</dt>
|
||||
<dd class="text-gray-900">{{formatDate .User.CreatedAt}}</dd>
|
||||
|
|
@ -24,6 +28,27 @@
|
|||
</dl>
|
||||
</div>
|
||||
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Project Access</h2>
|
||||
{{if .AllRepos}}
|
||||
<form method="POST" action="/users/{{.User.ID}}/repos" class="bg-white p-6 rounded-lg shadow ring-1 ring-gray-200 mb-8">
|
||||
<div class="space-y-2">
|
||||
{{range .AllRepos}}
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="repo_ids" value="{{.ID}}"
|
||||
{{if index $.Data.AssignedRepoIDs (print .ID)}}checked{{end}}
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span class="text-sm text-gray-900">{{.Name}}</span>
|
||||
</label>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Save Assignments</button>
|
||||
</div>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="text-sm text-gray-500 mb-8">No active projects available.</p>
|
||||
{{end}}
|
||||
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Tickets</h2>
|
||||
{{if .Tickets}}
|
||||
<div class="overflow-hidden bg-white shadow ring-1 ring-gray-200 rounded-lg">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
{{define "title"}}Pending Account Requests{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Pending Account Requests</h1>
|
||||
<a href="/users" class="text-sm text-blue-600 hover:text-blue-500">← Back to all users</a>
|
||||
</div>
|
||||
|
||||
{{with .Data}}
|
||||
{{if .Users}}
|
||||
<div class="overflow-hidden bg-white shadow ring-1 ring-gray-200 rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Requested</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{{range .Users}}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{.Name}}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{.Email}}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{formatDate .CreatedAt}}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<form method="POST" action="/users/{{.ID}}/approve">
|
||||
<button type="submit" class="rounded-md bg-green-600 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-green-500">Approve</button>
|
||||
</form>
|
||||
<form method="POST" action="/users/{{.ID}}/reject" onsubmit="return confirm('Are you sure you want to reject this request? The account will be deleted.')">
|
||||
<button type="submit" class="rounded-md bg-red-600 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-red-500">Reject</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-sm text-gray-500">No pending account requests.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
{{define "title"}}Register{{end}}
|
||||
{{define "title"}}Request Account{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="mx-auto max-w-sm">
|
||||
<h2 class="text-2xl font-bold text-gray-900 text-center">Create your account</h2>
|
||||
<h2 class="text-2xl font-bold text-gray-900 text-center">Request an Account</h2>
|
||||
|
||||
{{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">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Create account</button>
|
||||
<button type="submit" class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Request Account</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-center text-sm text-gray-500">
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
<option value="">Select a product...</option>
|
||||
{{with .Data}}
|
||||
{{range .Repos}}
|
||||
<option value="{{.ID}}" {{if eq (print .ID) (index $ "RepoID")}}selected{{end}}>{{.Name}}</option>
|
||||
<option value="{{.ID}}" {{if eq (print .ID) (index $.Data "RepoID")}}selected{{end}}>{{.Name}}</option>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{{define "flash"}}
|
||||
{{if .Flash}}
|
||||
<div class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 mt-4">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 mt-4">
|
||||
{{if eq .Flash.Type "success"}}
|
||||
<div class="rounded-md bg-green-50 p-4">
|
||||
<p class="text-sm font-medium text-green-800">{{.Flash.Message}}</p>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
</form>
|
||||
{{else}}
|
||||
<a href="/login" class="text-sm font-medium text-gray-700 hover:text-gray-900">Login</a>
|
||||
<a href="/register" class="text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-md">Register</a>
|
||||
<a href="/register" class="text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-md">Request Account</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue