diff --git a/backend/internal/api/handlers/auth_handler.go b/backend/internal/api/handlers/auth_handler.go
index 9f9f4c0e..19727cda 100644
--- a/backend/internal/api/handlers/auth_handler.go
+++ b/backend/internal/api/handlers/auth_handler.go
@@ -2,19 +2,64 @@ package handlers
import (
"net/http"
+ "os"
+ "strconv"
+ "strings"
+ "github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
+ "gorm.io/gorm"
)
type AuthHandler struct {
authService *services.AuthService
+ db *gorm.DB
}
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
return &AuthHandler{authService: authService}
}
+// NewAuthHandlerWithDB creates an AuthHandler with database access for forward auth.
+func NewAuthHandlerWithDB(authService *services.AuthService, db *gorm.DB) *AuthHandler {
+ return &AuthHandler{authService: authService, db: db}
+}
+
+// isProduction checks if we're running in production mode
+func isProduction() bool {
+ env := os.Getenv("CHARON_ENV")
+ return env == "production" || env == "prod"
+}
+
+// setSecureCookie sets an auth cookie with security best practices
+// - HttpOnly: prevents JavaScript access (XSS protection)
+// - Secure: only sent over HTTPS (in production)
+// - SameSite=Strict: prevents CSRF attacks
+func setSecureCookie(c *gin.Context, name, value string, maxAge int) {
+ secure := isProduction()
+ sameSite := http.SameSiteStrictMode
+
+ // Use the host without port for domain
+ domain := ""
+
+ c.SetSameSite(sameSite)
+ c.SetCookie(
+ name, // name
+ value, // value
+ maxAge, // maxAge in seconds
+ "/", // path
+ domain, // domain (empty = current host)
+ secure, // secure (HTTPS only in production)
+ true, // httpOnly (no JS access)
+ )
+}
+
+// clearSecureCookie removes a cookie with the same security settings
+func clearSecureCookie(c *gin.Context, name string) {
+ setSecureCookie(c, name, "", -1)
+}
+
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
@@ -33,8 +78,8 @@ func (h *AuthHandler) Login(c *gin.Context) {
return
}
- // Set cookie
- c.SetCookie("auth_token", token, 3600*24, "/", "", false, true) // Secure should be true in prod
+ // Set secure cookie (HttpOnly, Secure in prod, SameSite=Strict)
+ setSecureCookie(c, "auth_token", token, 3600*24)
c.JSON(http.StatusOK, gin.H{"token": token})
}
@@ -62,7 +107,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
}
func (h *AuthHandler) Logout(c *gin.Context) {
- c.SetCookie("auth_token", "", -1, "/", "", false, true)
+ clearSecureCookie(c, "auth_token")
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
}
@@ -109,3 +154,225 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"})
}
+
+// Verify is the forward auth endpoint for Caddy.
+// It validates the user's session and checks access permissions for the requested host.
+// Used by Caddy's forward_auth directive.
+//
+// Expected headers from Caddy:
+// - X-Forwarded-Host: The original host being accessed
+// - X-Forwarded-Uri: The original URI being accessed
+//
+// Response headers on success (200):
+// - X-Forwarded-User: The user's email
+// - X-Forwarded-Groups: The user's role (for future RBAC)
+//
+// Response on failure:
+// - 401: Not authenticated (redirect to login)
+// - 403: Authenticated but not authorized for this host
+func (h *AuthHandler) Verify(c *gin.Context) {
+ // Extract token from cookie or Authorization header
+ var tokenString string
+
+ // Try cookie first (most common for browser requests)
+ if cookie, err := c.Cookie("auth_token"); err == nil && cookie != "" {
+ tokenString = cookie
+ }
+
+ // Fall back to Authorization header
+ if tokenString == "" {
+ authHeader := c.GetHeader("Authorization")
+ if strings.HasPrefix(authHeader, "Bearer ") {
+ tokenString = strings.TrimPrefix(authHeader, "Bearer ")
+ }
+ }
+
+ // No token found - not authenticated
+ if tokenString == "" {
+ c.Header("X-Auth-Redirect", "/login")
+ c.AbortWithStatus(http.StatusUnauthorized)
+ return
+ }
+
+ // Validate token
+ claims, err := h.authService.ValidateToken(tokenString)
+ if err != nil {
+ c.Header("X-Auth-Redirect", "/login")
+ c.AbortWithStatus(http.StatusUnauthorized)
+ return
+ }
+
+ // Get user details
+ user, err := h.authService.GetUserByID(claims.UserID)
+ if err != nil || !user.Enabled {
+ c.Header("X-Auth-Redirect", "/login")
+ c.AbortWithStatus(http.StatusUnauthorized)
+ return
+ }
+
+ // Get the forwarded host from Caddy
+ forwardedHost := c.GetHeader("X-Forwarded-Host")
+ if forwardedHost == "" {
+ forwardedHost = c.GetHeader("X-Original-Host")
+ }
+
+ // If we have a database reference and a forwarded host, check permissions
+ if h.db != nil && forwardedHost != "" {
+ // Find the proxy host for this domain
+ var proxyHost models.ProxyHost
+ err := h.db.Where("domain_names LIKE ?", "%"+forwardedHost+"%").First(&proxyHost).Error
+
+ if err == nil && proxyHost.ForwardAuthEnabled {
+ // Load user's permitted hosts for permission check
+ var userWithHosts models.User
+ if err := h.db.Preload("PermittedHosts").First(&userWithHosts, user.ID).Error; err == nil {
+ // Check if user can access this host
+ if !userWithHosts.CanAccessHost(proxyHost.ID) {
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
+ "error": "Access denied to this application",
+ })
+ return
+ }
+ }
+ }
+ }
+
+ // Set headers for downstream services
+ c.Header("X-Forwarded-User", user.Email)
+ c.Header("X-Forwarded-Groups", user.Role)
+ c.Header("X-Forwarded-Name", user.Name)
+
+ // Return 200 OK - access granted
+ c.Status(http.StatusOK)
+}
+
+// VerifyStatus returns the current auth status without triggering a redirect.
+// Useful for frontend to check if user is logged in.
+func (h *AuthHandler) VerifyStatus(c *gin.Context) {
+ // Extract token
+ var tokenString string
+
+ if cookie, err := c.Cookie("auth_token"); err == nil && cookie != "" {
+ tokenString = cookie
+ }
+
+ if tokenString == "" {
+ authHeader := c.GetHeader("Authorization")
+ if strings.HasPrefix(authHeader, "Bearer ") {
+ tokenString = strings.TrimPrefix(authHeader, "Bearer ")
+ }
+ }
+
+ if tokenString == "" {
+ c.JSON(http.StatusOK, gin.H{
+ "authenticated": false,
+ })
+ return
+ }
+
+ claims, err := h.authService.ValidateToken(tokenString)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "authenticated": false,
+ })
+ return
+ }
+
+ user, err := h.authService.GetUserByID(claims.UserID)
+ if err != nil || !user.Enabled {
+ c.JSON(http.StatusOK, gin.H{
+ "authenticated": false,
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "authenticated": true,
+ "user": gin.H{
+ "id": user.ID,
+ "email": user.Email,
+ "name": user.Name,
+ "role": user.Role,
+ },
+ })
+}
+
+// GetAccessibleHosts returns the list of proxy hosts the authenticated user can access.
+func (h *AuthHandler) GetAccessibleHosts(c *gin.Context) {
+ userID, exists := c.Get("userID")
+ if !exists {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ if h.db == nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Database not available"})
+ return
+ }
+
+ // Load user with permitted hosts
+ var user models.User
+ if err := h.db.Preload("PermittedHosts").First(&user, userID).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
+ return
+ }
+
+ // Get all enabled proxy hosts
+ var allHosts []models.ProxyHost
+ if err := h.db.Where("enabled = ?", true).Find(&allHosts).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch hosts"})
+ return
+ }
+
+ // Filter to accessible hosts
+ accessibleHosts := make([]gin.H, 0)
+ for _, host := range allHosts {
+ if user.CanAccessHost(host.ID) {
+ accessibleHosts = append(accessibleHosts, gin.H{
+ "id": host.ID,
+ "name": host.Name,
+ "domain_names": host.DomainNames,
+ })
+ }
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "hosts": accessibleHosts,
+ "permission_mode": user.PermissionMode,
+ })
+}
+
+// CheckHostAccess checks if the current user can access a specific host.
+func (h *AuthHandler) CheckHostAccess(c *gin.Context) {
+ userID, exists := c.Get("userID")
+ if !exists {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ hostIDStr := c.Param("hostId")
+ hostID, err := strconv.ParseUint(hostIDStr, 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid host ID"})
+ return
+ }
+
+ if h.db == nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Database not available"})
+ return
+ }
+
+ // Load user with permitted hosts
+ var user models.User
+ if err := h.db.Preload("PermittedHosts").First(&user, userID).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
+ return
+ }
+
+ canAccess := user.CanAccessHost(uint(hostID))
+
+ c.JSON(http.StatusOK, gin.H{
+ "host_id": hostID,
+ "can_access": canAccess,
+ })
+}
diff --git a/backend/internal/api/handlers/auth_handler_test.go b/backend/internal/api/handlers/auth_handler_test.go
index 32100162..878821ba 100644
--- a/backend/internal/api/handlers/auth_handler_test.go
+++ b/backend/internal/api/handlers/auth_handler_test.go
@@ -293,3 +293,515 @@ func TestAuthHandler_ChangePassword_Errors(t *testing.T) {
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
+
+// setupAuthHandlerWithDB creates an AuthHandler with DB access for forward auth tests
+func setupAuthHandlerWithDB(t *testing.T) (*AuthHandler, *gorm.DB) {
+ dbName := "file:" + t.Name() + "?mode=memory&cache=shared"
+ db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
+ require.NoError(t, err)
+ db.AutoMigrate(&models.User{}, &models.Setting{}, &models.ProxyHost{})
+
+ cfg := config.Config{JWTSecret: "test-secret"}
+ authService := services.NewAuthService(db, cfg)
+ return NewAuthHandlerWithDB(authService, db), db
+}
+
+func TestNewAuthHandlerWithDB(t *testing.T) {
+ handler, db := setupAuthHandlerWithDB(t)
+ assert.NotNil(t, handler)
+ assert.NotNil(t, handler.db)
+ assert.NotNil(t, db)
+}
+
+func TestAuthHandler_Verify_NoCookie(t *testing.T) {
+ handler, _ := setupAuthHandlerWithDB(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.GET("/verify", handler.Verify)
+
+ req := httptest.NewRequest("GET", "/verify", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusUnauthorized, w.Code)
+ assert.Equal(t, "/login", w.Header().Get("X-Auth-Redirect"))
+}
+
+func TestAuthHandler_Verify_InvalidToken(t *testing.T) {
+ handler, _ := setupAuthHandlerWithDB(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.GET("/verify", handler.Verify)
+
+ req := httptest.NewRequest("GET", "/verify", nil)
+ req.AddCookie(&http.Cookie{Name: "auth_token", Value: "invalid-token"})
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusUnauthorized, w.Code)
+}
+
+func TestAuthHandler_Verify_ValidToken(t *testing.T) {
+ handler, db := setupAuthHandlerWithDB(t)
+
+ // Create user
+ user := &models.User{
+ UUID: uuid.NewString(),
+ Email: "test@example.com",
+ Name: "Test User",
+ Role: "user",
+ Enabled: true,
+ }
+ user.SetPassword("password123")
+ db.Create(user)
+
+ // Generate token
+ token, _ := handler.authService.GenerateToken(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.GET("/verify", handler.Verify)
+
+ req := httptest.NewRequest("GET", "/verify", nil)
+ req.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Equal(t, "test@example.com", w.Header().Get("X-Forwarded-User"))
+ assert.Equal(t, "user", w.Header().Get("X-Forwarded-Groups"))
+}
+
+func TestAuthHandler_Verify_BearerToken(t *testing.T) {
+ handler, db := setupAuthHandlerWithDB(t)
+
+ user := &models.User{
+ UUID: uuid.NewString(),
+ Email: "bearer@example.com",
+ Name: "Bearer User",
+ Role: "admin",
+ Enabled: true,
+ }
+ user.SetPassword("password123")
+ db.Create(user)
+
+ token, _ := handler.authService.GenerateToken(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.GET("/verify", handler.Verify)
+
+ req := httptest.NewRequest("GET", "/verify", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Equal(t, "bearer@example.com", w.Header().Get("X-Forwarded-User"))
+}
+
+func TestAuthHandler_Verify_DisabledUser(t *testing.T) {
+ handler, db := setupAuthHandlerWithDB(t)
+
+ user := &models.User{
+ UUID: uuid.NewString(),
+ Email: "disabled@example.com",
+ Name: "Disabled User",
+ Role: "user",
+ }
+ user.SetPassword("password123")
+ db.Create(user)
+ // Explicitly disable after creation to bypass GORM's default:true behavior
+ db.Model(user).Update("enabled", false)
+
+ token, _ := handler.authService.GenerateToken(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.GET("/verify", handler.Verify)
+
+ req := httptest.NewRequest("GET", "/verify", nil)
+ req.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusUnauthorized, w.Code)
+}
+
+func TestAuthHandler_Verify_ForwardAuthDenied(t *testing.T) {
+ handler, db := setupAuthHandlerWithDB(t)
+
+ // Create proxy host with forward auth enabled
+ proxyHost := &models.ProxyHost{
+ UUID: uuid.NewString(),
+ Name: "Protected App",
+ DomainNames: "app.example.com",
+ ForwardAuthEnabled: true,
+ Enabled: true,
+ }
+ db.Create(proxyHost)
+
+ // Create user with deny_all permission
+ user := &models.User{
+ UUID: uuid.NewString(),
+ Email: "denied@example.com",
+ Name: "Denied User",
+ Role: "user",
+ Enabled: true,
+ PermissionMode: models.PermissionModeDenyAll,
+ }
+ user.SetPassword("password123")
+ db.Create(user)
+
+ token, _ := handler.authService.GenerateToken(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.GET("/verify", handler.Verify)
+
+ req := httptest.NewRequest("GET", "/verify", nil)
+ req.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
+ req.Header.Set("X-Forwarded-Host", "app.example.com")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+}
+
+func TestAuthHandler_VerifyStatus_NotAuthenticated(t *testing.T) {
+ handler, _ := setupAuthHandlerWithDB(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.GET("/status", handler.VerifyStatus)
+
+ req := httptest.NewRequest("GET", "/status", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ var resp map[string]interface{}
+ json.Unmarshal(w.Body.Bytes(), &resp)
+ assert.Equal(t, false, resp["authenticated"])
+}
+
+func TestAuthHandler_VerifyStatus_InvalidToken(t *testing.T) {
+ handler, _ := setupAuthHandlerWithDB(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.GET("/status", handler.VerifyStatus)
+
+ req := httptest.NewRequest("GET", "/status", nil)
+ req.AddCookie(&http.Cookie{Name: "auth_token", Value: "invalid"})
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ var resp map[string]interface{}
+ json.Unmarshal(w.Body.Bytes(), &resp)
+ assert.Equal(t, false, resp["authenticated"])
+}
+
+func TestAuthHandler_VerifyStatus_Authenticated(t *testing.T) {
+ handler, db := setupAuthHandlerWithDB(t)
+
+ user := &models.User{
+ UUID: uuid.NewString(),
+ Email: "status@example.com",
+ Name: "Status User",
+ Role: "user",
+ Enabled: true,
+ }
+ user.SetPassword("password123")
+ db.Create(user)
+
+ token, _ := handler.authService.GenerateToken(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.GET("/status", handler.VerifyStatus)
+
+ req := httptest.NewRequest("GET", "/status", nil)
+ req.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ var resp map[string]interface{}
+ json.Unmarshal(w.Body.Bytes(), &resp)
+ assert.Equal(t, true, resp["authenticated"])
+ userObj := resp["user"].(map[string]interface{})
+ assert.Equal(t, "status@example.com", userObj["email"])
+}
+
+func TestAuthHandler_VerifyStatus_DisabledUser(t *testing.T) {
+ handler, db := setupAuthHandlerWithDB(t)
+
+ user := &models.User{
+ UUID: uuid.NewString(),
+ Email: "disabled2@example.com",
+ Name: "Disabled User 2",
+ Role: "user",
+ }
+ user.SetPassword("password123")
+ db.Create(user)
+ // Explicitly disable after creation to bypass GORM's default:true behavior
+ db.Model(user).Update("enabled", false)
+
+ token, _ := handler.authService.GenerateToken(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.GET("/status", handler.VerifyStatus)
+
+ req := httptest.NewRequest("GET", "/status", nil)
+ req.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ var resp map[string]interface{}
+ json.Unmarshal(w.Body.Bytes(), &resp)
+ assert.Equal(t, false, resp["authenticated"])
+}
+
+func TestAuthHandler_GetAccessibleHosts_Unauthorized(t *testing.T) {
+ handler, _ := setupAuthHandlerWithDB(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.GET("/hosts", handler.GetAccessibleHosts)
+
+ req := httptest.NewRequest("GET", "/hosts", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusUnauthorized, w.Code)
+}
+
+func TestAuthHandler_GetAccessibleHosts_AllowAll(t *testing.T) {
+ handler, db := setupAuthHandlerWithDB(t)
+
+ // Create proxy hosts
+ host1 := &models.ProxyHost{UUID: uuid.NewString(), Name: "Host 1", DomainNames: "host1.example.com", Enabled: true}
+ host2 := &models.ProxyHost{UUID: uuid.NewString(), Name: "Host 2", DomainNames: "host2.example.com", Enabled: true}
+ db.Create(host1)
+ db.Create(host2)
+
+ user := &models.User{
+ UUID: uuid.NewString(),
+ Email: "allowall@example.com",
+ Name: "Allow All User",
+ Role: "user",
+ Enabled: true,
+ PermissionMode: models.PermissionModeAllowAll,
+ }
+ db.Create(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("userID", user.ID)
+ c.Next()
+ })
+ r.GET("/hosts", handler.GetAccessibleHosts)
+
+ req := httptest.NewRequest("GET", "/hosts", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ var resp map[string]interface{}
+ json.Unmarshal(w.Body.Bytes(), &resp)
+ hosts := resp["hosts"].([]interface{})
+ assert.Len(t, hosts, 2)
+}
+
+func TestAuthHandler_GetAccessibleHosts_DenyAll(t *testing.T) {
+ handler, db := setupAuthHandlerWithDB(t)
+
+ // Create proxy hosts
+ host1 := &models.ProxyHost{UUID: uuid.NewString(), Name: "Host 1", DomainNames: "host1.example.com", Enabled: true}
+ db.Create(host1)
+
+ user := &models.User{
+ UUID: uuid.NewString(),
+ Email: "denyall@example.com",
+ Name: "Deny All User",
+ Role: "user",
+ Enabled: true,
+ PermissionMode: models.PermissionModeDenyAll,
+ }
+ db.Create(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("userID", user.ID)
+ c.Next()
+ })
+ r.GET("/hosts", handler.GetAccessibleHosts)
+
+ req := httptest.NewRequest("GET", "/hosts", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ var resp map[string]interface{}
+ json.Unmarshal(w.Body.Bytes(), &resp)
+ hosts := resp["hosts"].([]interface{})
+ assert.Len(t, hosts, 0)
+}
+
+func TestAuthHandler_GetAccessibleHosts_PermittedHosts(t *testing.T) {
+ handler, db := setupAuthHandlerWithDB(t)
+
+ // Create proxy hosts
+ host1 := &models.ProxyHost{UUID: uuid.NewString(), Name: "Host 1", DomainNames: "host1.example.com", Enabled: true}
+ host2 := &models.ProxyHost{UUID: uuid.NewString(), Name: "Host 2", DomainNames: "host2.example.com", Enabled: true}
+ db.Create(host1)
+ db.Create(host2)
+
+ user := &models.User{
+ UUID: uuid.NewString(),
+ Email: "permitted@example.com",
+ Name: "Permitted User",
+ Role: "user",
+ Enabled: true,
+ PermissionMode: models.PermissionModeDenyAll,
+ PermittedHosts: []models.ProxyHost{*host1}, // Only host1
+ }
+ db.Create(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("userID", user.ID)
+ c.Next()
+ })
+ r.GET("/hosts", handler.GetAccessibleHosts)
+
+ req := httptest.NewRequest("GET", "/hosts", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ var resp map[string]interface{}
+ json.Unmarshal(w.Body.Bytes(), &resp)
+ hosts := resp["hosts"].([]interface{})
+ assert.Len(t, hosts, 1)
+}
+
+func TestAuthHandler_GetAccessibleHosts_UserNotFound(t *testing.T) {
+ handler, _ := setupAuthHandlerWithDB(t)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("userID", uint(99999))
+ c.Next()
+ })
+ r.GET("/hosts", handler.GetAccessibleHosts)
+
+ req := httptest.NewRequest("GET", "/hosts", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusNotFound, w.Code)
+}
+
+func TestAuthHandler_CheckHostAccess_Unauthorized(t *testing.T) {
+ handler, _ := setupAuthHandlerWithDB(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.GET("/hosts/:hostId/access", handler.CheckHostAccess)
+
+ req := httptest.NewRequest("GET", "/hosts/1/access", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusUnauthorized, w.Code)
+}
+
+func TestAuthHandler_CheckHostAccess_InvalidHostID(t *testing.T) {
+ handler, db := setupAuthHandlerWithDB(t)
+
+ user := &models.User{UUID: uuid.NewString(), Email: "check@example.com", Enabled: true}
+ db.Create(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("userID", user.ID)
+ c.Next()
+ })
+ r.GET("/hosts/:hostId/access", handler.CheckHostAccess)
+
+ req := httptest.NewRequest("GET", "/hosts/invalid/access", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+func TestAuthHandler_CheckHostAccess_Allowed(t *testing.T) {
+ handler, db := setupAuthHandlerWithDB(t)
+
+ host := &models.ProxyHost{UUID: uuid.NewString(), Name: "Test Host", DomainNames: "test.example.com", Enabled: true}
+ db.Create(host)
+
+ user := &models.User{
+ UUID: uuid.NewString(),
+ Email: "checkallowed@example.com",
+ Enabled: true,
+ PermissionMode: models.PermissionModeAllowAll,
+ }
+ db.Create(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("userID", user.ID)
+ c.Next()
+ })
+ r.GET("/hosts/:hostId/access", handler.CheckHostAccess)
+
+ req := httptest.NewRequest("GET", "/hosts/1/access", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ var resp map[string]interface{}
+ json.Unmarshal(w.Body.Bytes(), &resp)
+ assert.Equal(t, true, resp["can_access"])
+}
+
+func TestAuthHandler_CheckHostAccess_Denied(t *testing.T) {
+ handler, db := setupAuthHandlerWithDB(t)
+
+ host := &models.ProxyHost{UUID: uuid.NewString(), Name: "Protected Host", DomainNames: "protected.example.com", Enabled: true}
+ db.Create(host)
+
+ user := &models.User{
+ UUID: uuid.NewString(),
+ Email: "checkdenied@example.com",
+ Enabled: true,
+ PermissionMode: models.PermissionModeDenyAll,
+ }
+ db.Create(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("userID", user.ID)
+ c.Next()
+ })
+ r.GET("/hosts/:hostId/access", handler.CheckHostAccess)
+
+ req := httptest.NewRequest("GET", "/hosts/1/access", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ var resp map[string]interface{}
+ json.Unmarshal(w.Body.Bytes(), &resp)
+ assert.Equal(t, false, resp["can_access"])
+}
diff --git a/backend/internal/api/handlers/benchmark_test.go b/backend/internal/api/handlers/benchmark_test.go
index 762a81aa..9b96c0ed 100644
--- a/backend/internal/api/handlers/benchmark_test.go
+++ b/backend/internal/api/handlers/benchmark_test.go
@@ -274,10 +274,10 @@ func BenchmarkSecurityHandler_UpdateConfig(b *testing.B) {
router.PUT("/api/v1/security/config", h.UpdateConfig)
payload := map[string]interface{}{
- "name": "default",
- "enabled": true,
- "rate_limit_enable": true,
- "rate_limit_burst": 10,
+ "name": "default",
+ "enabled": true,
+ "rate_limit_enable": true,
+ "rate_limit_burst": 10,
"rate_limit_requests": 100,
}
body, _ := json.Marshal(payload)
diff --git a/backend/internal/api/handlers/perf_assert_test.go b/backend/internal/api/handlers/perf_assert_test.go
index c252a1bb..49d5cd9a 100644
--- a/backend/internal/api/handlers/perf_assert_test.go
+++ b/backend/internal/api/handlers/perf_assert_test.go
@@ -1,13 +1,13 @@
package handlers
import (
+ "fmt"
"net/http"
"net/http/httptest"
"os"
"sort"
"testing"
"time"
- "fmt"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
@@ -74,9 +74,13 @@ func computePercentiles(samples []float64) (avg, p50, p95, p99, max float64) {
}
avg = sum / float64(len(samples))
p := func(pct float64) float64 {
- idx := int(float64(len(samples))*pct)
- if idx < 0 { idx = 0 }
- if idx >= len(samples) { idx = len(samples)-1 }
+ idx := int(float64(len(samples)) * pct)
+ if idx < 0 {
+ idx = 0
+ }
+ if idx >= len(samples) {
+ idx = len(samples) - 1
+ }
return samples[idx]
}
p50 = p(0.50)
@@ -112,7 +116,9 @@ func TestPerf_GetStatus_AssertThreshold(t *testing.T) {
// default thresholds ms
thresholdP95 := 2.0 // 2ms per request
if env := os.Getenv("PERF_MAX_MS_GETSTATUS_P95"); env != "" {
- if parsed, err := time.ParseDuration(env); err == nil { thresholdP95 = ms(parsed) }
+ if parsed, err := time.ParseDuration(env); err == nil {
+ thresholdP95 = ms(parsed)
+ }
}
// fail if p95 exceeds threshold
t.Logf("GetStatus avg=%.3fms p95=%.3fms max=%.3fms", avg, p95, max)
@@ -144,13 +150,19 @@ func TestPerf_GetStatus_Parallel_AssertThreshold(t *testing.T) {
}
// run 4 concurrent workers
- for k := 0; k < 4; k++ { go worker() }
+ for k := 0; k < 4; k++ {
+ go worker()
+ }
collected := make([]float64, 0, n*4)
- for i := 0; i < n*4; i++ { collected = append(collected, <-samples) }
+ for i := 0; i < n*4; i++ {
+ collected = append(collected, <-samples)
+ }
avg, _, p95, _, max := computePercentiles(collected)
thresholdP95 := 5.0 // 5ms default
if env := os.Getenv("PERF_MAX_MS_GETSTATUS_P95_PARALLEL"); env != "" {
- if parsed, err := time.ParseDuration(env); err == nil { thresholdP95 = ms(parsed) }
+ if parsed, err := time.ParseDuration(env); err == nil {
+ thresholdP95 = ms(parsed)
+ }
}
t.Logf("GetStatus Parallel avg=%.3fms p95=%.3fms max=%.3fms", avg, p95, max)
if p95 > thresholdP95 {
@@ -177,7 +189,9 @@ func TestPerf_ListDecisions_AssertThreshold(t *testing.T) {
avg, _, p95, _, max := computePercentiles(samples)
thresholdP95 := 30.0 // 30ms default
if env := os.Getenv("PERF_MAX_MS_LISTDECISIONS_P95"); env != "" {
- if parsed, err := time.ParseDuration(env); err == nil { thresholdP95 = ms(parsed) }
+ if parsed, err := time.ParseDuration(env); err == nil {
+ thresholdP95 = ms(parsed)
+ }
}
t.Logf("ListDecisions avg=%.3fms p95=%.3fms max=%.3fms", avg, p95, max)
if p95 > thresholdP95 {
diff --git a/backend/internal/api/handlers/settings_handler.go b/backend/internal/api/handlers/settings_handler.go
index e03e379b..9d8e6556 100644
--- a/backend/internal/api/handlers/settings_handler.go
+++ b/backend/internal/api/handlers/settings_handler.go
@@ -7,14 +7,19 @@ import (
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/models"
+ "github.com/Wikid82/charon/backend/internal/services"
)
type SettingsHandler struct {
- DB *gorm.DB
+ DB *gorm.DB
+ MailService *services.MailService
}
func NewSettingsHandler(db *gorm.DB) *SettingsHandler {
- return &SettingsHandler{DB: db}
+ return &SettingsHandler{
+ DB: db,
+ MailService: services.NewMailService(db),
+ }
}
// GetSettings returns all settings.
@@ -69,3 +74,153 @@ func (h *SettingsHandler) UpdateSetting(c *gin.Context) {
c.JSON(http.StatusOK, setting)
}
+
+// SMTPConfigRequest represents the request body for SMTP configuration.
+type SMTPConfigRequest struct {
+ Host string `json:"host" binding:"required"`
+ Port int `json:"port" binding:"required,min=1,max=65535"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ FromAddress string `json:"from_address" binding:"required,email"`
+ Encryption string `json:"encryption" binding:"required,oneof=none ssl starttls"`
+}
+
+// GetSMTPConfig returns the current SMTP configuration.
+func (h *SettingsHandler) GetSMTPConfig(c *gin.Context) {
+ config, err := h.MailService.GetSMTPConfig()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch SMTP configuration"})
+ return
+ }
+
+ // Don't expose the password
+ c.JSON(http.StatusOK, gin.H{
+ "host": config.Host,
+ "port": config.Port,
+ "username": config.Username,
+ "password": MaskPassword(config.Password),
+ "from_address": config.FromAddress,
+ "encryption": config.Encryption,
+ "configured": config.Host != "" && config.FromAddress != "",
+ })
+}
+
+// MaskPassword masks the password for display.
+func MaskPassword(password string) string {
+ if password == "" {
+ return ""
+ }
+ return "********"
+}
+
+// MaskPasswordForTest is an alias for testing.
+func MaskPasswordForTest(password string) string {
+ return MaskPassword(password)
+}
+
+// UpdateSMTPConfig updates the SMTP configuration.
+func (h *SettingsHandler) UpdateSMTPConfig(c *gin.Context) {
+ role, _ := c.Get("role")
+ if role != "admin" {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
+ return
+ }
+
+ var req SMTPConfigRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // If password is masked (i.e., unchanged), keep the existing password
+ existingConfig, _ := h.MailService.GetSMTPConfig()
+ if req.Password == "********" || req.Password == "" {
+ req.Password = existingConfig.Password
+ }
+
+ config := &services.SMTPConfig{
+ Host: req.Host,
+ Port: req.Port,
+ Username: req.Username,
+ Password: req.Password,
+ FromAddress: req.FromAddress,
+ Encryption: req.Encryption,
+ }
+
+ if err := h.MailService.SaveSMTPConfig(config); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save SMTP configuration: " + err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "SMTP configuration saved successfully"})
+}
+
+// TestSMTPConfig tests the SMTP connection.
+func (h *SettingsHandler) TestSMTPConfig(c *gin.Context) {
+ role, _ := c.Get("role")
+ if role != "admin" {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
+ return
+ }
+
+ if err := h.MailService.TestConnection(); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "success": false,
+ "error": err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "SMTP connection successful",
+ })
+}
+
+// SendTestEmail sends a test email to verify the SMTP configuration.
+func (h *SettingsHandler) SendTestEmail(c *gin.Context) {
+ role, _ := c.Get("role")
+ if role != "admin" {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
+ return
+ }
+
+ type TestEmailRequest struct {
+ To string `json:"to" binding:"required,email"`
+ }
+
+ var req TestEmailRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ htmlBody := `
+
+
+
+ Test Email
+
+
+
+
Test Email from Charon
+
If you received this email, your SMTP configuration is working correctly!
+
This is an automated test email.
+
+
+
+`
+
+ if err := h.MailService.SendEmail(req.To, "Charon - Test Email", htmlBody); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "success": false,
+ "error": err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "Test email sent successfully",
+ })
+}
diff --git a/backend/internal/api/handlers/settings_handler_test.go b/backend/internal/api/handlers/settings_handler_test.go
index c6aa6be6..ba55faf7 100644
--- a/backend/internal/api/handlers/settings_handler_test.go
+++ b/backend/internal/api/handlers/settings_handler_test.go
@@ -119,3 +119,286 @@ func TestSettingsHandler_Errors(t *testing.T) {
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
+
+// ============= SMTP Settings Tests =============
+
+func setupSettingsHandlerWithMail(t *testing.T) (*handlers.SettingsHandler, *gorm.DB) {
+ dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
+ db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+ if err != nil {
+ panic("failed to connect to test database")
+ }
+ db.AutoMigrate(&models.Setting{})
+ return handlers.NewSettingsHandler(db), db
+}
+
+func TestSettingsHandler_GetSMTPConfig(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ handler, db := setupSettingsHandlerWithMail(t)
+
+ // Seed SMTP config
+ db.Create(&models.Setting{Key: "smtp_host", Value: "smtp.example.com", Category: "smtp", Type: "string"})
+ db.Create(&models.Setting{Key: "smtp_port", Value: "587", Category: "smtp", Type: "number"})
+ db.Create(&models.Setting{Key: "smtp_username", Value: "user@example.com", Category: "smtp", Type: "string"})
+ db.Create(&models.Setting{Key: "smtp_password", Value: "secret123", Category: "smtp", Type: "string"})
+ db.Create(&models.Setting{Key: "smtp_from_address", Value: "noreply@example.com", Category: "smtp", Type: "string"})
+ db.Create(&models.Setting{Key: "smtp_encryption", Value: "starttls", Category: "smtp", Type: "string"})
+
+ router := gin.New()
+ router.GET("/settings/smtp", handler.GetSMTPConfig)
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/settings/smtp", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+
+ var resp map[string]interface{}
+ json.Unmarshal(w.Body.Bytes(), &resp)
+ assert.Equal(t, "smtp.example.com", resp["host"])
+ assert.Equal(t, float64(587), resp["port"])
+ assert.Equal(t, "********", resp["password"]) // Password should be masked
+ assert.Equal(t, true, resp["configured"])
+}
+
+func TestSettingsHandler_GetSMTPConfig_Empty(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ handler, _ := setupSettingsHandlerWithMail(t)
+
+ router := gin.New()
+ router.GET("/settings/smtp", handler.GetSMTPConfig)
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/settings/smtp", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+
+ var resp map[string]interface{}
+ json.Unmarshal(w.Body.Bytes(), &resp)
+ assert.Equal(t, false, resp["configured"])
+}
+
+func TestSettingsHandler_UpdateSMTPConfig_NonAdmin(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ handler, _ := setupSettingsHandlerWithMail(t)
+
+ router := gin.New()
+ router.Use(func(c *gin.Context) {
+ c.Set("role", "user")
+ c.Next()
+ })
+ router.PUT("/settings/smtp", handler.UpdateSMTPConfig)
+
+ body := map[string]interface{}{
+ "host": "smtp.example.com",
+ "port": 587,
+ "from_address": "test@example.com",
+ "encryption": "starttls",
+ }
+ jsonBody, _ := json.Marshal(body)
+ req, _ := http.NewRequest("PUT", "/settings/smtp", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+}
+
+func TestSettingsHandler_UpdateSMTPConfig_InvalidJSON(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ handler, _ := setupSettingsHandlerWithMail(t)
+
+ router := gin.New()
+ router.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ router.PUT("/settings/smtp", handler.UpdateSMTPConfig)
+
+ req, _ := http.NewRequest("PUT", "/settings/smtp", bytes.NewBufferString("invalid"))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+func TestSettingsHandler_UpdateSMTPConfig_Success(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ handler, _ := setupSettingsHandlerWithMail(t)
+
+ router := gin.New()
+ router.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ router.PUT("/settings/smtp", handler.UpdateSMTPConfig)
+
+ body := map[string]interface{}{
+ "host": "smtp.example.com",
+ "port": 587,
+ "username": "user@example.com",
+ "password": "password123",
+ "from_address": "noreply@example.com",
+ "encryption": "starttls",
+ }
+ jsonBody, _ := json.Marshal(body)
+ req, _ := http.NewRequest("PUT", "/settings/smtp", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+}
+
+func TestSettingsHandler_UpdateSMTPConfig_KeepExistingPassword(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ handler, db := setupSettingsHandlerWithMail(t)
+
+ // Seed existing password
+ db.Create(&models.Setting{Key: "smtp_password", Value: "existingpassword", Category: "smtp", Type: "string"})
+ db.Create(&models.Setting{Key: "smtp_host", Value: "old.example.com", Category: "smtp", Type: "string"})
+ db.Create(&models.Setting{Key: "smtp_port", Value: "25", Category: "smtp", Type: "number"})
+ db.Create(&models.Setting{Key: "smtp_from_address", Value: "old@example.com", Category: "smtp", Type: "string"})
+ db.Create(&models.Setting{Key: "smtp_encryption", Value: "none", Category: "smtp", Type: "string"})
+
+ router := gin.New()
+ router.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ router.PUT("/settings/smtp", handler.UpdateSMTPConfig)
+
+ // Send masked password (simulating frontend sending back masked value)
+ body := map[string]interface{}{
+ "host": "smtp.example.com",
+ "port": 587,
+ "password": "********", // Masked
+ "from_address": "noreply@example.com",
+ "encryption": "starttls",
+ }
+ jsonBody, _ := json.Marshal(body)
+ req, _ := http.NewRequest("PUT", "/settings/smtp", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+
+ // Verify password was preserved
+ var setting models.Setting
+ db.Where("key = ?", "smtp_password").First(&setting)
+ assert.Equal(t, "existingpassword", setting.Value)
+}
+
+func TestSettingsHandler_TestSMTPConfig_NonAdmin(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ handler, _ := setupSettingsHandlerWithMail(t)
+
+ router := gin.New()
+ router.Use(func(c *gin.Context) {
+ c.Set("role", "user")
+ c.Next()
+ })
+ router.POST("/settings/smtp/test", handler.TestSMTPConfig)
+
+ req, _ := http.NewRequest("POST", "/settings/smtp/test", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+}
+
+func TestSettingsHandler_TestSMTPConfig_NotConfigured(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ handler, _ := setupSettingsHandlerWithMail(t)
+
+ router := gin.New()
+ router.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ router.POST("/settings/smtp/test", handler.TestSMTPConfig)
+
+ req, _ := http.NewRequest("POST", "/settings/smtp/test", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ var resp map[string]interface{}
+ json.Unmarshal(w.Body.Bytes(), &resp)
+ assert.Equal(t, false, resp["success"])
+}
+
+func TestSettingsHandler_SendTestEmail_NonAdmin(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ handler, _ := setupSettingsHandlerWithMail(t)
+
+ router := gin.New()
+ router.Use(func(c *gin.Context) {
+ c.Set("role", "user")
+ c.Next()
+ })
+ router.POST("/settings/smtp/send-test", handler.SendTestEmail)
+
+ body := map[string]string{"to": "test@example.com"}
+ jsonBody, _ := json.Marshal(body)
+ req, _ := http.NewRequest("POST", "/settings/smtp/send-test", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+}
+
+func TestSettingsHandler_SendTestEmail_InvalidJSON(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ handler, _ := setupSettingsHandlerWithMail(t)
+
+ router := gin.New()
+ router.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ router.POST("/settings/smtp/send-test", handler.SendTestEmail)
+
+ req, _ := http.NewRequest("POST", "/settings/smtp/send-test", bytes.NewBufferString("invalid"))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+func TestSettingsHandler_SendTestEmail_NotConfigured(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ handler, _ := setupSettingsHandlerWithMail(t)
+
+ router := gin.New()
+ router.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ router.POST("/settings/smtp/send-test", handler.SendTestEmail)
+
+ body := map[string]string{"to": "test@example.com"}
+ jsonBody, _ := json.Marshal(body)
+ req, _ := http.NewRequest("POST", "/settings/smtp/send-test", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ var resp map[string]interface{}
+ json.Unmarshal(w.Body.Bytes(), &resp)
+ assert.Equal(t, false, resp["success"])
+}
+
+func TestMaskPassword(t *testing.T) {
+ // Empty password
+ assert.Equal(t, "", handlers.MaskPasswordForTest(""))
+
+ // Non-empty password
+ assert.Equal(t, "********", handlers.MaskPasswordForTest("secret"))
+}
diff --git a/backend/internal/api/handlers/user_handler.go b/backend/internal/api/handlers/user_handler.go
index 4498b3fe..6aae2e38 100644
--- a/backend/internal/api/handlers/user_handler.go
+++ b/backend/internal/api/handlers/user_handler.go
@@ -1,22 +1,31 @@
package handlers
import (
+ "crypto/rand"
+ "encoding/hex"
"net/http"
+ "strconv"
"strings"
+ "time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/models"
+ "github.com/Wikid82/charon/backend/internal/services"
)
type UserHandler struct {
- DB *gorm.DB
+ DB *gorm.DB
+ MailService *services.MailService
}
func NewUserHandler(db *gorm.DB) *UserHandler {
- return &UserHandler{DB: db}
+ return &UserHandler{
+ DB: db,
+ MailService: services.NewMailService(db),
+ }
}
func (h *UserHandler) RegisterRoutes(r *gin.RouterGroup) {
@@ -25,6 +34,19 @@ func (h *UserHandler) RegisterRoutes(r *gin.RouterGroup) {
r.GET("/profile", h.GetProfile)
r.POST("/regenerate-api-key", h.RegenerateAPIKey)
r.PUT("/profile", h.UpdateProfile)
+
+ // User management (admin only)
+ r.GET("/users", h.ListUsers)
+ r.POST("/users", h.CreateUser)
+ r.POST("/users/invite", h.InviteUser)
+ r.GET("/users/:id", h.GetUser)
+ r.PUT("/users/:id", h.UpdateUser)
+ r.DELETE("/users/:id", h.DeleteUser)
+ r.PUT("/users/:id/permissions", h.UpdateUserPermissions)
+
+ // Invite acceptance (public)
+ r.GET("/invite/validate", h.ValidateInvite)
+ r.POST("/invite/accept", h.AcceptInvite)
}
// GetSetupStatus checks if the application needs initial setup (i.e., no users exist).
@@ -220,3 +242,591 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Profile updated successfully"})
}
+
+// ListUsers returns all users (admin only).
+func (h *UserHandler) ListUsers(c *gin.Context) {
+ role, _ := c.Get("role")
+ if role != "admin" {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
+ return
+ }
+
+ var users []models.User
+ if err := h.DB.Preload("PermittedHosts").Find(&users).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch users"})
+ return
+ }
+
+ // Return users with safe fields only
+ result := make([]gin.H, len(users))
+ for i, u := range users {
+ result[i] = gin.H{
+ "id": u.ID,
+ "uuid": u.UUID,
+ "email": u.Email,
+ "name": u.Name,
+ "role": u.Role,
+ "enabled": u.Enabled,
+ "last_login": u.LastLogin,
+ "invite_status": u.InviteStatus,
+ "invited_at": u.InvitedAt,
+ "permission_mode": u.PermissionMode,
+ "created_at": u.CreatedAt,
+ "updated_at": u.UpdatedAt,
+ }
+ }
+
+ c.JSON(http.StatusOK, result)
+}
+
+// CreateUserRequest represents the request body for creating a user.
+type CreateUserRequest struct {
+ Email string `json:"email" binding:"required,email"`
+ Name string `json:"name" binding:"required"`
+ Password string `json:"password" binding:"required,min=8"`
+ Role string `json:"role"`
+ PermissionMode string `json:"permission_mode"`
+ PermittedHosts []uint `json:"permitted_hosts"`
+}
+
+// CreateUser creates a new user with a password (admin only).
+func (h *UserHandler) CreateUser(c *gin.Context) {
+ role, _ := c.Get("role")
+ if role != "admin" {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
+ return
+ }
+
+ var req CreateUserRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Default role to "user"
+ if req.Role == "" {
+ req.Role = "user"
+ }
+
+ // Default permission mode to "allow_all"
+ if req.PermissionMode == "" {
+ req.PermissionMode = "allow_all"
+ }
+
+ // Check if email already exists
+ var count int64
+ if err := h.DB.Model(&models.User{}).Where("email = ?", strings.ToLower(req.Email)).Count(&count).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check email"})
+ return
+ }
+ if count > 0 {
+ c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"})
+ return
+ }
+
+ user := models.User{
+ UUID: uuid.New().String(),
+ Email: strings.ToLower(req.Email),
+ Name: req.Name,
+ Role: req.Role,
+ Enabled: true,
+ APIKey: uuid.New().String(),
+ PermissionMode: models.PermissionMode(req.PermissionMode),
+ }
+
+ if err := user.SetPassword(req.Password); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
+ return
+ }
+
+ err := h.DB.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Create(&user).Error; err != nil {
+ return err
+ }
+
+ // Add permitted hosts if specified
+ if len(req.PermittedHosts) > 0 {
+ var hosts []models.ProxyHost
+ if err := tx.Where("id IN ?", req.PermittedHosts).Find(&hosts).Error; err != nil {
+ return err
+ }
+ if err := tx.Model(&user).Association("PermittedHosts").Replace(hosts); err != nil {
+ return err
+ }
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusCreated, gin.H{
+ "id": user.ID,
+ "uuid": user.UUID,
+ "email": user.Email,
+ "name": user.Name,
+ "role": user.Role,
+ })
+}
+
+// InviteUserRequest represents the request body for inviting a user.
+type InviteUserRequest struct {
+ Email string `json:"email" binding:"required,email"`
+ Role string `json:"role"`
+ PermissionMode string `json:"permission_mode"`
+ PermittedHosts []uint `json:"permitted_hosts"`
+}
+
+// generateSecureToken creates a cryptographically secure random token.
+func generateSecureToken(length int) (string, error) {
+ bytes := make([]byte, length)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(bytes), nil
+}
+
+// InviteUser creates a new user with an invite token and sends an email (admin only).
+func (h *UserHandler) InviteUser(c *gin.Context) {
+ role, _ := c.Get("role")
+ if role != "admin" {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
+ return
+ }
+
+ inviterID, _ := c.Get("userID")
+
+ var req InviteUserRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Default role to "user"
+ if req.Role == "" {
+ req.Role = "user"
+ }
+
+ // Default permission mode to "allow_all"
+ if req.PermissionMode == "" {
+ req.PermissionMode = "allow_all"
+ }
+
+ // Check if email already exists
+ var existingUser models.User
+ if err := h.DB.Where("email = ?", strings.ToLower(req.Email)).First(&existingUser).Error; err == nil {
+ c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"})
+ return
+ }
+
+ // Generate invite token
+ inviteToken, err := generateSecureToken(32)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate invite token"})
+ return
+ }
+
+ // Set invite expiration (48 hours)
+ inviteExpires := time.Now().Add(48 * time.Hour)
+ invitedAt := time.Now()
+ inviterIDUint := inviterID.(uint)
+
+ user := models.User{
+ UUID: uuid.New().String(),
+ Email: strings.ToLower(req.Email),
+ Role: req.Role,
+ Enabled: false, // Disabled until invite is accepted
+ APIKey: uuid.New().String(),
+ PermissionMode: models.PermissionMode(req.PermissionMode),
+ InviteToken: inviteToken,
+ InviteExpires: &inviteExpires,
+ InvitedAt: &invitedAt,
+ InvitedBy: &inviterIDUint,
+ InviteStatus: "pending",
+ }
+
+ err = h.DB.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Create(&user).Error; err != nil {
+ return err
+ }
+
+ // Explicitly disable user (bypass GORM's default:true)
+ if err := tx.Model(&user).Update("enabled", false).Error; err != nil {
+ return err
+ }
+
+ // Add permitted hosts if specified
+ if len(req.PermittedHosts) > 0 {
+ var hosts []models.ProxyHost
+ if err := tx.Where("id IN ?", req.PermittedHosts).Find(&hosts).Error; err != nil {
+ return err
+ }
+ if err := tx.Model(&user).Association("PermittedHosts").Replace(hosts); err != nil {
+ return err
+ }
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()})
+ return
+ }
+
+ // Try to send invite email
+ emailSent := false
+ if h.MailService.IsConfigured() {
+ baseURL := getBaseURL(c)
+ appName := getAppName(h.DB)
+ if err := h.MailService.SendInvite(user.Email, inviteToken, appName, baseURL); err == nil {
+ emailSent = true
+ }
+ }
+
+ c.JSON(http.StatusCreated, gin.H{
+ "id": user.ID,
+ "uuid": user.UUID,
+ "email": user.Email,
+ "role": user.Role,
+ "invite_token": inviteToken, // Return token in case email fails
+ "email_sent": emailSent,
+ "expires_at": inviteExpires,
+ })
+}
+
+// getBaseURL extracts the base URL from the request.
+func getBaseURL(c *gin.Context) string {
+ scheme := "https"
+ if c.Request.TLS == nil {
+ // Check for X-Forwarded-Proto header
+ if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" {
+ scheme = proto
+ } else {
+ scheme = "http"
+ }
+ }
+ return scheme + "://" + c.Request.Host
+}
+
+// getAppName retrieves the application name from settings or returns a default.
+func getAppName(db *gorm.DB) string {
+ var setting models.Setting
+ if err := db.Where("key = ?", "app_name").First(&setting).Error; err == nil && setting.Value != "" {
+ return setting.Value
+ }
+ return "Charon"
+}
+
+// GetUser returns a single user by ID (admin only).
+func (h *UserHandler) GetUser(c *gin.Context) {
+ role, _ := c.Get("role")
+ if role != "admin" {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
+ return
+ }
+
+ idParam := c.Param("id")
+ id, err := strconv.ParseUint(idParam, 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
+ return
+ }
+
+ var user models.User
+ if err := h.DB.Preload("PermittedHosts").First(&user, id).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
+ return
+ }
+
+ // Build permitted host IDs list
+ permittedHostIDs := make([]uint, len(user.PermittedHosts))
+ for i, host := range user.PermittedHosts {
+ permittedHostIDs[i] = host.ID
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "id": user.ID,
+ "uuid": user.UUID,
+ "email": user.Email,
+ "name": user.Name,
+ "role": user.Role,
+ "enabled": user.Enabled,
+ "last_login": user.LastLogin,
+ "invite_status": user.InviteStatus,
+ "invited_at": user.InvitedAt,
+ "permission_mode": user.PermissionMode,
+ "permitted_hosts": permittedHostIDs,
+ "created_at": user.CreatedAt,
+ "updated_at": user.UpdatedAt,
+ })
+}
+
+// UpdateUserRequest represents the request body for updating a user.
+type UpdateUserRequest struct {
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Role string `json:"role"`
+ Enabled *bool `json:"enabled"`
+}
+
+// UpdateUser updates an existing user (admin only).
+func (h *UserHandler) UpdateUser(c *gin.Context) {
+ role, _ := c.Get("role")
+ if role != "admin" {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
+ return
+ }
+
+ idParam := c.Param("id")
+ id, err := strconv.ParseUint(idParam, 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
+ return
+ }
+
+ var user models.User
+ if err := h.DB.First(&user, id).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
+ return
+ }
+
+ var req UpdateUserRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ updates := make(map[string]interface{})
+
+ if req.Name != "" {
+ updates["name"] = req.Name
+ }
+
+ if req.Email != "" {
+ email := strings.ToLower(req.Email)
+ // Check if email is taken by another user
+ var count int64
+ if err := h.DB.Model(&models.User{}).Where("email = ? AND id != ?", email, id).Count(&count).Error; err == nil && count > 0 {
+ c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"})
+ return
+ }
+ updates["email"] = email
+ }
+
+ if req.Role != "" {
+ updates["role"] = req.Role
+ }
+
+ if req.Enabled != nil {
+ updates["enabled"] = *req.Enabled
+ }
+
+ if len(updates) > 0 {
+ if err := h.DB.Model(&user).Updates(updates).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
+ return
+ }
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "User updated successfully"})
+}
+
+// DeleteUser deletes a user (admin only).
+func (h *UserHandler) DeleteUser(c *gin.Context) {
+ role, _ := c.Get("role")
+ if role != "admin" {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
+ return
+ }
+
+ currentUserID, _ := c.Get("userID")
+
+ idParam := c.Param("id")
+ id, err := strconv.ParseUint(idParam, 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
+ return
+ }
+
+ // Prevent self-deletion
+ if uint(id) == currentUserID.(uint) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Cannot delete your own account"})
+ return
+ }
+
+ var user models.User
+ if err := h.DB.First(&user, id).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
+ return
+ }
+
+ // Clear associations first
+ if err := h.DB.Model(&user).Association("PermittedHosts").Clear(); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear user associations"})
+ return
+ }
+
+ if err := h.DB.Delete(&user).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "User deleted successfully"})
+}
+
+// UpdateUserPermissionsRequest represents the request body for updating user permissions.
+type UpdateUserPermissionsRequest struct {
+ PermissionMode string `json:"permission_mode" binding:"required,oneof=allow_all deny_all"`
+ PermittedHosts []uint `json:"permitted_hosts"`
+}
+
+// UpdateUserPermissions updates a user's permission mode and host exceptions (admin only).
+func (h *UserHandler) UpdateUserPermissions(c *gin.Context) {
+ role, _ := c.Get("role")
+ if role != "admin" {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
+ return
+ }
+
+ idParam := c.Param("id")
+ id, err := strconv.ParseUint(idParam, 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
+ return
+ }
+
+ var user models.User
+ if err := h.DB.First(&user, id).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
+ return
+ }
+
+ var req UpdateUserPermissionsRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ err = h.DB.Transaction(func(tx *gorm.DB) error {
+ // Update permission mode
+ if err := tx.Model(&user).Update("permission_mode", req.PermissionMode).Error; err != nil {
+ return err
+ }
+
+ // Update permitted hosts
+ var hosts []models.ProxyHost
+ if len(req.PermittedHosts) > 0 {
+ if err := tx.Where("id IN ?", req.PermittedHosts).Find(&hosts).Error; err != nil {
+ return err
+ }
+ }
+
+ if err := tx.Model(&user).Association("PermittedHosts").Replace(hosts); err != nil {
+ return err
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update permissions: " + err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "Permissions updated successfully"})
+}
+
+// ValidateInvite validates an invite token (public endpoint).
+func (h *UserHandler) ValidateInvite(c *gin.Context) {
+ token := c.Query("token")
+ if token == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Token required"})
+ return
+ }
+
+ var user models.User
+ if err := h.DB.Where("invite_token = ?", token).First(&user).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Invalid or expired invite token"})
+ return
+ }
+
+ // Check if token is expired
+ if user.InviteExpires != nil && user.InviteExpires.Before(time.Now()) {
+ c.JSON(http.StatusGone, gin.H{"error": "Invite token has expired"})
+ return
+ }
+
+ // Check if already accepted
+ if user.InviteStatus != "pending" {
+ c.JSON(http.StatusConflict, gin.H{"error": "Invite has already been accepted"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "valid": true,
+ "email": user.Email,
+ })
+}
+
+// AcceptInviteRequest represents the request body for accepting an invite.
+type AcceptInviteRequest struct {
+ Token string `json:"token" binding:"required"`
+ Name string `json:"name" binding:"required"`
+ Password string `json:"password" binding:"required,min=8"`
+}
+
+// AcceptInvite accepts an invitation and sets the user's password (public endpoint).
+func (h *UserHandler) AcceptInvite(c *gin.Context) {
+ var req AcceptInviteRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ var user models.User
+ if err := h.DB.Where("invite_token = ?", req.Token).First(&user).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Invalid or expired invite token"})
+ return
+ }
+
+ // Check if token is expired
+ if user.InviteExpires != nil && user.InviteExpires.Before(time.Now()) {
+ // Mark as expired
+ h.DB.Model(&user).Update("invite_status", "expired")
+ c.JSON(http.StatusGone, gin.H{"error": "Invite token has expired"})
+ return
+ }
+
+ // Check if already accepted
+ if user.InviteStatus != "pending" {
+ c.JSON(http.StatusConflict, gin.H{"error": "Invite has already been accepted"})
+ return
+ }
+
+ // Set password and activate user
+ if err := user.SetPassword(req.Password); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to set password"})
+ return
+ }
+
+ if err := h.DB.Model(&user).Updates(map[string]interface{}{
+ "name": req.Name,
+ "password_hash": user.PasswordHash,
+ "enabled": true,
+ "invite_token": "", // Clear token
+ "invite_expires": nil, // Clear expiration
+ "invite_status": "accepted",
+ }).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to accept invite"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "message": "Invite accepted successfully",
+ "email": user.Email,
+ })
+}
diff --git a/backend/internal/api/handlers/user_handler_test.go b/backend/internal/api/handlers/user_handler_test.go
index 864d79c3..52e2a404 100644
--- a/backend/internal/api/handlers/user_handler_test.go
+++ b/backend/internal/api/handlers/user_handler_test.go
@@ -5,7 +5,9 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "strconv"
"testing"
+ "time"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/gin-gonic/gin"
@@ -386,3 +388,1036 @@ func TestUserHandler_UpdateProfile_Errors(t *testing.T) {
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
+
+// ============= User Management Tests (Admin functions) =============
+
+func setupUserHandlerWithProxyHosts(t *testing.T) (*UserHandler, *gorm.DB) {
+ dbName := "file:" + t.Name() + "?mode=memory&cache=shared"
+ db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
+ require.NoError(t, err)
+ db.AutoMigrate(&models.User{}, &models.Setting{}, &models.ProxyHost{})
+ return NewUserHandler(db), db
+}
+
+func TestUserHandler_ListUsers_NonAdmin(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "user")
+ c.Next()
+ })
+ r.GET("/users", handler.ListUsers)
+
+ req := httptest.NewRequest("GET", "/users", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+}
+
+func TestUserHandler_ListUsers_Admin(t *testing.T) {
+ handler, db := setupUserHandlerWithProxyHosts(t)
+
+ // Create users with unique API keys
+ user1 := &models.User{UUID: uuid.NewString(), Email: "user1@example.com", Name: "User 1", APIKey: uuid.NewString()}
+ user2 := &models.User{UUID: uuid.NewString(), Email: "user2@example.com", Name: "User 2", APIKey: uuid.NewString()}
+ db.Create(user1)
+ db.Create(user2)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ r.GET("/users", handler.ListUsers)
+
+ req := httptest.NewRequest("GET", "/users", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ var users []map[string]interface{}
+ json.Unmarshal(w.Body.Bytes(), &users)
+ assert.Len(t, users, 2)
+}
+
+func TestUserHandler_CreateUser_NonAdmin(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "user")
+ c.Next()
+ })
+ r.POST("/users", handler.CreateUser)
+
+ body := map[string]interface{}{
+ "email": "new@example.com",
+ "name": "New User",
+ "password": "password123",
+ }
+ jsonBody, _ := json.Marshal(body)
+ req := httptest.NewRequest("POST", "/users", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+}
+
+func TestUserHandler_CreateUser_Admin(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ r.POST("/users", handler.CreateUser)
+
+ body := map[string]interface{}{
+ "email": "newuser@example.com",
+ "name": "New User",
+ "password": "password123",
+ }
+ jsonBody, _ := json.Marshal(body)
+ req := httptest.NewRequest("POST", "/users", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusCreated, w.Code)
+}
+
+func TestUserHandler_CreateUser_InvalidJSON(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ r.POST("/users", handler.CreateUser)
+
+ req := httptest.NewRequest("POST", "/users", bytes.NewBufferString("invalid"))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+func TestUserHandler_CreateUser_DuplicateEmail(t *testing.T) {
+ handler, db := setupUserHandlerWithProxyHosts(t)
+
+ existing := &models.User{UUID: uuid.NewString(), Email: "existing@example.com", Name: "Existing"}
+ db.Create(existing)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ r.POST("/users", handler.CreateUser)
+
+ body := map[string]interface{}{
+ "email": "existing@example.com",
+ "name": "New User",
+ "password": "password123",
+ }
+ jsonBody, _ := json.Marshal(body)
+ req := httptest.NewRequest("POST", "/users", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusConflict, w.Code)
+}
+
+func TestUserHandler_CreateUser_WithPermittedHosts(t *testing.T) {
+ handler, db := setupUserHandlerWithProxyHosts(t)
+
+ host := &models.ProxyHost{Name: "Host 1", DomainNames: "host1.example.com", Enabled: true}
+ db.Create(host)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ r.POST("/users", handler.CreateUser)
+
+ body := map[string]interface{}{
+ "email": "withhosts@example.com",
+ "name": "User With Hosts",
+ "password": "password123",
+ "permission_mode": "deny_all",
+ "permitted_hosts": []uint{host.ID},
+ }
+ jsonBody, _ := json.Marshal(body)
+ req := httptest.NewRequest("POST", "/users", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusCreated, w.Code)
+}
+
+func TestUserHandler_GetUser_NonAdmin(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "user")
+ c.Next()
+ })
+ r.GET("/users/:id", handler.GetUser)
+
+ req := httptest.NewRequest("GET", "/users/1", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+}
+
+func TestUserHandler_GetUser_InvalidID(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ r.GET("/users/:id", handler.GetUser)
+
+ req := httptest.NewRequest("GET", "/users/invalid", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+func TestUserHandler_GetUser_NotFound(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ r.GET("/users/:id", handler.GetUser)
+
+ req := httptest.NewRequest("GET", "/users/999", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusNotFound, w.Code)
+}
+
+func TestUserHandler_GetUser_Success(t *testing.T) {
+ handler, db := setupUserHandlerWithProxyHosts(t)
+
+ user := &models.User{UUID: uuid.NewString(), Email: "getuser@example.com", Name: "Get User"}
+ db.Create(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ r.GET("/users/:id", handler.GetUser)
+
+ req := httptest.NewRequest("GET", "/users/1", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+}
+
+func TestUserHandler_UpdateUser_NonAdmin(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "user")
+ c.Next()
+ })
+ r.PUT("/users/:id", handler.UpdateUser)
+
+ body := map[string]interface{}{"name": "Updated"}
+ jsonBody, _ := json.Marshal(body)
+ req := httptest.NewRequest("PUT", "/users/1", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+}
+
+func TestUserHandler_UpdateUser_InvalidID(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ r.PUT("/users/:id", handler.UpdateUser)
+
+ body := map[string]interface{}{"name": "Updated"}
+ jsonBody, _ := json.Marshal(body)
+ req := httptest.NewRequest("PUT", "/users/invalid", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+func TestUserHandler_UpdateUser_InvalidJSON(t *testing.T) {
+ handler, db := setupUserHandlerWithProxyHosts(t)
+
+ // Create user first
+ user := &models.User{UUID: uuid.NewString(), Email: "toupdate@example.com", Name: "To Update", APIKey: uuid.NewString()}
+ db.Create(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ r.PUT("/users/:id", handler.UpdateUser)
+
+ req := httptest.NewRequest("PUT", "/users/1", bytes.NewBufferString("invalid"))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+func TestUserHandler_UpdateUser_NotFound(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ r.PUT("/users/:id", handler.UpdateUser)
+
+ body := map[string]interface{}{"name": "Updated"}
+ jsonBody, _ := json.Marshal(body)
+ req := httptest.NewRequest("PUT", "/users/999", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusNotFound, w.Code)
+}
+
+func TestUserHandler_UpdateUser_Success(t *testing.T) {
+ handler, db := setupUserHandlerWithProxyHosts(t)
+
+ user := &models.User{UUID: uuid.NewString(), Email: "update@example.com", Name: "Original", Role: "user"}
+ db.Create(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ r.PUT("/users/:id", handler.UpdateUser)
+
+ body := map[string]interface{}{
+ "name": "Updated Name",
+ "enabled": true,
+ }
+ jsonBody, _ := json.Marshal(body)
+ req := httptest.NewRequest("PUT", "/users/1", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+}
+
+func TestUserHandler_DeleteUser_NonAdmin(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "user")
+ c.Next()
+ })
+ r.DELETE("/users/:id", handler.DeleteUser)
+
+ req := httptest.NewRequest("DELETE", "/users/1", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+}
+
+func TestUserHandler_DeleteUser_InvalidID(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ r.DELETE("/users/:id", handler.DeleteUser)
+
+ req := httptest.NewRequest("DELETE", "/users/invalid", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+func TestUserHandler_DeleteUser_NotFound(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Set("userID", uint(1)) // Current user ID (different from target)
+ c.Next()
+ })
+ r.DELETE("/users/:id", handler.DeleteUser)
+
+ req := httptest.NewRequest("DELETE", "/users/999", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusNotFound, w.Code)
+}
+
+func TestUserHandler_DeleteUser_Success(t *testing.T) {
+ handler, db := setupUserHandlerWithProxyHosts(t)
+
+ user := &models.User{UUID: uuid.NewString(), Email: "delete@example.com", Name: "Delete Me"}
+ db.Create(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Set("userID", uint(999)) // Different user
+ c.Next()
+ })
+ r.DELETE("/users/:id", handler.DeleteUser)
+
+ req := httptest.NewRequest("DELETE", "/users/1", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+}
+
+func TestUserHandler_DeleteUser_CannotDeleteSelf(t *testing.T) {
+ handler, db := setupUserHandlerWithProxyHosts(t)
+
+ user := &models.User{UUID: uuid.NewString(), Email: "self@example.com", Name: "Self"}
+ db.Create(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Set("userID", user.ID) // Same user
+ c.Next()
+ })
+ r.DELETE("/users/:id", handler.DeleteUser)
+
+ req := httptest.NewRequest("DELETE", "/users/1", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+}
+
+func TestUserHandler_UpdateUserPermissions_NonAdmin(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "user")
+ c.Next()
+ })
+ r.PUT("/users/:id/permissions", handler.UpdateUserPermissions)
+
+ body := map[string]interface{}{"permission_mode": "allow_all"}
+ jsonBody, _ := json.Marshal(body)
+ req := httptest.NewRequest("PUT", "/users/1/permissions", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+}
+
+func TestUserHandler_UpdateUserPermissions_InvalidID(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ r.PUT("/users/:id/permissions", handler.UpdateUserPermissions)
+
+ body := map[string]interface{}{"permission_mode": "allow_all"}
+ jsonBody, _ := json.Marshal(body)
+ req := httptest.NewRequest("PUT", "/users/invalid/permissions", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+func TestUserHandler_UpdateUserPermissions_InvalidJSON(t *testing.T) {
+ handler, db := setupUserHandlerWithProxyHosts(t)
+
+ // Create a user first
+ user := &models.User{
+ UUID: uuid.NewString(),
+ APIKey: uuid.NewString(),
+ Email: "perms-invalid@example.com",
+ Name: "Perms Invalid Test",
+ Role: "user",
+ Enabled: true,
+ }
+ db.Create(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ r.PUT("/users/:id/permissions", handler.UpdateUserPermissions)
+
+ req := httptest.NewRequest("PUT", "/users/"+strconv.FormatUint(uint64(user.ID), 10)+"/permissions", bytes.NewBufferString("invalid"))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+func TestUserHandler_UpdateUserPermissions_NotFound(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ r.PUT("/users/:id/permissions", handler.UpdateUserPermissions)
+
+ body := map[string]interface{}{"permission_mode": "allow_all"}
+ jsonBody, _ := json.Marshal(body)
+ req := httptest.NewRequest("PUT", "/users/999/permissions", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusNotFound, w.Code)
+}
+
+func TestUserHandler_UpdateUserPermissions_Success(t *testing.T) {
+ handler, db := setupUserHandlerWithProxyHosts(t)
+
+ host := &models.ProxyHost{Name: "Host 1", DomainNames: "host1.example.com", Enabled: true}
+ db.Create(host)
+
+ user := &models.User{
+ UUID: uuid.NewString(),
+ Email: "perms@example.com",
+ Name: "Perms User",
+ PermissionMode: models.PermissionModeAllowAll,
+ }
+ db.Create(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ r.PUT("/users/:id/permissions", handler.UpdateUserPermissions)
+
+ body := map[string]interface{}{
+ "permission_mode": "deny_all",
+ "permitted_hosts": []uint{host.ID},
+ }
+ jsonBody, _ := json.Marshal(body)
+ req := httptest.NewRequest("PUT", "/users/1/permissions", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+}
+
+func TestUserHandler_ValidateInvite_MissingToken(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.GET("/invite/validate", handler.ValidateInvite)
+
+ req := httptest.NewRequest("GET", "/invite/validate", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+func TestUserHandler_ValidateInvite_InvalidToken(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.GET("/invite/validate", handler.ValidateInvite)
+
+ req := httptest.NewRequest("GET", "/invite/validate?token=invalidtoken", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusNotFound, w.Code)
+}
+
+func TestUserHandler_ValidateInvite_ExpiredToken(t *testing.T) {
+ handler, db := setupUserHandlerWithProxyHosts(t)
+
+ expiredTime := time.Now().Add(-24 * time.Hour) // Expired yesterday
+ user := &models.User{
+ UUID: uuid.NewString(),
+ Email: "expired@example.com",
+ Name: "Expired Invite",
+ InviteToken: "expiredtoken123",
+ InviteExpires: &expiredTime,
+ InviteStatus: "pending",
+ }
+ db.Create(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.GET("/invite/validate", handler.ValidateInvite)
+
+ req := httptest.NewRequest("GET", "/invite/validate?token=expiredtoken123", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusGone, w.Code)
+}
+
+func TestUserHandler_ValidateInvite_AlreadyAccepted(t *testing.T) {
+ handler, db := setupUserHandlerWithProxyHosts(t)
+
+ expiresAt := time.Now().Add(24 * time.Hour)
+ user := &models.User{
+ UUID: uuid.NewString(),
+ Email: "accepted@example.com",
+ Name: "Accepted Invite",
+ InviteToken: "acceptedtoken123",
+ InviteExpires: &expiresAt,
+ InviteStatus: "accepted",
+ }
+ db.Create(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.GET("/invite/validate", handler.ValidateInvite)
+
+ req := httptest.NewRequest("GET", "/invite/validate?token=acceptedtoken123", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusConflict, w.Code)
+}
+
+func TestUserHandler_ValidateInvite_Success(t *testing.T) {
+ handler, db := setupUserHandlerWithProxyHosts(t)
+
+ expiresAt := time.Now().Add(24 * time.Hour)
+ user := &models.User{
+ UUID: uuid.NewString(),
+ Email: "valid@example.com",
+ Name: "Valid Invite",
+ InviteToken: "validtoken123",
+ InviteExpires: &expiresAt,
+ InviteStatus: "pending",
+ }
+ db.Create(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.GET("/invite/validate", handler.ValidateInvite)
+
+ req := httptest.NewRequest("GET", "/invite/validate?token=validtoken123", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ var resp map[string]interface{}
+ json.Unmarshal(w.Body.Bytes(), &resp)
+ assert.Equal(t, "valid@example.com", resp["email"])
+}
+
+func TestUserHandler_AcceptInvite_InvalidJSON(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.POST("/invite/accept", handler.AcceptInvite)
+
+ req := httptest.NewRequest("POST", "/invite/accept", bytes.NewBufferString("invalid"))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+func TestUserHandler_AcceptInvite_InvalidToken(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.POST("/invite/accept", handler.AcceptInvite)
+
+ body := map[string]string{
+ "token": "invalidtoken",
+ "name": "Test User",
+ "password": "password123",
+ }
+ jsonBody, _ := json.Marshal(body)
+ req := httptest.NewRequest("POST", "/invite/accept", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusNotFound, w.Code)
+}
+
+func TestUserHandler_AcceptInvite_Success(t *testing.T) {
+ handler, db := setupUserHandlerWithProxyHosts(t)
+
+ expiresAt := time.Now().Add(24 * time.Hour)
+ user := &models.User{
+ UUID: uuid.NewString(),
+ Email: "accept@example.com",
+ Name: "Accept User",
+ InviteToken: "accepttoken123",
+ InviteExpires: &expiresAt,
+ InviteStatus: "pending",
+ }
+ db.Create(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.POST("/invite/accept", handler.AcceptInvite)
+
+ body := map[string]string{
+ "token": "accepttoken123",
+ "password": "newpassword123",
+ "name": "Accepted User",
+ }
+ jsonBody, _ := json.Marshal(body)
+ req := httptest.NewRequest("POST", "/invite/accept", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+
+ // Verify user was updated
+ var updated models.User
+ db.First(&updated, user.ID)
+ assert.Equal(t, "accepted", updated.InviteStatus)
+ assert.True(t, updated.Enabled)
+}
+
+func TestGenerateSecureToken(t *testing.T) {
+ token, err := generateSecureToken(32)
+ assert.NoError(t, err)
+ assert.Len(t, token, 64) // 32 bytes = 64 hex chars
+ assert.Regexp(t, "^[a-f0-9]+$", token)
+
+ // Ensure uniqueness
+ token2, err := generateSecureToken(32)
+ assert.NoError(t, err)
+ assert.NotEqual(t, token, token2)
+}
+
+func TestUserHandler_InviteUser_NonAdmin(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "user")
+ c.Set("userID", uint(1))
+ c.Next()
+ })
+ r.POST("/users/invite", handler.InviteUser)
+
+ body := map[string]string{"email": "invitee@example.com"}
+ jsonBody, _ := json.Marshal(body)
+ req := httptest.NewRequest("POST", "/users/invite", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+}
+
+func TestUserHandler_InviteUser_InvalidJSON(t *testing.T) {
+ handler, _ := setupUserHandlerWithProxyHosts(t)
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Set("userID", uint(1))
+ c.Next()
+ })
+ r.POST("/users/invite", handler.InviteUser)
+
+ req := httptest.NewRequest("POST", "/users/invite", bytes.NewBufferString("invalid"))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+func TestUserHandler_InviteUser_DuplicateEmail(t *testing.T) {
+ handler, db := setupUserHandlerWithProxyHosts(t)
+
+ // Create existing user
+ existingUser := &models.User{
+ UUID: uuid.NewString(),
+ APIKey: uuid.NewString(),
+ Email: "existing@example.com",
+ }
+ db.Create(existingUser)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Set("userID", uint(1))
+ c.Next()
+ })
+ r.POST("/users/invite", handler.InviteUser)
+
+ body := map[string]string{"email": "existing@example.com"}
+ jsonBody, _ := json.Marshal(body)
+ req := httptest.NewRequest("POST", "/users/invite", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusConflict, w.Code)
+}
+
+func TestUserHandler_InviteUser_Success(t *testing.T) {
+ handler, db := setupUserHandlerWithProxyHosts(t)
+
+ // Create admin user
+ admin := &models.User{
+ UUID: uuid.NewString(),
+ APIKey: uuid.NewString(),
+ Email: "admin@example.com",
+ Role: "admin",
+ }
+ db.Create(admin)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Set("userID", admin.ID)
+ c.Next()
+ })
+ r.POST("/users/invite", handler.InviteUser)
+
+ body := map[string]interface{}{
+ "email": "newinvite@example.com",
+ "role": "user",
+ }
+ jsonBody, _ := json.Marshal(body)
+ req := httptest.NewRequest("POST", "/users/invite", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusCreated, w.Code)
+
+ var resp map[string]interface{}
+ json.Unmarshal(w.Body.Bytes(), &resp)
+ assert.NotEmpty(t, resp["invite_token"])
+ // email_sent is false because no SMTP is configured
+ assert.Equal(t, false, resp["email_sent"].(bool))
+
+ // Verify user was created
+ var user models.User
+ db.Where("email = ?", "newinvite@example.com").First(&user)
+ assert.Equal(t, "pending", user.InviteStatus)
+ assert.False(t, user.Enabled)
+}
+
+func TestUserHandler_InviteUser_WithPermittedHosts(t *testing.T) {
+ handler, db := setupUserHandlerWithProxyHosts(t)
+
+ // Create admin user
+ admin := &models.User{
+ UUID: uuid.NewString(),
+ APIKey: uuid.NewString(),
+ Email: "admin-perm@example.com",
+ Role: "admin",
+ }
+ db.Create(admin)
+
+ // Create proxy host
+ host := &models.ProxyHost{
+ UUID: uuid.NewString(),
+ Name: "Test Host",
+ DomainNames: "test.example.com",
+ }
+ db.Create(host)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Set("userID", admin.ID)
+ c.Next()
+ })
+ r.POST("/users/invite", handler.InviteUser)
+
+ body := map[string]interface{}{
+ "email": "invitee-perms@example.com",
+ "permission_mode": "deny_all",
+ "permitted_hosts": []uint{host.ID},
+ }
+ jsonBody, _ := json.Marshal(body)
+ req := httptest.NewRequest("POST", "/users/invite", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusCreated, w.Code)
+
+ // Verify user has permitted hosts
+ var user models.User
+ db.Preload("PermittedHosts").Where("email = ?", "invitee-perms@example.com").First(&user)
+ assert.Len(t, user.PermittedHosts, 1)
+ assert.Equal(t, models.PermissionModeDenyAll, user.PermissionMode)
+}
+
+func TestGetBaseURL(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ // Test with X-Forwarded-Proto header
+ r := gin.New()
+ r.GET("/test", func(c *gin.Context) {
+ url := getBaseURL(c)
+ c.String(200, url)
+ })
+
+ req := httptest.NewRequest("GET", "/test", nil)
+ req.Host = "example.com"
+ req.Header.Set("X-Forwarded-Proto", "https")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, "https://example.com", w.Body.String())
+}
+
+func TestGetAppName(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open("file:appname?mode=memory&cache=shared"), &gorm.Config{})
+ require.NoError(t, err)
+ db.AutoMigrate(&models.Setting{})
+
+ // Test default
+ name := getAppName(db)
+ assert.Equal(t, "Charon", name)
+
+ // Test with custom setting
+ db.Create(&models.Setting{Key: "app_name", Value: "CustomApp"})
+ name = getAppName(db)
+ assert.Equal(t, "CustomApp", name)
+}
+
+func TestUserHandler_AcceptInvite_ExpiredToken(t *testing.T) {
+ handler, db := setupUserHandlerWithProxyHosts(t)
+
+ // Create user with expired invite
+ expired := time.Now().Add(-24 * time.Hour)
+ user := &models.User{
+ UUID: uuid.NewString(),
+ APIKey: uuid.NewString(),
+ Email: "expired-invite@example.com",
+ InviteToken: "expiredtoken123",
+ InviteExpires: &expired,
+ InviteStatus: "pending",
+ }
+ db.Create(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.POST("/invite/accept", handler.AcceptInvite)
+
+ body := map[string]string{
+ "token": "expiredtoken123",
+ "name": "Expired User",
+ "password": "password123",
+ }
+ jsonBody, _ := json.Marshal(body)
+ req := httptest.NewRequest("POST", "/invite/accept", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusGone, w.Code)
+}
+
+func TestUserHandler_AcceptInvite_AlreadyAccepted(t *testing.T) {
+ handler, db := setupUserHandlerWithProxyHosts(t)
+
+ expires := time.Now().Add(24 * time.Hour)
+ user := &models.User{
+ UUID: uuid.NewString(),
+ APIKey: uuid.NewString(),
+ Email: "accepted-already@example.com",
+ InviteToken: "acceptedtoken123",
+ InviteExpires: &expires,
+ InviteStatus: "accepted",
+ }
+ db.Create(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.POST("/invite/accept", handler.AcceptInvite)
+
+ body := map[string]string{
+ "token": "acceptedtoken123",
+ "name": "Already Accepted",
+ "password": "password123",
+ }
+ jsonBody, _ := json.Marshal(body)
+ req := httptest.NewRequest("POST", "/invite/accept", bytes.NewBuffer(jsonBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusConflict, w.Code)
+}
diff --git a/backend/internal/api/middleware/security.go b/backend/internal/api/middleware/security.go
new file mode 100644
index 00000000..6488f803
--- /dev/null
+++ b/backend/internal/api/middleware/security.go
@@ -0,0 +1,126 @@
+package middleware
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+// SecurityHeadersConfig holds configuration for the security headers middleware.
+type SecurityHeadersConfig struct {
+ // IsDevelopment enables less strict settings for local development
+ IsDevelopment bool
+ // CustomCSPDirectives allows adding extra CSP directives
+ CustomCSPDirectives map[string]string
+}
+
+// DefaultSecurityHeadersConfig returns a secure default configuration.
+func DefaultSecurityHeadersConfig() SecurityHeadersConfig {
+ return SecurityHeadersConfig{
+ IsDevelopment: false,
+ CustomCSPDirectives: nil,
+ }
+}
+
+// SecurityHeaders returns middleware that sets security-related HTTP headers.
+// This implements Phase 1 of the security hardening plan.
+func SecurityHeaders(cfg SecurityHeadersConfig) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Build Content-Security-Policy
+ csp := buildCSP(cfg)
+ c.Header("Content-Security-Policy", csp)
+
+ // Strict-Transport-Security (HSTS)
+ // max-age=31536000 = 1 year
+ // includeSubDomains ensures all subdomains also use HTTPS
+ // preload allows browser preload lists (requires submission to hstspreload.org)
+ if !cfg.IsDevelopment {
+ c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
+ }
+
+ // X-Frame-Options: Prevent clickjacking
+ // DENY prevents any framing; SAMEORIGIN would allow same-origin framing
+ c.Header("X-Frame-Options", "DENY")
+
+ // X-Content-Type-Options: Prevent MIME sniffing
+ c.Header("X-Content-Type-Options", "nosniff")
+
+ // X-XSS-Protection: Enable browser XSS filtering (legacy but still useful)
+ // mode=block tells browser to block the response if XSS is detected
+ c.Header("X-XSS-Protection", "1; mode=block")
+
+ // Referrer-Policy: Control referrer information sent with requests
+ // strict-origin-when-cross-origin sends full URL for same-origin, origin only for cross-origin
+ c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
+
+ // Permissions-Policy: Restrict browser features
+ // Disable features that aren't needed for security
+ c.Header("Permissions-Policy", buildPermissionsPolicy())
+
+ // Cross-Origin-Opener-Policy: Isolate browsing context
+ c.Header("Cross-Origin-Opener-Policy", "same-origin")
+
+ // Cross-Origin-Resource-Policy: Prevent cross-origin reads
+ c.Header("Cross-Origin-Resource-Policy", "same-origin")
+
+ // Cross-Origin-Embedder-Policy: Require CORP for cross-origin resources
+ // Note: This can break some external resources, use with caution
+ // c.Header("Cross-Origin-Embedder-Policy", "require-corp")
+
+ c.Next()
+ }
+}
+
+// buildCSP constructs the Content-Security-Policy header value.
+func buildCSP(cfg SecurityHeadersConfig) string {
+ // Base CSP directives for a secure single-page application
+ directives := map[string]string{
+ "default-src": "'self'",
+ "script-src": "'self'",
+ "style-src": "'self' 'unsafe-inline'", // unsafe-inline needed for many CSS-in-JS solutions
+ "img-src": "'self' data: https:", // Allow HTTPS images and data URIs
+ "font-src": "'self' data:", // Allow self-hosted fonts and data URIs
+ "connect-src": "'self'", // API connections
+ "frame-src": "'none'", // No iframes
+ "object-src": "'none'", // No plugins (Flash, etc.)
+ "base-uri": "'self'", // Restrict base tag
+ "form-action": "'self'", // Restrict form submissions
+ }
+
+ // In development, allow more sources for hot reloading, etc.
+ if cfg.IsDevelopment {
+ directives["script-src"] = "'self' 'unsafe-inline' 'unsafe-eval'"
+ directives["connect-src"] = "'self' ws: wss:" // WebSocket for HMR
+ }
+
+ // Apply custom directives
+ for key, value := range cfg.CustomCSPDirectives {
+ directives[key] = value
+ }
+
+ // Build the CSP string
+ var parts []string
+ for directive, value := range directives {
+ parts = append(parts, fmt.Sprintf("%s %s", directive, value))
+ }
+
+ return strings.Join(parts, "; ")
+}
+
+// buildPermissionsPolicy constructs the Permissions-Policy header value.
+func buildPermissionsPolicy() string {
+ // Disable features we don't need
+ policies := []string{
+ "accelerometer=()",
+ "camera=()",
+ "geolocation=()",
+ "gyroscope=()",
+ "magnetometer=()",
+ "microphone=()",
+ "payment=()",
+ "usb=()",
+ }
+
+ return strings.Join(policies, ", ")
+}
diff --git a/backend/internal/api/middleware/security_test.go b/backend/internal/api/middleware/security_test.go
new file mode 100644
index 00000000..d83cf7bf
--- /dev/null
+++ b/backend/internal/api/middleware/security_test.go
@@ -0,0 +1,182 @@
+package middleware
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSecurityHeaders(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ tests := []struct {
+ name string
+ isDevelopment bool
+ checkHeaders func(t *testing.T, resp *httptest.ResponseRecorder)
+ }{
+ {
+ name: "production mode sets HSTS",
+ isDevelopment: false,
+ checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
+ hsts := resp.Header().Get("Strict-Transport-Security")
+ assert.Contains(t, hsts, "max-age=31536000")
+ assert.Contains(t, hsts, "includeSubDomains")
+ assert.Contains(t, hsts, "preload")
+ },
+ },
+ {
+ name: "development mode skips HSTS",
+ isDevelopment: true,
+ checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
+ hsts := resp.Header().Get("Strict-Transport-Security")
+ assert.Empty(t, hsts)
+ },
+ },
+ {
+ name: "sets X-Frame-Options",
+ isDevelopment: false,
+ checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
+ assert.Equal(t, "DENY", resp.Header().Get("X-Frame-Options"))
+ },
+ },
+ {
+ name: "sets X-Content-Type-Options",
+ isDevelopment: false,
+ checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
+ assert.Equal(t, "nosniff", resp.Header().Get("X-Content-Type-Options"))
+ },
+ },
+ {
+ name: "sets X-XSS-Protection",
+ isDevelopment: false,
+ checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
+ assert.Equal(t, "1; mode=block", resp.Header().Get("X-XSS-Protection"))
+ },
+ },
+ {
+ name: "sets Referrer-Policy",
+ isDevelopment: false,
+ checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
+ assert.Equal(t, "strict-origin-when-cross-origin", resp.Header().Get("Referrer-Policy"))
+ },
+ },
+ {
+ name: "sets Content-Security-Policy",
+ isDevelopment: false,
+ checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
+ csp := resp.Header().Get("Content-Security-Policy")
+ assert.NotEmpty(t, csp)
+ assert.Contains(t, csp, "default-src")
+ },
+ },
+ {
+ name: "development mode CSP allows unsafe-eval",
+ isDevelopment: true,
+ checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
+ csp := resp.Header().Get("Content-Security-Policy")
+ assert.Contains(t, csp, "unsafe-eval")
+ },
+ },
+ {
+ name: "sets Permissions-Policy",
+ isDevelopment: false,
+ checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
+ pp := resp.Header().Get("Permissions-Policy")
+ assert.NotEmpty(t, pp)
+ assert.Contains(t, pp, "camera=()")
+ assert.Contains(t, pp, "microphone=()")
+ },
+ },
+ {
+ name: "sets Cross-Origin-Opener-Policy",
+ isDevelopment: false,
+ checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
+ assert.Equal(t, "same-origin", resp.Header().Get("Cross-Origin-Opener-Policy"))
+ },
+ },
+ {
+ name: "sets Cross-Origin-Resource-Policy",
+ isDevelopment: false,
+ checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
+ assert.Equal(t, "same-origin", resp.Header().Get("Cross-Origin-Resource-Policy"))
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ router := gin.New()
+ router.Use(SecurityHeaders(SecurityHeadersConfig{
+ IsDevelopment: tt.isDevelopment,
+ }))
+ router.GET("/test", func(c *gin.Context) {
+ c.String(http.StatusOK, "OK")
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ resp := httptest.NewRecorder()
+ router.ServeHTTP(resp, req)
+
+ assert.Equal(t, http.StatusOK, resp.Code)
+ tt.checkHeaders(t, resp)
+ })
+ }
+}
+
+func TestSecurityHeadersCustomCSP(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ router := gin.New()
+ router.Use(SecurityHeaders(SecurityHeadersConfig{
+ IsDevelopment: false,
+ CustomCSPDirectives: map[string]string{
+ "frame-src": "'self' https://trusted.com",
+ },
+ }))
+ router.GET("/test", func(c *gin.Context) {
+ c.String(http.StatusOK, "OK")
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ resp := httptest.NewRecorder()
+ router.ServeHTTP(resp, req)
+
+ csp := resp.Header().Get("Content-Security-Policy")
+ assert.Contains(t, csp, "frame-src 'self' https://trusted.com")
+}
+
+func TestDefaultSecurityHeadersConfig(t *testing.T) {
+ cfg := DefaultSecurityHeadersConfig()
+ assert.False(t, cfg.IsDevelopment)
+ assert.Nil(t, cfg.CustomCSPDirectives)
+}
+
+func TestBuildCSP(t *testing.T) {
+ t.Run("production CSP", func(t *testing.T) {
+ csp := buildCSP(SecurityHeadersConfig{IsDevelopment: false})
+ assert.Contains(t, csp, "default-src 'self'")
+ assert.Contains(t, csp, "script-src 'self'")
+ assert.NotContains(t, csp, "unsafe-eval")
+ })
+
+ t.Run("development CSP", func(t *testing.T) {
+ csp := buildCSP(SecurityHeadersConfig{IsDevelopment: true})
+ assert.Contains(t, csp, "unsafe-eval")
+ assert.Contains(t, csp, "ws:")
+ })
+}
+
+func TestBuildPermissionsPolicy(t *testing.T) {
+ pp := buildPermissionsPolicy()
+
+ // Check that dangerous features are disabled
+ disabledFeatures := []string{"camera", "microphone", "geolocation", "payment"}
+ for _, feature := range disabledFeatures {
+ assert.True(t, strings.Contains(pp, feature+"=()"),
+ "Expected %s to be disabled in permissions policy", feature)
+ }
+}
diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go
index 331c5301..4b556c10 100644
--- a/backend/internal/api/routes/routes.go
+++ b/backend/internal/api/routes/routes.go
@@ -23,6 +23,13 @@ import (
// Register wires up API routes and performs automatic migrations.
func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
+ // Apply security headers middleware globally
+ // This sets CSP, HSTS, X-Frame-Options, etc.
+ securityHeadersCfg := middleware.SecurityHeadersConfig{
+ IsDevelopment: cfg.Environment == "development",
+ }
+ router.Use(middleware.SecurityHeaders(securityHeadersCfg))
+
// AutoMigrate all models for Issue #5 persistence layer
if err := db.AutoMigrate(
&models.ProxyHost{},
@@ -46,6 +53,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
&models.SecurityDecision{},
&models.SecurityAudit{},
&models.SecurityRuleSet{},
+ &models.UserPermittedHost{}, // Join table for user permissions
); err != nil {
return fmt.Errorf("auto migrate: %w", err)
}
@@ -85,7 +93,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
// Auth routes
authService := services.NewAuthService(db, cfg)
- authHandler := handlers.NewAuthHandler(authService)
+ authHandler := handlers.NewAuthHandlerWithDB(authService, db)
authMiddleware := middleware.AuthMiddleware(authService)
// Backup routes
@@ -105,6 +113,15 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
api.POST("/auth/login", authHandler.Login)
api.POST("/auth/register", authHandler.Register)
+ // Forward auth endpoint for Caddy (public, validates session internally)
+ api.GET("/auth/verify", authHandler.Verify)
+ api.GET("/auth/status", authHandler.VerifyStatus)
+
+ // User invite acceptance (public endpoints)
+ userHandler := handlers.NewUserHandler(db)
+ api.GET("/invite/validate", userHandler.ValidateInvite)
+ api.POST("/invite/accept", userHandler.AcceptInvite)
+
// Uptime Service - define early so it can be used during route registration
uptimeService := services.NewUptimeService(db, notificationService)
@@ -132,17 +149,35 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
protected.GET("/settings", settingsHandler.GetSettings)
protected.POST("/settings", settingsHandler.UpdateSetting)
+ // SMTP Configuration
+ protected.GET("/settings/smtp", settingsHandler.GetSMTPConfig)
+ protected.POST("/settings/smtp", settingsHandler.UpdateSMTPConfig)
+ protected.POST("/settings/smtp/test", settingsHandler.TestSMTPConfig)
+ protected.POST("/settings/smtp/test-email", settingsHandler.SendTestEmail)
+
+ // Auth related protected routes
+ protected.GET("/auth/accessible-hosts", authHandler.GetAccessibleHosts)
+ protected.GET("/auth/check-host/:hostId", authHandler.CheckHostAccess)
+
// Feature flags (DB-backed with env fallback)
featureFlagsHandler := handlers.NewFeatureFlagsHandler(db)
protected.GET("/feature-flags", featureFlagsHandler.GetFlags)
protected.PUT("/feature-flags", featureFlagsHandler.UpdateFlags)
// User Profile & API Key
- userHandler := handlers.NewUserHandler(db)
protected.GET("/user/profile", userHandler.GetProfile)
protected.POST("/user/profile", userHandler.UpdateProfile)
protected.POST("/user/api-key", userHandler.RegenerateAPIKey)
+ // User Management (admin only routes are in RegisterRoutes)
+ protected.GET("/users", userHandler.ListUsers)
+ protected.POST("/users", userHandler.CreateUser)
+ protected.POST("/users/invite", userHandler.InviteUser)
+ protected.GET("/users/:id", userHandler.GetUser)
+ protected.PUT("/users/:id", userHandler.UpdateUser)
+ protected.DELETE("/users/:id", userHandler.DeleteUser)
+ protected.PUT("/users/:id/permissions", userHandler.UpdateUserPermissions)
+
// Updates
updateService := services.NewUpdateService()
updateHandler := handlers.NewUpdateHandler(updateService)
@@ -267,9 +302,6 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
protected.DELETE("/access-lists/:id", accessListHandler.Delete)
protected.POST("/access-lists/:id/test", accessListHandler.TestIP)
- userHandler := handlers.NewUserHandler(db)
- userHandler.RegisterRoutes(api)
-
// Certificate routes
// Use cfg.CaddyConfigDir + "/data" for cert service so we scan the actual Caddy storage
// where ACME and certificates are stored (e.g. /data).
diff --git a/backend/internal/models/proxy_host.go b/backend/internal/models/proxy_host.go
index 0d6503b8..5eceed0c 100644
--- a/backend/internal/models/proxy_host.go
+++ b/backend/internal/models/proxy_host.go
@@ -28,6 +28,11 @@ type ProxyHost struct {
Locations []Location `json:"locations" gorm:"foreignKey:ProxyHostID;constraint:OnDelete:CASCADE"`
AdvancedConfig string `json:"advanced_config" gorm:"type:text"`
AdvancedConfigBackup string `json:"advanced_config_backup" gorm:"type:text"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+
+ // Forward Auth / User Gateway settings
+ // When enabled, Caddy will use forward_auth to verify user access via Charon
+ ForwardAuthEnabled bool `json:"forward_auth_enabled" gorm:"default:false"`
+
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go
index 49640a95..c7cce43e 100644
--- a/backend/internal/models/user.go
+++ b/backend/internal/models/user.go
@@ -6,8 +6,18 @@ import (
"golang.org/x/crypto/bcrypt"
)
+// PermissionMode determines how user access to proxy hosts is evaluated.
+type PermissionMode string
+
+const (
+ // PermissionModeAllowAll grants access to all hosts except those in the exception list.
+ PermissionModeAllowAll PermissionMode = "allow_all"
+ // PermissionModeDenyAll denies access to all hosts except those in the exception list.
+ PermissionModeDenyAll PermissionMode = "deny_all"
+)
+
// User represents authenticated users with role-based access control.
-// Supports local auth, SSO integration planned for later phases.
+// Supports local auth, SSO integration, and invite-based onboarding.
type User struct {
ID uint `json:"id" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex"`
@@ -20,8 +30,20 @@ type User struct {
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"`
+
+ // Invite system fields
+ InviteToken string `json:"-" gorm:"index"` // Token sent via email for account setup
+ InviteExpires *time.Time `json:"-"` // When the invite token expires
+ InvitedAt *time.Time `json:"invited_at,omitempty"` // When the invite was sent
+ InvitedBy *uint `json:"invited_by,omitempty"` // ID of user who sent the invite
+ InviteStatus string `json:"invite_status,omitempty"` // "pending", "accepted", "expired"
+
+ // Permission system for forward auth / user gateway
+ PermissionMode PermissionMode `json:"permission_mode" gorm:"default:'allow_all'"` // "allow_all" or "deny_all"
+ PermittedHosts []ProxyHost `json:"permitted_hosts,omitempty" gorm:"many2many:user_permitted_hosts;"`
+
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
// SetPassword hashes and sets the user's password.
@@ -39,3 +61,49 @@ func (u *User) CheckPassword(password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password))
return err == nil
}
+
+// HasPendingInvite returns true if the user has a pending invite that hasn't expired.
+func (u *User) HasPendingInvite() bool {
+ if u.InviteToken == "" || u.InviteExpires == nil {
+ return false
+ }
+ return u.InviteExpires.After(time.Now()) && u.InviteStatus == "pending"
+}
+
+// CanAccessHost determines if the user can access a given proxy host based on their permission mode.
+// - allow_all mode: User can access everything EXCEPT hosts in PermittedHosts (blacklist)
+// - deny_all mode: User can ONLY access hosts in PermittedHosts (whitelist)
+func (u *User) CanAccessHost(hostID uint) bool {
+ // Admins always have access
+ if u.Role == "admin" {
+ return true
+ }
+
+ // Check if host is in the permitted hosts list
+ hostInList := false
+ for _, h := range u.PermittedHosts {
+ if h.ID == hostID {
+ hostInList = true
+ break
+ }
+ }
+
+ switch u.PermissionMode {
+ case PermissionModeAllowAll:
+ // Allow all except those in the list (blacklist)
+ return !hostInList
+ case PermissionModeDenyAll:
+ // Deny all except those in the list (whitelist)
+ return hostInList
+ default:
+ // Default to allow_all behavior
+ return !hostInList
+ }
+}
+
+// UserPermittedHost is the join table for the many-to-many relationship.
+// This is auto-created by GORM but defined here for clarity.
+type UserPermittedHost struct {
+ UserID uint `gorm:"primaryKey"`
+ ProxyHostID uint `gorm:"primaryKey"`
+}
diff --git a/backend/internal/models/user_test.go b/backend/internal/models/user_test.go
index eb3ef30c..281949b9 100644
--- a/backend/internal/models/user_test.go
+++ b/backend/internal/models/user_test.go
@@ -2,6 +2,7 @@ package models
import (
"testing"
+ "time"
"github.com/stretchr/testify/assert"
)
@@ -21,3 +22,162 @@ func TestUser_CheckPassword(t *testing.T) {
assert.True(t, u.CheckPassword("password123"))
assert.False(t, u.CheckPassword("wrongpassword"))
}
+
+func TestUser_HasPendingInvite(t *testing.T) {
+ tests := []struct {
+ name string
+ user User
+ expected bool
+ }{
+ {
+ name: "no invite token",
+ user: User{InviteToken: "", InviteStatus: ""},
+ expected: false,
+ },
+ {
+ name: "expired invite",
+ user: User{
+ InviteToken: "token123",
+ InviteExpires: timePtr(time.Now().Add(-1 * time.Hour)),
+ InviteStatus: "pending",
+ },
+ expected: false,
+ },
+ {
+ name: "valid pending invite",
+ user: User{
+ InviteToken: "token123",
+ InviteExpires: timePtr(time.Now().Add(24 * time.Hour)),
+ InviteStatus: "pending",
+ },
+ expected: true,
+ },
+ {
+ name: "already accepted invite",
+ user: User{
+ InviteToken: "token123",
+ InviteExpires: timePtr(time.Now().Add(24 * time.Hour)),
+ InviteStatus: "accepted",
+ },
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tt.user.HasPendingInvite()
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestUser_CanAccessHost_AllowAll(t *testing.T) {
+ // User with allow_all mode (blacklist) - can access everything except listed hosts
+ user := User{
+ Role: "user",
+ PermissionMode: PermissionModeAllowAll,
+ PermittedHosts: []ProxyHost{
+ {ID: 1}, // Blocked host
+ {ID: 2}, // Blocked host
+ },
+ }
+
+ // Should NOT be able to access hosts in the blacklist
+ assert.False(t, user.CanAccessHost(1))
+ assert.False(t, user.CanAccessHost(2))
+
+ // Should be able to access other hosts
+ assert.True(t, user.CanAccessHost(3))
+ assert.True(t, user.CanAccessHost(100))
+}
+
+func TestUser_CanAccessHost_DenyAll(t *testing.T) {
+ // User with deny_all mode (whitelist) - can only access listed hosts
+ user := User{
+ Role: "user",
+ PermissionMode: PermissionModeDenyAll,
+ PermittedHosts: []ProxyHost{
+ {ID: 5}, // Allowed host
+ {ID: 6}, // Allowed host
+ },
+ }
+
+ // Should be able to access hosts in the whitelist
+ assert.True(t, user.CanAccessHost(5))
+ assert.True(t, user.CanAccessHost(6))
+
+ // Should NOT be able to access other hosts
+ assert.False(t, user.CanAccessHost(1))
+ assert.False(t, user.CanAccessHost(100))
+}
+
+func TestUser_CanAccessHost_AdminBypass(t *testing.T) {
+ // Admin users should always have access regardless of permission mode
+ adminUser := User{
+ Role: "admin",
+ PermissionMode: PermissionModeDenyAll,
+ PermittedHosts: []ProxyHost{}, // No hosts in whitelist
+ }
+
+ // Admin should still be able to access any host
+ assert.True(t, adminUser.CanAccessHost(1))
+ assert.True(t, adminUser.CanAccessHost(999))
+}
+
+func TestUser_CanAccessHost_DefaultBehavior(t *testing.T) {
+ // User with empty/default permission mode should behave like allow_all
+ user := User{
+ Role: "user",
+ PermissionMode: "", // Empty = default
+ PermittedHosts: []ProxyHost{
+ {ID: 1}, // Should be blocked
+ },
+ }
+
+ assert.False(t, user.CanAccessHost(1))
+ assert.True(t, user.CanAccessHost(2))
+}
+
+func TestUser_CanAccessHost_EmptyPermittedHosts(t *testing.T) {
+ tests := []struct {
+ name string
+ permissionMode PermissionMode
+ hostID uint
+ expected bool
+ }{
+ {
+ name: "allow_all with no exceptions allows all",
+ permissionMode: PermissionModeAllowAll,
+ hostID: 1,
+ expected: true,
+ },
+ {
+ name: "deny_all with no exceptions denies all",
+ permissionMode: PermissionModeDenyAll,
+ hostID: 1,
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ user := User{
+ Role: "user",
+ PermissionMode: tt.permissionMode,
+ PermittedHosts: []ProxyHost{},
+ }
+ result := user.CanAccessHost(tt.hostID)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestPermissionMode_Constants(t *testing.T) {
+ assert.Equal(t, PermissionMode("allow_all"), PermissionModeAllowAll)
+ assert.Equal(t, PermissionMode("deny_all"), PermissionModeDenyAll)
+}
+
+// Helper function to create time pointers
+func timePtr(t time.Time) *time.Time {
+ return &t
+}
diff --git a/backend/internal/services/mail_service.go b/backend/internal/services/mail_service.go
new file mode 100644
index 00000000..d0238076
--- /dev/null
+++ b/backend/internal/services/mail_service.go
@@ -0,0 +1,368 @@
+package services
+
+import (
+ "bytes"
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "html/template"
+ "net/smtp"
+ "strings"
+
+ "github.com/Wikid82/charon/backend/internal/logger"
+ "github.com/Wikid82/charon/backend/internal/models"
+ "gorm.io/gorm"
+)
+
+// SMTPConfig holds the SMTP server configuration.
+type SMTPConfig struct {
+ Host string `json:"host"`
+ Port int `json:"port"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ FromAddress string `json:"from_address"`
+ Encryption string `json:"encryption"` // "none", "ssl", "starttls"
+}
+
+// MailService handles sending emails via SMTP.
+type MailService struct {
+ db *gorm.DB
+}
+
+// NewMailService creates a new mail service instance.
+func NewMailService(db *gorm.DB) *MailService {
+ return &MailService{db: db}
+}
+
+// GetSMTPConfig retrieves SMTP settings from the database.
+func (s *MailService) GetSMTPConfig() (*SMTPConfig, error) {
+ var settings []models.Setting
+ if err := s.db.Where("category = ?", "smtp").Find(&settings).Error; err != nil {
+ return nil, fmt.Errorf("failed to load SMTP settings: %w", err)
+ }
+
+ config := &SMTPConfig{
+ Port: 587, // Default port
+ Encryption: "starttls",
+ }
+
+ for _, setting := range settings {
+ switch setting.Key {
+ case "smtp_host":
+ config.Host = setting.Value
+ case "smtp_port":
+ if _, err := fmt.Sscanf(setting.Value, "%d", &config.Port); err != nil {
+ config.Port = 587
+ }
+ case "smtp_username":
+ config.Username = setting.Value
+ case "smtp_password":
+ config.Password = setting.Value
+ case "smtp_from_address":
+ config.FromAddress = setting.Value
+ case "smtp_encryption":
+ config.Encryption = setting.Value
+ }
+ }
+
+ return config, nil
+}
+
+// SaveSMTPConfig saves SMTP settings to the database.
+func (s *MailService) SaveSMTPConfig(config *SMTPConfig) error {
+ settings := map[string]string{
+ "smtp_host": config.Host,
+ "smtp_port": fmt.Sprintf("%d", config.Port),
+ "smtp_username": config.Username,
+ "smtp_password": config.Password,
+ "smtp_from_address": config.FromAddress,
+ "smtp_encryption": config.Encryption,
+ }
+
+ for key, value := range settings {
+ setting := models.Setting{
+ Key: key,
+ Value: value,
+ Type: "string",
+ Category: "smtp",
+ }
+
+ // Upsert: update if exists, create if not
+ result := s.db.Where("key = ?", key).First(&models.Setting{})
+ if result.Error == gorm.ErrRecordNotFound {
+ if err := s.db.Create(&setting).Error; err != nil {
+ return fmt.Errorf("failed to create setting %s: %w", key, err)
+ }
+ } else {
+ if err := s.db.Model(&models.Setting{}).Where("key = ?", key).Updates(map[string]interface{}{
+ "value": value,
+ "category": "smtp",
+ }).Error; err != nil {
+ return fmt.Errorf("failed to update setting %s: %w", key, err)
+ }
+ }
+ }
+
+ return nil
+}
+
+// IsConfigured returns true if SMTP is properly configured.
+func (s *MailService) IsConfigured() bool {
+ config, err := s.GetSMTPConfig()
+ if err != nil {
+ return false
+ }
+ return config.Host != "" && config.FromAddress != ""
+}
+
+// TestConnection tests the SMTP connection without sending an email.
+func (s *MailService) TestConnection() error {
+ config, err := s.GetSMTPConfig()
+ if err != nil {
+ return err
+ }
+
+ if config.Host == "" {
+ return errors.New("SMTP host not configured")
+ }
+
+ addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
+
+ // Try to connect based on encryption type
+ switch config.Encryption {
+ case "ssl":
+ tlsConfig := &tls.Config{
+ ServerName: config.Host,
+ MinVersion: tls.VersionTLS12,
+ }
+ conn, err := tls.Dial("tcp", addr, tlsConfig)
+ if err != nil {
+ return fmt.Errorf("SSL connection failed: %w", err)
+ }
+ defer conn.Close()
+
+ case "starttls", "none", "":
+ client, err := smtp.Dial(addr)
+ if err != nil {
+ return fmt.Errorf("SMTP connection failed: %w", err)
+ }
+ defer client.Close()
+
+ if config.Encryption == "starttls" {
+ tlsConfig := &tls.Config{
+ ServerName: config.Host,
+ MinVersion: tls.VersionTLS12,
+ }
+ if err := client.StartTLS(tlsConfig); err != nil {
+ return fmt.Errorf("STARTTLS failed: %w", err)
+ }
+ }
+
+ // Try authentication if credentials are provided
+ if config.Username != "" && config.Password != "" {
+ auth := smtp.PlainAuth("", config.Username, config.Password, config.Host)
+ if err := client.Auth(auth); err != nil {
+ return fmt.Errorf("authentication failed: %w", err)
+ }
+ }
+ }
+
+ return nil
+}
+
+// SendEmail sends an email using the configured SMTP settings.
+func (s *MailService) SendEmail(to, subject, htmlBody string) error {
+ config, err := s.GetSMTPConfig()
+ if err != nil {
+ return err
+ }
+
+ if config.Host == "" {
+ return errors.New("SMTP not configured")
+ }
+
+ // Build the email message
+ msg := s.buildEmail(config.FromAddress, to, subject, htmlBody)
+
+ addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
+ var auth smtp.Auth
+ if config.Username != "" && config.Password != "" {
+ auth = smtp.PlainAuth("", config.Username, config.Password, config.Host)
+ }
+
+ switch config.Encryption {
+ case "ssl":
+ return s.sendSSL(addr, config, auth, to, msg)
+ case "starttls":
+ return s.sendSTARTTLS(addr, config, auth, to, msg)
+ default:
+ return smtp.SendMail(addr, auth, config.FromAddress, []string{to}, msg)
+ }
+}
+
+// buildEmail constructs a properly formatted email message.
+func (s *MailService) buildEmail(from, to, subject, htmlBody string) []byte {
+ headers := make(map[string]string)
+ headers["From"] = from
+ headers["To"] = to
+ headers["Subject"] = subject
+ headers["MIME-Version"] = "1.0"
+ headers["Content-Type"] = "text/html; charset=UTF-8"
+
+ var msg bytes.Buffer
+ for key, value := range headers {
+ msg.WriteString(fmt.Sprintf("%s: %s\r\n", key, value))
+ }
+ msg.WriteString("\r\n")
+ msg.WriteString(htmlBody)
+
+ return msg.Bytes()
+}
+
+// sendSSL sends email using direct SSL/TLS connection.
+func (s *MailService) sendSSL(addr string, config *SMTPConfig, auth smtp.Auth, to string, msg []byte) error {
+ tlsConfig := &tls.Config{
+ ServerName: config.Host,
+ MinVersion: tls.VersionTLS12,
+ }
+
+ conn, err := tls.Dial("tcp", addr, tlsConfig)
+ if err != nil {
+ return fmt.Errorf("SSL connection failed: %w", err)
+ }
+ defer conn.Close()
+
+ client, err := smtp.NewClient(conn, config.Host)
+ if err != nil {
+ return fmt.Errorf("failed to create SMTP client: %w", err)
+ }
+ defer client.Close()
+
+ if auth != nil {
+ if err := client.Auth(auth); err != nil {
+ return fmt.Errorf("authentication failed: %w", err)
+ }
+ }
+
+ if err := client.Mail(config.FromAddress); err != nil {
+ return fmt.Errorf("MAIL FROM failed: %w", err)
+ }
+
+ if err := client.Rcpt(to); err != nil {
+ return fmt.Errorf("RCPT TO failed: %w", err)
+ }
+
+ w, err := client.Data()
+ if err != nil {
+ return fmt.Errorf("DATA failed: %w", err)
+ }
+
+ if _, err := w.Write(msg); err != nil {
+ return fmt.Errorf("failed to write message: %w", err)
+ }
+
+ if err := w.Close(); err != nil {
+ return fmt.Errorf("failed to close data writer: %w", err)
+ }
+
+ return client.Quit()
+}
+
+// sendSTARTTLS sends email using STARTTLS.
+func (s *MailService) sendSTARTTLS(addr string, config *SMTPConfig, auth smtp.Auth, to string, msg []byte) error {
+ client, err := smtp.Dial(addr)
+ if err != nil {
+ return fmt.Errorf("SMTP connection failed: %w", err)
+ }
+ defer client.Close()
+
+ tlsConfig := &tls.Config{
+ ServerName: config.Host,
+ MinVersion: tls.VersionTLS12,
+ }
+
+ if err := client.StartTLS(tlsConfig); err != nil {
+ return fmt.Errorf("STARTTLS failed: %w", err)
+ }
+
+ if auth != nil {
+ if err := client.Auth(auth); err != nil {
+ return fmt.Errorf("authentication failed: %w", err)
+ }
+ }
+
+ if err := client.Mail(config.FromAddress); err != nil {
+ return fmt.Errorf("MAIL FROM failed: %w", err)
+ }
+
+ if err := client.Rcpt(to); err != nil {
+ return fmt.Errorf("RCPT TO failed: %w", err)
+ }
+
+ w, err := client.Data()
+ if err != nil {
+ return fmt.Errorf("DATA failed: %w", err)
+ }
+
+ if _, err := w.Write(msg); err != nil {
+ return fmt.Errorf("failed to write message: %w", err)
+ }
+
+ if err := w.Close(); err != nil {
+ return fmt.Errorf("failed to close data writer: %w", err)
+ }
+
+ return client.Quit()
+}
+
+// SendInvite sends an invitation email to a new user.
+func (s *MailService) SendInvite(email, inviteToken, appName, baseURL string) error {
+ inviteURL := fmt.Sprintf("%s/accept-invite?token=%s", strings.TrimSuffix(baseURL, "/"), inviteToken)
+
+ tmpl := `
+
+
+
+
+ You've been invited to {{.AppName}}
+
+
+
+
{{.AppName}}
+
+
+
You've Been Invited!
+
You've been invited to join {{.AppName}}. Click the button below to set up your account:
+
+
This invitation link will expire in 48 hours.
+
If you didn't expect this invitation, you can safely ignore this email.
+
+
If the button doesn't work, copy and paste this link into your browser:
+ {{.InviteURL}}
+
+
+
+`
+
+ t, err := template.New("invite").Parse(tmpl)
+ if err != nil {
+ return fmt.Errorf("failed to parse email template: %w", err)
+ }
+
+ var body bytes.Buffer
+ data := map[string]string{
+ "AppName": appName,
+ "InviteURL": inviteURL,
+ }
+
+ if err := t.Execute(&body, data); err != nil {
+ return fmt.Errorf("failed to execute email template: %w", err)
+ }
+
+ subject := fmt.Sprintf("You've been invited to %s", appName)
+
+ logger.Log().WithField("email", email).Info("Sending invite email")
+ return s.SendEmail(email, subject, body.String())
+}
diff --git a/backend/internal/services/mail_service_test.go b/backend/internal/services/mail_service_test.go
new file mode 100644
index 00000000..56c140ae
--- /dev/null
+++ b/backend/internal/services/mail_service_test.go
@@ -0,0 +1,298 @@
+package services
+
+import (
+ "testing"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+ "gorm.io/gorm/logger"
+)
+
+func setupMailTestDB(t *testing.T) *gorm.DB {
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Silent),
+ })
+ require.NoError(t, err)
+
+ err = db.AutoMigrate(&models.Setting{})
+ require.NoError(t, err)
+
+ return db
+}
+
+func TestMailService_SaveAndGetSMTPConfig(t *testing.T) {
+ db := setupMailTestDB(t)
+ svc := NewMailService(db)
+
+ config := &SMTPConfig{
+ Host: "smtp.example.com",
+ Port: 587,
+ Username: "user@example.com",
+ Password: "secret123",
+ FromAddress: "noreply@example.com",
+ Encryption: "starttls",
+ }
+
+ // Save config
+ err := svc.SaveSMTPConfig(config)
+ require.NoError(t, err)
+
+ // Retrieve config
+ retrieved, err := svc.GetSMTPConfig()
+ require.NoError(t, err)
+
+ assert.Equal(t, config.Host, retrieved.Host)
+ assert.Equal(t, config.Port, retrieved.Port)
+ assert.Equal(t, config.Username, retrieved.Username)
+ assert.Equal(t, config.Password, retrieved.Password)
+ assert.Equal(t, config.FromAddress, retrieved.FromAddress)
+ assert.Equal(t, config.Encryption, retrieved.Encryption)
+}
+
+func TestMailService_UpdateSMTPConfig(t *testing.T) {
+ db := setupMailTestDB(t)
+ svc := NewMailService(db)
+
+ // Save initial config
+ config := &SMTPConfig{
+ Host: "smtp.example.com",
+ Port: 587,
+ Username: "user@example.com",
+ Password: "secret123",
+ FromAddress: "noreply@example.com",
+ Encryption: "starttls",
+ }
+ err := svc.SaveSMTPConfig(config)
+ require.NoError(t, err)
+
+ // Update config
+ config.Host = "smtp.newhost.com"
+ config.Port = 465
+ config.Encryption = "ssl"
+ err = svc.SaveSMTPConfig(config)
+ require.NoError(t, err)
+
+ // Verify update
+ retrieved, err := svc.GetSMTPConfig()
+ require.NoError(t, err)
+
+ assert.Equal(t, "smtp.newhost.com", retrieved.Host)
+ assert.Equal(t, 465, retrieved.Port)
+ assert.Equal(t, "ssl", retrieved.Encryption)
+}
+
+func TestMailService_IsConfigured(t *testing.T) {
+ tests := []struct {
+ name string
+ config *SMTPConfig
+ expected bool
+ }{
+ {
+ name: "configured with all fields",
+ config: &SMTPConfig{
+ Host: "smtp.example.com",
+ Port: 587,
+ FromAddress: "noreply@example.com",
+ Encryption: "starttls",
+ },
+ expected: true,
+ },
+ {
+ name: "not configured - missing host",
+ config: &SMTPConfig{
+ Port: 587,
+ FromAddress: "noreply@example.com",
+ },
+ expected: false,
+ },
+ {
+ name: "not configured - missing from address",
+ config: &SMTPConfig{
+ Host: "smtp.example.com",
+ Port: 587,
+ },
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ db := setupMailTestDB(t)
+ svc := NewMailService(db)
+
+ err := svc.SaveSMTPConfig(tt.config)
+ require.NoError(t, err)
+
+ result := svc.IsConfigured()
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestMailService_GetSMTPConfig_Defaults(t *testing.T) {
+ db := setupMailTestDB(t)
+ svc := NewMailService(db)
+
+ // Get config without saving anything
+ config, err := svc.GetSMTPConfig()
+ require.NoError(t, err)
+
+ // Should have defaults
+ assert.Equal(t, 587, config.Port)
+ assert.Equal(t, "starttls", config.Encryption)
+ assert.Empty(t, config.Host)
+}
+
+func TestMailService_BuildEmail(t *testing.T) {
+ db := setupMailTestDB(t)
+ svc := NewMailService(db)
+
+ msg := svc.buildEmail(
+ "sender@example.com",
+ "recipient@example.com",
+ "Test Subject",
+ "Test Body",
+ )
+
+ msgStr := string(msg)
+ assert.Contains(t, msgStr, "From: sender@example.com")
+ assert.Contains(t, msgStr, "To: recipient@example.com")
+ assert.Contains(t, msgStr, "Subject: Test Subject")
+ assert.Contains(t, msgStr, "Content-Type: text/html")
+ assert.Contains(t, msgStr, "Test Body")
+}
+
+func TestMailService_TestConnection_NotConfigured(t *testing.T) {
+ db := setupMailTestDB(t)
+ svc := NewMailService(db)
+
+ err := svc.TestConnection()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not configured")
+}
+
+func TestMailService_SendEmail_NotConfigured(t *testing.T) {
+ db := setupMailTestDB(t)
+ svc := NewMailService(db)
+
+ err := svc.SendEmail("test@example.com", "Subject", "Body
")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not configured")
+}
+
+// TestSMTPConfigSerialization ensures config fields are properly stored
+func TestSMTPConfigSerialization(t *testing.T) {
+ db := setupMailTestDB(t)
+ svc := NewMailService(db)
+
+ // Test with special characters in password
+ config := &SMTPConfig{
+ Host: "smtp.example.com",
+ Port: 587,
+ Username: "user@example.com",
+ Password: "p@$$w0rd!#$%",
+ FromAddress: "Charon ",
+ Encryption: "starttls",
+ }
+
+ err := svc.SaveSMTPConfig(config)
+ require.NoError(t, err)
+
+ retrieved, err := svc.GetSMTPConfig()
+ require.NoError(t, err)
+
+ assert.Equal(t, config.Password, retrieved.Password)
+ assert.Equal(t, config.FromAddress, retrieved.FromAddress)
+}
+
+// TestMailService_SendInvite tests the invite email template
+func TestMailService_SendInvite_Template(t *testing.T) {
+ db := setupMailTestDB(t)
+ svc := NewMailService(db)
+
+ // We can't actually send email, but we can verify the method doesn't panic
+ // and returns appropriate error when SMTP is not configured
+ err := svc.SendInvite("test@example.com", "abc123token", "TestApp", "https://example.com")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not configured")
+}
+
+// Benchmark tests
+func BenchmarkMailService_IsConfigured(b *testing.B) {
+ db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Silent),
+ })
+ db.AutoMigrate(&models.Setting{})
+ svc := NewMailService(db)
+
+ config := &SMTPConfig{
+ Host: "smtp.example.com",
+ Port: 587,
+ FromAddress: "noreply@example.com",
+ }
+ svc.SaveSMTPConfig(config)
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ svc.IsConfigured()
+ }
+}
+
+func BenchmarkMailService_BuildEmail(b *testing.B) {
+ db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Silent),
+ })
+ svc := NewMailService(db)
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ svc.buildEmail(
+ "sender@example.com",
+ "recipient@example.com",
+ "Test Subject",
+ "Test Body",
+ )
+ }
+}
+
+// Integration test placeholder - this would use a real SMTP server
+func TestMailService_Integration(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration test in short mode")
+ }
+
+ // This test would connect to a real SMTP server (like MailHog) for integration testing
+ t.Skip("Integration test requires SMTP server")
+}
+
+// Test for expired invite token handling in SendInvite
+func TestMailService_SendInvite_TokenFormat(t *testing.T) {
+ db := setupMailTestDB(t)
+ svc := NewMailService(db)
+
+ // Save SMTP config so we can test template generation
+ config := &SMTPConfig{
+ Host: "smtp.example.com",
+ Port: 587,
+ FromAddress: "noreply@example.com",
+ }
+ svc.SaveSMTPConfig(config)
+
+ // The SendInvite will fail at SMTP connection, but we're testing that
+ // the function correctly constructs the invite URL
+ err := svc.SendInvite("test@example.com", "token123", "Charon", "https://charon.local/")
+ assert.Error(t, err) // Will error on SMTP connection
+
+ // Test with trailing slash handling
+ err = svc.SendInvite("test@example.com", "token123", "Charon", "https://charon.local")
+ assert.Error(t, err) // Will error on SMTP connection
+}
+
+// Add timeout handling test
+// Note: Skipped as in-memory SQLite doesn't support concurrent writes well
+func TestMailService_SaveSMTPConfig_Concurrent(t *testing.T) {
+ t.Skip("In-memory SQLite doesn't support concurrent writes - test real DB in integration")
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 51ebea03..6659402e 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -16,6 +16,7 @@ const ImportCaddy = lazy(() => import('./pages/ImportCaddy'))
const ImportCrowdSec = lazy(() => import('./pages/ImportCrowdSec'))
const Certificates = lazy(() => import('./pages/Certificates'))
const SystemSettings = lazy(() => import('./pages/SystemSettings'))
+const SMTPSettings = lazy(() => import('./pages/SMTPSettings'))
const CrowdSecConfig = lazy(() => import('./pages/CrowdSecConfig'))
const Account = lazy(() => import('./pages/Account'))
const Settings = lazy(() => import('./pages/Settings'))
@@ -29,8 +30,10 @@ const WafConfig = lazy(() => import('./pages/WafConfig'))
const RateLimiting = lazy(() => import('./pages/RateLimiting'))
const Uptime = lazy(() => import('./pages/Uptime'))
const Notifications = lazy(() => import('./pages/Notifications'))
+const UsersPage = lazy(() => import('./pages/UsersPage'))
const Login = lazy(() => import('./pages/Login'))
const Setup = lazy(() => import('./pages/Setup'))
+const AcceptInvite = lazy(() => import('./pages/AcceptInvite'))
export default function App() {
return (
@@ -40,6 +43,7 @@ export default function App() {
} />
} />
+ } />
@@ -62,12 +66,14 @@ export default function App() {
} />
} />
} />
+ } />
} />
{/* Settings Routes */}
}>
} />
} />
+ } />
} />
} />
diff --git a/frontend/src/api/smtp.ts b/frontend/src/api/smtp.ts
new file mode 100644
index 00000000..04488967
--- /dev/null
+++ b/frontend/src/api/smtp.ts
@@ -0,0 +1,50 @@
+import client from './client'
+
+export interface SMTPConfig {
+ host: string
+ port: number
+ username: string
+ password: string
+ from_address: string
+ encryption: 'none' | 'ssl' | 'starttls'
+ configured: boolean
+}
+
+export interface SMTPConfigRequest {
+ host: string
+ port: number
+ username: string
+ password: string
+ from_address: string
+ encryption: 'none' | 'ssl' | 'starttls'
+}
+
+export interface TestEmailRequest {
+ to: string
+}
+
+export interface SMTPTestResult {
+ success: boolean
+ message?: string
+ error?: string
+}
+
+export const getSMTPConfig = async (): Promise => {
+ const response = await client.get('/settings/smtp')
+ return response.data
+}
+
+export const updateSMTPConfig = async (config: SMTPConfigRequest): Promise<{ message: string }> => {
+ const response = await client.post<{ message: string }>('/settings/smtp', config)
+ return response.data
+}
+
+export const testSMTPConnection = async (): Promise => {
+ const response = await client.post('/settings/smtp/test')
+ return response.data
+}
+
+export const sendTestEmail = async (request: TestEmailRequest): Promise => {
+ const response = await client.post('/settings/smtp/test-email', request)
+ return response.data
+}
diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts
new file mode 100644
index 00000000..29c3fc98
--- /dev/null
+++ b/frontend/src/api/users.ts
@@ -0,0 +1,119 @@
+import client from './client'
+
+export type PermissionMode = 'allow_all' | 'deny_all'
+
+export interface User {
+ id: number
+ uuid: string
+ email: string
+ name: string
+ role: 'admin' | 'user' | 'viewer'
+ enabled: boolean
+ last_login?: string
+ invite_status?: 'pending' | 'accepted' | 'expired'
+ invited_at?: string
+ permission_mode: PermissionMode
+ permitted_hosts?: number[]
+ created_at: string
+ updated_at: string
+}
+
+export interface CreateUserRequest {
+ email: string
+ name: string
+ password: string
+ role?: string
+ permission_mode?: PermissionMode
+ permitted_hosts?: number[]
+}
+
+export interface InviteUserRequest {
+ email: string
+ role?: string
+ permission_mode?: PermissionMode
+ permitted_hosts?: number[]
+}
+
+export interface InviteUserResponse {
+ id: number
+ uuid: string
+ email: string
+ role: string
+ invite_token: string
+ email_sent: boolean
+ expires_at: string
+}
+
+export interface UpdateUserRequest {
+ name?: string
+ email?: string
+ role?: string
+ enabled?: boolean
+}
+
+export interface UpdateUserPermissionsRequest {
+ permission_mode: PermissionMode
+ permitted_hosts: number[]
+}
+
+export interface ValidateInviteResponse {
+ valid: boolean
+ email: string
+}
+
+export interface AcceptInviteRequest {
+ token: string
+ name: string
+ password: string
+}
+
+export const listUsers = async (): Promise => {
+ const response = await client.get('/users')
+ return response.data
+}
+
+export const getUser = async (id: number): Promise => {
+ const response = await client.get(`/users/${id}`)
+ return response.data
+}
+
+export const createUser = async (data: CreateUserRequest): Promise => {
+ const response = await client.post('/users', data)
+ return response.data
+}
+
+export const inviteUser = async (data: InviteUserRequest): Promise => {
+ const response = await client.post('/users/invite', data)
+ return response.data
+}
+
+export const updateUser = async (id: number, data: UpdateUserRequest): Promise<{ message: string }> => {
+ const response = await client.put<{ message: string }>(`/users/${id}`, data)
+ return response.data
+}
+
+export const deleteUser = async (id: number): Promise<{ message: string }> => {
+ const response = await client.delete<{ message: string }>(`/users/${id}`)
+ return response.data
+}
+
+export const updateUserPermissions = async (
+ id: number,
+ data: UpdateUserPermissionsRequest
+): Promise<{ message: string }> => {
+ const response = await client.put<{ message: string }>(`/users/${id}/permissions`, data)
+ return response.data
+}
+
+// Public endpoints (no auth required)
+export const validateInvite = async (token: string): Promise => {
+ const response = await client.get('/invite/validate', {
+ params: { token }
+ })
+ return response.data
+}
+
+export const acceptInvite = async (data: AcceptInviteRequest): Promise<{ message: string; email: string }> => {
+ const response = await client.post<{ message: string; email: string }>('/invite/accept', data)
+ return response.data
+}
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
index ad6fc3a6..097b5f60 100644
--- a/frontend/src/components/Layout.tsx
+++ b/frontend/src/components/Layout.tsx
@@ -63,6 +63,7 @@ export default function Layout({ children }: LayoutProps) {
{ name: 'WAF (Coraza)', path: '/security/waf', icon: '🛡️' },
]},
{ name: 'Notifications', path: '/notifications', icon: '🔔' },
+ { name: 'Users', path: '/users', icon: '👥' },
// Import group moved under Tasks
{
name: 'Settings',
@@ -70,6 +71,7 @@ export default function Layout({ children }: LayoutProps) {
icon: '⚙️',
children: [
{ name: 'System', path: '/settings/system', icon: '⚙️' },
+ { name: 'Email (SMTP)', path: '/settings/smtp', icon: '📧' },
{ name: 'Account', path: '/settings/account', icon: '🛡️' },
]
},
diff --git a/frontend/src/pages/AcceptInvite.tsx b/frontend/src/pages/AcceptInvite.tsx
new file mode 100644
index 00000000..01d01654
--- /dev/null
+++ b/frontend/src/pages/AcceptInvite.tsx
@@ -0,0 +1,204 @@
+import { useState, useEffect } from 'react'
+import { useSearchParams, useNavigate } from 'react-router-dom'
+import { useMutation, useQuery } from '@tanstack/react-query'
+import { Card } from '../components/ui/Card'
+import { Button } from '../components/ui/Button'
+import { Input } from '../components/ui/Input'
+import { PasswordStrengthMeter } from '../components/PasswordStrengthMeter'
+import { toast } from '../utils/toast'
+import { validateInvite, acceptInvite } from '../api/users'
+import { Loader2, CheckCircle2, XCircle, UserCheck } from 'lucide-react'
+
+export default function AcceptInvite() {
+ const [searchParams] = useSearchParams()
+ const navigate = useNavigate()
+ const token = searchParams.get('token') || ''
+
+ const [name, setName] = useState('')
+ const [password, setPassword] = useState('')
+ const [confirmPassword, setConfirmPassword] = useState('')
+ const [accepted, setAccepted] = useState(false)
+
+ const {
+ data: validation,
+ isLoading: isValidating,
+ error: validationError,
+ } = useQuery({
+ queryKey: ['validate-invite', token],
+ queryFn: () => validateInvite(token),
+ enabled: !!token,
+ retry: false,
+ })
+
+ const acceptMutation = useMutation({
+ mutationFn: async () => {
+ return acceptInvite({ token, name, password })
+ },
+ onSuccess: (data) => {
+ setAccepted(true)
+ toast.success(`Welcome, ${data.email}! You can now log in.`)
+ },
+ onError: (error: unknown) => {
+ const err = error as { response?: { data?: { error?: string } } }
+ toast.error(err.response?.data?.error || 'Failed to accept invitation')
+ },
+ })
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault()
+ if (password !== confirmPassword) {
+ toast.error('Passwords do not match')
+ return
+ }
+ if (password.length < 8) {
+ toast.error('Password must be at least 8 characters')
+ return
+ }
+ acceptMutation.mutate()
+ }
+
+ // Redirect to login after successful acceptance
+ useEffect(() => {
+ if (accepted) {
+ const timer = setTimeout(() => {
+ navigate('/login')
+ }, 3000)
+ return () => clearTimeout(timer)
+ }
+ }, [accepted, navigate])
+
+ if (!token) {
+ return (
+
+
+
+
+
Invalid Link
+
+ This invitation link is invalid or incomplete.
+
+
+
+
+
+ )
+ }
+
+ if (isValidating) {
+ return (
+
+
+
+
+
Validating invitation...
+
+
+
+ )
+ }
+
+ if (validationError || !validation?.valid) {
+ const errorData = validationError as { response?: { data?: { error?: string } } } | undefined
+ const errorMessage = errorData?.response?.data?.error || 'This invitation has expired or is invalid.'
+
+ return (
+
+
+
+
+
Invitation Invalid
+
{errorMessage}
+
+
+
+
+ )
+ }
+
+ if (accepted) {
+ return (
+
+
+
+
+
Account Created!
+
+ Your account has been set up successfully. Redirecting to login...
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+

+
+
+
+
+
+
+
+ You've been invited!
+
+
+ Complete your account setup for {validation.email}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/SMTPSettings.tsx b/frontend/src/pages/SMTPSettings.tsx
new file mode 100644
index 00000000..c4289223
--- /dev/null
+++ b/frontend/src/pages/SMTPSettings.tsx
@@ -0,0 +1,233 @@
+import { useState, useEffect } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { Card } from '../components/ui/Card'
+import { Button } from '../components/ui/Button'
+import { Input } from '../components/ui/Input'
+import { toast } from '../utils/toast'
+import { getSMTPConfig, updateSMTPConfig, testSMTPConnection, sendTestEmail } from '../api/smtp'
+import type { SMTPConfigRequest } from '../api/smtp'
+import { Mail, Send, CheckCircle2, XCircle, Loader2 } from 'lucide-react'
+
+export default function SMTPSettings() {
+ const queryClient = useQueryClient()
+ const [host, setHost] = useState('')
+ const [port, setPort] = useState(587)
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [fromAddress, setFromAddress] = useState('')
+ const [encryption, setEncryption] = useState<'none' | 'ssl' | 'starttls'>('starttls')
+ const [testEmail, setTestEmail] = useState('')
+
+ const { data: smtpConfig, isLoading } = useQuery({
+ queryKey: ['smtp-config'],
+ queryFn: getSMTPConfig,
+ })
+
+ useEffect(() => {
+ if (smtpConfig) {
+ setHost(smtpConfig.host || '')
+ setPort(smtpConfig.port || 587)
+ setUsername(smtpConfig.username || '')
+ setPassword(smtpConfig.password || '')
+ setFromAddress(smtpConfig.from_address || '')
+ setEncryption(smtpConfig.encryption || 'starttls')
+ }
+ }, [smtpConfig])
+
+ const saveMutation = useMutation({
+ mutationFn: async () => {
+ const config: SMTPConfigRequest = {
+ host,
+ port,
+ username,
+ password,
+ from_address: fromAddress,
+ encryption,
+ }
+ return updateSMTPConfig(config)
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['smtp-config'] })
+ toast.success('SMTP settings saved successfully')
+ },
+ onError: (error: unknown) => {
+ const err = error as { response?: { data?: { error?: string } } }
+ toast.error(err.response?.data?.error || 'Failed to save SMTP settings')
+ },
+ })
+
+ const testConnectionMutation = useMutation({
+ mutationFn: testSMTPConnection,
+ onSuccess: (data) => {
+ if (data.success) {
+ toast.success(data.message || 'SMTP connection successful')
+ } else {
+ toast.error(data.error || 'SMTP connection failed')
+ }
+ },
+ onError: (error: unknown) => {
+ const err = error as { response?: { data?: { error?: string } } }
+ toast.error(err.response?.data?.error || 'Failed to test SMTP connection')
+ },
+ })
+
+ const sendTestEmailMutation = useMutation({
+ mutationFn: async () => sendTestEmail({ to: testEmail }),
+ onSuccess: (data) => {
+ if (data.success) {
+ toast.success(data.message || 'Test email sent successfully')
+ setTestEmail('')
+ } else {
+ toast.error(data.error || 'Failed to send test email')
+ }
+ },
+ onError: (error: unknown) => {
+ const err = error as { response?: { data?: { error?: string } } }
+ toast.error(err.response?.data?.error || 'Failed to send test email')
+ },
+ })
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
Email (SMTP) Settings
+
+
+
+ Configure SMTP settings to enable email notifications and user invitations.
+
+
+
+
+
+
+ {/* Status Indicator */}
+
+
+ {smtpConfig?.configured ? (
+ <>
+
+ SMTP Configured
+ >
+ ) : (
+ <>
+
+ SMTP Not Configured
+ >
+ )}
+
+
+
+ {/* Test Email */}
+ {smtpConfig?.configured && (
+
+
+ Send Test Email
+
+
+
+ setTestEmail(e.target.value)}
+ placeholder="recipient@example.com"
+ />
+
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
index c1d6af5e..1d98a004 100644
--- a/frontend/src/pages/Settings.tsx
+++ b/frontend/src/pages/Settings.tsx
@@ -24,6 +24,17 @@ export default function Settings() {
System
+
+ Email (SMTP)
+
+
void
+ proxyHosts: ProxyHost[]
+}
+
+function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
+ const queryClient = useQueryClient()
+ const [email, setEmail] = useState('')
+ const [role, setRole] = useState<'user' | 'admin'>('user')
+ const [permissionMode, setPermissionMode] = useState('allow_all')
+ const [selectedHosts, setSelectedHosts] = useState([])
+ const [inviteResult, setInviteResult] = useState<{
+ token: string
+ emailSent: boolean
+ expiresAt: string
+ } | null>(null)
+
+ const inviteMutation = useMutation({
+ mutationFn: async () => {
+ const request: InviteUserRequest = {
+ email,
+ role,
+ permission_mode: permissionMode,
+ permitted_hosts: selectedHosts,
+ }
+ return inviteUser(request)
+ },
+ onSuccess: (data) => {
+ queryClient.invalidateQueries({ queryKey: ['users'] })
+ setInviteResult({
+ token: data.invite_token,
+ emailSent: data.email_sent,
+ expiresAt: data.expires_at,
+ })
+ if (data.email_sent) {
+ toast.success('Invitation email sent')
+ } else {
+ toast.success('User invited - copy the invite link below')
+ }
+ },
+ onError: (error: unknown) => {
+ const err = error as { response?: { data?: { error?: string } } }
+ toast.error(err.response?.data?.error || 'Failed to invite user')
+ },
+ })
+
+ const copyInviteLink = () => {
+ if (inviteResult?.token) {
+ const link = `${window.location.origin}/accept-invite?token=${inviteResult.token}`
+ navigator.clipboard.writeText(link)
+ toast.success('Invite link copied to clipboard')
+ }
+ }
+
+ const handleClose = () => {
+ setEmail('')
+ setRole('user')
+ setPermissionMode('allow_all')
+ setSelectedHosts([])
+ setInviteResult(null)
+ onClose()
+ }
+
+ const toggleHost = (hostId: number) => {
+ setSelectedHosts((prev) =>
+ prev.includes(hostId) ? prev.filter((id) => id !== hostId) : [...prev, hostId]
+ )
+ }
+
+ if (!isOpen) return null
+
+ return (
+
+
+
+
+
+ Invite User
+
+
+
+
+
+ {inviteResult ? (
+
+
+
+
+ User Invited Successfully
+
+ {inviteResult.emailSent ? (
+
+ An invitation email has been sent to the user.
+
+ ) : (
+
+ Email was not sent. Share the invite link manually.
+
+ )}
+
+
+ {!inviteResult.emailSent && (
+
+
+
+
+
+
+
+ Expires: {new Date(inviteResult.expiresAt).toLocaleString()}
+
+
+ )}
+
+
+
+ ) : (
+ <>
+
setEmail(e.target.value)}
+ placeholder="user@example.com"
+ />
+
+
+
+
+
+
+ {role === 'user' && (
+ <>
+
+
+
+
+ {permissionMode === 'allow_all'
+ ? 'User can access all hosts EXCEPT those selected below'
+ : 'User can ONLY access hosts selected below'}
+
+
+
+
+
+
+ {proxyHosts.length === 0 ? (
+
No proxy hosts configured
+ ) : (
+ proxyHosts.map((host) => (
+
+ ))
+ )}
+
+
+ >
+ )}
+
+
+
+
+
+ >
+ )}
+
+
+
+ )
+}
+
+interface PermissionsModalProps {
+ isOpen: boolean
+ onClose: () => void
+ user: User | null
+ proxyHosts: ProxyHost[]
+}
+
+function PermissionsModal({ isOpen, onClose, user, proxyHosts }: PermissionsModalProps) {
+ const queryClient = useQueryClient()
+ const [permissionMode, setPermissionMode] = useState('allow_all')
+ const [selectedHosts, setSelectedHosts] = useState([])
+
+ // Update state when user changes
+ useState(() => {
+ if (user) {
+ setPermissionMode(user.permission_mode || 'allow_all')
+ setSelectedHosts(user.permitted_hosts || [])
+ }
+ })
+
+ const updatePermissionsMutation = useMutation({
+ mutationFn: async () => {
+ if (!user) return
+ const request: UpdateUserPermissionsRequest = {
+ permission_mode: permissionMode,
+ permitted_hosts: selectedHosts,
+ }
+ return updateUserPermissions(user.id, request)
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['users'] })
+ toast.success('Permissions updated')
+ onClose()
+ },
+ onError: (error: unknown) => {
+ const err = error as { response?: { data?: { error?: string } } }
+ toast.error(err.response?.data?.error || 'Failed to update permissions')
+ },
+ })
+
+ const toggleHost = (hostId: number) => {
+ setSelectedHosts((prev) =>
+ prev.includes(hostId) ? prev.filter((id) => id !== hostId) : [...prev, hostId]
+ )
+ }
+
+ if (!isOpen || !user) return null
+
+ return (
+
+
+
+
+
+ Edit Permissions - {user.name || user.email}
+
+
+
+
+
+
+
+
+
+ {permissionMode === 'allow_all'
+ ? 'User can access all hosts EXCEPT those selected below'
+ : 'User can ONLY access hosts selected below'}
+
+
+
+
+
+
+ {proxyHosts.length === 0 ? (
+
No proxy hosts configured
+ ) : (
+ proxyHosts.map((host) => (
+
+ ))
+ )}
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default function UsersPage() {
+ const queryClient = useQueryClient()
+ const [inviteModalOpen, setInviteModalOpen] = useState(false)
+ const [permissionsModalOpen, setPermissionsModalOpen] = useState(false)
+ const [selectedUser, setSelectedUser] = useState(null)
+
+ const { data: users, isLoading } = useQuery({
+ queryKey: ['users'],
+ queryFn: listUsers,
+ })
+
+ const { data: proxyHosts = [] } = useQuery({
+ queryKey: ['proxyHosts'],
+ queryFn: getProxyHosts,
+ })
+
+ const toggleEnabledMutation = useMutation({
+ mutationFn: async ({ id, enabled }: { id: number; enabled: boolean }) => {
+ return updateUser(id, { enabled })
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['users'] })
+ toast.success('User updated')
+ },
+ onError: (error: unknown) => {
+ const err = error as { response?: { data?: { error?: string } } }
+ toast.error(err.response?.data?.error || 'Failed to update user')
+ },
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: deleteUser,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['users'] })
+ toast.success('User deleted')
+ },
+ onError: (error: unknown) => {
+ const err = error as { response?: { data?: { error?: string } } }
+ toast.error(err.response?.data?.error || 'Failed to delete user')
+ },
+ })
+
+ const openPermissions = (user: User) => {
+ setSelectedUser(user)
+ setPermissionsModalOpen(true)
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
User Management
+
+
+
+
+
+
+
+
+
+ | User |
+ Role |
+ Status |
+ Permissions |
+ Enabled |
+ Actions |
+
+
+
+ {users?.map((user) => (
+
+
+
+ {user.name || '(No name)'}
+ {user.email}
+
+ |
+
+
+ {user.role}
+
+ |
+
+ {user.invite_status === 'pending' ? (
+
+
+ Pending Invite
+
+ ) : user.invite_status === 'expired' ? (
+
+
+ Invite Expired
+
+ ) : (
+
+
+ Active
+
+ )}
+ |
+
+
+ {user.permission_mode === 'deny_all' ? 'Whitelist' : 'Blacklist'}
+
+ |
+
+
+ toggleEnabledMutation.mutate({
+ id: user.id,
+ enabled: !user.enabled,
+ })
+ }
+ disabled={user.role === 'admin'}
+ />
+ |
+
+
+ {user.role !== 'admin' && (
+
+ )}
+
+
+ |
+
+ ))}
+
+
+
+
+
+
setInviteModalOpen(false)}
+ proxyHosts={proxyHosts}
+ />
+
+ {
+ setPermissionsModalOpen(false)
+ setSelectedUser(null)
+ }}
+ user={selectedUser}
+ proxyHosts={proxyHosts}
+ />
+
+ )
+}
diff --git a/frontend/src/pages/__tests__/AcceptInvite.test.tsx b/frontend/src/pages/__tests__/AcceptInvite.test.tsx
new file mode 100644
index 00000000..2e8b4f39
--- /dev/null
+++ b/frontend/src/pages/__tests__/AcceptInvite.test.tsx
@@ -0,0 +1,208 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import { vi, describe, it, expect, beforeEach } from 'vitest'
+import AcceptInvite from '../AcceptInvite'
+import * as usersApi from '../../api/users'
+
+// Mock APIs
+vi.mock('../../api/users', () => ({
+ validateInvite: vi.fn(),
+ acceptInvite: vi.fn(),
+ listUsers: vi.fn(),
+ getUser: vi.fn(),
+ createUser: vi.fn(),
+ inviteUser: vi.fn(),
+ updateUser: vi.fn(),
+ deleteUser: vi.fn(),
+ updateUserPermissions: vi.fn(),
+}))
+
+// Mock react-router-dom navigate
+const mockNavigate = vi.fn()
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom')
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ }
+})
+
+const createQueryClient = () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ })
+
+const renderWithProviders = (initialRoute: string = '/accept-invite?token=test-token') => {
+ const queryClient = createQueryClient()
+ return render(
+
+
+
+ } />
+ Login Page} />
+
+
+
+ )
+}
+
+describe('AcceptInvite', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('shows invalid link message when no token provided', async () => {
+ renderWithProviders('/accept-invite')
+
+ await waitFor(() => {
+ expect(screen.getByText('Invalid Link')).toBeTruthy()
+ })
+
+ expect(screen.getByText(/This invitation link is invalid/)).toBeTruthy()
+ })
+
+ it('shows validating state initially', () => {
+ vi.mocked(usersApi.validateInvite).mockReturnValue(new Promise(() => {}))
+
+ renderWithProviders()
+
+ expect(screen.getByText('Validating invitation...')).toBeTruthy()
+ })
+
+ it('shows error for invalid token', async () => {
+ vi.mocked(usersApi.validateInvite).mockRejectedValue({
+ response: { data: { error: 'Token expired' } },
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByText('Invitation Invalid')).toBeTruthy()
+ })
+ })
+
+ it('renders accept form for valid token', async () => {
+ vi.mocked(usersApi.validateInvite).mockResolvedValue({
+ valid: true,
+ email: 'invited@example.com',
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByText(/been invited/i)).toBeTruthy()
+ })
+
+ expect(screen.getByText(/invited@example.com/)).toBeTruthy()
+ expect(screen.getByPlaceholderText('John Doe')).toBeTruthy()
+ // Password and confirm password have same placeholder
+ expect(screen.getAllByPlaceholderText('••••••••').length).toBe(2)
+ })
+
+ it('shows password mismatch error', async () => {
+ vi.mocked(usersApi.validateInvite).mockResolvedValue({
+ valid: true,
+ email: 'invited@example.com',
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('John Doe')).toBeTruthy()
+ })
+
+ const user = userEvent.setup()
+ const [passwordInput, confirmInput] = screen.getAllByPlaceholderText('••••••••')
+ await user.type(passwordInput, 'password123')
+ await user.type(confirmInput, 'differentpassword')
+
+ await waitFor(() => {
+ expect(screen.getByText('Passwords do not match')).toBeTruthy()
+ })
+ })
+
+ it('submits form and shows success', async () => {
+ vi.mocked(usersApi.validateInvite).mockResolvedValue({
+ valid: true,
+ email: 'invited@example.com',
+ })
+ vi.mocked(usersApi.acceptInvite).mockResolvedValue({
+ message: 'Success',
+ email: 'invited@example.com',
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('John Doe')).toBeTruthy()
+ })
+
+ const user = userEvent.setup()
+ await user.type(screen.getByPlaceholderText('John Doe'), 'John Doe')
+ const [passwordInput, confirmInput] = screen.getAllByPlaceholderText('••••••••')
+ await user.type(passwordInput, 'securepassword123')
+ await user.type(confirmInput, 'securepassword123')
+
+ await user.click(screen.getByRole('button', { name: 'Create Account' }))
+
+ await waitFor(() => {
+ expect(usersApi.acceptInvite).toHaveBeenCalledWith({
+ token: 'test-token',
+ name: 'John Doe',
+ password: 'securepassword123',
+ })
+ })
+
+ await waitFor(() => {
+ expect(screen.getByText('Account Created!')).toBeTruthy()
+ })
+ })
+
+ it('shows error on submit failure', async () => {
+ vi.mocked(usersApi.validateInvite).mockResolvedValue({
+ valid: true,
+ email: 'invited@example.com',
+ })
+ vi.mocked(usersApi.acceptInvite).mockRejectedValue({
+ response: { data: { error: 'Token has expired' } },
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('John Doe')).toBeTruthy()
+ })
+
+ const user = userEvent.setup()
+ await user.type(screen.getByPlaceholderText('John Doe'), 'John Doe')
+ const [passwordInput, confirmInput] = screen.getAllByPlaceholderText('••••••••')
+ await user.type(passwordInput, 'securepassword123')
+ await user.type(confirmInput, 'securepassword123')
+
+ await user.click(screen.getByRole('button', { name: 'Create Account' }))
+
+ await waitFor(() => {
+ expect(usersApi.acceptInvite).toHaveBeenCalled()
+ })
+
+ // The toast should show error but we don't need to test toast specifically
+ })
+
+ it('navigates to login after clicking Go to Login button', async () => {
+ renderWithProviders('/accept-invite')
+
+ await waitFor(() => {
+ expect(screen.getByText('Invalid Link')).toBeTruthy()
+ })
+
+ const user = userEvent.setup()
+ await user.click(screen.getByRole('button', { name: 'Go to Login' }))
+
+ expect(mockNavigate).toHaveBeenCalledWith('/login')
+ })
+})
diff --git a/frontend/src/pages/__tests__/SMTPSettings.test.tsx b/frontend/src/pages/__tests__/SMTPSettings.test.tsx
new file mode 100644
index 00000000..77109ea7
--- /dev/null
+++ b/frontend/src/pages/__tests__/SMTPSettings.test.tsx
@@ -0,0 +1,209 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { MemoryRouter } from 'react-router-dom'
+import { vi, describe, it, expect, beforeEach } from 'vitest'
+import SMTPSettings from '../SMTPSettings'
+import * as smtpApi from '../../api/smtp'
+
+// Mock API
+vi.mock('../../api/smtp', () => ({
+ getSMTPConfig: vi.fn(),
+ updateSMTPConfig: vi.fn(),
+ testSMTPConnection: vi.fn(),
+ sendTestEmail: vi.fn(),
+}))
+
+const createQueryClient = () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ })
+
+const renderWithProviders = (ui: React.ReactNode) => {
+ const queryClient = createQueryClient()
+ return render(
+
+ {ui}
+
+ )
+}
+
+describe('SMTPSettings', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('renders loading state initially', () => {
+ vi.mocked(smtpApi.getSMTPConfig).mockReturnValue(new Promise(() => {}))
+
+ renderWithProviders()
+
+ // Should show loading spinner
+ expect(document.querySelector('.animate-spin')).toBeTruthy()
+ })
+
+ it('renders SMTP form with existing config', async () => {
+ vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
+ host: 'smtp.example.com',
+ port: 587,
+ username: 'user@example.com',
+ password: '********',
+ from_address: 'noreply@example.com',
+ encryption: 'starttls',
+ configured: true,
+ })
+
+ renderWithProviders()
+
+ // Wait for the form to populate with data
+ await waitFor(() => {
+ const hostInput = screen.getByPlaceholderText('smtp.gmail.com') as HTMLInputElement
+ return hostInput.value === 'smtp.example.com'
+ })
+
+ const hostInput = screen.getByPlaceholderText('smtp.gmail.com') as HTMLInputElement
+ expect(hostInput.value).toBe('smtp.example.com')
+
+ const portInput = screen.getByPlaceholderText('587') as HTMLInputElement
+ expect(portInput.value).toBe('587')
+
+ expect(screen.getByText('SMTP Configured')).toBeTruthy()
+ })
+
+ it('shows not configured state when SMTP is not set up', async () => {
+ vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
+ host: '',
+ port: 587,
+ username: '',
+ password: '',
+ from_address: '',
+ encryption: 'starttls',
+ configured: false,
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByText('SMTP Not Configured')).toBeTruthy()
+ })
+ })
+
+ it('saves SMTP settings successfully', async () => {
+ vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
+ host: '',
+ port: 587,
+ username: '',
+ password: '',
+ from_address: '',
+ encryption: 'starttls',
+ configured: false,
+ })
+ vi.mocked(smtpApi.updateSMTPConfig).mockResolvedValue({
+ message: 'SMTP configuration saved successfully',
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('smtp.gmail.com')).toBeTruthy()
+ })
+
+ const user = userEvent.setup()
+ await user.type(screen.getByPlaceholderText('smtp.gmail.com'), 'smtp.gmail.com')
+ await user.type(
+ screen.getByPlaceholderText('Charon '),
+ 'test@example.com'
+ )
+
+ await user.click(screen.getByRole('button', { name: 'Save Settings' }))
+
+ await waitFor(() => {
+ expect(smtpApi.updateSMTPConfig).toHaveBeenCalled()
+ })
+ })
+
+ it('tests SMTP connection', async () => {
+ vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
+ host: 'smtp.example.com',
+ port: 587,
+ username: 'user@example.com',
+ password: '********',
+ from_address: 'noreply@example.com',
+ encryption: 'starttls',
+ configured: true,
+ })
+ vi.mocked(smtpApi.testSMTPConnection).mockResolvedValue({
+ success: true,
+ message: 'Connection successful',
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Connection')).toBeTruthy()
+ })
+
+ const user = userEvent.setup()
+ await user.click(screen.getByText('Test Connection'))
+
+ await waitFor(() => {
+ expect(smtpApi.testSMTPConnection).toHaveBeenCalled()
+ })
+ })
+
+ it('shows test email form when SMTP is configured', async () => {
+ vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
+ host: 'smtp.example.com',
+ port: 587,
+ username: 'user@example.com',
+ password: '********',
+ from_address: 'noreply@example.com',
+ encryption: 'starttls',
+ configured: true,
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByText('Send Test Email')).toBeTruthy()
+ })
+
+ expect(screen.getByPlaceholderText('recipient@example.com')).toBeTruthy()
+ })
+
+ it('sends test email', async () => {
+ vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
+ host: 'smtp.example.com',
+ port: 587,
+ username: 'user@example.com',
+ password: '********',
+ from_address: 'noreply@example.com',
+ encryption: 'starttls',
+ configured: true,
+ })
+ vi.mocked(smtpApi.sendTestEmail).mockResolvedValue({
+ success: true,
+ message: 'Email sent',
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByText('Send Test Email')).toBeTruthy()
+ })
+
+ const user = userEvent.setup()
+ await user.type(
+ screen.getByPlaceholderText('recipient@example.com'),
+ 'test@test.com'
+ )
+ await user.click(screen.getByRole('button', { name: /Send Test/i }))
+
+ await waitFor(() => {
+ expect(smtpApi.sendTestEmail).toHaveBeenCalledWith({ to: 'test@test.com' })
+ })
+ })
+})
diff --git a/frontend/src/pages/__tests__/UsersPage.test.tsx b/frontend/src/pages/__tests__/UsersPage.test.tsx
new file mode 100644
index 00000000..4e1ec673
--- /dev/null
+++ b/frontend/src/pages/__tests__/UsersPage.test.tsx
@@ -0,0 +1,281 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { MemoryRouter } from 'react-router-dom'
+import { vi, describe, it, expect, beforeEach } from 'vitest'
+import UsersPage from '../UsersPage'
+import * as usersApi from '../../api/users'
+import * as proxyHostsApi from '../../api/proxyHosts'
+
+// Mock APIs
+vi.mock('../../api/users', () => ({
+ listUsers: vi.fn(),
+ getUser: vi.fn(),
+ createUser: vi.fn(),
+ inviteUser: vi.fn(),
+ updateUser: vi.fn(),
+ deleteUser: vi.fn(),
+ updateUserPermissions: vi.fn(),
+ validateInvite: vi.fn(),
+ acceptInvite: vi.fn(),
+}))
+
+vi.mock('../../api/proxyHosts', () => ({
+ getProxyHosts: vi.fn(),
+}))
+
+const createQueryClient = () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ })
+
+const renderWithProviders = (ui: React.ReactNode) => {
+ const queryClient = createQueryClient()
+ return render(
+
+ {ui}
+
+ )
+}
+
+const mockUsers = [
+ {
+ id: 1,
+ uuid: '123-456',
+ email: 'admin@example.com',
+ name: 'Admin User',
+ role: 'admin' as const,
+ enabled: true,
+ permission_mode: 'allow_all' as const,
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+ },
+ {
+ id: 2,
+ uuid: '789-012',
+ email: 'user@example.com',
+ name: 'Regular User',
+ role: 'user' as const,
+ enabled: true,
+ invite_status: 'accepted' as const,
+ permission_mode: 'allow_all' as const,
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+ },
+ {
+ id: 3,
+ uuid: '345-678',
+ email: 'pending@example.com',
+ name: '',
+ role: 'user' as const,
+ enabled: false,
+ invite_status: 'pending' as const,
+ permission_mode: 'deny_all' as const,
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+ },
+]
+
+const mockProxyHosts = [
+ {
+ uuid: 'host-1',
+ name: 'Test Host',
+ domain_names: 'test.example.com',
+ forward_scheme: 'http',
+ forward_host: 'localhost',
+ forward_port: 8080,
+ ssl_forced: true,
+ http2_support: true,
+ hsts_enabled: true,
+ hsts_subdomains: false,
+ block_exploits: true,
+ websocket_support: false,
+ application: 'none' as const,
+ locations: [],
+ enabled: true,
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+ },
+]
+
+describe('UsersPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(mockProxyHosts)
+ })
+
+ it('renders loading state initially', () => {
+ vi.mocked(usersApi.listUsers).mockReturnValue(new Promise(() => {}))
+
+ renderWithProviders()
+
+ expect(document.querySelector('.animate-spin')).toBeTruthy()
+ })
+
+ it('renders user list', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByText('User Management')).toBeTruthy()
+ })
+
+ expect(screen.getByText('Admin User')).toBeTruthy()
+ expect(screen.getByText('admin@example.com')).toBeTruthy()
+ expect(screen.getByText('Regular User')).toBeTruthy()
+ expect(screen.getByText('user@example.com')).toBeTruthy()
+ })
+
+ it('shows pending invite status', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByText('Pending Invite')).toBeTruthy()
+ })
+ })
+
+ it('shows active status for accepted users', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getAllByText('Active').length).toBeGreaterThan(0)
+ })
+ })
+
+ it('opens invite modal when clicking invite button', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByText('Invite User')).toBeTruthy()
+ })
+
+ const user = userEvent.setup()
+ await user.click(screen.getByRole('button', { name: /Invite User/i }))
+
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('user@example.com')).toBeTruthy()
+ })
+ })
+
+ it('shows permission mode in user list', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getAllByText('Blacklist').length).toBeGreaterThan(0)
+ })
+
+ expect(screen.getByText('Whitelist')).toBeTruthy()
+ })
+
+ it('toggles user enabled status', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+ vi.mocked(usersApi.updateUser).mockResolvedValue({ message: 'Updated' })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByText('Regular User')).toBeTruthy()
+ })
+
+ // Find the switch for the non-admin user and toggle it
+ const switches = screen.getAllByRole('checkbox')
+ // The second switch should be for the regular user (admin switch is disabled)
+ const userSwitch = switches.find(
+ (sw) => !(sw as HTMLInputElement).disabled && (sw as HTMLInputElement).checked
+ )
+
+ if (userSwitch) {
+ const user = userEvent.setup()
+ await user.click(userSwitch)
+
+ await waitFor(() => {
+ expect(usersApi.updateUser).toHaveBeenCalledWith(2, { enabled: false })
+ })
+ }
+ })
+
+ it('invites a new user', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+ vi.mocked(usersApi.inviteUser).mockResolvedValue({
+ id: 4,
+ uuid: 'new-user',
+ email: 'new@example.com',
+ role: 'user',
+ invite_token: 'test-token-123',
+ email_sent: false,
+ expires_at: '2024-01-03T00:00:00Z',
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByText('Invite User')).toBeTruthy()
+ })
+
+ const user = userEvent.setup()
+ await user.click(screen.getByRole('button', { name: /Invite User/i }))
+
+ // Wait for modal to open - look for the modal's email input placeholder
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('user@example.com')).toBeTruthy()
+ })
+
+ await user.type(screen.getByPlaceholderText('user@example.com'), 'new@example.com')
+ await user.click(screen.getByRole('button', { name: /Send Invite/i }))
+
+ await waitFor(() => {
+ expect(usersApi.inviteUser).toHaveBeenCalledWith({
+ email: 'new@example.com',
+ role: 'user',
+ permission_mode: 'allow_all',
+ permitted_hosts: [],
+ })
+ })
+ })
+
+ it('deletes a user after confirmation', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+ vi.mocked(usersApi.deleteUser).mockResolvedValue({ message: 'Deleted' })
+
+ // Mock window.confirm
+ const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByText('Regular User')).toBeTruthy()
+ })
+
+ // Find delete buttons (trash icons) - admin user's delete button is disabled
+ const deleteButtons = screen.getAllByTitle('Delete User')
+ // Find the first non-disabled delete button
+ const enabledDeleteButton = deleteButtons.find((btn) => !(btn as HTMLButtonElement).disabled)
+
+ expect(enabledDeleteButton).toBeTruthy()
+
+ const user = userEvent.setup()
+ await user.click(enabledDeleteButton!)
+
+ await waitFor(() => {
+ expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to delete this user?')
+ })
+
+ await waitFor(() => {
+ expect(usersApi.deleteUser).toHaveBeenCalled()
+ })
+
+ confirmSpy.mockRestore()
+ })
+})