This commit is contained in:
Matthew Knight 2026-02-17 11:01:34 -08:00
parent 1f3e1d3074
commit 4d688ffc20
No known key found for this signature in database
25 changed files with 139 additions and 241 deletions

View File

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

View File

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

View File

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

View File

@ -55,6 +55,21 @@ func RequireAuth(c *gin.Context) {
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 {
user, exists := c.Get(userContextKey)
if !exists {

View File

@ -3,7 +3,6 @@ package config
import (
"fmt"
"os"
"strings"
)
type Config struct {
@ -12,7 +11,6 @@ type Config struct {
// Server
PublicAddr string
AdminAddr string
BaseURL string
// Sessions
@ -42,14 +40,13 @@ type Config struct {
AppleKeyPath string
// Admin
TailscaleAllowedUsers []string
InitialAdminEmail string
}
func Load() (*Config, error) {
cfg := &Config{
DatabaseURL: getEnv("DATABASE_URL", ""),
PublicAddr: getEnv("PUBLIC_ADDR", ":8080"),
AdminAddr: getEnv("ADMIN_ADDR", ":8081"),
BaseURL: getEnv("BASE_URL", "http://localhost:8080"),
SessionSecret: getEnv("SESSION_SECRET", ""),
ForgejoURL: getEnv("FORGEJO_URL", ""),
@ -67,12 +64,7 @@ func Load() (*Config, error) {
AppleKeyPath: getEnv("APPLE_KEY_PATH", ""),
}
if allowed := getEnv("TAILSCALE_ALLOWED_USERS", ""); allowed != "" {
cfg.TailscaleAllowedUsers = strings.Split(allowed, ",")
for i := range cfg.TailscaleAllowedUsers {
cfg.TailscaleAllowedUsers[i] = strings.TrimSpace(cfg.TailscaleAllowedUsers[i])
}
}
cfg.InitialAdminEmail = getEnv("INITIAL_ADMIN_EMAIL", "")
if cfg.DatabaseURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required")

View File

@ -10,13 +10,13 @@ import (
func clearConfigEnv(t *testing.T) {
t.Helper()
envVars := []string{
"DATABASE_URL", "PUBLIC_ADDR", "ADMIN_ADDR", "BASE_URL",
"DATABASE_URL", "PUBLIC_ADDR", "BASE_URL",
"SESSION_SECRET", "FORGEJO_URL", "FORGEJO_API_TOKEN",
"POSTMARK_SERVER_TOKEN", "POSTMARK_FROM_EMAIL",
"GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET",
"MICROSOFT_CLIENT_ID", "MICROSOFT_CLIENT_SECRET", "MICROSOFT_TENANT_ID",
"APPLE_CLIENT_ID", "APPLE_TEAM_ID", "APPLE_KEY_ID", "APPLE_KEY_PATH",
"TAILSCALE_ALLOWED_USERS",
"INITIAL_ADMIN_EMAIL",
}
for _, v := range envVars {
os.Unsetenv(v)
@ -86,9 +86,6 @@ func TestLoad_DefaultValues(t *testing.T) {
if cfg.PublicAddr != ":8080" {
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" {
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("SESSION_SECRET", "my-secret")
t.Setenv("PUBLIC_ADDR", ":9090")
t.Setenv("ADMIN_ADDR", ":9091")
t.Setenv("BASE_URL", "https://example.com")
cfg, err := Load()
@ -113,49 +109,23 @@ func TestLoad_OverrideDefaults(t *testing.T) {
if cfg.PublicAddr != ":9090" {
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" {
t.Errorf("expected BaseURL %q, got %q", "https://example.com", cfg.BaseURL)
}
}
func TestLoad_TailscaleAllowedUsers(t *testing.T) {
func TestLoad_InitialAdminEmail(t *testing.T) {
clearConfigEnv(t)
t.Setenv("DATABASE_URL", "postgres://localhost/test")
t.Setenv("SESSION_SECRET", "my-secret")
t.Setenv("TAILSCALE_ALLOWED_USERS", "alice@example.com, bob@example.com , charlie@example.com")
t.Setenv("INITIAL_ADMIN_EMAIL", "admin@example.com")
cfg, err := Load()
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if len(cfg.TailscaleAllowedUsers) != 3 {
t.Fatalf("expected 3 tailscale users, got %d", len(cfg.TailscaleAllowedUsers))
}
expected := []string{"alice@example.com", "bob@example.com", "charlie@example.com"}
for i, want := range expected {
if cfg.TailscaleAllowedUsers[i] != want {
t.Errorf("TailscaleAllowedUsers[%d]: expected %q, got %q", i, want, cfg.TailscaleAllowedUsers[i])
}
}
}
func TestLoad_EmptyTailscaleAllowedUsers(t *testing.T) {
clearConfigEnv(t)
t.Setenv("DATABASE_URL", "postgres://localhost/test")
t.Setenv("SESSION_SECRET", "my-secret")
// TAILSCALE_ALLOWED_USERS not set
cfg, err := Load()
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if cfg.TailscaleAllowedUsers != nil {
t.Errorf("expected nil TailscaleAllowedUsers, got %v", cfg.TailscaleAllowedUsers)
if cfg.InitialAdminEmail != "admin@example.com" {
t.Errorf("expected InitialAdminEmail %q, got %q", "admin@example.com", cfg.InitialAdminEmail)
}
}

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
}
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) {
@ -163,5 +163,5 @@ func (h *RepoHandler) Update(c *gin.Context) {
return
}
c.Redirect(http.StatusSeeOther, "/repos/"+repoID.String()+"/edit")
c.Redirect(http.StatusSeeOther, "/admin/repos/"+repoID.String()+"/edit")
}

View File

@ -6,7 +6,6 @@ import (
"github.com/mattnite/forgejo-tickets/internal/config"
"github.com/mattnite/forgejo-tickets/internal/email"
"github.com/mattnite/forgejo-tickets/internal/forgejo"
"github.com/mattnite/forgejo-tickets/internal/middleware"
"github.com/mattnite/forgejo-tickets/internal/templates"
"gorm.io/gorm"
)
@ -15,45 +14,35 @@ type Dependencies struct {
DB *gorm.DB
Renderer *templates.Renderer
Auth *auth.Service
SessionStore *auth.PGStore
EmailClient *email.Client
ForgejoClient *forgejo.Client
Config *config.Config
}
func NewRouter(deps Dependencies) *gin.Engine {
r := gin.New()
r.Use(middleware.RequestID)
r.Use(middleware.Logging)
r.Use(middleware.Recovery)
tsAuth := &TailscaleAuth{allowedUsers: deps.Config.TailscaleAllowedUsers}
r.Use(tsAuth.Middleware)
func RegisterRoutes(g *gin.RouterGroup, deps Dependencies) {
dashboardHandler := &DashboardHandler{deps: deps}
r.GET("/", dashboardHandler.Index)
g.GET("/", dashboardHandler.Index)
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)
g.GET("/users", userHandler.List)
g.GET("/users/pending", userHandler.PendingList)
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)
ticketHandler := &TicketHandler{deps: deps}
r.GET("/tickets", ticketHandler.List)
r.GET("/tickets/:id", ticketHandler.Detail)
r.POST("/tickets/:id/status", ticketHandler.UpdateStatus)
g.GET("/tickets", ticketHandler.List)
g.GET("/tickets/:id", ticketHandler.Detail)
g.POST("/tickets/:id/status", ticketHandler.UpdateStatus)
repoHandler := &RepoHandler{deps: deps}
r.GET("/repos", repoHandler.List)
r.GET("/repos/new", repoHandler.NewForm)
r.POST("/repos", repoHandler.Create)
r.GET("/repos/:id/edit", repoHandler.EditForm)
r.POST("/repos/:id", repoHandler.Update)
return r
g.GET("/repos", repoHandler.List)
g.GET("/repos/new", repoHandler.NewForm)
g.POST("/repos", repoHandler.Create)
g.GET("/repos/:id/edit", repoHandler.EditForm)
g.POST("/repos/:id", repoHandler.Update)
}

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)
}
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")
}
c.Redirect(http.StatusSeeOther, "/users/"+user.ID.String())
c.Redirect(http.StatusSeeOther, "/admin/users/"+user.ID.String())
}
func (h *UserHandler) PendingList(c *gin.Context) {
@ -202,7 +202,7 @@ func (h *UserHandler) Approve(c *gin.Context) {
log.Error().Err(err).Msg("send approval email error")
}
redirectURL := "/users/pending?" + url.Values{
redirectURL := "/admin/users/pending?" + url.Values{
"flash": {"User " + user.Email + " has been approved"},
"flash_type": {"success"},
}.Encode()
@ -222,7 +222,7 @@ func (h *UserHandler) Reject(c *gin.Context) {
return
}
redirectURL := "/users/pending?" + url.Values{
redirectURL := "/admin/users/pending?" + url.Values{
"flash": {"User request has been rejected"},
"flash_type": {"success"},
}.Encode()
@ -257,7 +257,7 @@ func (h *UserHandler) UpdateRepos(c *gin.Context) {
h.deps.DB.Create(&models.UserRepo{UserID: userID, RepoID: repoID})
}
redirectURL := "/users/" + userID.String() + "?" + url.Values{
redirectURL := "/admin/users/" + userID.String() + "?" + url.Values{
"flash": {"Project assignments updated"},
"flash_type": {"success"},
}.Encode()

View File

@ -9,6 +9,7 @@ import (
"github.com/mattnite/forgejo-tickets/internal/config"
"github.com/mattnite/forgejo-tickets/internal/email"
"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/templates"
"gorm.io/gorm"
@ -83,6 +84,18 @@ func NewRouter(deps Dependencies) *gin.Engine {
authenticated.GET("/tickets/:id/assets/:attachmentId/*filename", ticketHandler.DownloadIssueAttachment)
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

View File

@ -1,9 +1,11 @@
package models
import (
"os"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
@ -19,12 +21,17 @@ type User struct {
Email string `gorm:"uniqueIndex;not null" json:"email"`
PasswordHash *string `json:"-"`
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"`
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"`
}
func (u *User) IsAdmin() bool {
return u.Role == "admin"
}
type UserRepo struct {
UserID 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
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
}

View File

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

View File

@ -10,7 +10,7 @@
<div class="min-h-full">
{{template "nav" .}}
{{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}}
</main>
</div>

View File

@ -4,7 +4,7 @@
{{with .Data}}
<div class="mx-auto max-w-lg">
<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>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Edit Repo</h1>
@ -63,7 +63,8 @@
</div>
{{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}}">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Display Name</label>
<input type="text" name="name" id="name" required value="{{.Repo.Name}}"

View File

@ -3,7 +3,7 @@
{{define "content"}}
<div class="flex items-center justify-between mb-6">
<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>
{{with .Data}}
@ -39,7 +39,7 @@
{{end}}
</td>
<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>
</tr>
{{end}}

View File

@ -3,7 +3,7 @@
{{define "content"}}
<div class="mx-auto max-w-lg">
<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>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Add Repo</h1>
@ -16,7 +16,8 @@
{{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}}">
<div>
<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"

View File

@ -3,7 +3,7 @@
{{define "content"}}
{{with .Data}}
<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 class="bg-white p-6 rounded-lg shadow ring-1 ring-gray-200">
@ -59,7 +59,8 @@
<!-- Status Update -->
<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}}">
<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">
<option value="open" {{if eq (print .Ticket.Status) "open"}}selected{{end}}>Open</option>
@ -79,7 +80,7 @@
{{range .RelatedIssues}}
<li class="text-sm">
{{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>
{{else}}
<span class="text-gray-600">{{.DisplayText}}</span>

View File

@ -5,10 +5,10 @@
{{with .Data}}
<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="/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="/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" 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?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=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=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>
{{if .Tickets}}
@ -31,7 +31,7 @@
<td class="px-4 py-3">
<div class="flex items-center gap-1">
{{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>
</td>
<td class="px-4 py-3 text-sm text-gray-500">{{.UserName}}</td>

View File

@ -3,7 +3,7 @@
{{define "content"}}
{{with .Data}}
<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 class="bg-white p-6 rounded-lg shadow ring-1 ring-gray-200 mb-8">
@ -30,7 +30,8 @@
<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">
<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}}">
<div class="space-y-2">
{{range .AllRepos}}
<label class="flex items-center gap-2">
@ -65,7 +66,7 @@
{{range .Tickets}}
<tr class="hover:bg-gray-50">
<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 class="px-4 py-3 text-sm text-gray-500">{{.RepoName}}</td>
<td class="px-4 py-3">{{statusBadge (print .Status)}}</td>

View File

@ -3,7 +3,7 @@
{{define "content"}}
<div class="flex items-center justify-between mb-6">
<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>
{{with .Data}}
@ -21,7 +21,7 @@
{{range .Users}}
<tr class="hover:bg-gray-50">
<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 class="px-4 py-3 text-sm text-gray-500">{{.Email}}</td>
<td class="px-4 py-3 text-sm">

View File

@ -3,7 +3,7 @@
{{define "content"}}
<div class="mx-auto max-w-lg">
<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>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Create Customer User</h1>
@ -17,7 +17,8 @@
{{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}}">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Name</label>
<input type="text" name="name" id="name" required

View File

@ -3,7 +3,7 @@
{{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>
<a href="/admin/users" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to all users</a>
</div>
{{with .Data}}
@ -26,10 +26,12 @@
<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">
<form method="POST" action="/admin/users/{{.ID}}/approve">
<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>
</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}}">
<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>

View File

@ -1,13 +1,24 @@
{{define "nav"}}
<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 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">
{{if .User}}
<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>
<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">
<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>