feat: add SMTP settings page and user management features
- Added a new SMTP settings page with functionality to configure SMTP settings, test connections, and send test emails. - Implemented user management page to list users, invite new users, and manage user permissions. - Created modals for inviting users and editing user permissions. - Added tests for the new SMTP settings and user management functionalities. - Updated navigation to include links to the new SMTP settings and user management pages.
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 := `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test Email</title>
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #333;">Test Email from Charon</h2>
|
||||
<p>If you received this email, your SMTP configuration is working correctly!</p>
|
||||
<p style="color: #666; font-size: 12px;">This is an automated test email.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
126
backend/internal/api/middleware/security.go
Normal file
126
backend/internal/api/middleware/security.go
Normal file
@@ -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, ", ")
|
||||
}
|
||||
182
backend/internal/api/middleware/security_test.go
Normal file
182
backend/internal/api/middleware/security_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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. <CaddyConfigDir>/data).
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
368
backend/internal/services/mail_service.go
Normal file
368
backend/internal/services/mail_service.go
Normal file
@@ -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 := `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>You've been invited to {{.AppName}}</title>
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px 10px 0 0; text-align: center;">
|
||||
<h1 style="color: white; margin: 0;">{{.AppName}}</h1>
|
||||
</div>
|
||||
<div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 10px 10px; border: 1px solid #e0e0e0; border-top: none;">
|
||||
<h2 style="margin-top: 0;">You've Been Invited!</h2>
|
||||
<p>You've been invited to join <strong>{{.AppName}}</strong>. Click the button below to set up your account:</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{.InviteURL}}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 15px 30px; text-decoration: none; border-radius: 5px; font-weight: bold; display: inline-block;">Accept Invitation</a>
|
||||
</div>
|
||||
<p style="color: #666; font-size: 14px;">This invitation link will expire in 48 hours.</p>
|
||||
<p style="color: #666; font-size: 14px;">If you didn't expect this invitation, you can safely ignore this email.</p>
|
||||
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;">
|
||||
<p style="color: #999; font-size: 12px;">If the button doesn't work, copy and paste this link into your browser:<br>
|
||||
<a href="{{.InviteURL}}" style="color: #667eea;">{{.InviteURL}}</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
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())
|
||||
}
|
||||
298
backend/internal/services/mail_service_test.go
Normal file
298
backend/internal/services/mail_service_test.go
Normal file
@@ -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",
|
||||
"<html><body>Test Body</body></html>",
|
||||
)
|
||||
|
||||
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", "<p>Body</p>")
|
||||
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 <noreply@example.com>",
|
||||
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",
|
||||
"<html><body>Test Body</body></html>",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
@@ -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() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/setup" element={<Setup />} />
|
||||
<Route path="/accept-invite" element={<AcceptInvite />} />
|
||||
<Route path="/" element={
|
||||
<SetupGuard>
|
||||
<RequireAuth>
|
||||
@@ -62,12 +66,14 @@ export default function App() {
|
||||
<Route path="access-lists" element={<AccessLists />} />
|
||||
<Route path="uptime" element={<Uptime />} />
|
||||
<Route path="notifications" element={<Notifications />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="import" element={<Navigate to="/tasks/import/caddyfile" replace />} />
|
||||
|
||||
{/* Settings Routes */}
|
||||
<Route path="settings" element={<Settings />}>
|
||||
<Route index element={<SystemSettings />} />
|
||||
<Route path="system" element={<SystemSettings />} />
|
||||
<Route path="smtp" element={<SMTPSettings />} />
|
||||
<Route path="crowdsec" element={<Navigate to="/security/crowdsec" replace />} />
|
||||
<Route path="account" element={<Account />} />
|
||||
</Route>
|
||||
|
||||
50
frontend/src/api/smtp.ts
Normal file
50
frontend/src/api/smtp.ts
Normal file
@@ -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<SMTPConfig> => {
|
||||
const response = await client.get<SMTPConfig>('/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<SMTPTestResult> => {
|
||||
const response = await client.post<SMTPTestResult>('/settings/smtp/test')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const sendTestEmail = async (request: TestEmailRequest): Promise<SMTPTestResult> => {
|
||||
const response = await client.post<SMTPTestResult>('/settings/smtp/test-email', request)
|
||||
return response.data
|
||||
}
|
||||
119
frontend/src/api/users.ts
Normal file
119
frontend/src/api/users.ts
Normal file
@@ -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<User[]> => {
|
||||
const response = await client.get<User[]>('/users')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getUser = async (id: number): Promise<User> => {
|
||||
const response = await client.get<User>(`/users/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const createUser = async (data: CreateUserRequest): Promise<User> => {
|
||||
const response = await client.post<User>('/users', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const inviteUser = async (data: InviteUserRequest): Promise<InviteUserResponse> => {
|
||||
const response = await client.post<InviteUserResponse>('/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<ValidateInviteResponse> => {
|
||||
const response = await client.get<ValidateInviteResponse>('/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
|
||||
}
|
||||
@@ -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: '🛡️' },
|
||||
]
|
||||
},
|
||||
|
||||
204
frontend/src/pages/AcceptInvite.tsx
Normal file
204
frontend/src/pages/AcceptInvite.tsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<XCircle className="h-16 w-16 text-red-500 mb-4" />
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Invalid Link</h2>
|
||||
<p className="text-gray-400 text-center mb-6">
|
||||
This invitation link is invalid or incomplete.
|
||||
</p>
|
||||
<Button onClick={() => navigate('/login')}>Go to Login</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isValidating) {
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-blue-500 mb-4" />
|
||||
<p className="text-gray-400">Validating invitation...</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<XCircle className="h-16 w-16 text-red-500 mb-4" />
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Invitation Invalid</h2>
|
||||
<p className="text-gray-400 text-center mb-6">{errorMessage}</p>
|
||||
<Button onClick={() => navigate('/login')}>Go to Login</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (accepted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<CheckCircle2 className="h-16 w-16 text-green-500 mb-4" />
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Account Created!</h2>
|
||||
<p className="text-gray-400 text-center mb-6">
|
||||
Your account has been set up successfully. Redirecting to login...
|
||||
</p>
|
||||
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<img src="/logo.png" alt="Charon" style={{ height: '100px', width: 'auto' }} />
|
||||
</div>
|
||||
|
||||
<Card title="Accept Invitation">
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-900/20 border border-blue-800 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-center gap-2 text-blue-400 mb-1">
|
||||
<UserCheck className="h-4 w-4" />
|
||||
<span className="font-medium">You've been invited!</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">
|
||||
Complete your account setup for <strong>{validation.email}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Your Name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="John Doe"
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<PasswordStrengthMeter password={password} />
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
error={
|
||||
confirmPassword && password !== confirmPassword
|
||||
? 'Passwords do not match'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={acceptMutation.isPending}
|
||||
disabled={!name || !password || password !== confirmPassword}
|
||||
>
|
||||
Create Account
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
233
frontend/src/pages/SMTPSettings.tsx
Normal file
233
frontend/src/pages/SMTPSettings.tsx
Normal file
@@ -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 (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-6 w-6 text-blue-500" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Email (SMTP) Settings</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Configure SMTP settings to enable email notifications and user invitations.
|
||||
</p>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="SMTP Host"
|
||||
type="text"
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
placeholder="smtp.gmail.com"
|
||||
/>
|
||||
<Input
|
||||
label="Port"
|
||||
type="number"
|
||||
value={port}
|
||||
onChange={(e) => setPort(parseInt(e.target.value) || 587)}
|
||||
placeholder="587"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
helperText="Use app-specific password for Gmail"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="From Address"
|
||||
type="email"
|
||||
value={fromAddress}
|
||||
onChange={(e) => setFromAddress(e.target.value)}
|
||||
placeholder="Charon <no-reply@example.com>"
|
||||
/>
|
||||
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
Encryption
|
||||
</label>
|
||||
<select
|
||||
value={encryption}
|
||||
onChange={(e) => setEncryption(e.target.value as 'none' | 'ssl' | 'starttls')}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
<option value="starttls">STARTTLS (Recommended)</option>
|
||||
<option value="ssl">SSL/TLS</option>
|
||||
<option value="none">None</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-700">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => testConnectionMutation.mutate()}
|
||||
isLoading={testConnectionMutation.isPending}
|
||||
disabled={!host || !fromAddress}
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
isLoading={saveMutation.isPending}
|
||||
>
|
||||
Save Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Status Indicator */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{smtpConfig?.configured ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
<span className="text-green-500 font-medium">SMTP Configured</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-5 w-5 text-yellow-500" />
|
||||
<span className="text-yellow-500 font-medium">SMTP Not Configured</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Test Email */}
|
||||
{smtpConfig?.configured && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
Send Test Email
|
||||
</h3>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="email"
|
||||
value={testEmail}
|
||||
onChange={(e) => setTestEmail(e.target.value)}
|
||||
placeholder="recipient@example.com"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => sendTestEmailMutation.mutate()}
|
||||
isLoading={sendTestEmailMutation.isPending}
|
||||
disabled={!testEmail}
|
||||
>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Send Test
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,17 @@ export default function Settings() {
|
||||
System
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/settings/smtp"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive('/settings/smtp')
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
Email (SMTP)
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/settings/account"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
|
||||
582
frontend/src/pages/UsersPage.tsx
Normal file
582
frontend/src/pages/UsersPage.tsx
Normal file
@@ -0,0 +1,582 @@
|
||||
import { useState } 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 { Switch } from '../components/ui/Switch'
|
||||
import { toast } from '../utils/toast'
|
||||
import {
|
||||
listUsers,
|
||||
inviteUser,
|
||||
deleteUser,
|
||||
updateUser,
|
||||
updateUserPermissions,
|
||||
} from '../api/users'
|
||||
import type { User, InviteUserRequest, PermissionMode, UpdateUserPermissionsRequest } from '../api/users'
|
||||
import { getProxyHosts } from '../api/proxyHosts'
|
||||
import type { ProxyHost } from '../api/proxyHosts'
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
Mail,
|
||||
Shield,
|
||||
Trash2,
|
||||
Settings,
|
||||
X,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Copy,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface InviteModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => 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<PermissionMode>('allow_all')
|
||||
const [selectedHosts, setSelectedHosts] = useState<number[]>([])
|
||||
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 (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-dark-card border border-gray-800 rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5" />
|
||||
Invite User
|
||||
</h3>
|
||||
<button onClick={handleClose} className="text-gray-400 hover:text-white">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{inviteResult ? (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-green-900/20 border border-green-800 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-green-400 mb-2">
|
||||
<Check className="h-5 w-5" />
|
||||
<span className="font-medium">User Invited Successfully</span>
|
||||
</div>
|
||||
{inviteResult.emailSent ? (
|
||||
<p className="text-sm text-gray-300">
|
||||
An invitation email has been sent to the user.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-300">
|
||||
Email was not sent. Share the invite link manually.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!inviteResult.emailSent && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Invite Link
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={`${window.location.origin}/accept-invite?token=${inviteResult.token}`}
|
||||
readOnly
|
||||
className="flex-1 text-sm"
|
||||
/>
|
||||
<Button onClick={copyInviteLink}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Expires: {new Date(inviteResult.expiresAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleClose} className="w-full">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
label="Email Address"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as 'user' | 'admin')}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{role === 'user' && (
|
||||
<>
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
Permission Mode
|
||||
</label>
|
||||
<select
|
||||
value={permissionMode}
|
||||
onChange={(e) => setPermissionMode(e.target.value as PermissionMode)}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="allow_all">Allow All (Blacklist)</option>
|
||||
<option value="deny_all">Deny All (Whitelist)</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{permissionMode === 'allow_all'
|
||||
? 'User can access all hosts EXCEPT those selected below'
|
||||
: 'User can ONLY access hosts selected below'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{permissionMode === 'allow_all' ? 'Blocked Hosts' : 'Allowed Hosts'}
|
||||
</label>
|
||||
<div className="max-h-48 overflow-y-auto border border-gray-700 rounded-lg">
|
||||
{proxyHosts.length === 0 ? (
|
||||
<p className="p-3 text-sm text-gray-500">No proxy hosts configured</p>
|
||||
) : (
|
||||
proxyHosts.map((host) => (
|
||||
<label
|
||||
key={host.uuid}
|
||||
className="flex items-center gap-3 p-3 hover:bg-gray-800/50 cursor-pointer border-b border-gray-800 last:border-0"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedHosts.includes(
|
||||
parseInt(host.uuid.split('-')[0], 16) || 0
|
||||
)}
|
||||
onChange={() =>
|
||||
toggleHost(parseInt(host.uuid.split('-')[0], 16) || 0)
|
||||
}
|
||||
className="rounded bg-gray-900 border-gray-700 text-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm text-white">{host.name || host.domain_names}</p>
|
||||
<p className="text-xs text-gray-500">{host.domain_names}</p>
|
||||
</div>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-700">
|
||||
<Button variant="secondary" onClick={handleClose} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => inviteMutation.mutate()}
|
||||
isLoading={inviteMutation.isPending}
|
||||
disabled={!email}
|
||||
className="flex-1"
|
||||
>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Send Invite
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<PermissionMode>('allow_all')
|
||||
const [selectedHosts, setSelectedHosts] = useState<number[]>([])
|
||||
|
||||
// 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 (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-dark-card border border-gray-800 rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Edit Permissions - {user.name || user.email}
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
Permission Mode
|
||||
</label>
|
||||
<select
|
||||
value={permissionMode}
|
||||
onChange={(e) => setPermissionMode(e.target.value as PermissionMode)}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="allow_all">Allow All (Blacklist)</option>
|
||||
<option value="deny_all">Deny All (Whitelist)</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{permissionMode === 'allow_all'
|
||||
? 'User can access all hosts EXCEPT those selected below'
|
||||
: 'User can ONLY access hosts selected below'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{permissionMode === 'allow_all' ? 'Blocked Hosts' : 'Allowed Hosts'}
|
||||
</label>
|
||||
<div className="max-h-64 overflow-y-auto border border-gray-700 rounded-lg">
|
||||
{proxyHosts.length === 0 ? (
|
||||
<p className="p-3 text-sm text-gray-500">No proxy hosts configured</p>
|
||||
) : (
|
||||
proxyHosts.map((host) => (
|
||||
<label
|
||||
key={host.uuid}
|
||||
className="flex items-center gap-3 p-3 hover:bg-gray-800/50 cursor-pointer border-b border-gray-800 last:border-0"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedHosts.includes(
|
||||
parseInt(host.uuid.split('-')[0], 16) || 0
|
||||
)}
|
||||
onChange={() =>
|
||||
toggleHost(parseInt(host.uuid.split('-')[0], 16) || 0)
|
||||
}
|
||||
className="rounded bg-gray-900 border-gray-700 text-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm text-white">{host.name || host.domain_names}</p>
|
||||
<p className="text-xs text-gray-500">{host.domain_names}</p>
|
||||
</div>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-700">
|
||||
<Button variant="secondary" onClick={onClose} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => updatePermissionsMutation.mutate()}
|
||||
isLoading={updatePermissionsMutation.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
Save Permissions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [inviteModalOpen, setInviteModalOpen] = useState(false)
|
||||
const [permissionsModalOpen, setPermissionsModalOpen] = useState(false)
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(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 (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-6 w-6 text-blue-500" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">User Management</h1>
|
||||
</div>
|
||||
<Button onClick={() => setInviteModalOpen(true)}>
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Invite User
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">User</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">Role</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">Status</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">Permissions</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">Enabled</th>
|
||||
<th className="text-right py-3 px-4 text-sm font-medium text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users?.map((user) => (
|
||||
<tr key={user.id} className="border-b border-gray-800/50 hover:bg-gray-800/30">
|
||||
<td className="py-3 px-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{user.name || '(No name)'}</p>
|
||||
<p className="text-xs text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-900/30 text-purple-400'
|
||||
: 'bg-blue-900/30 text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{user.invite_status === 'pending' ? (
|
||||
<span className="inline-flex items-center gap-1 text-yellow-400 text-xs">
|
||||
<Clock className="h-3 w-3" />
|
||||
Pending Invite
|
||||
</span>
|
||||
) : user.invite_status === 'expired' ? (
|
||||
<span className="inline-flex items-center gap-1 text-red-400 text-xs">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Invite Expired
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-green-400 text-xs">
|
||||
<Check className="h-3 w-3" />
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-xs text-gray-400">
|
||||
{user.permission_mode === 'deny_all' ? 'Whitelist' : 'Blacklist'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Switch
|
||||
checked={user.enabled}
|
||||
onChange={() =>
|
||||
toggleEnabledMutation.mutate({
|
||||
id: user.id,
|
||||
enabled: !user.enabled,
|
||||
})
|
||||
}
|
||||
disabled={user.role === 'admin'}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{user.role !== 'admin' && (
|
||||
<button
|
||||
onClick={() => openPermissions(user)}
|
||||
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-800 rounded"
|
||||
title="Edit Permissions"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to delete this user?')) {
|
||||
deleteMutation.mutate(user.id)
|
||||
}
|
||||
}}
|
||||
className="p-1.5 text-gray-400 hover:text-red-400 hover:bg-gray-800 rounded"
|
||||
title="Delete User"
|
||||
disabled={user.role === 'admin'}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<InviteModal
|
||||
isOpen={inviteModalOpen}
|
||||
onClose={() => setInviteModalOpen(false)}
|
||||
proxyHosts={proxyHosts}
|
||||
/>
|
||||
|
||||
<PermissionsModal
|
||||
isOpen={permissionsModalOpen}
|
||||
onClose={() => {
|
||||
setPermissionsModalOpen(false)
|
||||
setSelectedUser(null)
|
||||
}}
|
||||
user={selectedUser}
|
||||
proxyHosts={proxyHosts}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
208
frontend/src/pages/__tests__/AcceptInvite.test.tsx
Normal file
208
frontend/src/pages/__tests__/AcceptInvite.test.tsx
Normal file
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[initialRoute]}>
|
||||
<Routes>
|
||||
<Route path="/accept-invite" element={<AcceptInvite />} />
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
209
frontend/src/pages/__tests__/SMTPSettings.test.tsx
Normal file
209
frontend/src/pages/__tests__/SMTPSettings.test.tsx
Normal file
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('SMTPSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders loading state initially', () => {
|
||||
vi.mocked(smtpApi.getSMTPConfig).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
renderWithProviders(<SMTPSettings />)
|
||||
|
||||
// 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(<SMTPSettings />)
|
||||
|
||||
// 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(<SMTPSettings />)
|
||||
|
||||
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(<SMTPSettings />)
|
||||
|
||||
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 <no-reply@example.com>'),
|
||||
'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(<SMTPSettings />)
|
||||
|
||||
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(<SMTPSettings />)
|
||||
|
||||
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(<SMTPSettings />)
|
||||
|
||||
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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
281
frontend/src/pages/__tests__/UsersPage.test.tsx
Normal file
281
frontend/src/pages/__tests__/UsersPage.test.tsx
Normal file
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
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(<UsersPage />)
|
||||
|
||||
expect(document.querySelector('.animate-spin')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders user list', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithProviders(<UsersPage />)
|
||||
|
||||
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(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Pending Invite')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows active status for accepted users', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithProviders(<UsersPage />)
|
||||
|
||||
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(<UsersPage />)
|
||||
|
||||
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(<UsersPage />)
|
||||
|
||||
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(<UsersPage />)
|
||||
|
||||
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(<UsersPage />)
|
||||
|
||||
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(<UsersPage />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user