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:
commit
86cfdcff52
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">← Back to repos</a>
|
<a href="/admin/repos" class="text-sm text-blue-600 hover:text-blue-500">← 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>
|
||||||
|
|
|
||||||
|
|
@ -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}}
|
||||||
|
|
|
||||||
|
|
@ -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">← Back to repos</a>
|
<a href="/admin/repos" class="text-sm text-blue-600 hover:text-blue-500">← 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"
|
||||||
|
|
|
||||||
|
|
@ -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">← Back to tickets</a>
|
<a href="/admin/tickets" class="text-sm text-blue-600 hover:text-blue-500">← 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>
|
||||||
|
|
|
||||||
|
|
@ -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">📌</span>{{end}}
|
{{if .Pinned}}<span class="text-gray-400 text-xs" title="Pinned">📌</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>
|
||||||
|
|
|
||||||
|
|
@ -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">← Back to users</a>
|
<a href="/admin/users" class="text-sm text-blue-600 hover:text-blue-500">← 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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">← Back to users</a>
|
<a href="/admin/users" class="text-sm text-blue-600 hover:text-blue-500">← 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
|
||||||
|
|
|
||||||
|
|
@ -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">← Back to all users</a>
|
<a href="/admin/users" class="text-sm text-blue-600 hover:text-blue-500">← 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue