Merge pull request 'Unify UI' (#58) from unify-ui into main

Reviewed-on: https://git.ts.mattnite.net/mattnite/forgejo-tickets/pulls/58
This commit is contained in:
Matthew Knight 2026-02-18 00:48:42 +00:00
commit 86cfdcff52
25 changed files with 150 additions and 224 deletions

View File

@ -3,7 +3,6 @@ DATABASE_URL=postgres://user:password@localhost:5432/forgejo_tickets?sslmode=dis
# Server # Server
PUBLIC_ADDR=:8080 PUBLIC_ADDR=:8080
ADMIN_ADDR=:8081
BASE_URL=http://localhost:8080 BASE_URL=http://localhost:8080
# Sessions (generate with: openssl rand -hex 32) # Sessions (generate with: openssl rand -hex 32)
@ -32,5 +31,5 @@ APPLE_TEAM_ID=
APPLE_KEY_ID= APPLE_KEY_ID=
APPLE_KEY_PATH= APPLE_KEY_PATH=
# Admin (comma-separated Tailscale login names) # Admin (email of user to promote to admin on startup)
TAILSCALE_ALLOWED_USERS=user@example.com INITIAL_ADMIN_EMAIL=admin@example.com

View File

@ -25,5 +25,5 @@ WORKDIR /app
COPY --from=builder /app/forgejo-tickets . COPY --from=builder /app/forgejo-tickets .
COPY --from=builder /app/web/templates web/templates COPY --from=builder /app/web/templates web/templates
COPY --from=builder /app/web/static web/static COPY --from=builder /app/web/static web/static
EXPOSE 8080 8081 EXPOSE 8080
CMD ["./forgejo-tickets"] CMD ["./forgejo-tickets"]

View File

@ -15,7 +15,6 @@ import (
"github.com/mattnite/forgejo-tickets/internal/database" "github.com/mattnite/forgejo-tickets/internal/database"
"github.com/mattnite/forgejo-tickets/internal/email" "github.com/mattnite/forgejo-tickets/internal/email"
"github.com/mattnite/forgejo-tickets/internal/forgejo" "github.com/mattnite/forgejo-tickets/internal/forgejo"
adminhandlers "github.com/mattnite/forgejo-tickets/internal/handlers/admin"
publichandlers "github.com/mattnite/forgejo-tickets/internal/handlers/public" publichandlers "github.com/mattnite/forgejo-tickets/internal/handlers/public"
"github.com/mattnite/forgejo-tickets/internal/templates" "github.com/mattnite/forgejo-tickets/internal/templates"
"github.com/rs/zerolog" "github.com/rs/zerolog"
@ -64,7 +63,7 @@ func main() {
go sessionStore.Cleanup(ctx, 30*time.Minute) go sessionStore.Cleanup(ctx, 30*time.Minute)
go authService.CleanupExpiredTokens(ctx, 1*time.Hour) go authService.CleanupExpiredTokens(ctx, 1*time.Hour)
publicRouter := publichandlers.NewRouter(publichandlers.Dependencies{ router := publichandlers.NewRouter(publichandlers.Dependencies{
DB: db, DB: db,
Renderer: renderer, Renderer: renderer,
Auth: authService, Auth: authService,
@ -74,61 +73,33 @@ func main() {
Config: cfg, Config: cfg,
}) })
adminRouter := adminhandlers.NewRouter(adminhandlers.Dependencies{ server := &http.Server{
DB: db,
Renderer: renderer,
Auth: authService,
SessionStore: sessionStore,
EmailClient: emailClient,
ForgejoClient: forgejoClient,
Config: cfg,
})
publicServer := &http.Server{
Addr: cfg.PublicAddr, Addr: cfg.PublicAddr,
Handler: publicRouter, Handler: router,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
adminServer := &http.Server{
Addr: cfg.AdminAddr,
Handler: adminRouter,
ReadTimeout: 15 * time.Second, ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second,
} }
go func() { go func() {
log.Info().Msgf("Public server listening on %s", cfg.PublicAddr) log.Info().Msgf("Server listening on %s", cfg.PublicAddr)
if err := publicServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal().Msgf("public server error: %v", err) log.Fatal().Msgf("server error: %v", err)
}
}()
go func() {
log.Info().Msgf("Admin server listening on %s", cfg.AdminAddr)
if err := adminServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal().Msgf("admin server error: %v", err)
} }
}() }()
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit <-quit
log.Info().Msg("Shutting down servers...") log.Info().Msg("Shutting down server...")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel() defer shutdownCancel()
if err := publicServer.Shutdown(shutdownCtx); err != nil { if err := server.Shutdown(shutdownCtx); err != nil {
log.Error().Err(err).Msg("public server shutdown error") log.Error().Err(err).Msg("server shutdown error")
}
if err := adminServer.Shutdown(shutdownCtx); err != nil {
log.Error().Err(err).Msg("admin server shutdown error")
} }
cancel() cancel()
log.Info().Msg("Servers stopped") log.Info().Msg("Server stopped")
} }

View File

@ -55,6 +55,21 @@ func RequireAuth(c *gin.Context) {
c.Next() c.Next()
} }
func RequireAdmin(c *gin.Context) {
user := CurrentUser(c)
if user == nil {
c.Redirect(http.StatusSeeOther, "/login")
c.Abort()
return
}
if !user.IsAdmin() {
c.Redirect(http.StatusSeeOther, "/")
c.Abort()
return
}
c.Next()
}
func CurrentUser(c *gin.Context) *models.User { func CurrentUser(c *gin.Context) *models.User {
user, exists := c.Get(userContextKey) user, exists := c.Get(userContextKey)
if !exists { if !exists {

View File

@ -12,7 +12,6 @@ type Config struct {
// Server // Server
PublicAddr string PublicAddr string
AdminAddr string
BaseURL string BaseURL string
// Sessions // Sessions
@ -42,6 +41,7 @@ type Config struct {
AppleKeyPath string AppleKeyPath string
// Admin // Admin
InitialAdminEmail string
TailscaleAllowedUsers []string TailscaleAllowedUsers []string
} }
@ -49,7 +49,6 @@ func Load() (*Config, error) {
cfg := &Config{ cfg := &Config{
DatabaseURL: getEnv("DATABASE_URL", ""), DatabaseURL: getEnv("DATABASE_URL", ""),
PublicAddr: getEnv("PUBLIC_ADDR", ":8080"), PublicAddr: getEnv("PUBLIC_ADDR", ":8080"),
AdminAddr: getEnv("ADMIN_ADDR", ":8081"),
BaseURL: getEnv("BASE_URL", "http://localhost:8080"), BaseURL: getEnv("BASE_URL", "http://localhost:8080"),
SessionSecret: getEnv("SESSION_SECRET", ""), SessionSecret: getEnv("SESSION_SECRET", ""),
ForgejoURL: getEnv("FORGEJO_URL", ""), ForgejoURL: getEnv("FORGEJO_URL", ""),
@ -67,6 +66,8 @@ func Load() (*Config, error) {
AppleKeyPath: getEnv("APPLE_KEY_PATH", ""), AppleKeyPath: getEnv("APPLE_KEY_PATH", ""),
} }
cfg.InitialAdminEmail = getEnv("INITIAL_ADMIN_EMAIL", "")
if allowed := getEnv("TAILSCALE_ALLOWED_USERS", ""); allowed != "" { if allowed := getEnv("TAILSCALE_ALLOWED_USERS", ""); allowed != "" {
cfg.TailscaleAllowedUsers = strings.Split(allowed, ",") cfg.TailscaleAllowedUsers = strings.Split(allowed, ",")
for i := range cfg.TailscaleAllowedUsers { for i := range cfg.TailscaleAllowedUsers {

View File

@ -10,13 +10,13 @@ import (
func clearConfigEnv(t *testing.T) { func clearConfigEnv(t *testing.T) {
t.Helper() t.Helper()
envVars := []string{ envVars := []string{
"DATABASE_URL", "PUBLIC_ADDR", "ADMIN_ADDR", "BASE_URL", "DATABASE_URL", "PUBLIC_ADDR", "BASE_URL",
"SESSION_SECRET", "FORGEJO_URL", "FORGEJO_API_TOKEN", "SESSION_SECRET", "FORGEJO_URL", "FORGEJO_API_TOKEN",
"POSTMARK_SERVER_TOKEN", "POSTMARK_FROM_EMAIL", "POSTMARK_SERVER_TOKEN", "POSTMARK_FROM_EMAIL",
"GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET",
"MICROSOFT_CLIENT_ID", "MICROSOFT_CLIENT_SECRET", "MICROSOFT_TENANT_ID", "MICROSOFT_CLIENT_ID", "MICROSOFT_CLIENT_SECRET", "MICROSOFT_TENANT_ID",
"APPLE_CLIENT_ID", "APPLE_TEAM_ID", "APPLE_KEY_ID", "APPLE_KEY_PATH", "APPLE_CLIENT_ID", "APPLE_TEAM_ID", "APPLE_KEY_ID", "APPLE_KEY_PATH",
"TAILSCALE_ALLOWED_USERS", "INITIAL_ADMIN_EMAIL",
} }
for _, v := range envVars { for _, v := range envVars {
os.Unsetenv(v) os.Unsetenv(v)
@ -86,9 +86,6 @@ func TestLoad_DefaultValues(t *testing.T) {
if cfg.PublicAddr != ":8080" { if cfg.PublicAddr != ":8080" {
t.Errorf("expected default PublicAddr %q, got %q", ":8080", cfg.PublicAddr) t.Errorf("expected default PublicAddr %q, got %q", ":8080", cfg.PublicAddr)
} }
if cfg.AdminAddr != ":8081" {
t.Errorf("expected default AdminAddr %q, got %q", ":8081", cfg.AdminAddr)
}
if cfg.BaseURL != "http://localhost:8080" { if cfg.BaseURL != "http://localhost:8080" {
t.Errorf("expected default BaseURL %q, got %q", "http://localhost:8080", cfg.BaseURL) t.Errorf("expected default BaseURL %q, got %q", "http://localhost:8080", cfg.BaseURL)
} }
@ -102,7 +99,6 @@ func TestLoad_OverrideDefaults(t *testing.T) {
t.Setenv("DATABASE_URL", "postgres://localhost/test") t.Setenv("DATABASE_URL", "postgres://localhost/test")
t.Setenv("SESSION_SECRET", "test-session-secret-that-is-32ch") t.Setenv("SESSION_SECRET", "test-session-secret-that-is-32ch")
t.Setenv("PUBLIC_ADDR", ":9090") t.Setenv("PUBLIC_ADDR", ":9090")
t.Setenv("ADMIN_ADDR", ":9091")
t.Setenv("BASE_URL", "https://example.com") t.Setenv("BASE_URL", "https://example.com")
cfg, err := Load() cfg, err := Load()
@ -113,14 +109,27 @@ func TestLoad_OverrideDefaults(t *testing.T) {
if cfg.PublicAddr != ":9090" { if cfg.PublicAddr != ":9090" {
t.Errorf("expected PublicAddr %q, got %q", ":9090", cfg.PublicAddr) t.Errorf("expected PublicAddr %q, got %q", ":9090", cfg.PublicAddr)
} }
if cfg.AdminAddr != ":9091" {
t.Errorf("expected AdminAddr %q, got %q", ":9091", cfg.AdminAddr)
}
if cfg.BaseURL != "https://example.com" { if cfg.BaseURL != "https://example.com" {
t.Errorf("expected BaseURL %q, got %q", "https://example.com", cfg.BaseURL) t.Errorf("expected BaseURL %q, got %q", "https://example.com", cfg.BaseURL)
} }
} }
func TestLoad_InitialAdminEmail(t *testing.T) {
clearConfigEnv(t)
t.Setenv("DATABASE_URL", "postgres://localhost/test")
t.Setenv("SESSION_SECRET", "test-session-secret-that-is-32ch")
t.Setenv("INITIAL_ADMIN_EMAIL", "admin@example.com")
cfg, err := Load()
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if cfg.InitialAdminEmail != "admin@example.com" {
t.Errorf("expected InitialAdminEmail %q, got %q", "admin@example.com", cfg.InitialAdminEmail)
}
}
func TestLoad_TailscaleAllowedUsers(t *testing.T) { func TestLoad_TailscaleAllowedUsers(t *testing.T) {
clearConfigEnv(t) clearConfigEnv(t)
t.Setenv("DATABASE_URL", "postgres://localhost/test") t.Setenv("DATABASE_URL", "postgres://localhost/test")

View File

@ -1,71 +0,0 @@
package admin
import (
"encoding/json"
"fmt"
"net"
"net/http"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
)
type TailscaleAuth struct {
allowedUsers []string
}
type tailscaleWhoisResponse struct {
UserProfile struct {
LoginName string `json:"LoginName"`
} `json:"UserProfile"`
}
func (t *TailscaleAuth) Middleware(c *gin.Context) {
if len(t.allowedUsers) == 0 {
// No allowed users configured - allow all (dev mode)
c.Next()
return
}
remoteAddr := c.Request.RemoteAddr
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
host = remoteAddr
}
whoisURL := fmt.Sprintf("http://100.100.100.100/localapi/v0/whois?addr=%s", host)
resp, err := http.Get(whoisURL)
if err != nil {
log.Error().Err(err).Msg("tailscale whois error")
c.String(http.StatusUnauthorized, "Unauthorized")
c.Abort()
return
}
defer resp.Body.Close()
var whois tailscaleWhoisResponse
if err := json.NewDecoder(resp.Body).Decode(&whois); err != nil {
log.Error().Err(err).Msg("tailscale whois decode error")
c.String(http.StatusUnauthorized, "Unauthorized")
c.Abort()
return
}
loginName := whois.UserProfile.LoginName
allowed := false
for _, u := range t.allowedUsers {
if u == loginName {
allowed = true
break
}
}
if !allowed {
log.Error().Msgf("tailscale auth: user %q not in allowed list", loginName)
c.String(http.StatusForbidden, "Forbidden")
c.Abort()
return
}
c.Next()
}

View File

@ -102,7 +102,7 @@ func (h *RepoHandler) Create(c *gin.Context) {
return return
} }
c.Redirect(http.StatusSeeOther, "/repos/"+repo.ID.String()+"/edit") c.Redirect(http.StatusSeeOther, "/admin/repos/"+repo.ID.String()+"/edit")
} }
func (h *RepoHandler) EditForm(c *gin.Context) { func (h *RepoHandler) EditForm(c *gin.Context) {
@ -163,5 +163,5 @@ func (h *RepoHandler) Update(c *gin.Context) {
return return
} }
c.Redirect(http.StatusSeeOther, "/repos/"+repoID.String()+"/edit") c.Redirect(http.StatusSeeOther, "/admin/repos/"+repoID.String()+"/edit")
} }

View File

@ -1,14 +1,11 @@
package admin package admin
import ( import (
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/mattnite/forgejo-tickets/internal/auth" "github.com/mattnite/forgejo-tickets/internal/auth"
"github.com/mattnite/forgejo-tickets/internal/config" "github.com/mattnite/forgejo-tickets/internal/config"
"github.com/mattnite/forgejo-tickets/internal/email" "github.com/mattnite/forgejo-tickets/internal/email"
"github.com/mattnite/forgejo-tickets/internal/forgejo" "github.com/mattnite/forgejo-tickets/internal/forgejo"
"github.com/mattnite/forgejo-tickets/internal/middleware"
"github.com/mattnite/forgejo-tickets/internal/templates" "github.com/mattnite/forgejo-tickets/internal/templates"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -23,49 +20,29 @@ type Dependencies struct {
Config *config.Config Config *config.Config
} }
func NewRouter(deps Dependencies) *gin.Engine { func RegisterRoutes(g *gin.RouterGroup, deps Dependencies) {
r := gin.New() dashboardHandler := &DashboardHandler{deps: deps}
g.GET("/", dashboardHandler.Index)
r.Use(middleware.RequestID) userHandler := &UserHandler{deps: deps}
r.Use(middleware.Logging) g.GET("/users", userHandler.List)
r.Use(middleware.Recovery) g.GET("/users/pending", userHandler.PendingList)
r.Use(middleware.SecurityHeaders(strings.HasPrefix(deps.Config.BaseURL, "https"))) g.GET("/users/new", userHandler.NewForm)
g.GET("/users/:id", userHandler.Detail)
g.POST("/users", userHandler.Create)
g.POST("/users/:id/approve", userHandler.Approve)
g.POST("/users/:id/reject", userHandler.Reject)
g.POST("/users/:id/repos", userHandler.UpdateRepos)
tsAuth := &TailscaleAuth{allowedUsers: deps.Config.TailscaleAllowedUsers} ticketHandler := &TicketHandler{deps: deps}
r.Use(tsAuth.Middleware) g.GET("/tickets", ticketHandler.List)
g.GET("/tickets/:id", ticketHandler.Detail)
g.POST("/tickets/:id/status", ticketHandler.UpdateStatus)
csrfSecret := []byte(deps.Config.SessionSecret) repoHandler := &RepoHandler{deps: deps}
isSecure := strings.HasPrefix(deps.Config.BaseURL, "https") g.GET("/repos", repoHandler.List)
csrfMiddleware := middleware.CSRF(csrfSecret, isSecure) g.GET("/repos/new", repoHandler.NewForm)
g.POST("/repos", repoHandler.Create)
csrf := r.Group("/") g.GET("/repos/:id/edit", repoHandler.EditForm)
csrf.Use(csrfMiddleware) g.POST("/repos/:id", repoHandler.Update)
{
dashboardHandler := &DashboardHandler{deps: deps}
csrf.GET("/", dashboardHandler.Index)
userHandler := &UserHandler{deps: deps}
csrf.GET("/users", userHandler.List)
csrf.GET("/users/pending", userHandler.PendingList)
csrf.GET("/users/new", userHandler.NewForm)
csrf.GET("/users/:id", userHandler.Detail)
csrf.POST("/users", userHandler.Create)
csrf.POST("/users/:id/approve", userHandler.Approve)
csrf.POST("/users/:id/reject", userHandler.Reject)
csrf.POST("/users/:id/repos", userHandler.UpdateRepos)
ticketHandler := &TicketHandler{deps: deps}
csrf.GET("/tickets", ticketHandler.List)
csrf.GET("/tickets/:id", ticketHandler.Detail)
csrf.POST("/tickets/:id/status", ticketHandler.UpdateStatus)
repoHandler := &RepoHandler{deps: deps}
csrf.GET("/repos", repoHandler.List)
csrf.GET("/repos/new", repoHandler.NewForm)
csrf.POST("/repos", repoHandler.Create)
csrf.GET("/repos/:id/edit", repoHandler.EditForm)
csrf.POST("/repos/:id", repoHandler.Update)
}
return r
} }

View File

@ -338,5 +338,5 @@ func (h *TicketHandler) UpdateStatus(c *gin.Context) {
h.deps.ForgejoClient.RemoveLabel(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber, inProgressLabel.ID) h.deps.ForgejoClient.RemoveLabel(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber, inProgressLabel.ID)
} }
c.Redirect(http.StatusSeeOther, "/tickets/"+ticketID.String()) c.Redirect(http.StatusSeeOther, "/admin/tickets/"+ticketID.String())
} }

View File

@ -167,7 +167,7 @@ func (h *UserHandler) Create(c *gin.Context) {
log.Error().Err(err).Msg("send welcome email error") log.Error().Err(err).Msg("send welcome email error")
} }
c.Redirect(http.StatusSeeOther, "/users/"+user.ID.String()) c.Redirect(http.StatusSeeOther, "/admin/users/"+user.ID.String())
} }
func (h *UserHandler) PendingList(c *gin.Context) { func (h *UserHandler) PendingList(c *gin.Context) {
@ -203,7 +203,7 @@ func (h *UserHandler) Approve(c *gin.Context) {
} }
middleware.SetFlash(c, "success", "User "+user.Email+" has been approved") middleware.SetFlash(c, "success", "User "+user.Email+" has been approved")
c.Redirect(http.StatusSeeOther, "/users/pending") c.Redirect(http.StatusSeeOther, "/admin/users/pending")
} }
func (h *UserHandler) Reject(c *gin.Context) { func (h *UserHandler) Reject(c *gin.Context) {
@ -220,7 +220,7 @@ func (h *UserHandler) Reject(c *gin.Context) {
} }
middleware.SetFlash(c, "success", "User request has been rejected") middleware.SetFlash(c, "success", "User request has been rejected")
c.Redirect(http.StatusSeeOther, "/users/pending") c.Redirect(http.StatusSeeOther, "/admin/users/pending")
} }
func (h *UserHandler) UpdateRepos(c *gin.Context) { func (h *UserHandler) UpdateRepos(c *gin.Context) {
@ -252,5 +252,5 @@ func (h *UserHandler) UpdateRepos(c *gin.Context) {
} }
middleware.SetFlash(c, "success", "Project assignments updated") middleware.SetFlash(c, "success", "Project assignments updated")
c.Redirect(http.StatusSeeOther, "/users/"+userID.String()) c.Redirect(http.StatusSeeOther, "/admin/users/"+userID.String())
} }

View File

@ -10,6 +10,7 @@ import (
"github.com/mattnite/forgejo-tickets/internal/config" "github.com/mattnite/forgejo-tickets/internal/config"
"github.com/mattnite/forgejo-tickets/internal/email" "github.com/mattnite/forgejo-tickets/internal/email"
"github.com/mattnite/forgejo-tickets/internal/forgejo" "github.com/mattnite/forgejo-tickets/internal/forgejo"
adminhandlers "github.com/mattnite/forgejo-tickets/internal/handlers/admin"
"github.com/mattnite/forgejo-tickets/internal/middleware" "github.com/mattnite/forgejo-tickets/internal/middleware"
"github.com/mattnite/forgejo-tickets/internal/templates" "github.com/mattnite/forgejo-tickets/internal/templates"
"gorm.io/gorm" "gorm.io/gorm"
@ -88,6 +89,18 @@ func NewRouter(deps Dependencies) *gin.Engine {
authenticated.GET("/tickets/:id/assets/:attachmentId/*filename", ticketHandler.DownloadIssueAttachment) authenticated.GET("/tickets/:id/assets/:attachmentId/*filename", ticketHandler.DownloadIssueAttachment)
authenticated.GET("/tickets/:id/comments/:commentId/assets/:attachmentId/*filename", ticketHandler.DownloadCommentAttachment) authenticated.GET("/tickets/:id/comments/:commentId/assets/:attachmentId/*filename", ticketHandler.DownloadCommentAttachment)
} }
adminGroup := csrf.Group("/admin")
adminGroup.Use(auth.RequireAdmin)
adminhandlers.RegisterRoutes(adminGroup, adminhandlers.Dependencies{
DB: deps.DB,
Renderer: deps.Renderer,
Auth: deps.Auth,
SessionStore: deps.SessionStore,
EmailClient: deps.EmailClient,
ForgejoClient: deps.ForgejoClient,
Config: deps.Config,
})
} }
return r return r

View File

@ -1,9 +1,11 @@
package models package models
import ( import (
"os"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/rs/zerolog/log"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -19,12 +21,17 @@ type User struct {
Email string `gorm:"uniqueIndex;not null" json:"email"` Email string `gorm:"uniqueIndex;not null" json:"email"`
PasswordHash *string `json:"-"` PasswordHash *string `json:"-"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Role string `gorm:"not null;default:'customer'" json:"role"`
EmailVerified bool `gorm:"not null;default:false" json:"email_verified"` EmailVerified bool `gorm:"not null;default:false" json:"email_verified"`
Approved bool `gorm:"not null;default:false" json:"approved"` Approved bool `gorm:"not null;default:false" json:"approved"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"` CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"` UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"`
} }
func (u *User) IsAdmin() bool {
return u.Role == "admin"
}
type UserRepo struct { type UserRepo struct {
UserID uuid.UUID `gorm:"type:uuid;not null;primaryKey"` UserID uuid.UUID `gorm:"type:uuid;not null;primaryKey"`
RepoID uuid.UUID `gorm:"type:uuid;not null;primaryKey"` RepoID uuid.UUID `gorm:"type:uuid;not null;primaryKey"`
@ -131,5 +138,16 @@ func AutoMigrate(db *gorm.DB) error {
// Approve all existing verified users so they aren't locked out // 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") db.Exec("UPDATE users SET approved = true WHERE approved = false AND email_verified = true")
// Set default role for existing users that have NULL or empty role
db.Exec("UPDATE users SET role = 'customer' WHERE role IS NULL OR role = ''")
// Bootstrap initial admin from environment variable
if adminEmail := os.Getenv("INITIAL_ADMIN_EMAIL"); adminEmail != "" {
result := db.Model(&User{}).Where("email = ?", adminEmail).Update("role", "admin")
if result.RowsAffected > 0 {
log.Info().Str("email", adminEmail).Msg("promoted user to admin")
}
}
return nil return nil
} }

View File

@ -8,24 +8,7 @@
</head> </head>
<body class="h-full"> <body class="h-full">
<div class="min-h-full"> <div class="min-h-full">
<nav class="bg-white shadow"> {{template "nav" .}}
<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">
<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-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" .}} {{template "flash" .}}
<main class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8"> <main class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{{block "content" .}}{{end}} {{block "content" .}}{{end}}

View File

@ -10,7 +10,7 @@
<div class="min-h-full"> <div class="min-h-full">
{{template "nav" .}} {{template "nav" .}}
{{template "flash" .}} {{template "flash" .}}
<main class="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8"> <main class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{{block "content" .}}{{end}} {{block "content" .}}{{end}}
</main> </main>
</div> </div>

View File

@ -4,7 +4,7 @@
{{with .Data}} {{with .Data}}
<div class="mx-auto max-w-lg"> <div class="mx-auto max-w-lg">
<div class="mb-4"> <div class="mb-4">
<a href="/repos" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to repos</a> <a href="/admin/repos" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to repos</a>
</div> </div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Edit Repo</h1> <h1 class="text-2xl font-bold text-gray-900 mb-6">Edit Repo</h1>
@ -63,7 +63,7 @@
</div> </div>
{{end}} {{end}}
<form method="POST" action="/repos/{{.Repo.ID}}" class="space-y-6 bg-white p-6 rounded-lg shadow ring-1 ring-gray-200"> <form method="POST" action="/admin/repos/{{.Repo.ID}}" class="space-y-6 bg-white p-6 rounded-lg shadow ring-1 ring-gray-200">
<input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}"> <input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}">
<div> <div>
<label for="name" class="block text-sm font-medium text-gray-700">Display Name</label> <label for="name" class="block text-sm font-medium text-gray-700">Display Name</label>

View File

@ -3,7 +3,7 @@
{{define "content"}} {{define "content"}}
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Repos</h1> <h1 class="text-2xl font-bold text-gray-900">Repos</h1>
<a href="/repos/new" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Add Repo</a> <a href="/admin/repos/new" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Add Repo</a>
</div> </div>
{{with .Data}} {{with .Data}}
@ -39,7 +39,7 @@
{{end}} {{end}}
</td> </td>
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3 text-sm">
<a href="/repos/{{.ID}}/edit" class="text-blue-600 hover:text-blue-500">Edit</a> <a href="/admin/repos/{{.ID}}/edit" class="text-blue-600 hover:text-blue-500">Edit</a>
</td> </td>
</tr> </tr>
{{end}} {{end}}

View File

@ -3,7 +3,7 @@
{{define "content"}} {{define "content"}}
<div class="mx-auto max-w-lg"> <div class="mx-auto max-w-lg">
<div class="mb-4"> <div class="mb-4">
<a href="/repos" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to repos</a> <a href="/admin/repos" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to repos</a>
</div> </div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Add Repo</h1> <h1 class="text-2xl font-bold text-gray-900 mb-6">Add Repo</h1>
@ -16,8 +16,8 @@
{{end}} {{end}}
{{end}} {{end}}
<form method="POST" action="/repos" class="space-y-6 bg-white p-6 rounded-lg shadow ring-1 ring-gray-200"> <form method="POST" action="/admin/repos" class="space-y-6 bg-white p-6 rounded-lg shadow ring-1 ring-gray-200">
<input type="hidden" name="gorilla.csrf.Token" value="{{.CSRFToken}}"> <input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}">
<div> <div>
<label for="name" class="block text-sm font-medium text-gray-700">Display Name</label> <label for="name" class="block text-sm font-medium text-gray-700">Display Name</label>
<input type="text" name="name" id="name" required placeholder="Billing App" <input type="text" name="name" id="name" required placeholder="Billing App"

View File

@ -3,7 +3,7 @@
{{define "content"}} {{define "content"}}
{{with .Data}} {{with .Data}}
<div class="mb-4"> <div class="mb-4">
<a href="/tickets" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to tickets</a> <a href="/admin/tickets" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to tickets</a>
</div> </div>
<div class="bg-white p-6 rounded-lg shadow ring-1 ring-gray-200"> <div class="bg-white p-6 rounded-lg shadow ring-1 ring-gray-200">
@ -59,7 +59,7 @@
<!-- Status Update --> <!-- Status Update -->
<div class="mt-6 pt-4 border-t border-gray-200"> <div class="mt-6 pt-4 border-t border-gray-200">
<form method="POST" action="/tickets/{{.Ticket.ID}}/status" class="flex items-center gap-3"> <form method="POST" action="/admin/tickets/{{.Ticket.ID}}/status" class="flex items-center gap-3">
<input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}"> <input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}">
<label for="status" class="text-sm font-medium text-gray-700">Update Status:</label> <label for="status" class="text-sm font-medium text-gray-700">Update Status:</label>
<select name="status" id="status" class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"> <select name="status" id="status" class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
@ -80,7 +80,7 @@
{{range .RelatedIssues}} {{range .RelatedIssues}}
<li class="text-sm"> <li class="text-sm">
{{if .TicketID}} {{if .TicketID}}
<a href="/tickets/{{.TicketID}}" class="text-blue-600 hover:text-blue-500">{{.DisplayText}}</a> <a href="/admin/tickets/{{.TicketID}}" class="text-blue-600 hover:text-blue-500">{{.DisplayText}}</a>
<span class="text-gray-400">(#{{.Number}})</span> <span class="text-gray-400">(#{{.Number}})</span>
{{else}} {{else}}
<span class="text-gray-600">{{.DisplayText}}</span> <span class="text-gray-600">{{.DisplayText}}</span>

View File

@ -5,10 +5,10 @@
{{with .Data}} {{with .Data}}
<div class="mb-4 flex gap-2"> <div class="mb-4 flex gap-2">
<a href="/tickets" class="rounded-md px-3 py-1.5 text-sm font-medium {{if not .StatusFilter}}bg-gray-900 text-white{{else}}bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50{{end}}">All</a> <a href="/admin/tickets" class="rounded-md px-3 py-1.5 text-sm font-medium {{if not .StatusFilter}}bg-gray-900 text-white{{else}}bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50{{end}}">All</a>
<a href="/tickets?status=open" class="rounded-md px-3 py-1.5 text-sm font-medium {{if eq .StatusFilter "open"}}bg-gray-900 text-white{{else}}bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50{{end}}">Open</a> <a href="/admin/tickets?status=open" class="rounded-md px-3 py-1.5 text-sm font-medium {{if eq .StatusFilter "open"}}bg-gray-900 text-white{{else}}bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50{{end}}">Open</a>
<a href="/tickets?status=in_progress" class="rounded-md px-3 py-1.5 text-sm font-medium {{if eq .StatusFilter "in_progress"}}bg-gray-900 text-white{{else}}bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50{{end}}">In Progress</a> <a href="/admin/tickets?status=in_progress" class="rounded-md px-3 py-1.5 text-sm font-medium {{if eq .StatusFilter "in_progress"}}bg-gray-900 text-white{{else}}bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50{{end}}">In Progress</a>
<a href="/tickets?status=closed" class="rounded-md px-3 py-1.5 text-sm font-medium {{if eq .StatusFilter "closed"}}bg-gray-900 text-white{{else}}bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50{{end}}">Closed</a> <a href="/admin/tickets?status=closed" class="rounded-md px-3 py-1.5 text-sm font-medium {{if eq .StatusFilter "closed"}}bg-gray-900 text-white{{else}}bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50{{end}}">Closed</a>
</div> </div>
{{if .Tickets}} {{if .Tickets}}
@ -31,7 +31,7 @@
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
{{if .Pinned}}<span class="text-gray-400 text-xs" title="Pinned">&#128204;</span>{{end}} {{if .Pinned}}<span class="text-gray-400 text-xs" title="Pinned">&#128204;</span>{{end}}
<a href="/tickets/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Title}}</a> <a href="/admin/tickets/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Title}}</a>
</div> </div>
</td> </td>
<td class="px-4 py-3 text-sm text-gray-500">{{.UserName}}</td> <td class="px-4 py-3 text-sm text-gray-500">{{.UserName}}</td>

View File

@ -3,7 +3,7 @@
{{define "content"}} {{define "content"}}
{{with .Data}} {{with .Data}}
<div class="mb-4"> <div class="mb-4">
<a href="/users" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to users</a> <a href="/admin/users" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to users</a>
</div> </div>
<div class="bg-white p-6 rounded-lg shadow ring-1 ring-gray-200 mb-8"> <div class="bg-white p-6 rounded-lg shadow ring-1 ring-gray-200 mb-8">
@ -30,7 +30,7 @@
<h2 class="text-lg font-semibold text-gray-900 mb-4">Project Access</h2> <h2 class="text-lg font-semibold text-gray-900 mb-4">Project Access</h2>
{{if .AllRepos}} {{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"> <form method="POST" action="/admin/users/{{.User.ID}}/repos" class="bg-white p-6 rounded-lg shadow ring-1 ring-gray-200 mb-8">
<input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}"> <input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}">
<div class="space-y-2"> <div class="space-y-2">
{{range .AllRepos}} {{range .AllRepos}}
@ -66,7 +66,7 @@
{{range .Tickets}} {{range .Tickets}}
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
<td class="px-4 py-3"> <td class="px-4 py-3">
<a href="/tickets/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Title}}</a> <a href="/admin/tickets/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Title}}</a>
</td> </td>
<td class="px-4 py-3 text-sm text-gray-500">{{.RepoName}}</td> <td class="px-4 py-3 text-sm text-gray-500">{{.RepoName}}</td>
<td class="px-4 py-3">{{statusBadge (print .Status)}}</td> <td class="px-4 py-3">{{statusBadge (print .Status)}}</td>

View File

@ -3,7 +3,7 @@
{{define "content"}} {{define "content"}}
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Users</h1> <h1 class="text-2xl font-bold text-gray-900">Users</h1>
<a href="/users/new" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Create User</a> <a href="/admin/users/new" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Create User</a>
</div> </div>
{{with .Data}} {{with .Data}}
@ -21,7 +21,7 @@
{{range .Users}} {{range .Users}}
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
<td class="px-4 py-3"> <td class="px-4 py-3">
<a href="/users/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Name}}</a> <a href="/admin/users/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Name}}</a>
</td> </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">{{.Email}}</td>
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3 text-sm">

View File

@ -3,7 +3,7 @@
{{define "content"}} {{define "content"}}
<div class="mx-auto max-w-lg"> <div class="mx-auto max-w-lg">
<div class="mb-4"> <div class="mb-4">
<a href="/users" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to users</a> <a href="/admin/users" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to users</a>
</div> </div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Create Customer User</h1> <h1 class="text-2xl font-bold text-gray-900 mb-6">Create Customer User</h1>
@ -17,8 +17,8 @@
{{end}} {{end}}
{{end}} {{end}}
<form method="POST" action="/users" class="space-y-6 bg-white p-6 rounded-lg shadow ring-1 ring-gray-200"> <form method="POST" action="/admin/users" class="space-y-6 bg-white p-6 rounded-lg shadow ring-1 ring-gray-200">
<input type="hidden" name="gorilla.csrf.Token" value="{{.CSRFToken}}"> <input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}">
<div> <div>
<label for="name" class="block text-sm font-medium text-gray-700">Name</label> <label for="name" class="block text-sm font-medium text-gray-700">Name</label>
<input type="text" name="name" id="name" required <input type="text" name="name" id="name" required

View File

@ -3,7 +3,7 @@
{{define "content"}} {{define "content"}}
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Pending Account Requests</h1> <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> <a href="/admin/users" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to all users</a>
</div> </div>
{{with .Data}} {{with .Data}}
@ -26,11 +26,11 @@
<td class="px-4 py-3 text-sm text-gray-500">{{formatDate .CreatedAt}}</td> <td class="px-4 py-3 text-sm text-gray-500">{{formatDate .CreatedAt}}</td>
<td class="px-4 py-3 text-right"> <td class="px-4 py-3 text-right">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<form method="POST" action="/users/{{.ID}}/approve"> <form method="POST" action="/admin/users/{{.ID}}/approve">
<input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}"> <input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}">
<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> <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>
<form method="POST" action="/users/{{.ID}}/reject" onsubmit="return confirm('Are you sure you want to reject this request? The account will be deleted.')"> <form method="POST" action="/admin/users/{{.ID}}/reject" onsubmit="return confirm('Are you sure you want to reject this request? The account will be deleted.')">
<input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}"> <input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}">
<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> <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> </form>

View File

@ -1,13 +1,24 @@
{{define "nav"}} {{define "nav"}}
<nav class="bg-white shadow"> <nav class="bg-white shadow">
<div class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8"> <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 h-16 items-center justify-between">
<a href="/" class="text-xl font-bold text-gray-900">Support</a> <div class="flex items-center gap-8">
<a href="/" class="text-xl font-bold text-gray-900">Support</a>
{{if .User}}{{if .User.IsAdmin}}
<div class="flex gap-4">
<a href="/admin/" class="text-sm font-medium text-gray-700 hover:text-gray-900">Dashboard</a>
<a href="/admin/users" class="text-sm font-medium text-gray-700 hover:text-gray-900">Users</a>
<a href="/admin/tickets" class="text-sm font-medium text-gray-700 hover:text-gray-900">Tickets</a>
<a href="/admin/repos" class="text-sm font-medium text-gray-700 hover:text-gray-900">Repos</a>
</div>
{{end}}{{end}}
</div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
{{if .User}} {{if .User}}
<a href="/tickets" class="text-sm font-medium text-gray-700 hover:text-gray-900">My Tickets</a> <a href="/tickets" class="text-sm font-medium text-gray-700 hover:text-gray-900">My Tickets</a>
<a href="/tickets/new" class="text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-md">New Ticket</a> <a href="/tickets/new" class="text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-md">New Ticket</a>
<span class="text-sm text-gray-500">{{.User.Name}}</span> <span class="text-sm text-gray-500">{{.User.Name}}</span>
{{if .User.IsAdmin}}<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>{{end}}
<form method="POST" action="/logout" class="inline"> <form method="POST" action="/logout" class="inline">
<input type="hidden" name="gorilla.csrf.Token" value="{{.CSRFToken}}"> <input type="hidden" name="gorilla.csrf.Token" value="{{.CSRFToken}}">
<button type="submit" class="text-sm font-medium text-gray-500 hover:text-gray-700">Logout</button> <button type="submit" class="text-sm font-medium text-gray-500 hover:text-gray-700">Logout</button>