diff --git a/backend/gsmaster/routes.go b/backend/gsmaster/routes.go index 02c6405..6e81de2 100644 --- a/backend/gsmaster/routes.go +++ b/backend/gsmaster/routes.go @@ -1,39 +1,44 @@ package gsmaster import ( + "bamort/user" + "github.com/gin-gonic/gin" ) func RegisterRoutes(r *gin.RouterGroup) { maintGrp := r.Group("/maintenance") - maintGrp.GET("", GetMasterData) - maintGrp.GET("/skills", GetMDSkills) - maintGrp.GET("/skills/:id", GetMDSkill) - maintGrp.PUT("/skills/:id", UpdateMDSkill) - maintGrp.POST("/skills", AddSkill) - maintGrp.DELETE("/skills/:id", DeleteMDSkill) + maintGrp.Use(user.RequireMaintainer()) + { + maintGrp.GET("", GetMasterData) + maintGrp.GET("/skills", GetMDSkills) + maintGrp.GET("/skills/:id", GetMDSkill) + maintGrp.PUT("/skills/:id", UpdateMDSkill) + maintGrp.POST("/skills", AddSkill) + maintGrp.DELETE("/skills/:id", DeleteMDSkill) - maintGrp.GET("/weaponskills", GetMDWeaponSkills) - maintGrp.GET("/weaponskills/:id", GetMDWeaponSkill) - maintGrp.PUT("/weaponskills/:id", UpdateMDWeaponSkill) - maintGrp.POST("/weaponskills", AddWeaponSkill) - maintGrp.DELETE("/weaponskills/:id", DeleteMDWeaponSkill) + maintGrp.GET("/weaponskills", GetMDWeaponSkills) + maintGrp.GET("/weaponskills/:id", GetMDWeaponSkill) + maintGrp.PUT("/weaponskills/:id", UpdateMDWeaponSkill) + maintGrp.POST("/weaponskills", AddWeaponSkill) + maintGrp.DELETE("/weaponskills/:id", DeleteMDWeaponSkill) - maintGrp.GET("/spells", GetMDSpells) - maintGrp.GET("/spells/:id", GetMDSpell) - maintGrp.PUT("/spells/:id", UpdateMDSpell) - maintGrp.POST("/spells", AddSpell) - maintGrp.DELETE("/spells/:id", DeleteMDSpell) + maintGrp.GET("/spells", GetMDSpells) + maintGrp.GET("/spells/:id", GetMDSpell) + maintGrp.PUT("/spells/:id", UpdateMDSpell) + maintGrp.POST("/spells", AddSpell) + maintGrp.DELETE("/spells/:id", DeleteMDSpell) - maintGrp.GET("/equipment", GetMDEquipments) - maintGrp.GET("/equipment/:id", GetMDEquipment) - maintGrp.PUT("/equipment/:id", UpdateMDEquipment) - maintGrp.POST("/equipment", AddEquipment) - maintGrp.DELETE("/equipment/:id", DeleteMDEquipment) + maintGrp.GET("/equipment", GetMDEquipments) + maintGrp.GET("/equipment/:id", GetMDEquipment) + maintGrp.PUT("/equipment/:id", UpdateMDEquipment) + maintGrp.POST("/equipment", AddEquipment) + maintGrp.DELETE("/equipment/:id", DeleteMDEquipment) - maintGrp.GET("/weapons", GetMDWeapons) - maintGrp.GET("/weapons/:id", GetMDWeapon) - maintGrp.PUT("/weapons/:id", UpdateMDWeapon) - maintGrp.POST("/weapons", AddWeapon) - maintGrp.DELETE("/weapons/:id", DeleteMDWeapon) + maintGrp.GET("/weapons", GetMDWeapons) + maintGrp.GET("/weapons/:id", GetMDWeapon) + maintGrp.PUT("/weapons/:id", UpdateMDWeapon) + maintGrp.POST("/weapons", AddWeapon) + maintGrp.DELETE("/weapons/:id", DeleteMDWeapon) + } } diff --git a/backend/maintenance/routes.go b/backend/maintenance/routes.go index bed64f4..df73f06 100644 --- a/backend/maintenance/routes.go +++ b/backend/maintenance/routes.go @@ -1,22 +1,26 @@ package maintenance import ( + "bamort/user" + "github.com/gin-gonic/gin" ) func RegisterRoutes(r *gin.RouterGroup) { charGrp := r.Group("/maintenance") - charGrp.GET("/setupcheck", SetupCheck) - charGrp.GET("/setupcheck-dev", SetupCheckDev) - charGrp.GET("/mktestdata", MakeTestdataFromLive) - charGrp.GET("/reconndb", ReconnectDataBase) // Datenbank neu verbinden - charGrp.GET("/reloadenv", ReloadENV) - charGrp.POST("/transfer-sqlite-to-mariadb", TransferSQLiteToMariaDB) // Transfer data from SQLite to MariaDB - /* - //nur zur einmaligen Ausführung, um das Lernkosten-System zu initialisieren - charGrp.POST("/initialize-learning-costs", InitializeLearningCosts) - // Zur Überprüfung der Lernkosten-Daten - charGrp.GET("/learning-costs-summary", gsmaster.GetLearningCostsSummaryHandler) - */ - + charGrp.Use(user.RequireMaintainer()) + { + charGrp.GET("/setupcheck", SetupCheck) + charGrp.GET("/setupcheck-dev", SetupCheckDev) + charGrp.GET("/mktestdata", MakeTestdataFromLive) + charGrp.GET("/reconndb", ReconnectDataBase) // Datenbank neu verbinden + charGrp.GET("/reloadenv", ReloadENV) + charGrp.POST("/transfer-sqlite-to-mariadb", TransferSQLiteToMariaDB) // Transfer data from SQLite to MariaDB + /* + //nur zur einmaligen Ausführung, um das Lernkosten-System zu initialisieren + charGrp.POST("/initialize-learning-costs", InitializeLearningCosts) + // Zur Überprüfung der Lernkosten-Daten + charGrp.GET("/learning-costs-summary", gsmaster.GetLearningCostsSummaryHandler) + */ + } } diff --git a/backend/pdfrender/routes.go b/backend/pdfrender/routes.go index b08aa78..442fac0 100644 --- a/backend/pdfrender/routes.go +++ b/backend/pdfrender/routes.go @@ -20,6 +20,6 @@ func RegisterRoutes(r *gin.RouterGroup) { // RegisterPublicRoutes registers public PDF routes (no authentication required) func RegisterPublicRoutes(r *gin.Engine) { - // Get PDF file from xporttemp (public - for direct browser access) + // Get PDF file from export_temp (public - for direct browser access) r.GET("/api/pdf/file/:filename", GetPDFFile) } diff --git a/backend/user/admin_handlers.go b/backend/user/admin_handlers.go new file mode 100644 index 0000000..26b6f0a --- /dev/null +++ b/backend/user/admin_handlers.go @@ -0,0 +1,245 @@ +package user + +import ( + "bamort/database" + "bamort/logger" + "crypto/md5" + "encoding/hex" + "fmt" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +// ListUsers returns all users (admin only) +func ListUsers(c *gin.Context) { + logger.Debug("Listing all users...") + + var users []User + if err := database.DB.Find(&users).Error; err != nil { + logger.Error("Failed to fetch users: %s", err.Error()) + respondWithError(c, http.StatusInternalServerError, "Failed to fetch users") + return + } + + // Remove password hashes from response + for i := range users { + users[i].PasswordHash = "" + users[i].ResetPwHash = nil + } + + logger.Info("Successfully fetched %d users", len(users)) + c.JSON(http.StatusOK, users) +} + +// GetUser returns a specific user by ID (admin only, or own profile) +func GetUser(c *gin.Context) { + logger.Debug("Fetching user by ID...") + + userIDParam := c.Param("id") + targetUserID, err := strconv.ParseUint(userIDParam, 10, 32) + if err != nil { + logger.Error("Invalid user ID: %s", userIDParam) + respondWithError(c, http.StatusBadRequest, "Invalid user ID") + return + } + + // Get requesting user from context + requestingUserInterface, exists := c.Get("user") + if !exists { + logger.Error("User not found in context") + respondWithError(c, http.StatusUnauthorized, "Unauthorized") + return + } + + requestingUser, ok := requestingUserInterface.(*User) + if !ok { + logger.Error("Invalid user context") + respondWithError(c, http.StatusInternalServerError, "Invalid user context") + return + } + + // Allow users to view their own profile, or admins to view any profile + if requestingUser.UserID != uint(targetUserID) && !requestingUser.IsAdmin() { + logger.Warn("User %s attempted to access user %d without permission", requestingUser.Username, targetUserID) + respondWithError(c, http.StatusForbidden, "Forbidden") + return + } + + var user User + if err := user.FirstId(uint(targetUserID)); err != nil { + logger.Error("User not found: %d", targetUserID) + respondWithError(c, http.StatusNotFound, "User not found") + return + } + + // Remove sensitive data + user.PasswordHash = "" + user.ResetPwHash = nil + + logger.Info("Successfully fetched user: %s (ID: %d)", user.Username, user.UserID) + c.JSON(http.StatusOK, user) +} + +// UpdateUserRole updates a user's role (admin only) +func UpdateUserRole(c *gin.Context) { + logger.Debug("Updating user role...") + + userIDParam := c.Param("id") + targetUserID, err := strconv.ParseUint(userIDParam, 10, 32) + if err != nil { + logger.Error("Invalid user ID: %s", userIDParam) + respondWithError(c, http.StatusBadRequest, "Invalid user ID") + return + } + + var input struct { + Role string `json:"role" binding:"required"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + logger.Error("Failed to parse role update data: %s", err.Error()) + respondWithError(c, http.StatusBadRequest, "Role is required") + return + } + + // Validate role + if !ValidateRole(input.Role) { + logger.Error("Invalid role: %s", input.Role) + respondWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid role. Must be one of: %s, %s, %s", RoleStandardUser, RoleMaintainer, RoleAdmin)) + return + } + + var user User + if err := user.FirstId(uint(targetUserID)); err != nil { + logger.Error("User not found: %d", targetUserID) + respondWithError(c, http.StatusNotFound, "User not found") + return + } + + // Get requesting user for logging + requestingUserInterface, _ := c.Get("user") + requestingUser, _ := requestingUserInterface.(*User) + + oldRole := user.Role + user.Role = input.Role + + if err := user.Save(); err != nil { + logger.Error("Failed to update user role for user %s: %s", user.Username, err.Error()) + respondWithError(c, http.StatusInternalServerError, "Failed to update user role") + return + } + + logger.Info("User role updated: %s (ID: %d) from %s to %s by %s", user.Username, user.UserID, oldRole, user.Role, requestingUser.Username) + c.JSON(http.StatusOK, gin.H{ + "message": "User role updated successfully", + "user": gin.H{ + "id": user.UserID, + "username": user.Username, + "role": user.Role, + }, + }) +} + +// DeleteUser deletes a user (admin only) +func DeleteUser(c *gin.Context) { + logger.Debug("Deleting user...") + + userIDParam := c.Param("id") + targetUserID, err := strconv.ParseUint(userIDParam, 10, 32) + if err != nil { + logger.Error("Invalid user ID: %s", userIDParam) + respondWithError(c, http.StatusBadRequest, "Invalid user ID") + return + } + + // Get requesting user + requestingUserInterface, exists := c.Get("user") + if !exists { + logger.Error("User not found in context") + respondWithError(c, http.StatusUnauthorized, "Unauthorized") + return + } + + requestingUser, ok := requestingUserInterface.(*User) + if !ok { + logger.Error("Invalid user context") + respondWithError(c, http.StatusInternalServerError, "Invalid user context") + return + } + + // Prevent self-deletion + if requestingUser.UserID == uint(targetUserID) { + logger.Warn("User %s attempted to delete themselves", requestingUser.Username) + respondWithError(c, http.StatusBadRequest, "Cannot delete your own account") + return + } + + var user User + if err := user.FirstId(uint(targetUserID)); err != nil { + logger.Error("User not found: %d", targetUserID) + respondWithError(c, http.StatusNotFound, "User not found") + return + } + + if err := database.DB.Delete(&user).Error; err != nil { + logger.Error("Failed to delete user %s: %s", user.Username, err.Error()) + respondWithError(c, http.StatusInternalServerError, "Failed to delete user") + return + } + + logger.Info("User deleted: %s (ID: %d) by %s", user.Username, user.UserID, requestingUser.Username) + c.JSON(http.StatusOK, gin.H{ + "message": "User deleted successfully", + }) +} + +// ChangeUserPassword allows admin to change a user's password (admin only) +func ChangeUserPassword(c *gin.Context) { + logger.Debug("Admin changing user password...") + + userIDParam := c.Param("id") + targetUserID, err := strconv.ParseUint(userIDParam, 10, 32) + if err != nil { + logger.Error("Invalid user ID: %s", userIDParam) + respondWithError(c, http.StatusBadRequest, "Invalid user ID") + return + } + + var input struct { + NewPassword string `json:"new_password" binding:"required,min=6"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + logger.Error("Failed to parse password data: %s", err.Error()) + respondWithError(c, http.StatusBadRequest, "New password (min 6 characters) is required") + return + } + + var user User + if err := user.FirstId(uint(targetUserID)); err != nil { + logger.Error("User not found: %d", targetUserID) + respondWithError(c, http.StatusNotFound, "User not found") + return + } + + // Get requesting user for logging + requestingUserInterface, _ := c.Get("user") + requestingUser, _ := requestingUserInterface.(*User) + + // Hash new password using MD5 (same as registration) + hashedPassword := md5.Sum([]byte(input.NewPassword)) + user.PasswordHash = hex.EncodeToString(hashedPassword[:]) + + if err := user.Save(); err != nil { + logger.Error("Failed to update password for user %s: %s", user.Username, err.Error()) + respondWithError(c, http.StatusInternalServerError, "Failed to update password") + return + } + + logger.Info("Password changed for user %s (ID: %d) by admin %s", user.Username, user.UserID, requestingUser.Username) + c.JSON(http.StatusOK, gin.H{ + "message": "Password updated successfully", + }) +} diff --git a/backend/user/handlers.go b/backend/user/handlers.go index baba9a7..0363322 100644 --- a/backend/user/handlers.go +++ b/backend/user/handlers.go @@ -60,6 +60,11 @@ func RegisterUser(c *gin.Context) { user.PasswordHash = hex.EncodeToString(hashedPassword[:]) logger.Debug("Passwort-Hash erstellt für Benutzer: %s", user.Username) + // Set default role for new users + if user.Role == "" { + user.Role = RoleStandardUser + } + //fmt.Printf("pwdh: %s", user.PasswordHash) if err := user.Create(); err != nil { logger.Error("Fehler beim Erstellen des Benutzers %s: %s", user.Username, err.Error()) @@ -217,6 +222,7 @@ func AuthMiddleware() gin.HandlerFunc { // Set user information in context c.Set("userID", user.UserID) c.Set("username", user.Username) + c.Set("user", user) c.Next() } @@ -438,6 +444,7 @@ func GetUserProfile(c *gin.Context) { "id": user.UserID, "username": user.Username, "email": user.Email, + "role": user.Role, }) } diff --git a/backend/user/middleware.go b/backend/user/middleware.go new file mode 100644 index 0000000..5296735 --- /dev/null +++ b/backend/user/middleware.go @@ -0,0 +1,65 @@ +package user + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// RequireRole is a middleware that checks if the user has the required role +func RequireRole(requiredRole string) gin.HandlerFunc { + return func(c *gin.Context) { + // Get user from context (set by auth middleware) + userInterface, exists := c.Get("user") + if !exists { + respondWithError(c, http.StatusUnauthorized, "Unauthorized") + c.Abort() + return + } + + user, ok := userInterface.(*User) + if !ok { + respondWithError(c, http.StatusInternalServerError, "Invalid user context") + c.Abort() + return + } + + // Check if user has required role + switch requiredRole { + case RoleAdmin: + if !user.IsAdmin() { + respondWithError(c, http.StatusForbidden, "Admin role required") + c.Abort() + return + } + case RoleMaintainer: + if !user.IsMaintainer() { + respondWithError(c, http.StatusForbidden, "Maintainer role required") + c.Abort() + return + } + case RoleStandardUser: + if !user.IsStandardUser() { + respondWithError(c, http.StatusForbidden, "Insufficient permissions") + c.Abort() + return + } + default: + respondWithError(c, http.StatusInternalServerError, "Invalid role requirement") + c.Abort() + return + } + + c.Next() + } +} + +// RequireAdmin is a convenience middleware for admin-only endpoints +func RequireAdmin() gin.HandlerFunc { + return RequireRole(RoleAdmin) +} + +// RequireMaintainer is a convenience middleware for maintainer-or-higher endpoints +func RequireMaintainer() gin.HandlerFunc { + return RequireRole(RoleMaintainer) +} diff --git a/backend/user/model.go b/backend/user/model.go index 3a4a4a0..922e723 100644 --- a/backend/user/model.go +++ b/backend/user/model.go @@ -8,11 +8,19 @@ import ( "gorm.io/gorm" ) +// Role constants +const ( + RoleStandardUser = "standard" + RoleMaintainer = "maintainer" + RoleAdmin = "admin" +) + type User struct { UserID uint `gorm:"primaryKey" json:"id"` Username string `gorm:"unique" json:"username"` PasswordHash string `json:"password"` Email string `gorm:"unique" json:"email"` + Role string `gorm:"default:standard" json:"role"` ResetPwHash *string `gorm:"index" json:"-"` // Hash für Password-Reset (wird nicht serialisiert) ResetPwHashExpires *time.Time `json:"-"` // Ablaufzeit für Password-Reset-Hash CreatedAt time.Time `json:"created_at"` @@ -120,3 +128,28 @@ func (object *User) IsResetHashValid(resetHash string) bool { } return *object.ResetPwHash == resetHash && time.Now().Before(*object.ResetPwHashExpires) } + +// HasRole checks if the user has the specified role +func (u *User) HasRole(role string) bool { + return u.Role == role +} + +// IsAdmin checks if the user is an admin +func (u *User) IsAdmin() bool { + return u.Role == RoleAdmin +} + +// IsMaintainer checks if the user is a maintainer or higher +func (u *User) IsMaintainer() bool { + return u.Role == RoleMaintainer || u.Role == RoleAdmin +} + +// IsStandardUser checks if the user is a standard user or higher +func (u *User) IsStandardUser() bool { + return u.Role == RoleStandardUser || u.IsMaintainer() +} + +// ValidateRole checks if the given role is valid +func ValidateRole(role string) bool { + return role == RoleStandardUser || role == RoleMaintainer || role == RoleAdmin +} diff --git a/backend/user/role_test.go b/backend/user/role_test.go new file mode 100644 index 0000000..abcbf50 --- /dev/null +++ b/backend/user/role_test.go @@ -0,0 +1,233 @@ +package user + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "bamort/database" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupRoleTestEnvironment sets up the test environment for role tests +func setupRoleTestEnvironment(t *testing.T) { + setupTestEnvironment(t) + database.SetupTestDB() + err := MigrateStructure() + require.NoError(t, err, "Should migrate user structure") + gin.SetMode(gin.TestMode) +} + +// TestUserRoleDefaults tests that new users get standard role by default +func TestUserRoleDefaults(t *testing.T) { + setupRoleTestEnvironment(t) + + user := &User{ + Username: "roletest_user", + PasswordHash: "hashedpw", + Email: "roletest@example.com", + } + + err := user.Create() + require.NoError(t, err, "Should create user") + assert.Equal(t, RoleStandardUser, user.Role, "New users should have standard role") +} + +// TestRoleValidation tests role validation +func TestRoleValidation(t *testing.T) { + assert.True(t, ValidateRole(RoleStandardUser), "standard should be valid") + assert.True(t, ValidateRole(RoleMaintainer), "maintainer should be valid") + assert.True(t, ValidateRole(RoleAdmin), "admin should be valid") + assert.False(t, ValidateRole("invalid"), "invalid should not be valid") + assert.False(t, ValidateRole(""), "empty should not be valid") +} + +// TestRoleHierarchy tests role hierarchy methods +func TestRoleHierarchy(t *testing.T) { + standardUser := &User{Role: RoleStandardUser} + maintainer := &User{Role: RoleMaintainer} + admin := &User{Role: RoleAdmin} + + // Test IsStandardUser + assert.True(t, standardUser.IsStandardUser(), "standard user should pass IsStandardUser") + assert.True(t, maintainer.IsStandardUser(), "maintainer should pass IsStandardUser") + assert.True(t, admin.IsStandardUser(), "admin should pass IsStandardUser") + + // Test IsMaintainer + assert.False(t, standardUser.IsMaintainer(), "standard user should not pass IsMaintainer") + assert.True(t, maintainer.IsMaintainer(), "maintainer should pass IsMaintainer") + assert.True(t, admin.IsMaintainer(), "admin should pass IsMaintainer") + + // Test IsAdmin + assert.False(t, standardUser.IsAdmin(), "standard user should not pass IsAdmin") + assert.False(t, maintainer.IsAdmin(), "maintainer should not pass IsAdmin") + assert.True(t, admin.IsAdmin(), "admin should pass IsAdmin") +} + +// TestListUsers tests listing all users (admin only) +func TestListUsers(t *testing.T) { + setupRoleTestEnvironment(t) + + // Create test users + admin := &User{Username: "listadmin", PasswordHash: "hash", Email: "listadmin@test.com", Role: RoleAdmin} + require.NoError(t, admin.Create()) + + standardUser := &User{Username: "listuser", PasswordHash: "hash", Email: "listuser@test.com", Role: RoleStandardUser} + require.NoError(t, standardUser.Create()) + + // Test admin access + router := gin.Default() + router.GET("/users", func(c *gin.Context) { + c.Set("user", admin) + }, RequireAdmin(), ListUsers) + + req, _ := http.NewRequest("GET", "/users", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code, "Admin should access list") + + // Test standard user access (should fail) + router2 := gin.Default() + router2.GET("/users", func(c *gin.Context) { + c.Set("user", standardUser) + }, RequireAdmin(), ListUsers) + + req2, _ := http.NewRequest("GET", "/users", nil) + w2 := httptest.NewRecorder() + router2.ServeHTTP(w2, req2) + + assert.Equal(t, http.StatusForbidden, w2.Code, "Standard user should not access list") +} + +// TestUpdateUserRole tests updating user role (admin only) +func TestUpdateUserRole(t *testing.T) { + setupRoleTestEnvironment(t) + + // Create admin and target user + admin := &User{Username: "roleadmin", PasswordHash: "hash", Email: "roleadmin@test.com", Role: RoleAdmin} + require.NoError(t, admin.Create()) + + targetUser := &User{Username: "roletarget", PasswordHash: "hash", Email: "roletarget@test.com", Role: RoleStandardUser} + require.NoError(t, targetUser.Create()) + + // Test valid role update + router := gin.Default() + router.PUT("/users/:id/role", func(c *gin.Context) { + c.Set("user", admin) + }, RequireAdmin(), UpdateUserRole) + + updateData := map[string]string{"role": RoleMaintainer} + jsonData, _ := json.Marshal(updateData) + + req, _ := http.NewRequest("PUT", fmt.Sprintf("/users/%d/role", targetUser.UserID), bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code, "Admin should update role") + + // Verify role was updated + var updatedUser User + require.NoError(t, updatedUser.FirstId(targetUser.UserID)) + assert.Equal(t, RoleMaintainer, updatedUser.Role, "Role should be updated to maintainer") + + // Test invalid role update + invalidData := map[string]string{"role": "invalid"} + jsonData2, _ := json.Marshal(invalidData) + + req2, _ := http.NewRequest("PUT", fmt.Sprintf("/users/%d/role", targetUser.UserID), bytes.NewBuffer(jsonData2)) + req2.Header.Set("Content-Type", "application/json") + w2 := httptest.NewRecorder() + router.ServeHTTP(w2, req2) + + assert.Equal(t, http.StatusBadRequest, w2.Code, "Should reject invalid role") +} + +// TestDeleteUser tests deleting a user (admin only) +func TestDeleteUser(t *testing.T) { + setupRoleTestEnvironment(t) + + // Create admin and target user + admin := &User{Username: "deladmin", PasswordHash: "hash", Email: "deladmin@test.com", Role: RoleAdmin} + require.NoError(t, admin.Create()) + + targetUser := &User{Username: "deltarget", PasswordHash: "hash", Email: "deltarget@test.com", Role: RoleStandardUser} + require.NoError(t, targetUser.Create()) + + // Test deletion + router := gin.Default() + router.DELETE("/users/:id", func(c *gin.Context) { + c.Set("user", admin) + }, RequireAdmin(), DeleteUser) + + req, _ := http.NewRequest("DELETE", fmt.Sprintf("/users/%d", targetUser.UserID), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code, "Admin should delete user") + + // Verify user was deleted + var deletedUser User + err := deletedUser.FirstId(targetUser.UserID) + assert.Error(t, err, "User should not exist after deletion") +} + +// TestMaintainerPermissions tests maintainer-specific permissions +func TestMaintainerPermissions(t *testing.T) { + setupRoleTestEnvironment(t) + + standardUser := &User{Username: "permuser", PasswordHash: "hash", Email: "permuser@test.com", Role: RoleStandardUser} + require.NoError(t, standardUser.Create()) + + maintainer := &User{Username: "permmaintainer", PasswordHash: "hash", Email: "permmaintainer@test.com", Role: RoleMaintainer} + require.NoError(t, maintainer.Create()) + + admin := &User{Username: "permadmin", PasswordHash: "hash", Email: "permadmin@test.com", Role: RoleAdmin} + require.NoError(t, admin.Create()) + + router := gin.Default() + router.GET("/maintainer-only", func(c *gin.Context) { + c.Set("user", standardUser) + }, RequireMaintainer(), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "success"}) + }) + + // Standard user should fail + req, _ := http.NewRequest("GET", "/maintainer-only", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code, "Standard user should not access") + + // Maintainer should succeed + router2 := gin.Default() + router2.GET("/maintainer-only", func(c *gin.Context) { + c.Set("user", maintainer) + }, RequireMaintainer(), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "success"}) + }) + + req2, _ := http.NewRequest("GET", "/maintainer-only", nil) + w2 := httptest.NewRecorder() + router2.ServeHTTP(w2, req2) + assert.Equal(t, http.StatusOK, w2.Code, "Maintainer should access") + + // Admin should also succeed + router3 := gin.Default() + router3.GET("/maintainer-only", func(c *gin.Context) { + c.Set("user", admin) + }, RequireMaintainer(), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "success"}) + }) + + req3, _ := http.NewRequest("GET", "/maintainer-only", nil) + w3 := httptest.NewRecorder() + router3.ServeHTTP(w3, req3) + assert.Equal(t, http.StatusOK, w3.Code, "Admin should access maintainer endpoints") +} diff --git a/backend/user/routes.go b/backend/user/routes.go index bf5f2de..613ba6a 100644 --- a/backend/user/routes.go +++ b/backend/user/routes.go @@ -13,4 +13,15 @@ func RegisterRoutes(r *gin.RouterGroup) { userGroup.PUT("/email", UpdateEmail) userGroup.PUT("/password", UpdatePassword) } + + // Admin routes - require admin role + adminGroup := r.Group("/users") + adminGroup.Use(RequireAdmin()) + { + adminGroup.GET("", ListUsers) + adminGroup.GET("/:id", GetUser) + adminGroup.PUT("/:id/role", UpdateUserRole) + adminGroup.PUT("/:id/password", ChangeUserPassword) + adminGroup.DELETE("/:id", DeleteUser) + } } diff --git a/frontend/src/components/LoginForm.vue b/frontend/src/components/LoginForm.vue index 919f69d..30ddb5c 100644 --- a/frontend/src/components/LoginForm.vue +++ b/frontend/src/components/LoginForm.vue @@ -56,6 +56,7 @@ diff --git a/frontend/src/locales/de b/frontend/src/locales/de index 5076fde..9cdd6b9 100644 --- a/frontend/src/locales/de +++ b/frontend/src/locales/de @@ -43,6 +43,7 @@ export default { Logout:'Abmelden', Register:'Registrieren', Maintenance:'Wartung', + UserManagement:'Benutzerverwaltung', }, Equipment:'Ausrüstung', equipment:{ @@ -444,12 +445,52 @@ export default { pleaseWait: 'Bitte warten, dies kann einen Moment dauern', popupBlocked: 'Popup wurde blockiert. Bitte erlauben Sie Popups für diese Seite.' }, + userManagement: { + title: 'Benutzerverwaltung', + loading: 'Lade Benutzer...', + loadError: 'Fehler beim Laden der Benutzer', + id: 'ID', + username: 'Benutzername', + email: 'E-Mail', + role: 'Rolle', + createdAt: 'Erstellt am', + actions: 'Aktionen', + changeRole: 'Rolle ändern', + changePassword: 'Passwort ändern✏️', + delete: 'Löschen🗑️', + changeRoleTitle: 'Benutzerrolle ändern', + changeRoleFor: 'Rolle ändern für', + selectRole: 'Rolle auswählen', + save: 'Speichern', + cancel: 'Abbrechen', + deleteUserTitle: 'Benutzer löschen', + deleteConfirm: 'Möchten Sie wirklich den Benutzer löschen', + deleteWarning: 'Diese Aktion kann nicht rückgängig gemacht werden!', + updateError: 'Fehler beim Aktualisieren der Benutzerrolle', + deleteError: 'Fehler beim Löschen des Benutzers', + changePasswordTitle: 'Benutzerpasswort ändern', + changePasswordFor: 'Passwort ändern für', + newPassword: 'Neues Passwort', + newPasswordPlaceholder: 'Mindestens 6 Zeichen', + confirmPassword: 'Passwort bestätigen', + confirmPasswordPlaceholder: 'Neues Passwort wiederholen', + passwordRequired: 'Passwort ist erforderlich', + passwordTooShort: 'Das Passwort muss mindestens 6 Zeichen lang sein', + passwordMismatch: 'Die Passwörter stimmen nicht überein', + passwordChangeError: 'Fehler beim Ändern des Passworts', + roles: { + standard: 'Standard-Benutzer', + maintainer: 'Maintainer', + admin: 'Administrator' + } + }, profile: { title: 'Benutzerprofil', loading: 'Lade Profil...', userInfo: 'Benutzerinformationen', username: 'Benutzername', currentEmail: 'Aktuelle E-Mail', + role: 'Rolle', changeEmail: 'E-Mail ändern', newEmail: 'Neue E-Mail', emailPlaceholder: 'ihre.email@example.com', diff --git a/frontend/src/locales/en b/frontend/src/locales/en index 54d8a36..db7a837 100644 --- a/frontend/src/locales/en +++ b/frontend/src/locales/en @@ -42,8 +42,7 @@ export default { ImportData:'Import Data', Logout:'Logout', Register:'Register', - Maintenance:'Maintenance', - }, + Maintenance:'Maintenance', UserManagement:'User Management', }, Equipment:'Equipment', equipment:{ id:'ID', @@ -442,12 +441,52 @@ export default { pleaseWait: 'Please wait, this may take a moment', popupBlocked: 'Popup was blocked. Please allow popups for this site.' }, + userManagement: { + title: 'User Management', + loading: 'Loading users...', + loadError: 'Failed to load users', + id: 'ID', + username: 'Username', + email: 'Email', + role: 'Role', + createdAt: 'Created At', + actions: 'Actions', + changeRole: 'Change Role', + changePassword: 'Change Password', + delete: 'Delete', + changeRoleTitle: 'Change User Role', + changeRoleFor: 'Change role for', + selectRole: 'Select Role', + save: 'Save', + cancel: 'Cancel', + deleteUserTitle: 'Delete User', + deleteConfirm: 'Do you really want to delete user', + deleteWarning: 'This action cannot be undone!', + updateError: 'Failed to update user role', + deleteError: 'Failed to delete user', + changePasswordTitle: 'Change User Password', + changePasswordFor: 'Change password for', + newPassword: 'New Password', + newPasswordPlaceholder: 'At least 6 characters', + confirmPassword: 'Confirm Password', + confirmPasswordPlaceholder: 'Repeat new password', + passwordRequired: 'Password is required', + passwordTooShort: 'Password must be at least 6 characters long', + passwordMismatch: 'Passwords do not match', + passwordChangeError: 'Failed to change password', + roles: { + standard: 'Standard User', + maintainer: 'Maintainer', + admin: 'Administrator' + } + }, profile: { title: 'User Profile', loading: 'Loading profile...', userInfo: 'User Information', username: 'Username', currentEmail: 'Current Email', + role: 'Role', changeEmail: 'Change Email', newEmail: 'New Email', emailPlaceholder: 'your.email@example.com', diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index f160ba7..3a3e959 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -9,6 +9,7 @@ import AusruestungView from "../views/AusruestungView.vue"; import MaintenanceView from "../views/MaintenanceView.vue"; import FileUploadPage from "../views/FileUploadPage.vue"; import UserProfileView from "../views/UserProfileView.vue"; +import UserManagementView from "../views/UserManagementView.vue"; import CharacterDetails from "@/components/CharacterDetails.vue"; import CharacterCreation from "@/components/CharacterCreation.vue"; @@ -23,6 +24,7 @@ const routes = [ { path: "/reset-password", name: "ResetPassword", component: ResetPasswordView }, { path: "/dashboard", name: "Dashboard", component: DashboardView, meta: { requiresAuth: true } }, { path: "/profile", name: "UserProfile", component: UserProfileView, meta: { requiresAuth: true } }, + { path: "/users", name: "UserManagement", component: UserManagementView, meta: { requiresAuth: true, requiresAdmin: true } }, { path: "/ausruestung/:characterId", name: "Ausruestung", component: AusruestungView, meta: { requiresAuth: true } }, { path: "/maintenance", name: "Maintenance", component: MaintenanceView, meta: { requiresAuth: true } }, { path: "/upload", name: "FileUpload", component: FileUploadPage }, @@ -39,10 +41,26 @@ const router = createRouter({ }); // Navigation guard -router.beforeEach((to, from, next) => { +router.beforeEach(async (to, from, next) => { if (to.meta.requiresAuth && !isLoggedIn()) { // Redirect to login if not authenticated next({ name: "Login" }); + } else if (to.meta.requiresAdmin) { + // Check if route requires admin role + const { useUserStore } = await import('../stores/userStore') + const userStore = useUserStore() + + // Fetch user if not already loaded + if (!userStore.currentUser) { + await userStore.fetchCurrentUser() + } + + if (!userStore.isAdmin) { + // Redirect to dashboard if not admin + next({ name: "Dashboard" }); + } else { + next(); + } } else { next(); // Allow navigation } diff --git a/frontend/src/stores/userStore.js b/frontend/src/stores/userStore.js new file mode 100644 index 0000000..39da4be --- /dev/null +++ b/frontend/src/stores/userStore.js @@ -0,0 +1,36 @@ +import { defineStore } from 'pinia' +import API from '../utils/api' + +export const useUserStore = defineStore('user', { + state: () => ({ + currentUser: null, + isLoading: false + }), + + getters: { + isAuthenticated: (state) => !!state.currentUser, + userRole: (state) => state.currentUser?.role || 'standard', + isAdmin: (state) => state.currentUser?.role === 'admin', + isMaintainer: (state) => state.currentUser?.role === 'maintainer' || state.currentUser?.role === 'admin', + isStandardUser: (state) => !!state.currentUser + }, + + actions: { + async fetchCurrentUser() { + this.isLoading = true + try { + const response = await API.get('/api/user/profile') + this.currentUser = response.data + } catch (error) { + console.error('Failed to fetch user profile:', error) + this.currentUser = null + } finally { + this.isLoading = false + } + }, + + clearUser() { + this.currentUser = null + } + } +}) diff --git a/frontend/src/views/UserManagementView.vue b/frontend/src/views/UserManagementView.vue new file mode 100644 index 0000000..9b7f2a0 --- /dev/null +++ b/frontend/src/views/UserManagementView.vue @@ -0,0 +1,368 @@ + + + + + diff --git a/frontend/src/views/UserProfileView.vue b/frontend/src/views/UserProfileView.vue index c18b66c..92619ed 100644 --- a/frontend/src/views/UserProfileView.vue +++ b/frontend/src/views/UserProfileView.vue @@ -19,6 +19,12 @@ {{ userProfile.email }} +
+ + + {{ $t(`userManagement.roles.${userProfile.role}`) }} + +
@@ -200,6 +206,33 @@ h1 { opacity: 0.6; cursor: not-allowed; } + +.badge-role-standard { + background-color: #6c757d; + color: white; + padding: 4px 12px; + border-radius: 4px; + font-size: 0.875rem; + font-weight: 600; +} + +.badge-role-maintainer { + background-color: #0dcaf0; + color: white; + padding: 4px 12px; + border-radius: 4px; + font-size: 0.875rem; + font-weight: 600; +} + +.badge-role-admin { + background-color: #dc3545; + color: white; + padding: 4px 12px; + border-radius: 4px; + font-size: 0.875rem; + font-weight: 600; +}