added an easy role concept

User roles
This commit is contained in:
Bardioc26
2025-12-30 08:33:20 +01:00
committed by GitHub
17 changed files with 1227 additions and 45 deletions
+31 -26
View File
@@ -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)
}
}
+17 -13
View File
@@ -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)
*/
}
}
+1 -1
View File
@@ -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)
}
+245
View File
@@ -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",
})
}
+7
View File
@@ -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,
})
}
+65
View File
@@ -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)
}
+33
View File
@@ -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
}
+233
View File
@@ -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")
}
+11
View File
@@ -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)
}
}
+6
View File
@@ -56,6 +56,7 @@
<script>
import API from '../utils/api'
import { useUserStore } from '../stores/userStore'
export default {
data() {
@@ -73,6 +74,11 @@ export default {
password: this.password,
})
localStorage.setItem('token', response.data.token)
// Fetch user profile to get role information
const userStore = useUserStore()
await userStore.fetchCurrentUser()
// Emit auth change event
window.dispatchEvent(new Event('auth-changed'))
this.$router.push('/dashboard')
+35 -1
View File
@@ -13,9 +13,12 @@
<li v-if="!isLoggedIn">
<router-link to="/register" active-class="active">{{ $t('menu.Register') }}</router-link>
</li>
<li v-if="isLoggedIn">
<li v-if="isLoggedIn && isMaintainer">
<router-link to="/maintenance" active-class="active">{{ $t('menu.Maintenance') }}</router-link>
</li>
<li v-if="isLoggedIn && isAdmin">
<router-link to="/users" active-class="active">{{ $t('menu.UserManagement') }}</router-link>
</li>
</ul>
<div class="menu-right">
<LanguageSwitcher />
@@ -27,6 +30,7 @@
<script>
import { isLoggedIn, logout } from "../utils/auth";
import LanguageSwitcher from "./LanguageSwitcher.vue";
import { useUserStore } from "../stores/userStore";
export default {
@@ -34,18 +38,48 @@ export default {
components: {
LanguageSwitcher,
},
data() {
return {
userStore: null
}
},
async created() {
this.userStore = useUserStore()
if (isLoggedIn() && !this.userStore.currentUser) {
await this.userStore.fetchCurrentUser()
}
// Listen for auth changes to refresh user data
window.addEventListener('auth-changed', this.handleAuthChange)
},
beforeUnmount() {
window.removeEventListener('auth-changed', this.handleAuthChange)
},
computed: {
isLoggedIn() {
return isLoggedIn();
},
isAdmin() {
return this.userStore?.isAdmin || false
},
isMaintainer() {
return this.userStore?.isMaintainer || false
}
},
methods: {
logout() {
logout();
this.userStore.clearUser()
// Emit auth change event
window.dispatchEvent(new Event('auth-changed'));
this.$router.push("/");
},
async handleAuthChange() {
if (isLoggedIn()) {
await this.userStore.fetchCurrentUser()
} else {
this.userStore.clearUser()
}
}
},
};
</script>
+41
View File
@@ -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',
+41 -2
View File
@@ -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',
+19 -1
View File
@@ -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
}
+36
View File
@@ -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
}
}
})
+368
View File
@@ -0,0 +1,368 @@
<template>
<div class="page">
<div class="page-header">
<h2>{{ $t('userManagement.title') }}</h2>
</div>
<div v-if="isLoading" class="loading">{{ $t('userManagement.loading') }}</div>
<div v-else-if="error" class="badge badge-danger">{{ error }}</div>
<div v-else class="card">
<table class="data-table">
<thead>
<tr>
<th>{{ $t('userManagement.id') }}</th>
<th>{{ $t('userManagement.username') }}</th>
<th>{{ $t('userManagement.email') }}</th>
<th>{{ $t('userManagement.role') }}</th>
<th>{{ $t('userManagement.createdAt') }}</th>
<th>{{ $t('userManagement.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.id }}</td>
<td>{{ user.username }}</td>
<td>{{ user.email }}</td>
<td>
<span :class="getRoleBadgeClass(user.role)">
{{ $t(`userManagement.roles.${user.role}`) }}
</span>
</td>
<td>{{ formatDate(user.created_at) }}</td>
<td>
<button
@click="openRoleDialog(user)"
class="btn btn-secondary btn-sm"
:disabled="user.id === currentUser.id"
>
{{ $t('userManagement.changeRole') }}
</button>
<button
@click="openPasswordDialog(user)"
class="btn btn-sm"
>
{{ $t('userManagement.changePassword') }}
</button>
<button
@click="confirmDeleteUser(user)"
class="btn btn-sm"
:disabled="user.id === currentUser.id"
>
{{ $t('userManagement.delete') }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Role Change Dialog -->
<div v-if="showRoleDialog" class="modal-overlay" @click.self="showRoleDialog = false">
<div class="modal-content">
<div class="modal-header">
<h3>{{ $t('userManagement.changeRoleTitle') }}</h3>
</div>
<div class="modal-body">
<p>{{ $t('userManagement.changeRoleFor') }}: <strong>{{ selectedUser.username }}</strong></p>
<div class="form-group">
<label>{{ $t('userManagement.selectRole') }}</label>
<select v-model="newRole" class="form-control">
<option value="standard">{{ $t('userManagement.roles.standard') }}</option>
<option value="maintainer">{{ $t('userManagement.roles.maintainer') }}</option>
<option value="admin">{{ $t('userManagement.roles.admin') }}</option>
</select>
</div>
</div>
<div class="modal-footer">
<button @click="updateUserRole" class="btn btn-primary">
{{ $t('userManagement.save') }}
</button>
<button @click="showRoleDialog = false" class="btn btn-secondary">
{{ $t('userManagement.cancel') }}
</button>
</div>
</div>
</div>
<!-- Delete Confirmation Dialog -->
<div v-if="showDeleteDialog" class="modal-overlay" @click.self="showDeleteDialog = false">
<div class="modal-content">
<div class="modal-header">
<h3>{{ $t('userManagement.deleteUserTitle') }}</h3>
</div>
<div class="modal-body">
<p>{{ $t('userManagement.deleteConfirm') }}: <strong>{{ selectedUser.username }}</strong>?</p>
<p class="badge badge-warning">{{ $t('userManagement.deleteWarning') }}</p>
</div>
<div class="modal-footer">
<button @click="deleteUser" class="btn btn-danger">
{{ $t('userManagement.delete') }}
</button>
<button @click="showDeleteDialog = false" class="btn btn-secondary">
{{ $t('userManagement.cancel') }}
</button>
</div>
</div>
</div>
<!-- Change Password Dialog -->
<div v-if="showPasswordDialog" class="modal-overlay" @click.self="showPasswordDialog = false">
<div class="modal-content">
<div class="modal-header">
<h3>{{ $t('userManagement.changePasswordTitle') }}</h3>
</div>
<div class="modal-body">
<p>{{ $t('userManagement.changePasswordFor') }}: <strong>{{ selectedUser.username }}</strong></p>
<div class="form-group">
<label>{{ $t('userManagement.newPassword') }}</label>
<input
v-model="newPassword"
type="password"
class="form-control"
:placeholder="$t('userManagement.newPasswordPlaceholder')"
minlength="6"
/>
</div>
<div class="form-group">
<label>{{ $t('userManagement.confirmPassword') }}</label>
<input
v-model="confirmPassword"
type="password"
class="form-control"
:placeholder="$t('userManagement.confirmPasswordPlaceholder')"
minlength="6"
/>
</div>
</div>
<div class="modal-footer">
<button @click="changeUserPassword" class="btn btn-primary">
{{ $t('userManagement.save') }}
</button>
<button @click="showPasswordDialog = false" class="btn btn-secondary">
{{ $t('userManagement.cancel') }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #dee2e6;
}
.data-table th {
background-color: #f8f9fa;
font-weight: 600;
}
.data-table tr:hover {
background-color: #f8f9fa;
}
.btn-sm {
padding: 5px 10px;
font-size: 0.875rem;
margin-right: 5px;
}
.badge-role-standard {
background-color: #6c757d;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.875rem;
}
.badge-role-maintainer {
background-color: #0dcaf0;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.875rem;
}
.badge-role-admin {
background-color: #dc3545;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.875rem;
}
.loading {
text-align: center;
padding: 20px;
color: #6c757d;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 0;
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
padding: 20px;
border-bottom: 1px solid #dee2e6;
}
.modal-header h3 {
margin: 0;
}
.modal-body {
padding: 20px;
}
.modal-footer {
padding: 15px 20px;
border-top: 1px solid #dee2e6;
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
<script>
import API from '../utils/api'
import { useUserStore } from '../stores/userStore'
export default {
name: 'UserManagementView',
data() {
return {
users: [],
isLoading: false,
error: null,
showRoleDialog: false,
showDeleteDialog: false,
showPasswordDialog: false,
selectedUser: null,
newRole: '',
newPassword: '',
confirmPassword: ''
}
},
computed: {
currentUser() {
const userStore = useUserStore()
return userStore.currentUser
}
},
async created() {
await this.loadUsers()
},
methods: {
async loadUsers() {
this.isLoading = true
this.error = null
try {
const response = await API.get('/api/users')
this.users = response.data
} catch (error) {
console.error('Failed to load users:', error)
this.error = this.$t('userManagement.loadError')
} finally {
this.isLoading = false
}
},
openRoleDialog(user) {
this.selectedUser = user
this.newRole = user.role
this.showRoleDialog = true
},
async updateUserRole() {
try {
await API.put(`/api/users/${this.selectedUser.id}/role`, {
role: this.newRole
})
this.showRoleDialog = false
await this.loadUsers()
} catch (error) {
console.error('Failed to update user role:', error)
this.error = this.$t('userManagement.updateError')
}
},
confirmDeleteUser(user) {
this.selectedUser = user
this.showDeleteDialog = true
},
async deleteUser() {
try {
await API.delete(`/api/users/${this.selectedUser.id}`)
this.showDeleteDialog = false
await this.loadUsers()
} catch (error) {
console.error('Failed to delete user:', error)
this.error = this.$t('userManagement.deleteError')
}
},
openPasswordDialog(user) {
this.selectedUser = user
this.newPassword = ''
this.confirmPassword = ''
this.showPasswordDialog = true
},
async changeUserPassword() {
if (!this.newPassword || !this.confirmPassword) {
this.error = this.$t('userManagement.passwordRequired')
return
}
if (this.newPassword.length < 6) {
this.error = this.$t('userManagement.passwordTooShort')
return
}
if (this.newPassword !== this.confirmPassword) {
this.error = this.$t('userManagement.passwordMismatch')
return
}
try {
await API.put(`/api/users/${this.selectedUser.id}/password`, {
new_password: this.newPassword
})
this.showPasswordDialog = false
this.error = null
} catch (error) {
console.error('Failed to change password:', error)
this.error = this.$t('userManagement.passwordChangeError')
}
},
getRoleBadgeClass(role) {
return `badge-role-${role}`
},
formatDate(dateString) {
const date = new Date(dateString)
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
}
}
}
</script>
+38 -1
View File
@@ -19,6 +19,12 @@
<label>{{ $t('profile.currentEmail') }}:</label>
<span>{{ userProfile.email }}</span>
</div>
<div class="info-row">
<label>{{ $t('profile.role') }}:</label>
<span :class="getRoleBadgeClass(userProfile.role)">
{{ $t(`userManagement.roles.${userProfile.role}`) }}
</span>
</div>
</div>
<!-- Change Email Section -->
@@ -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;
}
</style>
<script>
@@ -213,7 +246,8 @@ export default {
isUpdating: false,
userProfile: {
username: '',
email: ''
email: '',
role: 'standard'
},
emailForm: {
newEmail: ''
@@ -242,6 +276,9 @@ export default {
this.loading = false
}
},
getRoleBadgeClass(role) {
return `badge-role-${role}`
},
async updateEmail() {
if (!this.emailForm.newEmail) {
alert(this.$t('profile.emailRequired'))