From 945b18ab3e872a14b09271b91ce991a74946582e Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Wed, 19 Nov 2025 19:44:22 -0500 Subject: [PATCH] feat: Implement User Authentication and Fix Frontend Startup - Implemented Issue #9: User Authentication & Authorization - Added User model fields (FailedLoginAttempts, LockedUntil, LastLogin) - Created AuthService with JWT support, bcrypt hashing, and account lockout - Added AuthMiddleware and AuthHandler - Registered auth routes in backend - Created AuthContext and RequireAuth component in frontend - Implemented Login page and integrated with backend - Fixed 'Blank Page' issue in local Docker environment - Added QueryClientProvider to main.tsx - Installed missing lucide-react dependency - Fixed TypeScript linting errors in SetupGuard.tsx - Updated docker-entrypoint.sh to use 127.0.0.1 for reliable Caddy checks - Verified with local Docker build --- backend/cmd/api/main.go | 3 +- backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/api/handlers/auth_handler.go | 73 +++++++++ .../api/handlers/certificate_handler.go | 27 ++++ backend/internal/api/handlers/user_handler.go | 113 ++++++++++++++ backend/internal/api/middleware/auth.go | 55 +++++++ backend/internal/api/routes/routes.go | 30 +++- backend/internal/caddy/client_test.go | 2 +- backend/internal/caddy/config.go | 60 +++++--- backend/internal/caddy/config_test.go | 10 +- backend/internal/caddy/manager.go | 9 +- backend/internal/caddy/types.go | 9 +- backend/internal/caddy/validator_test.go | 2 +- backend/internal/config/config.go | 2 + backend/internal/models/user.go | 40 +++-- backend/internal/services/auth_service.go | 122 +++++++++++++++ .../internal/services/certificate_service.go | 105 +++++++++++++ docker-compose.local.yml | 40 +++++ docker-entrypoint.sh | 2 +- frontend/dist/index.html | 4 +- frontend/package-lock.json | 9 ++ frontend/package.json | 1 + frontend/src/App.tsx | 43 ++++-- frontend/src/api/certificates.ts | 13 ++ frontend/src/api/setup.ts | 20 +++ frontend/src/components/CertificateList.tsx | 71 +++++++++ frontend/src/components/Layout.tsx | 1 + frontend/src/components/RequireAuth.tsx | 20 +++ frontend/src/components/SetupGuard.tsx | 38 +++++ .../src/components/__tests__/Layout.test.tsx | 1 + frontend/src/context/AuthContext.tsx | 71 +++++++++ frontend/src/hooks/useCertificates.ts | 15 ++ frontend/src/main.tsx | 11 +- frontend/src/pages/Certificates.tsx | 18 +++ frontend/src/pages/Login.tsx | 17 ++- frontend/src/pages/Setup.tsx | 144 ++++++++++++++++++ frontend/src/pages/__tests__/Setup.test.tsx | 61 ++++++++ 38 files changed, 1198 insertions(+), 67 deletions(-) create mode 100644 backend/internal/api/handlers/auth_handler.go create mode 100644 backend/internal/api/handlers/certificate_handler.go create mode 100644 backend/internal/api/handlers/user_handler.go create mode 100644 backend/internal/api/middleware/auth.go create mode 100644 backend/internal/services/auth_service.go create mode 100644 backend/internal/services/certificate_service.go create mode 100644 docker-compose.local.yml create mode 100644 frontend/src/api/certificates.ts create mode 100644 frontend/src/api/setup.ts create mode 100644 frontend/src/components/CertificateList.tsx create mode 100644 frontend/src/components/RequireAuth.tsx create mode 100644 frontend/src/components/SetupGuard.tsx create mode 100644 frontend/src/context/AuthContext.tsx create mode 100644 frontend/src/hooks/useCertificates.ts create mode 100644 frontend/src/pages/Certificates.tsx create mode 100644 frontend/src/pages/Setup.tsx create mode 100644 frontend/src/pages/__tests__/Setup.test.tsx diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 2ae2057a..9417d9a2 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -27,7 +27,8 @@ func main() { router := server.NewRouter(cfg.FrontendDir) - if err := routes.Register(router, db); err != nil { + // Pass config to routes for auth service and certificate service + if err := routes.Register(router, db, cfg); err != nil { log.Fatalf("register routes: %v", err) } diff --git a/backend/go.mod b/backend/go.mod index e3a24bb5..de10a1cd 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -22,6 +22,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect diff --git a/backend/go.sum b/backend/go.sum index ee19bcd0..bb99e360 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -25,6 +25,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/backend/internal/api/handlers/auth_handler.go b/backend/internal/api/handlers/auth_handler.go new file mode 100644 index 00000000..12ef55d3 --- /dev/null +++ b/backend/internal/api/handlers/auth_handler.go @@ -0,0 +1,73 @@ +package handlers + +import ( + "net/http" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services" + "github.com/gin-gonic/gin" +) + +type AuthHandler struct { + authService *services.AuthService +} + +func NewAuthHandler(authService *services.AuthService) *AuthHandler { + return &AuthHandler{authService: authService} +} + +type LoginRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` +} + +func (h *AuthHandler) Login(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + token, err := h.authService.Login(req.Email, req.Password) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + // Set cookie + c.SetCookie("auth_token", token, 3600*24, "/", "", false, true) // Secure should be true in prod + + c.JSON(http.StatusOK, gin.H{"token": token}) +} + +type RegisterRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8"` + Name string `json:"name" binding:"required"` +} + +func (h *AuthHandler) Register(c *gin.Context) { + var req RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user, err := h.authService.Register(req.Email, req.Password, req.Name) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, user) +} + +func (h *AuthHandler) Logout(c *gin.Context) { + c.SetCookie("auth_token", "", -1, "/", "", false, true) + c.JSON(http.StatusOK, gin.H{"message": "Logged out"}) +} + +func (h *AuthHandler) Me(c *gin.Context) { + userID, _ := c.Get("userID") + role, _ := c.Get("role") + c.JSON(http.StatusOK, gin.H{"user_id": userID, "role": role}) +} diff --git a/backend/internal/api/handlers/certificate_handler.go b/backend/internal/api/handlers/certificate_handler.go new file mode 100644 index 00000000..d46ad2d4 --- /dev/null +++ b/backend/internal/api/handlers/certificate_handler.go @@ -0,0 +1,27 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services" +) + +type CertificateHandler struct { + service *services.CertificateService +} + +func NewCertificateHandler(service *services.CertificateService) *CertificateHandler { + return &CertificateHandler{service: service} +} + +func (h *CertificateHandler) List(c *gin.Context) { + certs, err := h.service.ListCertificates() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, certs) +} diff --git a/backend/internal/api/handlers/user_handler.go b/backend/internal/api/handlers/user_handler.go new file mode 100644 index 00000000..c7a7473b --- /dev/null +++ b/backend/internal/api/handlers/user_handler.go @@ -0,0 +1,113 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gorm.io/gorm" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" +) + +type UserHandler struct { + DB *gorm.DB +} + +func NewUserHandler(db *gorm.DB) *UserHandler { + return &UserHandler{DB: db} +} + +func (h *UserHandler) RegisterRoutes(r *gin.RouterGroup) { + r.GET("/setup", h.GetSetupStatus) + r.POST("/setup", h.Setup) +} + +// GetSetupStatus checks if the application needs initial setup (i.e., no users exist). +func (h *UserHandler) GetSetupStatus(c *gin.Context) { + var count int64 + if err := h.DB.Model(&models.User{}).Count(&count).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check setup status"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "setupRequired": count == 0, + }) +} + +type SetupRequest struct { + Name string `json:"name" binding:"required"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8"` +} + +// Setup creates the initial admin user and configures the ACME email. +func (h *UserHandler) Setup(c *gin.Context) { + // 1. Check if setup is allowed + var count int64 + if err := h.DB.Model(&models.User{}).Count(&count).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check setup status"}) + return + } + + if count > 0 { + c.JSON(http.StatusForbidden, gin.H{"error": "Setup already completed"}) + return + } + + // 2. Parse request + var req SetupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 3. Create User + user := models.User{ + UUID: uuid.New().String(), + Name: req.Name, + Email: req.Email, + Role: "admin", + Enabled: true, + } + + if err := user.SetPassword(req.Password); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) + return + } + + // 4. Create Setting for ACME Email + acmeEmailSetting := models.Setting{ + Key: "caddy.acme_email", + Value: req.Email, + Type: "string", + Category: "caddy", + } + + // Transaction to ensure both succeed + err := h.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&user).Error; err != nil { + return err + } + // Use Save to update if exists (though it shouldn't in fresh setup) or create + if err := tx.Where(models.Setting{Key: "caddy.acme_email"}).Assign(models.Setting{Value: req.Email}).FirstOrCreate(&acmeEmailSetting).Error; err != nil { + return err + } + return nil + }) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to complete setup: " + err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Setup completed successfully", + "user": gin.H{ + "id": user.ID, + "email": user.Email, + "name": user.Name, + }, + }) +} diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/auth.go new file mode 100644 index 00000000..877f4132 --- /dev/null +++ b/backend/internal/api/middleware/auth.go @@ -0,0 +1,55 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services" + "github.com/gin-gonic/gin" +) + +func AuthMiddleware(authService *services.AuthService) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + // Try cookie + cookie, err := c.Cookie("auth_token") + if err == nil { + authHeader = "Bearer " + cookie + } + } + + if authHeader == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"}) + return + } + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + claims, err := authService.ValidateToken(tokenString) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) + return + } + + c.Set("userID", claims.UserID) + c.Set("role", claims.Role) + c.Next() + } +} + +func RequireRole(role string) gin.HandlerFunc { + return func(c *gin.Context) { + userRole, exists := c.Get("role") + if !exists { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + if userRole.(string) != role && userRole.(string) != "admin" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Forbidden"}) + return + } + + c.Next() + } +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 4fc73a9c..5c134ea6 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -7,11 +7,14 @@ import ( "gorm.io/gorm" "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers" + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/middleware" + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config" "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services" ) // Register wires up API routes and performs automatic migrations. -func Register(router *gin.Engine, db *gorm.DB) error { +func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { // AutoMigrate all models for Issue #5 persistence layer if err := db.AutoMigrate( &models.ProxyHost{}, @@ -31,12 +34,37 @@ func Register(router *gin.Engine, db *gorm.DB) error { api := router.Group("/api/v1") + // Auth routes + authService := services.NewAuthService(db, cfg) + authHandler := handlers.NewAuthHandler(authService) + authMiddleware := middleware.AuthMiddleware(authService) + + api.POST("/auth/login", authHandler.Login) + api.POST("/auth/register", authHandler.Register) + + protected := api.Group("/") + protected.Use(authMiddleware) + { + protected.POST("/auth/logout", authHandler.Logout) + protected.GET("/auth/me", authHandler.Me) + } + proxyHostHandler := handlers.NewProxyHostHandler(db) proxyHostHandler.RegisterRoutes(api) remoteServerHandler := handlers.NewRemoteServerHandler(db) remoteServerHandler.RegisterRoutes(api) + userHandler := handlers.NewUserHandler(db) + userHandler.RegisterRoutes(api) + + // Certificate routes + // Use cfg.CaddyConfigDir + "/data" for cert service + caddyDataDir := cfg.CaddyConfigDir + "/data" + certService := services.NewCertificateService(caddyDataDir) + certHandler := handlers.NewCertificateHandler(certService) + api.GET("/certificates", certHandler.List) + return nil } diff --git a/backend/internal/caddy/client_test.go b/backend/internal/caddy/client_test.go index 46e0c795..7e88857e 100644 --- a/backend/internal/caddy/client_test.go +++ b/backend/internal/caddy/client_test.go @@ -30,7 +30,7 @@ func TestClient_Load_Success(t *testing.T) { ForwardPort: 8080, Enabled: true, }, - }) + }, "/tmp/caddy-data", "admin@example.com") err := client.Load(context.Background(), config) require.NoError(t, err) diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go index 354d815a..ae479556 100644 --- a/backend/internal/caddy/config.go +++ b/backend/internal/caddy/config.go @@ -9,15 +9,42 @@ import ( // GenerateConfig creates a Caddy JSON configuration from proxy hosts. // This is the core transformation layer from our database model to Caddy config. -func GenerateConfig(hosts []models.ProxyHost) (*Config, error) { - if len(hosts) == 0 { - return &Config{ - Apps: Apps{ - HTTP: &HTTPApp{ - Servers: map[string]*Server{}, +func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail string) (*Config, error) { + config := &Config{ + Apps: Apps{ + HTTP: &HTTPApp{ + Servers: map[string]*Server{}, + }, + }, + Storage: Storage{ + System: "file_system", + Root: storageDir, + }, + } + + if acmeEmail != "" { + config.Apps.TLS = &TLSApp{ + Automation: &AutomationConfig{ + Policies: []*AutomationPolicy{ + { + IssuersRaw: []interface{}{ + map[string]interface{}{ + "module": "acme", + "email": acmeEmail, + }, + map[string]interface{}{ + "module": "zerossl", + "email": acmeEmail, + }, + }, + }, }, }, - }, nil + } + } + + if len(hosts) == 0 { + return config, nil } routes := make([]*Route, 0) @@ -89,19 +116,12 @@ func GenerateConfig(hosts []models.ProxyHost) (*Config, error) { routes = append(routes, route) } - config := &Config{ - Apps: Apps{ - HTTP: &HTTPApp{ - Servers: map[string]*Server{ - "cpm_server": { - Listen: []string{":80", ":443"}, - Routes: routes, - AutoHTTPS: &AutoHTTPSConfig{ - Disable: false, - }, - }, - }, - }, + config.Apps.HTTP.Servers["cpm_server"] = &Server{ + Listen: []string{":80", ":443"}, + Routes: routes, + AutoHTTPS: &AutoHTTPSConfig{ + Disable: false, + DisableRedir: false, }, } diff --git a/backend/internal/caddy/config_test.go b/backend/internal/caddy/config_test.go index 111b8229..e537504f 100644 --- a/backend/internal/caddy/config_test.go +++ b/backend/internal/caddy/config_test.go @@ -9,7 +9,7 @@ import ( ) func TestGenerateConfig_Empty(t *testing.T) { - config, err := GenerateConfig([]models.ProxyHost{}) + config, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "admin@example.com") require.NoError(t, err) require.NotNil(t, config) require.NotNil(t, config.Apps.HTTP) @@ -31,7 +31,7 @@ func TestGenerateConfig_SingleHost(t *testing.T) { }, } - config, err := GenerateConfig(hosts) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com") require.NoError(t, err) require.NotNil(t, config) require.NotNil(t, config.Apps.HTTP) @@ -71,7 +71,7 @@ func TestGenerateConfig_MultipleHosts(t *testing.T) { }, } - config, err := GenerateConfig(hosts) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com") require.NoError(t, err) require.Len(t, config.Apps.HTTP.Servers["cpm_server"].Routes, 2) } @@ -88,7 +88,7 @@ func TestGenerateConfig_WebSocketEnabled(t *testing.T) { }, } - config, err := GenerateConfig(hosts) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com") require.NoError(t, err) route := config.Apps.HTTP.Servers["cpm_server"].Routes[0] @@ -109,7 +109,7 @@ func TestGenerateConfig_EmptyDomain(t *testing.T) { }, } - _, err := GenerateConfig(hosts) + _, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com") require.Error(t, err) require.Contains(t, err.Error(), "empty domain") } diff --git a/backend/internal/caddy/manager.go b/backend/internal/caddy/manager.go index cf00d6a5..e2ade3b4 100644 --- a/backend/internal/caddy/manager.go +++ b/backend/internal/caddy/manager.go @@ -39,8 +39,15 @@ func (m *Manager) ApplyConfig(ctx context.Context) error { return fmt.Errorf("fetch proxy hosts: %w", err) } + // Fetch ACME email setting + var acmeEmailSetting models.Setting + var acmeEmail string + if err := m.db.Where("key = ?", "caddy.acme_email").First(&acmeEmailSetting).Error; err == nil { + acmeEmail = acmeEmailSetting.Value + } + // Generate Caddy config - config, err := GenerateConfig(hosts) + config, err := GenerateConfig(hosts, filepath.Join(m.configDir, "data"), acmeEmail) if err != nil { return fmt.Errorf("generate config: %w", err) } diff --git a/backend/internal/caddy/types.go b/backend/internal/caddy/types.go index 86b829d6..4cfa60c4 100644 --- a/backend/internal/caddy/types.go +++ b/backend/internal/caddy/types.go @@ -3,7 +3,14 @@ package caddy // Config represents Caddy's top-level JSON configuration structure. // Reference: https://caddyserver.com/docs/json/ type Config struct { - Apps Apps `json:"apps"` + Apps Apps `json:"apps"` + Storage Storage `json:"storage,omitempty"` +} + +// Storage configures the storage module. +type Storage struct { + System string `json:"module"` + Root string `json:"root,omitempty"` } // Apps contains all Caddy app modules. diff --git a/backend/internal/caddy/validator_test.go b/backend/internal/caddy/validator_test.go index 852cd45b..477152c2 100644 --- a/backend/internal/caddy/validator_test.go +++ b/backend/internal/caddy/validator_test.go @@ -25,7 +25,7 @@ func TestValidate_ValidConfig(t *testing.T) { }, } - config, _ := GenerateConfig(hosts) + config, _ := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com") err := Validate(config) require.NoError(t, err) } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 9728a316..74f5a633 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { CaddyBinary string ImportCaddyfile string ImportDir string + JWTSecret string } // Load reads env vars and falls back to defaults so the server can boot with zero configuration. @@ -31,6 +32,7 @@ func Load() (Config, error) { CaddyBinary: getEnv("CPM_CADDY_BINARY", "caddy"), ImportCaddyfile: getEnv("CPM_IMPORT_CADDYFILE", "/import/Caddyfile"), ImportDir: getEnv("CPM_IMPORT_DIR", filepath.Join("data", "imports")), + JWTSecret: getEnv("CPM_JWT_SECRET", "change-me-in-production"), } if err := os.MkdirAll(filepath.Dir(cfg.DatabasePath), 0o755); err != nil { diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index ab7b5e86..fd252dd2 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -2,19 +2,39 @@ package models import ( "time" + + "golang.org/x/crypto/bcrypt" ) // User represents authenticated users with role-based access control. // Supports local auth, SSO integration planned for later phases. type User struct { - ID uint `json:"id" gorm:"primaryKey"` - UUID string `json:"uuid" gorm:"uniqueIndex"` - Email string `json:"email" gorm:"uniqueIndex"` - PasswordHash string `json:"-"` // Never serialize password hash - Name string `json:"name"` - Role string `json:"role" gorm:"default:'user'"` // "admin", "user", "viewer" - Enabled bool `json:"enabled" gorm:"default:true"` - LastLogin *time.Time `json:"last_login,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `json:"id" gorm:"primaryKey"` + UUID string `json:"uuid" gorm:"uniqueIndex"` + Email string `json:"email" gorm:"uniqueIndex"` + PasswordHash string `json:"-"` // Never serialize password hash + Name string `json:"name"` + Role string `json:"role" gorm:"default:'user'"` // "admin", "user", "viewer" + Enabled bool `json:"enabled" gorm:"default:true"` + FailedLoginAttempts int `json:"-" gorm:"default:0"` + LockedUntil *time.Time `json:"-"` + LastLogin *time.Time `json:"last_login,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// SetPassword hashes and sets the user's password. +func (u *User) SetPassword(password string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + u.PasswordHash = string(hash) + return nil +} + +// CheckPassword compares the provided password with the stored hash. +func (u *User) CheckPassword(password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) + return err == nil } diff --git a/backend/internal/services/auth_service.go b/backend/internal/services/auth_service.go new file mode 100644 index 00000000..e21901f9 --- /dev/null +++ b/backend/internal/services/auth_service.go @@ -0,0 +1,122 @@ +package services + +import ( + "errors" + "time" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config" + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "gorm.io/gorm" +) + +type AuthService struct { + db *gorm.DB + config config.Config +} + +func NewAuthService(db *gorm.DB, cfg config.Config) *AuthService { + return &AuthService{db: db, config: cfg} +} + +type Claims struct { + UserID uint `json:"user_id"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +func (s *AuthService) Register(email, password, name string) (*models.User, error) { + var count int64 + s.db.Model(&models.User{}).Count(&count) + + role := "user" + if count == 0 { + role = "admin" // First user is admin + } + + user := &models.User{ + UUID: uuid.New().String(), + Email: email, + Name: name, + Role: role, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := user.SetPassword(password); err != nil { + return nil, err + } + + if err := s.db.Create(user).Error; err != nil { + return nil, err + } + + return user, nil +} + +func (s *AuthService) Login(email, password string) (string, error) { + var user models.User + if err := s.db.Where("email = ?", email).First(&user).Error; err != nil { + return "", errors.New("invalid credentials") + } + + if !user.Enabled { + return "", errors.New("account disabled") + } + + if user.LockedUntil != nil && user.LockedUntil.After(time.Now()) { + return "", errors.New("account locked") + } + + if !user.CheckPassword(password) { + user.FailedLoginAttempts++ + if user.FailedLoginAttempts >= 5 { + lockTime := time.Now().Add(15 * time.Minute) + user.LockedUntil = &lockTime + } + s.db.Save(&user) + return "", errors.New("invalid credentials") + } + + // Reset failed attempts + user.FailedLoginAttempts = 0 + user.LockedUntil = nil + now := time.Now() + user.LastLogin = &now + s.db.Save(&user) + + return s.GenerateToken(&user) +} + +func (s *AuthService) GenerateToken(user *models.User) (string, error) { + expirationTime := time.Now().Add(24 * time.Hour) + claims := &Claims{ + UserID: user.ID, + Role: user.Role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationTime), + Issuer: "cpmp", + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(s.config.JWTSecret)) +} + +func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) { + claims := &Claims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + return []byte(s.config.JWTSecret), nil + }) + + if err != nil { + return nil, err + } + + if !token.Valid { + return nil, errors.New("invalid token") + } + + return claims, nil +} diff --git a/backend/internal/services/certificate_service.go b/backend/internal/services/certificate_service.go new file mode 100644 index 00000000..bee1efb6 --- /dev/null +++ b/backend/internal/services/certificate_service.go @@ -0,0 +1,105 @@ +package services + +import ( + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// CertificateInfo represents parsed certificate details. +type CertificateInfo struct { + Domain string `json:"domain"` + Issuer string `json:"issuer"` + ExpiresAt time.Time `json:"expires_at"` + Status string `json:"status"` // "valid", "expiring", "expired" +} + +// CertificateService manages certificate retrieval and parsing. +type CertificateService struct { + dataDir string +} + +// NewCertificateService creates a new certificate service. +func NewCertificateService(dataDir string) *CertificateService { + return &CertificateService{ + dataDir: dataDir, + } +} + +// ListCertificates scans the Caddy data directory for certificates. +// It looks in certificates/acme-v02.api.letsencrypt.org-directory/ and others. +func (s *CertificateService) ListCertificates() ([]CertificateInfo, error) { + certs := []CertificateInfo{} + certRoot := filepath.Join(s.dataDir, "certificates") + + // Walk through the certificate directory + err := filepath.Walk(certRoot, func(path string, info os.FileInfo, err error) error { + if err != nil { + // If directory doesn't exist yet (fresh install), just return empty + if os.IsNotExist(err) { + return nil + } + return err + } + + // We only care about .crt files + if !info.IsDir() && strings.HasSuffix(info.Name(), ".crt") { + cert, err := s.parseCertificate(path) + if err != nil { + // Log error but continue scanning other certs + fmt.Printf("failed to parse cert %s: %v\n", path, err) + return nil + } + certs = append(certs, *cert) + } + return nil + }) + + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("walk certificates: %w", err) + } + + return certs, nil +} + +func (s *CertificateService) parseCertificate(path string) (*CertificateInfo, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read file: %w", err) + } + + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM block") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse certificate: %w", err) + } + + status := "valid" + now := time.Now() + if now.After(cert.NotAfter) { + status = "expired" + } else if now.Add(30 * 24 * time.Hour).After(cert.NotAfter) { + status = "expiring" + } + + // Domain is usually the CommonName or the first SAN + domain := cert.Subject.CommonName + if domain == "" && len(cert.DNSNames) > 0 { + domain = cert.DNSNames[0] + } + + return &CertificateInfo{ + Domain: domain, + Issuer: cert.Issuer.CommonName, + ExpiresAt: cert.NotAfter, + Status: status, + }, nil +} diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 00000000..3b60453c --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,40 @@ +version: '3.9' + +services: + app: + image: cpmp:local + container_name: caddyproxymanagerplus-local + restart: unless-stopped + ports: + - "80:80" # HTTP (Caddy proxy) + - "443:443" # HTTPS (Caddy proxy) + - "443:443/udp" # HTTP/3 (Caddy proxy) + - "8080:8080" # Management UI (CPM+) + environment: + - CPM_ENV=production + - CPM_HTTP_PORT=8080 + - CPM_DB_PATH=/app/data/cpm.db + - CPM_FRONTEND_DIR=/app/frontend/dist + - CPM_CADDY_ADMIN_API=http://localhost:2019 + - CPM_CADDY_CONFIG_DIR=/app/data/caddy + - CPM_CADDY_BINARY=caddy + - CPM_IMPORT_CADDYFILE=/import/Caddyfile + - CPM_IMPORT_DIR=/app/data/imports + volumes: + - cpm_data_local:/app/data + - caddy_data_local:/data + - caddy_config_local:/config + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/v1/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +volumes: + cpm_data_local: + driver: local + caddy_data_local: + driver: local + caddy_config_local: + driver: local diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index f8bcc6f5..33f144b2 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -17,7 +17,7 @@ echo "Caddy started (PID: $CADDY_PID)" echo "Waiting for Caddy admin API..." i=1 while [ "$i" -le 30 ]; do - if wget -q -O- http://localhost:2019/config/ > /dev/null 2>&1; then + if wget -q -O- http://127.0.0.1:2019/config/ > /dev/null 2>&1; then echo "Caddy is ready!" break fi diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 92c25eec..a5507bec 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -5,8 +5,8 @@ Caddy Proxy Manager+ - - + +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index abc79e5e..00030368 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@tanstack/react-query": "^5.62.8", "axios": "^1.7.9", "clsx": "^2.1.1", + "lucide-react": "^0.554.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.30.2" @@ -4042,6 +4043,14 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.554.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.554.0.tgz", + "integrity": "sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 309baf88..1387f2d3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "@tanstack/react-query": "^5.62.8", "axios": "^1.7.9", "clsx": "^2.1.1", + "lucide-react": "^0.554.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.30.2" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 543c3c04..07df3a88 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,27 +1,44 @@ import { BrowserRouter as Router, Routes, Route, Outlet } from 'react-router-dom' import Layout from './components/Layout' import { ToastContainer } from './components/Toast' +import { SetupGuard } from './components/SetupGuard' +import RequireAuth from './components/RequireAuth' +import { AuthProvider } from './context/AuthContext' import Dashboard from './pages/Dashboard' import ProxyHosts from './pages/ProxyHosts' import RemoteServers from './pages/RemoteServers' import ImportCaddy from './pages/ImportCaddy' +import Certificates from './pages/Certificates' import Settings from './pages/Settings' import Login from './pages/Login' +import Setup from './pages/Setup' export default function App() { return ( - - - } /> - }> - } /> - } /> - } /> - } /> - } /> - - - - + + + + } /> + } /> + + + + + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ) } diff --git a/frontend/src/api/certificates.ts b/frontend/src/api/certificates.ts new file mode 100644 index 00000000..cd51ddd9 --- /dev/null +++ b/frontend/src/api/certificates.ts @@ -0,0 +1,13 @@ +import client from './client' + +export interface Certificate { + domain: string + issuer: string + expires_at: string + status: 'valid' | 'expiring' | 'expired' +} + +export async function getCertificates(): Promise { + const response = await client.get('/certificates') + return response.data +} diff --git a/frontend/src/api/setup.ts b/frontend/src/api/setup.ts new file mode 100644 index 00000000..eb6b86e8 --- /dev/null +++ b/frontend/src/api/setup.ts @@ -0,0 +1,20 @@ +import client from './client'; + +export interface SetupStatus { + setupRequired: boolean; +} + +export interface SetupRequest { + name: string; + email: string; + password: string; +} + +export const getSetupStatus = async (): Promise => { + const response = await client.get('/setup'); + return response.data; +}; + +export const performSetup = async (data: SetupRequest): Promise => { + await client.post('/setup', data); +}; diff --git a/frontend/src/components/CertificateList.tsx b/frontend/src/components/CertificateList.tsx new file mode 100644 index 00000000..529d5c25 --- /dev/null +++ b/frontend/src/components/CertificateList.tsx @@ -0,0 +1,71 @@ +import { useCertificates } from '../hooks/useCertificates' +import { LoadingSpinner } from './LoadingStates' + +export default function CertificateList() { + const { certificates, isLoading, error } = useCertificates() + + if (isLoading) return + if (error) return
Failed to load certificates
+ + return ( +
+
+ + + + + + + + + + + {certificates.length === 0 ? ( + + + + ) : ( + certificates.map((cert) => ( + + + + + + + )) + )} + +
DomainIssuerExpiresStatus
+ No certificates found. +
{cert.domain}{cert.issuer} + {new Date(cert.expires_at).toLocaleDateString()} + + +
+
+
+ ) +} + +function StatusBadge({ status }: { status: string }) { + const styles = { + valid: 'bg-green-900/30 text-green-400 border-green-800', + expiring: 'bg-yellow-900/30 text-yellow-400 border-yellow-800', + expired: 'bg-red-900/30 text-red-400 border-red-800', + } + + const labels = { + valid: 'Valid', + expiring: 'Expiring Soon', + expired: 'Expired', + } + + const style = styles[status as keyof typeof styles] || styles.valid + const label = labels[status as keyof typeof labels] || status + + return ( + + {label} + + ) +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index f23d7ea1..fc9e880d 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -15,6 +15,7 @@ export default function Layout({ children }: LayoutProps) { { name: 'Dashboard', path: '/', icon: '📊' }, { name: 'Proxy Hosts', path: '/proxy-hosts', icon: '🌐' }, { name: 'Remote Servers', path: '/remote-servers', icon: '🖥️' }, + { name: 'Certificates', path: '/certificates', icon: '🔒' }, { name: 'Import Caddyfile', path: '/import', icon: '📥' }, { name: 'Settings', path: '/settings', icon: '⚙️' }, ] diff --git a/frontend/src/components/RequireAuth.tsx b/frontend/src/components/RequireAuth.tsx new file mode 100644 index 00000000..61a26aee --- /dev/null +++ b/frontend/src/components/RequireAuth.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +const RequireAuth: React.FC<{ children: JSX.Element }> = ({ children }) => { + const { isAuthenticated, isLoading } = useAuth(); + const location = useLocation(); + + if (isLoading) { + return
Loading...
; // Or a spinner + } + + if (!isAuthenticated) { + return ; + } + + return children; +}; + +export default RequireAuth; diff --git a/frontend/src/components/SetupGuard.tsx b/frontend/src/components/SetupGuard.tsx new file mode 100644 index 00000000..625c2f55 --- /dev/null +++ b/frontend/src/components/SetupGuard.tsx @@ -0,0 +1,38 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { getSetupStatus } from '../api/setup'; + +interface SetupGuardProps { + children: React.ReactNode; +} + +export const SetupGuard: React.FC = ({ children }) => { + const navigate = useNavigate(); + + const { data: status, isLoading } = useQuery({ + queryKey: ['setupStatus'], + queryFn: getSetupStatus, + retry: false, + }); + + useEffect(() => { + if (status?.setupRequired) { + navigate('/setup'); + } + }, [status, navigate]); + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (status?.setupRequired) { + return null; // Will redirect + } + + return <>{children}; +}; diff --git a/frontend/src/components/__tests__/Layout.test.tsx b/frontend/src/components/__tests__/Layout.test.tsx index c3a06213..88020954 100644 --- a/frontend/src/components/__tests__/Layout.test.tsx +++ b/frontend/src/components/__tests__/Layout.test.tsx @@ -36,6 +36,7 @@ describe('Layout', () => { expect(screen.getByText('Dashboard')).toBeInTheDocument() expect(screen.getByText('Proxy Hosts')).toBeInTheDocument() expect(screen.getByText('Remote Servers')).toBeInTheDocument() + expect(screen.getByText('Certificates')).toBeInTheDocument() expect(screen.getByText('Import Caddyfile')).toBeInTheDocument() expect(screen.getByText('Settings')).toBeInTheDocument() }) diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 00000000..0f30588a --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -0,0 +1,71 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import client from '../api/client'; +import { AxiosResponse } from 'axios'; + +interface User { + user_id: number; + role: string; +} + +interface AuthContextType { + user: User | null; + login: () => void; + logout: () => void; + isAuthenticated: boolean; + isLoading: boolean; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const checkAuth = async () => { + try { + const response = await client.get('/auth/me'); + setUser(response.data); + } catch (error) { + setUser(null); + } finally { + setIsLoading(false); + } + }; + + checkAuth(); + }, []); + + const login = () => { + // Token is stored in cookie by backend, but we might want to store it in memory or trigger a re-fetch + // Actually, if backend sets cookie, we just need to fetch /auth/me + client.get('/auth/me').then((response: AxiosResponse) => { + setUser(response.data); + }).catch(() => { + setUser(null); + }); + }; + + const logout = async () => { + try { + await client.post('/auth/logout'); + } catch (error) { + console.error("Logout failed", error); + } + setUser(null); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/frontend/src/hooks/useCertificates.ts b/frontend/src/hooks/useCertificates.ts new file mode 100644 index 00000000..a669ad88 --- /dev/null +++ b/frontend/src/hooks/useCertificates.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query' +import { getCertificates } from '../api/certificates' + +export function useCertificates() { + const { data: certificates = [], isLoading, error } = useQuery({ + queryKey: ['certificates'], + queryFn: getCertificates, + }) + + return { + certificates, + isLoading, + error, + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 8c0e5223..d8028105 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,13 +1,18 @@ import React from 'react' import ReactDOM from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import App from './App.tsx' import { ThemeProvider } from './context/ThemeContext' import './index.css' +const queryClient = new QueryClient() + ReactDOM.createRoot(document.getElementById('root')!).render( - - - + + + + + , ) diff --git a/frontend/src/pages/Certificates.tsx b/frontend/src/pages/Certificates.tsx new file mode 100644 index 00000000..e5038619 --- /dev/null +++ b/frontend/src/pages/Certificates.tsx @@ -0,0 +1,18 @@ +import CertificateList from '../components/CertificateList' + +export default function Certificates() { + return ( +
+
+
+

Certificates

+

+ View and manage SSL certificates automatically acquired by Caddy. +

+
+
+ + +
+ ) +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index b2dcd2ce..976eac2a 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -4,27 +4,30 @@ import { Card } from '../components/ui/Card' import { Input } from '../components/ui/Input' import { Button } from '../components/ui/Button' import { toast } from '../components/Toast' +import client from '../api/client' +import { useAuth } from '../context/AuthContext' export default function Login() { const navigate = useNavigate() const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [loading, setLoading] = useState(false) + const { login } = useAuth() const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setLoading(true) - // Mock login delay - await new Promise(resolve => setTimeout(resolve, 1000)) - - if (email === 'admin@example.com' && password === 'changeme') { + try { + await client.post('/auth/login', { email, password }) + login() toast.success('Logged in successfully') navigate('/') - } else { - toast.error('Invalid credentials') + } catch (err: any) { + toast.error(err.response?.data?.error || 'Login failed') + } finally { + setLoading(false) } - setLoading(false) } return ( diff --git a/frontend/src/pages/Setup.tsx b/frontend/src/pages/Setup.tsx new file mode 100644 index 00000000..e47a57d3 --- /dev/null +++ b/frontend/src/pages/Setup.tsx @@ -0,0 +1,144 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { getSetupStatus, performSetup, SetupRequest } from '../api/setup'; + +const Setup: React.FC = () => { + const navigate = useNavigate(); + const [formData, setFormData] = useState({ + name: '', + email: '', + password: '', + }); + const [error, setError] = useState(null); + + const { data: status, isLoading: statusLoading } = useQuery({ + queryKey: ['setupStatus'], + queryFn: getSetupStatus, + retry: false, + }); + + useEffect(() => { + if (status && !status.setupRequired) { + navigate('/login'); + } + }, [status, navigate]); + + const mutation = useMutation({ + mutationFn: performSetup, + onSuccess: () => { + navigate('/login'); + }, + onError: (err: any) => { + setError(err.response?.data?.error || 'Setup failed'); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + mutation.mutate(formData); + }; + + if (statusLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (status && !status.setupRequired) { + return null; // Will redirect in useEffect + } + + return ( +
+
+
+

+ Welcome to CPM+ +

+

+ Create your administrator account to get started. +

+
+
+
+
+ + setFormData({ ...formData, name: e.target.value })} + /> +
+
+ + setFormData({ ...formData, email: e.target.value })} + /> +

+ This email will be used for Let's Encrypt certificate notifications and recovery. +

+
+
+ + setFormData({ ...formData, password: e.target.value })} + /> +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ +
+
+
+
+ ); +}; + +export default Setup; diff --git a/frontend/src/pages/__tests__/Setup.test.tsx b/frontend/src/pages/__tests__/Setup.test.tsx new file mode 100644 index 00000000..878ec603 --- /dev/null +++ b/frontend/src/pages/__tests__/Setup.test.tsx @@ -0,0 +1,61 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import Setup from '../Setup'; +import * as setupApi from '../../api/setup'; + +// Mock the API module +vi.mock('../../api/setup', () => ({ + getSetupStatus: vi.fn(), + performSetup: vi.fn(), +})); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const renderWithProviders = (ui: React.ReactNode) => { + return render( + + + {ui} + + + ); +}; + +describe('Setup Page', () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient.clear(); + }); + + it('renders setup form when setup is required', async () => { + vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: true }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('Welcome to CPM+')).toBeTruthy(); + }); + + expect(screen.getByLabelText('Name')).toBeTruthy(); + expect(screen.getByLabelText('Email Address')).toBeTruthy(); + expect(screen.getByLabelText('Password')).toBeTruthy(); + }); + + it('does not render form when setup is not required', async () => { + vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: false }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.queryByText('Welcome to CPM+')).toBeNull(); + }); + }); +});