Improvements

This commit is contained in:
Matthew Knight 2026-02-14 00:19:49 -08:00
parent 1ef523d096
commit 61e9f00b1c
No known key found for this signature in database
18 changed files with 386 additions and 32 deletions

View File

@ -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 {

View File

@ -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
}

View File

@ -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")

View File

@ -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>

View File

@ -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)

View File

@ -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)

View File

@ -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)
}

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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
}

View File

@ -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>

View File

@ -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">

View File

@ -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">&larr; 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}}

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -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>