masih ngerjain user management
This commit is contained in:
Binary file not shown.
@@ -270,7 +270,20 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
|||||||
iamGroup.POST("/users", iamHandler.CreateUser)
|
iamGroup.POST("/users", iamHandler.CreateUser)
|
||||||
iamGroup.PUT("/users/:id", iamHandler.UpdateUser)
|
iamGroup.PUT("/users/:id", iamHandler.UpdateUser)
|
||||||
iamGroup.DELETE("/users/:id", iamHandler.DeleteUser)
|
iamGroup.DELETE("/users/:id", iamHandler.DeleteUser)
|
||||||
|
// Roles routes
|
||||||
iamGroup.GET("/roles", iamHandler.ListRoles)
|
iamGroup.GET("/roles", iamHandler.ListRoles)
|
||||||
|
iamGroup.GET("/roles/:id", iamHandler.GetRole)
|
||||||
|
iamGroup.POST("/roles", iamHandler.CreateRole)
|
||||||
|
iamGroup.PUT("/roles/:id", iamHandler.UpdateRole)
|
||||||
|
iamGroup.DELETE("/roles/:id", iamHandler.DeleteRole)
|
||||||
|
iamGroup.GET("/roles/:id/permissions", iamHandler.GetRolePermissions)
|
||||||
|
iamGroup.POST("/roles/:id/permissions", iamHandler.AssignPermissionToRole)
|
||||||
|
iamGroup.DELETE("/roles/:id/permissions", iamHandler.RemovePermissionFromRole)
|
||||||
|
|
||||||
|
// Permissions routes
|
||||||
|
iamGroup.GET("/permissions", iamHandler.ListPermissions)
|
||||||
|
|
||||||
|
// User role/group assignment
|
||||||
iamGroup.POST("/users/:id/roles", iamHandler.AssignRoleToUser)
|
iamGroup.POST("/users/:id/roles", iamHandler.AssignRoleToUser)
|
||||||
iamGroup.DELETE("/users/:id/roles", iamHandler.RemoveRoleFromUser)
|
iamGroup.DELETE("/users/:id/roles", iamHandler.RemoveRoleFromUser)
|
||||||
iamGroup.POST("/users/:id/groups", iamHandler.AssignGroupToUser)
|
iamGroup.POST("/users/:id/groups", iamHandler.AssignGroupToUser)
|
||||||
|
|||||||
@@ -202,13 +202,18 @@ func (h *Handler) UpdateUser(c *gin.Context) {
|
|||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
FullName *string `json:"full_name"`
|
FullName *string `json:"full_name"`
|
||||||
IsActive *bool `json:"is_active"`
|
IsActive *bool `json:"is_active"`
|
||||||
|
Roles *[]string `json:"roles"`
|
||||||
|
Groups *[]string `json:"groups"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
h.logger.Error("Failed to bind JSON", "error", err)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.logger.Info("UpdateUser request received", "user_id", userID, "email", req.Email, "full_name", req.FullName, "is_active", req.IsActive, "roles", req.Roles, "groups", req.Groups)
|
||||||
|
|
||||||
// Build update query dynamically
|
// Build update query dynamically
|
||||||
updates := []string{"updated_at = NOW()"}
|
updates := []string{"updated_at = NOW()"}
|
||||||
args := []interface{}{}
|
args := []interface{}{}
|
||||||
@@ -230,20 +235,145 @@ func (h *Handler) UpdateUser(c *gin.Context) {
|
|||||||
argPos++
|
argPos++
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(updates) == 1 {
|
// Allow update if roles or groups are provided, even if no other fields are updated
|
||||||
|
if len(updates) == 1 && req.Roles == nil && req.Groups == nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update user basic info if there are any changes
|
||||||
|
if len(updates) > 1 {
|
||||||
args = append(args, userID)
|
args = append(args, userID)
|
||||||
query := "UPDATE users SET " + strings.Join(updates, ", ") + fmt.Sprintf(" WHERE id = $%d", argPos)
|
query := "UPDATE users SET " + strings.Join(updates, ", ") + fmt.Sprintf(" WHERE id = $%d", argPos)
|
||||||
|
|
||||||
_, err := h.db.Exec(query, args...)
|
_, err := h.db.Exec(query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("Failed to update user", "error", err)
|
h.logger.Error("Failed to update user", "error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update user"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update user"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current user ID from context for audit
|
||||||
|
authUser, _ := c.Get("user")
|
||||||
|
currentUser := authUser.(*User)
|
||||||
|
|
||||||
|
// Update roles if provided
|
||||||
|
if req.Roles != nil {
|
||||||
|
h.logger.Info("Updating user roles", "user_id", userID, "roles", *req.Roles)
|
||||||
|
// Get current roles
|
||||||
|
currentRoles, err := GetUserRoles(h.db, userID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to get current roles", "error", err)
|
||||||
|
}
|
||||||
|
h.logger.Info("Current roles", "user_id", userID, "current_roles", currentRoles)
|
||||||
|
|
||||||
|
// Remove roles that are not in the new list
|
||||||
|
for _, role := range currentRoles {
|
||||||
|
found := false
|
||||||
|
for _, newRole := range *req.Roles {
|
||||||
|
if role == newRole {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
roleID, err := GetRoleIDByName(h.db, role)
|
||||||
|
if err == nil {
|
||||||
|
err = RemoveUserRole(h.db, userID, roleID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to remove role", "error", err, "role", role)
|
||||||
|
} else {
|
||||||
|
h.logger.Info("Role removed", "user_id", userID, "role", role)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.logger.Error("Failed to get role ID", "error", err, "role", role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new roles that are not in the current list
|
||||||
|
for _, roleName := range *req.Roles {
|
||||||
|
found := false
|
||||||
|
for _, currentRole := range currentRoles {
|
||||||
|
if roleName == currentRole {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
roleID, err := GetRoleIDByName(h.db, roleName)
|
||||||
|
if err == nil {
|
||||||
|
err = AddUserRole(h.db, userID, roleID, currentUser.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to add role", "error", err, "role", roleName)
|
||||||
|
} else {
|
||||||
|
h.logger.Info("Role added", "user_id", userID, "role", roleName)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.logger.Error("Failed to get role ID", "error", err, "role", roleName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update groups if provided
|
||||||
|
if req.Groups != nil {
|
||||||
|
h.logger.Info("Updating user groups", "user_id", userID, "groups", *req.Groups)
|
||||||
|
// Get current groups
|
||||||
|
currentGroups, err := GetUserGroups(h.db, userID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to get current groups", "error", err)
|
||||||
|
}
|
||||||
|
h.logger.Info("Current groups", "user_id", userID, "current_groups", currentGroups)
|
||||||
|
|
||||||
|
// Remove groups that are not in the new list
|
||||||
|
for _, group := range currentGroups {
|
||||||
|
found := false
|
||||||
|
for _, newGroup := range *req.Groups {
|
||||||
|
if group == newGroup {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
groupObj, err := GetGroupByName(h.db, group)
|
||||||
|
if err == nil {
|
||||||
|
err = RemoveUserFromGroup(h.db, userID, groupObj.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to remove group", "error", err, "group", group)
|
||||||
|
} else {
|
||||||
|
h.logger.Info("Group removed", "user_id", userID, "group", group)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.logger.Error("Failed to get group", "error", err, "group", group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new groups that are not in the current list
|
||||||
|
for _, groupName := range *req.Groups {
|
||||||
|
found := false
|
||||||
|
for _, currentGroup := range currentGroups {
|
||||||
|
if groupName == currentGroup {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
groupObj, err := GetGroupByName(h.db, groupName)
|
||||||
|
if err == nil {
|
||||||
|
err = AddUserToGroup(h.db, userID, groupObj.ID, currentUser.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to add group", "error", err, "group", groupName)
|
||||||
|
} else {
|
||||||
|
h.logger.Info("Group added", "user_id", userID, "group", groupName)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.logger.Error("Failed to get group", "error", err, "group", groupName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
h.logger.Info("User updated", "user_id", userID)
|
h.logger.Info("User updated", "user_id", userID)
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "user updated successfully"})
|
c.JSON(http.StatusOK, gin.H{"message": "user updated successfully"})
|
||||||
@@ -581,17 +711,16 @@ func (h *Handler) AssignRoleToUser(c *gin.Context) {
|
|||||||
// RemoveRoleFromUser removes a role from a user
|
// RemoveRoleFromUser removes a role from a user
|
||||||
func (h *Handler) RemoveRoleFromUser(c *gin.Context) {
|
func (h *Handler) RemoveRoleFromUser(c *gin.Context) {
|
||||||
userID := c.Param("id")
|
userID := c.Param("id")
|
||||||
var req struct {
|
|
||||||
RoleName string `json:"role_name" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
// Get role_name from query parameter
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
roleName := c.Query("role_name")
|
||||||
|
if roleName == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "role_name is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get role ID by name
|
// Get role ID by name
|
||||||
roleID, err := GetRoleIDByName(h.db, req.RoleName)
|
roleID, err := GetRoleIDByName(h.db, roleName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "role not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "role not found"})
|
||||||
@@ -609,7 +738,7 @@ func (h *Handler) RemoveRoleFromUser(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.logger.Info("Role removed from user", "user_id", userID, "role", req.RoleName)
|
h.logger.Info("Role removed from user", "user_id", userID, "role", roleName)
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "role removed successfully"})
|
c.JSON(http.StatusOK, gin.H{"message": "role removed successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,15 +766,6 @@ func (h *Handler) AssignGroupToUser(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
groupID := group.ID
|
groupID := group.ID
|
||||||
if err != nil {
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.logger.Error("Failed to get group", "error", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to assign group"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current user ID from context
|
// Get current user ID from context
|
||||||
authUser, _ := c.Get("user")
|
authUser, _ := c.Get("user")
|
||||||
@@ -665,27 +785,16 @@ func (h *Handler) AssignGroupToUser(c *gin.Context) {
|
|||||||
// RemoveGroupFromUser removes a group from a user
|
// RemoveGroupFromUser removes a group from a user
|
||||||
func (h *Handler) RemoveGroupFromUser(c *gin.Context) {
|
func (h *Handler) RemoveGroupFromUser(c *gin.Context) {
|
||||||
userID := c.Param("id")
|
userID := c.Param("id")
|
||||||
var req struct {
|
|
||||||
GroupName string `json:"group_name" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
// Get group_name from query parameter
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
groupName := c.Query("group_name")
|
||||||
|
if groupName == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "group_name is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get group ID by name
|
// Get group ID by name
|
||||||
group, err := GetGroupByName(h.db, req.GroupName)
|
group, err := GetGroupByName(h.db, groupName)
|
||||||
if err != nil {
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.logger.Error("Failed to get group", "error", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to assign group"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
groupID := group.ID
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
||||||
@@ -695,6 +804,7 @@ func (h *Handler) RemoveGroupFromUser(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove group"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove group"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
groupID := group.ID
|
||||||
|
|
||||||
err = RemoveUserFromGroup(h.db, userID, groupID)
|
err = RemoveUserFromGroup(h.db, userID, groupID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -703,6 +813,320 @@ func (h *Handler) RemoveGroupFromUser(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.logger.Info("Group removed from user", "user_id", userID, "group", req.GroupName)
|
h.logger.Info("Group removed from user", "user_id", userID, "group", groupName)
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "group removed successfully"})
|
c.JSON(http.StatusOK, gin.H{"message": "group removed successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListRoles lists all available roles
|
||||||
|
func (h *Handler) ListRoles(c *gin.Context) {
|
||||||
|
roles, err := ListRoles(h.db)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to list roles", "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list roles"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []map[string]interface{}
|
||||||
|
for _, role := range roles {
|
||||||
|
// Get user count for this role
|
||||||
|
userCount := 0
|
||||||
|
userIDs, _ := GetRoleUsers(h.db, role.ID)
|
||||||
|
userCount = len(userIDs)
|
||||||
|
|
||||||
|
result = append(result, map[string]interface{}{
|
||||||
|
"id": role.ID,
|
||||||
|
"name": role.Name,
|
||||||
|
"description": role.Description,
|
||||||
|
"is_system": role.IsSystem,
|
||||||
|
"user_count": userCount,
|
||||||
|
"created_at": role.CreatedAt,
|
||||||
|
"updated_at": role.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"roles": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRole retrieves a single role
|
||||||
|
func (h *Handler) GetRole(c *gin.Context) {
|
||||||
|
roleID := c.Param("id")
|
||||||
|
|
||||||
|
role, err := GetRoleByID(h.db, roleID)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "role not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("Failed to get role", "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get role"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user count
|
||||||
|
userIDs, _ := GetRoleUsers(h.db, role.ID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"id": role.ID,
|
||||||
|
"name": role.Name,
|
||||||
|
"description": role.Description,
|
||||||
|
"is_system": role.IsSystem,
|
||||||
|
"user_count": len(userIDs),
|
||||||
|
"created_at": role.CreatedAt,
|
||||||
|
"updated_at": role.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRole creates a new role
|
||||||
|
func (h *Handler) CreateRole(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
h.logger.Error("Invalid request to create role", "error", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim whitespace
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
if req.Name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle empty description
|
||||||
|
description := strings.TrimSpace(req.Description)
|
||||||
|
if description == "" {
|
||||||
|
description = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
role, err := CreateRole(h.db, req.Name, description)
|
||||||
|
if err != nil {
|
||||||
|
// Check if it's a unique constraint violation
|
||||||
|
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique constraint") {
|
||||||
|
h.logger.Error("Role name already exists", "name", req.Name, "error", err)
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "role name already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("Failed to create role", "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create role"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("Role created", "role_id", role.ID, "name", role.Name)
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"id": role.ID, "name": role.Name})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRole updates an existing role
|
||||||
|
func (h *Handler) UpdateRole(c *gin.Context) {
|
||||||
|
roleID := c.Param("id")
|
||||||
|
|
||||||
|
// Check if role is system role
|
||||||
|
var isSystem bool
|
||||||
|
err := h.db.QueryRow("SELECT is_system FROM roles WHERE id = $1", roleID).Scan(&isSystem)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "role not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isSystem {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify system role"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update query dynamically
|
||||||
|
updates := []string{"updated_at = NOW()"}
|
||||||
|
args := []interface{}{}
|
||||||
|
argIndex := 1
|
||||||
|
|
||||||
|
if req.Name != nil {
|
||||||
|
name := strings.TrimSpace(*req.Name)
|
||||||
|
if name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name cannot be empty"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates = append(updates, fmt.Sprintf("name = $%d", argIndex))
|
||||||
|
args = append(args, name)
|
||||||
|
argIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Description != nil {
|
||||||
|
description := strings.TrimSpace(*req.Description)
|
||||||
|
updates = append(updates, fmt.Sprintf("description = $%d", argIndex))
|
||||||
|
args = append(args, description)
|
||||||
|
argIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updates) == 1 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, roleID)
|
||||||
|
query := "UPDATE roles SET " + strings.Join(updates, ", ") + fmt.Sprintf(" WHERE id = $%d", argIndex)
|
||||||
|
|
||||||
|
_, err = h.db.Exec(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
// Check if it's a unique constraint violation
|
||||||
|
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique constraint") {
|
||||||
|
h.logger.Error("Role name already exists", "error", err)
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "role name already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("Failed to update role", "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update role"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("Role updated", "role_id", roleID)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "role updated successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRole deletes a role
|
||||||
|
func (h *Handler) DeleteRole(c *gin.Context) {
|
||||||
|
roleID := c.Param("id")
|
||||||
|
|
||||||
|
// Check if role is system role
|
||||||
|
var isSystem bool
|
||||||
|
err := h.db.QueryRow("SELECT is_system FROM roles WHERE id = $1", roleID).Scan(&isSystem)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "role not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isSystem {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "cannot delete system role"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.db.Exec("DELETE FROM roles WHERE id = $1", roleID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to delete role", "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete role"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("Role deleted", "role_id", roleID)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "role deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRolePermissions retrieves all permissions for a role
|
||||||
|
func (h *Handler) GetRolePermissions(c *gin.Context) {
|
||||||
|
roleID := c.Param("id")
|
||||||
|
|
||||||
|
permissions, err := GetRolePermissions(h.db, roleID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to get role permissions", "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get role permissions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"permissions": permissions})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignPermissionToRole assigns a permission to a role
|
||||||
|
func (h *Handler) AssignPermissionToRole(c *gin.Context) {
|
||||||
|
roleID := c.Param("id")
|
||||||
|
var req struct {
|
||||||
|
PermissionName string `json:"permission_name" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get permission ID by name
|
||||||
|
permissionID, err := GetPermissionIDByName(h.db, req.PermissionName)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "permission not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("Failed to get permission", "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to assign permission"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = AddPermissionToRole(h.db, roleID, permissionID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to assign permission to role", "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to assign permission"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("Permission assigned to role", "role_id", roleID, "permission", req.PermissionName)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "permission assigned successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemovePermissionFromRole removes a permission from a role
|
||||||
|
func (h *Handler) RemovePermissionFromRole(c *gin.Context) {
|
||||||
|
roleID := c.Param("id")
|
||||||
|
|
||||||
|
// For DELETE requests, we can get permission_name from query param or body
|
||||||
|
var req struct {
|
||||||
|
PermissionName string `json:"permission_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get from query param first
|
||||||
|
permissionName := c.Query("permission_name")
|
||||||
|
if permissionName == "" {
|
||||||
|
// Try to get from body
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "permission_name is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
permissionName = req.PermissionName
|
||||||
|
}
|
||||||
|
|
||||||
|
if permissionName == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "permission_name is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get permission ID by name
|
||||||
|
permissionID, err := GetPermissionIDByName(h.db, permissionName)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "permission not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("Failed to get permission", "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove permission"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = RemovePermissionFromRole(h.db, roleID, permissionID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to remove permission from role", "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove permission"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("Permission removed from role", "role_id", roleID, "permission", permissionName)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "permission removed successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPermissions lists all available permissions
|
||||||
|
func (h *Handler) ListPermissions(c *gin.Context) {
|
||||||
|
permissions, err := ListPermissions(h.db)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to list permissions", "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list permissions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"permissions": permissions})
|
||||||
|
}
|
||||||
|
|||||||
237
backend/internal/iam/role.go
Normal file
237
backend/internal/iam/role.go
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
package iam
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/atlasos/calypso/internal/common/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Role represents a system role
|
||||||
|
type Role struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
IsSystem bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoleByID retrieves a role by ID
|
||||||
|
func GetRoleByID(db *database.DB, roleID string) (*Role, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, description, is_system, created_at, updated_at
|
||||||
|
FROM roles
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
var role Role
|
||||||
|
err := db.QueryRow(query, roleID).Scan(
|
||||||
|
&role.ID, &role.Name, &role.Description, &role.IsSystem,
|
||||||
|
&role.CreatedAt, &role.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &role, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoleByName retrieves a role by name
|
||||||
|
func GetRoleByName(db *database.DB, name string) (*Role, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, description, is_system, created_at, updated_at
|
||||||
|
FROM roles
|
||||||
|
WHERE name = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
var role Role
|
||||||
|
err := db.QueryRow(query, name).Scan(
|
||||||
|
&role.ID, &role.Name, &role.Description, &role.IsSystem,
|
||||||
|
&role.CreatedAt, &role.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &role, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRoles retrieves all roles
|
||||||
|
func ListRoles(db *database.DB) ([]*Role, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, description, is_system, created_at, updated_at
|
||||||
|
FROM roles
|
||||||
|
ORDER BY name
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var roles []*Role
|
||||||
|
for rows.Next() {
|
||||||
|
var role Role
|
||||||
|
if err := rows.Scan(
|
||||||
|
&role.ID, &role.Name, &role.Description, &role.IsSystem,
|
||||||
|
&role.CreatedAt, &role.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
roles = append(roles, &role)
|
||||||
|
}
|
||||||
|
|
||||||
|
return roles, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRole creates a new role
|
||||||
|
func CreateRole(db *database.DB, name, description string) (*Role, error) {
|
||||||
|
query := `
|
||||||
|
INSERT INTO roles (name, description)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
RETURNING id, name, description, is_system, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
var role Role
|
||||||
|
err := db.QueryRow(query, name, description).Scan(
|
||||||
|
&role.ID, &role.Name, &role.Description, &role.IsSystem,
|
||||||
|
&role.CreatedAt, &role.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &role, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRole updates an existing role
|
||||||
|
func UpdateRole(db *database.DB, roleID, name, description string) error {
|
||||||
|
query := `
|
||||||
|
UPDATE roles
|
||||||
|
SET name = $1, description = $2, updated_at = NOW()
|
||||||
|
WHERE id = $3
|
||||||
|
`
|
||||||
|
_, err := db.Exec(query, name, description, roleID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRole deletes a role
|
||||||
|
func DeleteRole(db *database.DB, roleID string) error {
|
||||||
|
query := `DELETE FROM roles WHERE id = $1`
|
||||||
|
_, err := db.Exec(query, roleID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoleUsers retrieves all users with a specific role
|
||||||
|
func GetRoleUsers(db *database.DB, roleID string) ([]string, error) {
|
||||||
|
query := `
|
||||||
|
SELECT u.id
|
||||||
|
FROM users u
|
||||||
|
INNER JOIN user_roles ur ON u.id = ur.user_id
|
||||||
|
WHERE ur.role_id = $1
|
||||||
|
ORDER BY u.username
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := db.Query(query, roleID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var userIDs []string
|
||||||
|
for rows.Next() {
|
||||||
|
var userID string
|
||||||
|
if err := rows.Scan(&userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
userIDs = append(userIDs, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userIDs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRolePermissions retrieves all permissions for a role
|
||||||
|
func GetRolePermissions(db *database.DB, roleID string) ([]string, error) {
|
||||||
|
query := `
|
||||||
|
SELECT p.name
|
||||||
|
FROM permissions p
|
||||||
|
INNER JOIN role_permissions rp ON p.id = rp.permission_id
|
||||||
|
WHERE rp.role_id = $1
|
||||||
|
ORDER BY p.name
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := db.Query(query, roleID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var permissions []string
|
||||||
|
for rows.Next() {
|
||||||
|
var perm string
|
||||||
|
if err := rows.Scan(&perm); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
permissions = append(permissions, perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddPermissionToRole assigns a permission to a role
|
||||||
|
func AddPermissionToRole(db *database.DB, roleID, permissionID string) error {
|
||||||
|
query := `
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (role_id, permission_id) DO NOTHING
|
||||||
|
`
|
||||||
|
_, err := db.Exec(query, roleID, permissionID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemovePermissionFromRole removes a permission from a role
|
||||||
|
func RemovePermissionFromRole(db *database.DB, roleID, permissionID string) error {
|
||||||
|
query := `DELETE FROM role_permissions WHERE role_id = $1 AND permission_id = $2`
|
||||||
|
_, err := db.Exec(query, roleID, permissionID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPermissionIDByName retrieves a permission ID by name
|
||||||
|
func GetPermissionIDByName(db *database.DB, permissionName string) (string, error) {
|
||||||
|
var permissionID string
|
||||||
|
err := db.QueryRow("SELECT id FROM permissions WHERE name = $1", permissionName).Scan(&permissionID)
|
||||||
|
return permissionID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPermissions retrieves all permissions
|
||||||
|
func ListPermissions(db *database.DB) ([]map[string]interface{}, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, resource, action, description
|
||||||
|
FROM permissions
|
||||||
|
ORDER BY resource, action
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var permissions []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, name, resource, action, description string
|
||||||
|
if err := rows.Scan(&id, &name, &resource, &action, &description); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
permissions = append(permissions, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"resource": resource,
|
||||||
|
"action": action,
|
||||||
|
"description": description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions, rows.Err()
|
||||||
|
}
|
||||||
@@ -53,6 +53,8 @@ export interface UpdateUserRequest {
|
|||||||
email?: string
|
email?: string
|
||||||
full_name?: string
|
full_name?: string
|
||||||
is_active?: boolean
|
is_active?: boolean
|
||||||
|
roles?: string[]
|
||||||
|
groups?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const iamApi = {
|
export const iamApi = {
|
||||||
@@ -143,9 +145,47 @@ export const iamApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// List all available roles
|
// List all available roles
|
||||||
listRoles: async (): Promise<Array<{ name: string; description?: string; is_system: boolean }>> => {
|
listRoles: async (): Promise<Array<{ id: string; name: string; description?: string; is_system: boolean; user_count?: number; created_at?: string; updated_at?: string }>> => {
|
||||||
const response = await apiClient.get<{ roles: Array<{ name: string; description?: string; is_system: boolean }> }>('/iam/roles')
|
const response = await apiClient.get<{ roles: Array<{ id: string; name: string; description?: string; is_system: boolean; user_count?: number; created_at?: string; updated_at?: string }> }>('/iam/roles')
|
||||||
return response.data.roles
|
return response.data.roles
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getRole: async (id: string): Promise<{ id: string; name: string; description?: string; is_system: boolean; user_count?: number; created_at?: string; updated_at?: string }> => {
|
||||||
|
const response = await apiClient.get<{ id: string; name: string; description?: string; is_system: boolean; user_count?: number; created_at?: string; updated_at?: string }>(`/iam/roles/${id}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
createRole: async (data: { name: string; description?: string }): Promise<{ id: string; name: string }> => {
|
||||||
|
const response = await apiClient.post<{ id: string; name: string }>('/iam/roles', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
updateRole: async (id: string, data: { name?: string; description?: string }): Promise<void> => {
|
||||||
|
await apiClient.put(`/iam/roles/${id}`, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteRole: async (id: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/iam/roles/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Role permissions
|
||||||
|
getRolePermissions: async (roleId: string): Promise<string[]> => {
|
||||||
|
const response = await apiClient.get<{ permissions: string[] }>(`/iam/roles/${roleId}/permissions`)
|
||||||
|
return response.data.permissions
|
||||||
|
},
|
||||||
|
|
||||||
|
assignPermissionToRole: async (roleId: string, permissionName: string): Promise<void> => {
|
||||||
|
await apiClient.post(`/iam/roles/${roleId}/permissions`, { permission_name: permissionName })
|
||||||
|
},
|
||||||
|
|
||||||
|
removePermissionFromRole: async (roleId: string, permissionName: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/iam/roles/${roleId}/permissions?permission_name=${encodeURIComponent(permissionName)}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
listPermissions: async (): Promise<Array<{ id: string; name: string; resource: string; action: string; description?: string }>> => {
|
||||||
|
const response = await apiClient.get<{ permissions: Array<{ id: string; name: string; resource: string; action: string; description?: string }> }>('/iam/permissions')
|
||||||
|
return response.data.permissions
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { ChevronRight, Search, Filter, UserPlus, History, ChevronLeft, MoreVertical, Lock, Verified, Wrench, Eye, HardDrive, Shield, ArrowRight, Network, ChevronRight as ChevronRightIcon, X, Edit, Trash2, User as UserIcon, Plus } from 'lucide-react'
|
import { ChevronRight, Search, Filter, UserPlus, History, ChevronLeft, MoreVertical, Lock, Verified, Wrench, Eye, HardDrive, Shield, ArrowRight, Network, ChevronRight as ChevronRightIcon, X, Edit, Trash2, User as UserIcon, Plus } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -145,6 +145,17 @@ export default function IAM() {
|
|||||||
<Network size={20} />
|
<Network size={20} />
|
||||||
<span className="text-sm font-bold">Groups</span>
|
<span className="text-sm font-bold">Groups</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('roles')}
|
||||||
|
className={`flex items-center gap-2 pb-3 border-b-[3px] transition-colors ${
|
||||||
|
activeTab === 'roles'
|
||||||
|
? 'border-primary text-white'
|
||||||
|
: 'border-transparent text-text-secondary hover:text-white hover:border-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Shield size={20} />
|
||||||
|
<span className="text-sm font-bold">Roles</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('directory')}
|
onClick={() => setActiveTab('directory')}
|
||||||
className={`flex items-center gap-2 pb-3 border-b-[3px] transition-colors ${
|
className={`flex items-center gap-2 pb-3 border-b-[3px] transition-colors ${
|
||||||
@@ -403,6 +414,8 @@ export default function IAM() {
|
|||||||
|
|
||||||
{/* Groups Tab */}
|
{/* Groups Tab */}
|
||||||
{activeTab === 'groups' && <GroupsTab />}
|
{activeTab === 'groups' && <GroupsTab />}
|
||||||
|
{/* Roles Tab */}
|
||||||
|
{activeTab === 'roles' && <RolesTab />}
|
||||||
|
|
||||||
{activeTab !== 'users' && activeTab !== 'groups' && (
|
{activeTab !== 'users' && activeTab !== 'groups' && (
|
||||||
<div className="p-8 text-center text-text-secondary">
|
<div className="p-8 text-center text-text-secondary">
|
||||||
@@ -655,7 +668,7 @@ function EditUserForm({ user, onClose, onSuccess }: EditUserFormProps) {
|
|||||||
const unassignedGroups = availableGroups.filter(g => !userGroups.includes(g.name))
|
const unassignedGroups = availableGroups.filter(g => !userGroups.includes(g.name))
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (data: { email?: string; full_name?: string; is_active?: boolean }) =>
|
mutationFn: (data: { email?: string; full_name?: string; is_active?: boolean; roles?: string[]; groups?: string[] }) =>
|
||||||
iamApi.updateUser(user.id, data),
|
iamApi.updateUser(user.id, data),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
onSuccess()
|
onSuccess()
|
||||||
@@ -673,24 +686,42 @@ function EditUserForm({ user, onClose, onSuccess }: EditUserFormProps) {
|
|||||||
|
|
||||||
const assignRoleMutation = useMutation({
|
const assignRoleMutation = useMutation({
|
||||||
mutationFn: (roleName: string) => iamApi.assignRoleToUser(user.id, roleName),
|
mutationFn: (roleName: string) => iamApi.assignRoleToUser(user.id, roleName),
|
||||||
|
onMutate: async (roleName: string) => {
|
||||||
|
// Optimistic update: add role to state immediately
|
||||||
|
setUserRoles(prev => {
|
||||||
|
if (prev.includes(roleName)) return prev
|
||||||
|
return [...prev, roleName]
|
||||||
|
})
|
||||||
|
setSelectedRole('')
|
||||||
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
// Verify with server data
|
||||||
const updatedUser = await iamApi.getUser(user.id)
|
const updatedUser = await iamApi.getUser(user.id)
|
||||||
setUserRoles(updatedUser.roles || [])
|
setUserRoles(updatedUser.roles || [])
|
||||||
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
||||||
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
|
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
||||||
await queryClient.refetchQueries({ queryKey: ['iam-user', user.id] })
|
await queryClient.refetchQueries({ queryKey: ['iam-user', user.id] })
|
||||||
setSelectedRole('')
|
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any, roleName: string) => {
|
||||||
console.error('Failed to assign role:', error)
|
console.error('Failed to assign role:', error, roleName)
|
||||||
|
// Rollback: remove role from state if API call failed
|
||||||
|
setUserRoles(prev => prev.filter(r => r !== roleName))
|
||||||
alert(error.response?.data?.error || error.message || 'Failed to assign role')
|
alert(error.response?.data?.error || error.message || 'Failed to assign role')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const removeRoleMutation = useMutation({
|
const removeRoleMutation = useMutation({
|
||||||
mutationFn: (roleName: string) => iamApi.removeRoleFromUser(user.id, roleName),
|
mutationFn: (roleName: string) => iamApi.removeRoleFromUser(user.id, roleName),
|
||||||
|
onMutate: async (roleName: string) => {
|
||||||
|
// Store previous state for rollback
|
||||||
|
const previousRoles = userRoles
|
||||||
|
// Optimistic update: remove role from state immediately
|
||||||
|
setUserRoles(prev => prev.filter(r => r !== roleName))
|
||||||
|
return { previousRoles }
|
||||||
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
// Verify with server data
|
||||||
const updatedUser = await iamApi.getUser(user.id)
|
const updatedUser = await iamApi.getUser(user.id)
|
||||||
setUserRoles(updatedUser.roles || [])
|
setUserRoles(updatedUser.roles || [])
|
||||||
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
||||||
@@ -698,32 +729,54 @@ function EditUserForm({ user, onClose, onSuccess }: EditUserFormProps) {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
||||||
await queryClient.refetchQueries({ queryKey: ['iam-user', user.id] })
|
await queryClient.refetchQueries({ queryKey: ['iam-user', user.id] })
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any, _roleName: string, context: any) => {
|
||||||
console.error('Failed to remove role:', error)
|
console.error('Failed to remove role:', error)
|
||||||
|
// Rollback: restore previous state if API call failed
|
||||||
|
if (context?.previousRoles) {
|
||||||
|
setUserRoles(context.previousRoles)
|
||||||
|
}
|
||||||
alert(error.response?.data?.error || error.message || 'Failed to remove role')
|
alert(error.response?.data?.error || error.message || 'Failed to remove role')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const assignGroupMutation = useMutation({
|
const assignGroupMutation = useMutation({
|
||||||
mutationFn: (groupName: string) => iamApi.assignGroupToUser(user.id, groupName),
|
mutationFn: (groupName: string) => iamApi.assignGroupToUser(user.id, groupName),
|
||||||
|
onMutate: async (groupName: string) => {
|
||||||
|
// Optimistic update: add group to state immediately
|
||||||
|
setUserGroups(prev => {
|
||||||
|
if (prev.includes(groupName)) return prev
|
||||||
|
return [...prev, groupName]
|
||||||
|
})
|
||||||
|
setSelectedGroup('')
|
||||||
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
// Verify with server data
|
||||||
const updatedUser = await iamApi.getUser(user.id)
|
const updatedUser = await iamApi.getUser(user.id)
|
||||||
setUserGroups(updatedUser.groups || [])
|
setUserGroups(updatedUser.groups || [])
|
||||||
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
||||||
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
|
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
||||||
await queryClient.refetchQueries({ queryKey: ['iam-user', user.id] })
|
await queryClient.refetchQueries({ queryKey: ['iam-user', user.id] })
|
||||||
setSelectedGroup('')
|
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any, groupName: string) => {
|
||||||
console.error('Failed to assign group:', error)
|
console.error('Failed to assign group:', error, groupName)
|
||||||
|
// Rollback: remove group from state if API call failed
|
||||||
|
setUserGroups(prev => prev.filter(g => g !== groupName))
|
||||||
alert(error.response?.data?.error || error.message || 'Failed to assign group')
|
alert(error.response?.data?.error || error.message || 'Failed to assign group')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const removeGroupMutation = useMutation({
|
const removeGroupMutation = useMutation({
|
||||||
mutationFn: (groupName: string) => iamApi.removeGroupFromUser(user.id, groupName),
|
mutationFn: (groupName: string) => iamApi.removeGroupFromUser(user.id, groupName),
|
||||||
|
onMutate: async (groupName: string) => {
|
||||||
|
// Store previous state for rollback
|
||||||
|
const previousGroups = userGroups
|
||||||
|
// Optimistic update: remove group from state immediately
|
||||||
|
setUserGroups(prev => prev.filter(g => g !== groupName))
|
||||||
|
return { previousGroups }
|
||||||
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
// Verify with server data
|
||||||
const updatedUser = await iamApi.getUser(user.id)
|
const updatedUser = await iamApi.getUser(user.id)
|
||||||
setUserGroups(updatedUser.groups || [])
|
setUserGroups(updatedUser.groups || [])
|
||||||
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
||||||
@@ -731,19 +784,29 @@ function EditUserForm({ user, onClose, onSuccess }: EditUserFormProps) {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
||||||
await queryClient.refetchQueries({ queryKey: ['iam-user', user.id] })
|
await queryClient.refetchQueries({ queryKey: ['iam-user', user.id] })
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any, _groupName: string, context: any) => {
|
||||||
console.error('Failed to remove group:', error)
|
console.error('Failed to remove group:', error)
|
||||||
|
// Rollback: restore previous state if API call failed
|
||||||
|
if (context?.previousGroups) {
|
||||||
|
setUserGroups(context.previousGroups)
|
||||||
|
}
|
||||||
alert(error.response?.data?.error || error.message || 'Failed to remove group')
|
alert(error.response?.data?.error || error.message || 'Failed to remove group')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
updateMutation.mutate({
|
const payload = {
|
||||||
email: email.trim(),
|
email: email.trim(),
|
||||||
full_name: fullName.trim() || undefined,
|
full_name: fullName.trim() || undefined,
|
||||||
is_active: isActive,
|
is_active: isActive,
|
||||||
})
|
roles: userRoles,
|
||||||
|
groups: userGroups,
|
||||||
|
}
|
||||||
|
console.log('EditUserForm - Submitting payload:', payload)
|
||||||
|
console.log('EditUserForm - userRoles:', userRoles)
|
||||||
|
console.log('EditUserForm - userGroups:', userGroups)
|
||||||
|
updateMutation.mutate(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -962,6 +1025,9 @@ function GroupsTab() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false)
|
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||||
|
const [showEditForm, setShowEditForm] = useState(false)
|
||||||
|
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null)
|
||||||
|
const [openActionMenu, setOpenActionMenu] = useState<string | null>(null)
|
||||||
|
|
||||||
const { data: groups, isLoading } = useQuery<Group[]>({
|
const { data: groups, isLoading } = useQuery<Group[]>({
|
||||||
queryKey: ['iam-groups'],
|
queryKey: ['iam-groups'],
|
||||||
@@ -973,6 +1039,27 @@ function GroupsTab() {
|
|||||||
(group.description && group.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
(group.description && group.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
) || []
|
) || []
|
||||||
|
|
||||||
|
const deleteGroupMutation = useMutation({
|
||||||
|
mutationFn: iamApi.deleteGroup,
|
||||||
|
onSuccess: async () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['iam-groups'] })
|
||||||
|
await queryClient.refetchQueries({ queryKey: ['iam-groups'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
||||||
|
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
|
||||||
|
alert('Group deleted successfully!')
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.error('Failed to delete group:', error)
|
||||||
|
alert(error.response?.data?.error || error.message || 'Failed to delete group')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDeleteGroup = (groupId: string, groupName: string) => {
|
||||||
|
if (confirm(`Are you sure you want to delete group "${groupName}"? This action cannot be undone.`)) {
|
||||||
|
deleteGroupMutation.mutate(groupId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
@@ -1052,9 +1139,54 @@ function GroupsTab() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right">
|
<td className="px-6 py-4 text-right">
|
||||||
<button className="p-2 text-text-secondary hover:text-white hover:bg-border-dark rounded-lg transition-colors">
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setOpenActionMenu(openActionMenu === group.id ? null : group.id)
|
||||||
|
}}
|
||||||
|
className="p-2 text-text-secondary hover:text-white hover:bg-border-dark rounded-lg transition-colors"
|
||||||
|
>
|
||||||
<MoreVertical size={20} />
|
<MoreVertical size={20} />
|
||||||
</button>
|
</button>
|
||||||
|
{openActionMenu === group.id && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10"
|
||||||
|
onClick={() => setOpenActionMenu(null)}
|
||||||
|
/>
|
||||||
|
<div className="absolute right-0 mt-1 w-48 bg-card-dark border border-border-dark rounded-lg shadow-xl z-20">
|
||||||
|
<div className="py-1">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSelectedGroup(group)
|
||||||
|
setShowEditForm(true)
|
||||||
|
setOpenActionMenu(null)
|
||||||
|
}}
|
||||||
|
disabled={group.is_system}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-white hover:bg-[#233648] flex items-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Edit size={16} />
|
||||||
|
Edit Group
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDeleteGroup(group.id, group.name)
|
||||||
|
setOpenActionMenu(null)
|
||||||
|
}}
|
||||||
|
disabled={group.is_system || deleteGroupMutation.isPending}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-red-500/10 flex items-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
Delete Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
@@ -1089,9 +1221,29 @@ function GroupsTab() {
|
|||||||
{showCreateForm && (
|
{showCreateForm && (
|
||||||
<CreateGroupForm
|
<CreateGroupForm
|
||||||
onClose={() => setShowCreateForm(false)}
|
onClose={() => setShowCreateForm(false)}
|
||||||
onSuccess={() => {
|
onSuccess={async () => {
|
||||||
setShowCreateForm(false)
|
setShowCreateForm(false)
|
||||||
queryClient.invalidateQueries({ queryKey: ['iam-groups'] })
|
queryClient.invalidateQueries({ queryKey: ['iam-groups'] })
|
||||||
|
await queryClient.refetchQueries({ queryKey: ['iam-groups'] })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Group Form Modal */}
|
||||||
|
{showEditForm && selectedGroup && (
|
||||||
|
<EditGroupForm
|
||||||
|
group={selectedGroup}
|
||||||
|
onClose={() => {
|
||||||
|
setShowEditForm(false)
|
||||||
|
setSelectedGroup(null)
|
||||||
|
}}
|
||||||
|
onSuccess={async () => {
|
||||||
|
setShowEditForm(false)
|
||||||
|
setSelectedGroup(null)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['iam-groups'] })
|
||||||
|
await queryClient.refetchQueries({ queryKey: ['iam-groups'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
||||||
|
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1208,3 +1360,623 @@ function CreateGroupForm({ onClose, onSuccess }: CreateGroupFormProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Roles Tab Component
|
||||||
|
function RolesTab() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||||
|
const [showEditForm, setShowEditForm] = useState(false)
|
||||||
|
const [selectedRole, setSelectedRole] = useState<{ id: string; name: string; description?: string; is_system: boolean } | null>(null)
|
||||||
|
|
||||||
|
const { data: roles, isLoading } = useQuery({
|
||||||
|
queryKey: ['iam-roles'],
|
||||||
|
queryFn: iamApi.listRoles,
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredRoles = roles?.filter(role =>
|
||||||
|
role.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
(role.description && role.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
|
) || []
|
||||||
|
|
||||||
|
const deleteRoleMutation = useMutation({
|
||||||
|
mutationFn: iamApi.deleteRole,
|
||||||
|
onSuccess: async () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['iam-roles'] })
|
||||||
|
await queryClient.refetchQueries({ queryKey: ['iam-roles'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
||||||
|
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
|
||||||
|
alert('Role deleted successfully!')
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.error('Failed to delete role:', error)
|
||||||
|
alert(error.response?.data?.error || error.message || 'Failed to delete role')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDeleteRole = (roleId: string) => {
|
||||||
|
if (confirm('Are you sure you want to delete this role? This action cannot be undone.')) {
|
||||||
|
deleteRoleMutation.mutate(roleId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex flex-wrap gap-4 items-center justify-between">
|
||||||
|
<div className="flex flex-1 max-w-xl gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" size={20} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search roles by name or description..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full bg-card-dark border border-border-dark rounded-lg pl-10 pr-4 py-2.5 text-white placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-card-dark border border-border-dark rounded-lg text-text-secondary hover:text-white hover:border-slate-500 transition-colors"
|
||||||
|
>
|
||||||
|
<Filter size={20} />
|
||||||
|
<span className="text-sm font-medium">Filter</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="flex items-center gap-2 bg-primary hover:bg-blue-600 text-white px-5 py-2.5 rounded-lg font-bold shadow-lg shadow-blue-500/20 transition-all"
|
||||||
|
>
|
||||||
|
<UserPlus size={20} />
|
||||||
|
<span>Create Role</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Roles Table */}
|
||||||
|
<div className="rounded-xl border border-border-dark bg-[#111a22] overflow-hidden shadow-sm">
|
||||||
|
<div className="overflow-x-auto custom-scrollbar">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-card-dark border-b border-border-dark text-left">
|
||||||
|
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider">Name</th>
|
||||||
|
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider">Description</th>
|
||||||
|
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider">Users</th>
|
||||||
|
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider">Type</th>
|
||||||
|
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-dark">
|
||||||
|
{isLoading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-6 py-8 text-center text-text-secondary">
|
||||||
|
Loading roles...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : filteredRoles.length > 0 ? (
|
||||||
|
filteredRoles.map((role) => (
|
||||||
|
<tr key={role.id} className="group hover:bg-card-dark transition-colors">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="text-white font-medium">{role.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-text-secondary text-sm">
|
||||||
|
{role.description || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-text-secondary text-sm">
|
||||||
|
{role.user_count || 0}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{role.is_system ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-purple-500/10 text-purple-400 text-xs font-medium border border-purple-500/20">
|
||||||
|
<Shield size={12} />
|
||||||
|
System
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-blue-500/10 text-blue-400 text-xs font-medium border border-blue-500/20">
|
||||||
|
Custom
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedRole(role)
|
||||||
|
setShowEditForm(true)
|
||||||
|
}}
|
||||||
|
className="p-2 text-text-secondary hover:text-white hover:bg-border-dark rounded-lg transition-colors"
|
||||||
|
title="Edit role"
|
||||||
|
>
|
||||||
|
<Edit size={16} />
|
||||||
|
</button>
|
||||||
|
{!role.is_system && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteRole(role.id)}
|
||||||
|
disabled={deleteRoleMutation.isPending}
|
||||||
|
className="p-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||||
|
title="Delete role"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-6 py-8 text-center text-text-secondary">
|
||||||
|
No roles found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Role Form Modal */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<CreateRoleForm
|
||||||
|
onClose={() => setShowCreateForm(false)}
|
||||||
|
onSuccess={async () => {
|
||||||
|
setShowCreateForm(false)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['iam-roles'] })
|
||||||
|
await queryClient.refetchQueries({ queryKey: ['iam-roles'] })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Role Form Modal */}
|
||||||
|
{showEditForm && selectedRole && (
|
||||||
|
<EditRoleForm
|
||||||
|
role={selectedRole}
|
||||||
|
onClose={() => {
|
||||||
|
setShowEditForm(false)
|
||||||
|
setSelectedRole(null)
|
||||||
|
}}
|
||||||
|
onSuccess={async () => {
|
||||||
|
setShowEditForm(false)
|
||||||
|
setSelectedRole(null)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['iam-roles'] })
|
||||||
|
await queryClient.refetchQueries({ queryKey: ['iam-roles'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
||||||
|
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Role Form Component
|
||||||
|
interface CreateRoleFormProps {
|
||||||
|
onClose: () => void
|
||||||
|
onSuccess: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateRoleForm({ onClose, onSuccess }: CreateRoleFormProps) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: { name: string; description?: string }) => iamApi.createRole(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
onSuccess()
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.error('Failed to create role:', error)
|
||||||
|
const errorMessage = error.response?.data?.error || error.message || 'Failed to create role'
|
||||||
|
alert(errorMessage)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
createMutation.mutate({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
|
<div className="bg-card-dark border border-border-dark rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto custom-scrollbar">
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-border-dark bg-[#1e2832]">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">Create Role</h2>
|
||||||
|
<p className="text-sm text-text-secondary mt-1">Create a new role for access control</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-white/70 hover:text-white transition-colors p-2 hover:bg-[#233648] rounded-lg"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="role-name" className="block text-sm font-medium text-white mb-2">
|
||||||
|
Role Name <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="role-name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g., operator, auditor"
|
||||||
|
className="w-full px-4 py-3 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="role-description" className="block text-sm font-medium text-white mb-2">
|
||||||
|
Description <span className="text-text-secondary text-xs">(Optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="role-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Describe the role's purpose and permissions"
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-3 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-border-dark">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-6"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
className="px-6 bg-primary hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? 'Creating...' : 'Create Role'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit Role Form Component
|
||||||
|
interface EditRoleFormProps {
|
||||||
|
role: { id: string; name: string; description?: string; is_system: boolean }
|
||||||
|
onClose: () => void
|
||||||
|
onSuccess: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditRoleForm({ role, onClose, onSuccess }: EditRoleFormProps) {
|
||||||
|
const [name, setName] = useState(role.name)
|
||||||
|
const [description, setDescription] = useState(role.description || '')
|
||||||
|
const [rolePermissions, setRolePermissions] = useState<string[]>([])
|
||||||
|
const [selectedPermission, setSelectedPermission] = useState('')
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// Fetch role permissions
|
||||||
|
const { data: permissions = [] } = useQuery({
|
||||||
|
queryKey: ['iam-role-permissions', role.id],
|
||||||
|
queryFn: () => iamApi.getRolePermissions(role.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update rolePermissions when permissions data changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (permissions) {
|
||||||
|
setRolePermissions(permissions)
|
||||||
|
}
|
||||||
|
}, [permissions])
|
||||||
|
|
||||||
|
// Fetch all available permissions
|
||||||
|
const { data: availablePermissions = [] } = useQuery({
|
||||||
|
queryKey: ['iam-permissions'],
|
||||||
|
queryFn: iamApi.listPermissions,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter out already assigned permissions
|
||||||
|
const unassignedPermissions = availablePermissions.filter(p => !rolePermissions.includes(p.name))
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: { name?: string; description?: string }) => iamApi.updateRole(role.id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
onSuccess()
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.error('Failed to update role:', error)
|
||||||
|
const errorMessage = error.response?.data?.error || error.message || 'Failed to update role'
|
||||||
|
alert(errorMessage)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const assignPermissionMutation = useMutation({
|
||||||
|
mutationFn: (permissionName: string) => iamApi.assignPermissionToRole(role.id, permissionName),
|
||||||
|
onSuccess: async () => {
|
||||||
|
const updatedPermissions = await iamApi.getRolePermissions(role.id)
|
||||||
|
setRolePermissions(updatedPermissions)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['iam-role-permissions', role.id] })
|
||||||
|
await queryClient.refetchQueries({ queryKey: ['iam-role-permissions', role.id] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
||||||
|
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
|
||||||
|
setSelectedPermission('')
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.error('Failed to assign permission:', error)
|
||||||
|
alert(error.response?.data?.error || error.message || 'Failed to assign permission')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const removePermissionMutation = useMutation({
|
||||||
|
mutationFn: (permissionName: string) => iamApi.removePermissionFromRole(role.id, permissionName),
|
||||||
|
onSuccess: async () => {
|
||||||
|
const updatedPermissions = await iamApi.getRolePermissions(role.id)
|
||||||
|
setRolePermissions(updatedPermissions)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['iam-role-permissions', role.id] })
|
||||||
|
await queryClient.refetchQueries({ queryKey: ['iam-role-permissions', role.id] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
||||||
|
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.error('Failed to remove permission:', error)
|
||||||
|
alert(error.response?.data?.error || error.message || 'Failed to remove permission')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
updateMutation.mutate({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
|
<div className="bg-card-dark border border-border-dark rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto custom-scrollbar">
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-border-dark bg-[#1e2832]">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">Edit Role: {role.name}</h2>
|
||||||
|
<p className="text-sm text-text-secondary mt-1">Modify role details</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-white/70 hover:text-white transition-colors p-2 hover:bg-[#233648] rounded-lg"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="edit-role-name" className="block text-sm font-medium text-white mb-2">
|
||||||
|
Role Name <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="edit-role-name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
disabled={role.is_system}
|
||||||
|
className={`w-full px-4 py-3 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors ${
|
||||||
|
role.is_system ? 'cursor-not-allowed opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{role.is_system && (
|
||||||
|
<p className="text-xs text-text-secondary mt-1">System roles cannot be renamed</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="edit-role-description" className="block text-sm font-medium text-white mb-2">
|
||||||
|
Description <span className="text-text-secondary text-xs">(Optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="edit-role-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Describe the role's purpose and permissions"
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-3 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permissions Section */}
|
||||||
|
<div className="border-t border-border-dark pt-6">
|
||||||
|
<label className="block text-sm font-medium text-white mb-3">
|
||||||
|
Permissions
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<select
|
||||||
|
value={selectedPermission}
|
||||||
|
onChange={(e) => setSelectedPermission(e.target.value)}
|
||||||
|
className="flex-1 px-4 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="">Select a permission...</option>
|
||||||
|
{unassignedPermissions.map((perm) => (
|
||||||
|
<option key={perm.id} value={perm.name}>
|
||||||
|
{perm.name} {perm.description ? `- ${perm.description}` : `(${perm.resource}:${perm.action})`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedPermission) {
|
||||||
|
assignPermissionMutation.mutate(selectedPermission)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!selectedPermission || assignPermissionMutation.isPending}
|
||||||
|
className="px-4 bg-primary hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-64 overflow-y-auto custom-scrollbar">
|
||||||
|
{rolePermissions.length > 0 ? (
|
||||||
|
rolePermissions.map((perm) => {
|
||||||
|
const permInfo = availablePermissions.find(p => p.name === perm)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={perm}
|
||||||
|
className="flex items-center justify-between px-4 py-2 bg-[#0f161d] border border-border-dark rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-white text-sm font-medium">{perm}</span>
|
||||||
|
{permInfo && (
|
||||||
|
<span className="text-text-secondary text-xs">
|
||||||
|
{permInfo.resource}:{permInfo.action}
|
||||||
|
{permInfo.description && ` - ${permInfo.description}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removePermissionMutation.mutate(perm)}
|
||||||
|
disabled={removePermissionMutation.isPending}
|
||||||
|
className="text-red-400 hover:text-red-300 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<p className="text-text-secondary text-sm text-center py-2">No permissions assigned</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-border-dark">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-6"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={updateMutation.isPending || role.is_system}
|
||||||
|
className="px-6 bg-primary hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit Group Form Component
|
||||||
|
interface EditGroupFormProps {
|
||||||
|
group: Group
|
||||||
|
onClose: () => void
|
||||||
|
onSuccess: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditGroupForm({ group, onClose, onSuccess }: EditGroupFormProps) {
|
||||||
|
const [name, setName] = useState(group.name)
|
||||||
|
const [description, setDescription] = useState(group.description || '')
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: { name?: string; description?: string }) => iamApi.updateGroup(group.id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
onSuccess()
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.error('Failed to update group:', error)
|
||||||
|
const errorMessage = error.response?.data?.error || error.message || 'Failed to update group'
|
||||||
|
alert(errorMessage)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
updateMutation.mutate({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
|
<div className="bg-card-dark border border-border-dark rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto custom-scrollbar">
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-border-dark bg-[#1e2832]">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">Edit Group: {group.name}</h2>
|
||||||
|
<p className="text-sm text-text-secondary mt-1">Modify group details</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-white/70 hover:text-white transition-colors p-2 hover:bg-[#233648] rounded-lg"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="edit-group-name" className="block text-sm font-medium text-white mb-2">
|
||||||
|
Group Name <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="edit-group-name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
disabled={group.is_system}
|
||||||
|
className={`w-full px-4 py-3 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors ${
|
||||||
|
group.is_system ? 'cursor-not-allowed opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{group.is_system && (
|
||||||
|
<p className="text-xs text-text-secondary mt-1">System groups cannot be renamed</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="edit-group-description" className="block text-sm font-medium text-white mb-2">
|
||||||
|
Description <span className="text-text-secondary text-xs">(Optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="edit-group-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Describe the group's purpose"
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-3 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-border-dark">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-6"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={updateMutation.isPending || group.is_system}
|
||||||
|
className="px-6 bg-primary hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user