diff --git a/backend/internal/api/handlers/auth_handler.go b/backend/internal/api/handlers/auth_handler.go index 5bb42718..841468a9 100644 --- a/backend/internal/api/handlers/auth_handler.go +++ b/backend/internal/api/handlers/auth_handler.go @@ -69,7 +69,19 @@ func (h *AuthHandler) Logout(c *gin.Context) { func (h *AuthHandler) Me(c *gin.Context) { userID, _ := c.Get("userID") role, _ := c.Get("role") - c.JSON(http.StatusOK, gin.H{"user_id": userID, "role": role}) + + u, err := h.authService.GetUserByID(userID.(uint)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "user_id": userID, + "role": role, + "name": u.Name, + "email": u.Email, + }) } type ChangePasswordRequest struct { diff --git a/backend/internal/api/handlers/auth_handler_test.go b/backend/internal/api/handlers/auth_handler_test.go index d27a6229..90305cbf 100644 --- a/backend/internal/api/handlers/auth_handler_test.go +++ b/backend/internal/api/handlers/auth_handler_test.go @@ -124,14 +124,23 @@ func TestAuthHandler_Logout(t *testing.T) { } func TestAuthHandler_Me(t *testing.T) { - handler, _ := setupAuthHandler(t) + handler, db := setupAuthHandler(t) + + // Create user that matches the middleware ID + user := &models.User{ + UUID: uuid.NewString(), + Email: "me@example.com", + Name: "Me User", + Role: "admin", + } + db.Create(user) gin.SetMode(gin.TestMode) r := gin.New() // Simulate middleware r.Use(func(c *gin.Context) { - c.Set("userID", uint(1)) - c.Set("role", "admin") + c.Set("userID", user.ID) + c.Set("role", user.Role) c.Next() }) r.GET("/me", handler.Me) @@ -143,8 +152,10 @@ func TestAuthHandler_Me(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) - assert.Equal(t, float64(1), resp["user_id"]) + assert.Equal(t, float64(user.ID), resp["user_id"]) assert.Equal(t, "admin", resp["role"]) + assert.Equal(t, "Me User", resp["name"]) + assert.Equal(t, "me@example.com", resp["email"]) } func TestAuthHandler_ChangePassword(t *testing.T) { diff --git a/backend/internal/api/handlers/user_handler.go b/backend/internal/api/handlers/user_handler.go index d5761277..fa2d0f68 100644 --- a/backend/internal/api/handlers/user_handler.go +++ b/backend/internal/api/handlers/user_handler.go @@ -2,6 +2,7 @@ package handlers import ( "net/http" + "strings" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -70,7 +71,7 @@ func (h *UserHandler) Setup(c *gin.Context) { user := models.User{ UUID: uuid.New().String(), Name: req.Name, - Email: req.Email, + Email: strings.ToLower(req.Email), Role: "admin", Enabled: true, } @@ -176,6 +177,7 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) { } // Check if email is already taken by another user + req.Email = strings.ToLower(req.Email) var count int64 if err := h.DB.Model(&models.User{}).Where("email = ? AND id != ?", req.Email, userID).Count(&count).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check email availability"}) diff --git a/backend/internal/api/handlers/user_integration_test.go b/backend/internal/api/handlers/user_integration_test.go new file mode 100644 index 00000000..745ab30f --- /dev/null +++ b/backend/internal/api/handlers/user_integration_test.go @@ -0,0 +1,117 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config" + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestUserLoginAfterEmailChange(t *testing.T) { + // Setup 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{}) + + // Setup Services and Handlers + cfg := config.Config{} + authService := services.NewAuthService(db, cfg) + authHandler := NewAuthHandler(authService) + userHandler := NewUserHandler(db) + + // Setup Router + gin.SetMode(gin.TestMode) + r := gin.New() + + // Register Routes + r.POST("/auth/login", authHandler.Login) + + // Mock Auth Middleware for UpdateProfile + r.POST("/user/profile", func(c *gin.Context) { + // Simulate authenticated user + var user models.User + db.First(&user) + c.Set("userID", user.ID) + c.Set("role", user.Role) + c.Next() + }, userHandler.UpdateProfile) + + // 1. Create Initial User + initialEmail := "initial@example.com" + password := "password123" + user, err := authService.Register(initialEmail, password, "Test User") + require.NoError(t, err) + require.NotNil(t, user) + + // 2. Login with Initial Credentials (Verify it works) + loginBody := map[string]string{ + "email": initialEmail, + "password": password, + } + jsonBody, _ := json.Marshal(loginBody) + req, _ := http.NewRequest("POST", "/auth/login", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, "Initial login should succeed") + + // 3. Update Profile (Change Email) + newEmail := "updated@example.com" + updateBody := map[string]string{ + "name": "Test User Updated", + "email": newEmail, + } + jsonUpdate, _ := json.Marshal(updateBody) + req, _ = http.NewRequest("POST", "/user/profile", bytes.NewBuffer(jsonUpdate)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, "Update profile should succeed") + + // Verify DB update + var updatedUser models.User + db.First(&updatedUser, user.ID) + assert.Equal(t, newEmail, updatedUser.Email, "Email should be updated in DB") + + // 4. Login with New Email + loginBodyNew := map[string]string{ + "email": newEmail, + "password": password, + } + jsonBodyNew, _ := json.Marshal(loginBodyNew) + req, _ = http.NewRequest("POST", "/auth/login", bytes.NewBuffer(jsonBodyNew)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + + // This is where the user says it fails + assert.Equal(t, http.StatusOK, w.Code, "Login with new email should succeed") + if w.Code != http.StatusOK { + t.Logf("Response Body: %s", w.Body.String()) + } + + // 5. Login with New Email (Different Case) + loginBodyCase := map[string]string{ + "email": "Updated@Example.com", // Different case + "password": password, + } + jsonBodyCase, _ := json.Marshal(loginBodyCase) + req, _ = http.NewRequest("POST", "/auth/login", bytes.NewBuffer(jsonBodyCase)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + + // If this fails, it confirms case sensitivity issue + assert.Equal(t, http.StatusOK, w.Code, "Login with mixed case email should succeed") +} diff --git a/backend/internal/services/auth_service.go b/backend/internal/services/auth_service.go index e07aeb5e..cde2c3a8 100644 --- a/backend/internal/services/auth_service.go +++ b/backend/internal/services/auth_service.go @@ -2,6 +2,7 @@ package services import ( "errors" + "strings" "time" "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config" @@ -27,6 +28,7 @@ type Claims struct { } func (s *AuthService) Register(email, password, name string) (*models.User, error) { + email = strings.ToLower(email) var count int64 s.db.Model(&models.User{}).Count(&count) @@ -57,6 +59,7 @@ func (s *AuthService) Register(email, password, name string) (*models.User, erro } func (s *AuthService) Login(email, password string) (string, error) { + email = strings.ToLower(email) var user models.User if err := s.db.Where("email = ?", email).First(&user).Error; err != nil { return "", errors.New("invalid credentials") @@ -138,3 +141,11 @@ func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) { return claims, nil } + +func (s *AuthService) GetUserByID(id uint) (*models.User, error) { + var user models.User + if err := s.db.First(&user, id).Error; err != nil { + return nil, err + } + return &user, nil +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 56c44a07..c595e9a0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,7 +11,7 @@ import ImportCaddy from './pages/ImportCaddy' import Certificates from './pages/Certificates' import SettingsLayout from './pages/SettingsLayout' import SystemSettings from './pages/SystemSettings' -import Security from './pages/Security' +import Account from './pages/Account' import Backups from './pages/Backups' import Logs from './pages/Logs' import Login from './pages/Login' @@ -41,14 +41,15 @@ export default function App() { {/* Settings Routes */} }> - } /> {/* Default to System */} + } /> } /> - } /> + } /> } /> } /> + diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 7117bd7f..c79ccfd2 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -7,7 +7,7 @@ import { useAuth } from '../hooks/useAuth' import { checkHealth } from '../api/health' import NotificationCenter from './NotificationCenter' import SystemStatus from './SystemStatus' -import { ChevronLeft, ChevronRight } from 'lucide-react' +import { Menu } from 'lucide-react' interface LayoutProps { children: ReactNode @@ -20,7 +20,7 @@ export default function Layout({ children }: LayoutProps) { const saved = localStorage.getItem('sidebarCollapsed') return saved ? JSON.parse(saved) : false }) - const { logout } = useAuth() + const { logout, user } = useAuth() useEffect(() => { localStorage.setItem('sidebarCollapsed', JSON.stringify(isCollapsed)) @@ -38,7 +38,7 @@ export default function Layout({ children }: LayoutProps) { { name: 'Remote Servers', path: '/remote-servers', icon: '🖥️' }, { name: 'Certificates', path: '/certificates', icon: '🔒' }, { name: 'Import Caddyfile', path: '/import', icon: '📥' }, - { name: 'Settings', path: '/settings/security', icon: '⚙️' }, + { name: 'Settings', path: '/settings/system', icon: '⚙️' }, ] return ( @@ -62,21 +62,15 @@ export default function Layout({ children }: LayoutProps) { ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'} ${isCollapsed ? 'w-20' : 'w-64'} `}> -