Merge pull request #120 from Wikid82/feature/Automatic-HTTPS-&-Certificate-Management
feat: Implement User Authentication & Fix Frontend Startup
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
73
backend/internal/api/handlers/auth_handler.go
Normal file
73
backend/internal/api/handlers/auth_handler.go
Normal file
@@ -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})
|
||||
}
|
||||
27
backend/internal/api/handlers/certificate_handler.go
Normal file
27
backend/internal/api/handlers/certificate_handler.go
Normal file
@@ -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)
|
||||
}
|
||||
113
backend/internal/api/handlers/user_handler.go
Normal file
113
backend/internal/api/handlers/user_handler.go
Normal file
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
55
backend/internal/api/middleware/auth.go
Normal file
55
backend/internal/api/middleware/auth.go
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
122
backend/internal/services/auth_service.go
Normal file
122
backend/internal/services/auth_service.go
Normal file
@@ -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
|
||||
}
|
||||
105
backend/internal/services/certificate_service.go
Normal file
105
backend/internal/services/certificate_service.go
Normal file
@@ -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
|
||||
}
|
||||
40
docker-compose.local.yml
Normal file
40
docker-compose.local.yml
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
4
frontend/dist/index.html
vendored
4
frontend/dist/index.html
vendored
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Caddy Proxy Manager+</title>
|
||||
<script type="module" crossorigin src="/assets/index-BQ-TMhGu.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-ChYJmfs0.css">
|
||||
<script type="module" crossorigin src="/assets/index-8SDdHIgk.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-uCmiid6Y.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
9
frontend/package-lock.json
generated
9
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<Layout><Outlet /></Layout>}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="proxy-hosts" element={<ProxyHosts />} />
|
||||
<Route path="remote-servers" element={<RemoteServers />} />
|
||||
<Route path="import" element={<ImportCaddy />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<ToastContainer />
|
||||
</Router>
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/setup" element={<Setup />} />
|
||||
<Route path="/" element={
|
||||
<SetupGuard>
|
||||
<RequireAuth>
|
||||
<Layout>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
</RequireAuth>
|
||||
</SetupGuard>
|
||||
}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="proxy-hosts" element={<ProxyHosts />} />
|
||||
<Route path="remote-servers" element={<RemoteServers />} />
|
||||
<Route path="certificates" element={<Certificates />} />
|
||||
<Route path="import" element={<ImportCaddy />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<ToastContainer />
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
13
frontend/src/api/certificates.ts
Normal file
13
frontend/src/api/certificates.ts
Normal file
@@ -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<Certificate[]> {
|
||||
const response = await client.get<Certificate[]>('/certificates')
|
||||
return response.data
|
||||
}
|
||||
20
frontend/src/api/setup.ts
Normal file
20
frontend/src/api/setup.ts
Normal file
@@ -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<SetupStatus> => {
|
||||
const response = await client.get<SetupStatus>('/setup');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const performSetup = async (data: SetupRequest): Promise<void> => {
|
||||
await client.post('/setup', data);
|
||||
};
|
||||
71
frontend/src/components/CertificateList.tsx
Normal file
71
frontend/src/components/CertificateList.tsx
Normal file
@@ -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 <LoadingSpinner />
|
||||
if (error) return <div className="text-red-500">Failed to load certificates</div>
|
||||
|
||||
return (
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm text-gray-400">
|
||||
<thead className="bg-gray-900 text-gray-200 uppercase font-medium">
|
||||
<tr>
|
||||
<th className="px-6 py-3">Domain</th>
|
||||
<th className="px-6 py-3">Issuer</th>
|
||||
<th className="px-6 py-3">Expires</th>
|
||||
<th className="px-6 py-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{certificates.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-8 text-center text-gray-500">
|
||||
No certificates found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
certificates.map((cert) => (
|
||||
<tr key={cert.domain} className="hover:bg-gray-800/50 transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-white">{cert.domain}</td>
|
||||
<td className="px-6 py-4">{cert.issuer}</td>
|
||||
<td className="px-6 py-4">
|
||||
{new Date(cert.expires_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<StatusBadge status={cert.status} />
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${style}`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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: '⚙️' },
|
||||
]
|
||||
|
||||
20
frontend/src/components/RequireAuth.tsx
Normal file
20
frontend/src/components/RequireAuth.tsx
Normal file
@@ -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 <div>Loading...</div>; // Or a spinner
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default RequireAuth;
|
||||
38
frontend/src/components/SetupGuard.tsx
Normal file
38
frontend/src/components/SetupGuard.tsx
Normal file
@@ -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<SetupGuardProps> = ({ 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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-blue-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status?.setupRequired) {
|
||||
return null; // Will redirect
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
71
frontend/src/context/AuthContext.tsx
Normal file
71
frontend/src/context/AuthContext.tsx
Normal file
@@ -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<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(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<User>) => {
|
||||
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 (
|
||||
<AuthContext.Provider value={{ user, login, logout, isAuthenticated: !!user, isLoading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
15
frontend/src/hooks/useCertificates.ts
Normal file
15
frontend/src/hooks/useCertificates.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
18
frontend/src/pages/Certificates.tsx
Normal file
18
frontend/src/pages/Certificates.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import CertificateList from '../components/CertificateList'
|
||||
|
||||
export default function Certificates() {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Certificates</h1>
|
||||
<p className="text-gray-400">
|
||||
View and manage SSL certificates automatically acquired by Caddy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CertificateList />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
144
frontend/src/pages/Setup.tsx
Normal file
144
frontend/src/pages/Setup.tsx
Normal file
@@ -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<SetupRequest>({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-blue-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status && !status.setupRequired) {
|
||||
return null; // Will redirect in useEffect
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8 bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Welcome to CPM+
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Create your administrator account to get started.
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 text-gray-900 dark:text-white dark:bg-gray-700 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Admin User"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 text-gray-900 dark:text-white dark:bg-gray-700 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="admin@example.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
This email will be used for Let's Encrypt certificate notifications and recovery.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 text-gray-900 dark:text-white dark:bg-gray-700 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="********"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-500 text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
'Loading...'
|
||||
) : (
|
||||
'Create Admin Account'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Setup;
|
||||
61
frontend/src/pages/__tests__/Setup.test.tsx
Normal file
61
frontend/src/pages/__tests__/Setup.test.tsx
Normal file
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
{ui}
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
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(<Setup />);
|
||||
|
||||
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(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Welcome to CPM+')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user