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 @@
+
+ {{ $t('userManagement.title') }}
+
+
+
+
+
+
+
+ {{ $t('userManagement.id') }}
+ {{ $t('userManagement.username') }}
+ {{ $t('userManagement.email') }}
+ {{ $t('userManagement.role') }}
+ {{ $t('userManagement.createdAt') }}
+ {{ $t('userManagement.actions') }}
+
+
+
+ {{ user.id }}
+ {{ user.username }}
+ {{ user.email }}
+
+
+ {{ $t(`userManagement.roles.${user.role}`) }}
+
+
+ {{ formatDate(user.created_at) }}
+
+
+
+
+
+