diff --git a/backend/bin/calypso-api b/backend/bin/calypso-api index 684773b..96bf517 100755 Binary files a/backend/bin/calypso-api and b/backend/bin/calypso-api differ diff --git a/backend/internal/common/database/migrations/008_add_user_groups.sql b/backend/internal/common/database/migrations/008_add_user_groups.sql new file mode 100644 index 0000000..962757b --- /dev/null +++ b/backend/internal/common/database/migrations/008_add_user_groups.sql @@ -0,0 +1,45 @@ +-- Add user groups feature +-- Groups table +CREATE TABLE IF NOT EXISTS groups ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL UNIQUE, + description TEXT, + is_system BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- User groups junction table +CREATE TABLE IF NOT EXISTS user_groups ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + assigned_at TIMESTAMP NOT NULL DEFAULT NOW(), + assigned_by UUID REFERENCES users(id), + PRIMARY KEY (user_id, group_id) +); + +-- Group roles junction table (groups can have roles) +CREATE TABLE IF NOT EXISTS group_roles ( + group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + granted_at TIMESTAMP NOT NULL DEFAULT NOW(), + PRIMARY KEY (group_id, role_id) +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_groups_name ON groups(name); +CREATE INDEX IF NOT EXISTS idx_user_groups_user_id ON user_groups(user_id); +CREATE INDEX IF NOT EXISTS idx_user_groups_group_id ON user_groups(group_id); +CREATE INDEX IF NOT EXISTS idx_group_roles_group_id ON group_roles(group_id); +CREATE INDEX IF NOT EXISTS idx_group_roles_role_id ON group_roles(role_id); + +-- Insert default system groups +INSERT INTO groups (name, description, is_system) VALUES + ('wheel', 'System administrators group', true), + ('operators', 'System operators group', true), + ('backup', 'Backup operators group', true), + ('auditors', 'Auditors group', true), + ('storage_admins', 'Storage administrators group', true), + ('services', 'Service accounts group', true) +ON CONFLICT (name) DO NOTHING; + diff --git a/backend/internal/common/router/router.go b/backend/internal/common/router/router.go index 0ba7437..fa8bc24 100644 --- a/backend/internal/common/router/router.go +++ b/backend/internal/common/router/router.go @@ -258,16 +258,32 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng systemGroup.GET("/interfaces", systemHandler.ListNetworkInterfaces) } - // IAM (admin only) + // IAM routes - GetUser can be accessed by user viewing own profile or admin iamHandler := iam.NewHandler(db, cfg, log) + protected.GET("/iam/users/:id", iamHandler.GetUser) + + // IAM admin routes iamGroup := protected.Group("/iam") iamGroup.Use(requireRole("admin")) { iamGroup.GET("/users", iamHandler.ListUsers) - iamGroup.GET("/users/:id", iamHandler.GetUser) iamGroup.POST("/users", iamHandler.CreateUser) iamGroup.PUT("/users/:id", iamHandler.UpdateUser) iamGroup.DELETE("/users/:id", iamHandler.DeleteUser) + iamGroup.GET("/roles", iamHandler.ListRoles) + iamGroup.POST("/users/:id/roles", iamHandler.AssignRoleToUser) + iamGroup.DELETE("/users/:id/roles", iamHandler.RemoveRoleFromUser) + iamGroup.POST("/users/:id/groups", iamHandler.AssignGroupToUser) + iamGroup.DELETE("/users/:id/groups", iamHandler.RemoveGroupFromUser) + + // Groups routes + iamGroup.GET("/groups", iamHandler.ListGroups) + iamGroup.GET("/groups/:id", iamHandler.GetGroup) + iamGroup.POST("/groups", iamHandler.CreateGroup) + iamGroup.PUT("/groups/:id", iamHandler.UpdateGroup) + iamGroup.DELETE("/groups/:id", iamHandler.DeleteGroup) + iamGroup.POST("/groups/:id/users", iamHandler.AddUserToGroup) + iamGroup.DELETE("/groups/:id/users/:user_id", iamHandler.RemoveUserFromGroup) } // Monitoring diff --git a/backend/internal/iam/group.go b/backend/internal/iam/group.go new file mode 100644 index 0000000..b64243b --- /dev/null +++ b/backend/internal/iam/group.go @@ -0,0 +1,218 @@ +package iam + +import ( + "time" + + "github.com/atlasos/calypso/internal/common/database" +) + +// Group represents a user group +type Group struct { + ID string + Name string + Description string + IsSystem bool + CreatedAt time.Time + UpdatedAt time.Time + UserCount int + RoleCount int +} + +// GetGroupByID retrieves a group by ID +func GetGroupByID(db *database.DB, groupID string) (*Group, error) { + query := ` + SELECT id, name, description, is_system, created_at, updated_at + FROM groups + WHERE id = $1 + ` + + var group Group + err := db.QueryRow(query, groupID).Scan( + &group.ID, &group.Name, &group.Description, &group.IsSystem, + &group.CreatedAt, &group.UpdatedAt, + ) + if err != nil { + return nil, err + } + + // Get user count + var userCount int + db.QueryRow("SELECT COUNT(*) FROM user_groups WHERE group_id = $1", groupID).Scan(&userCount) + group.UserCount = userCount + + // Get role count + var roleCount int + db.QueryRow("SELECT COUNT(*) FROM group_roles WHERE group_id = $1", groupID).Scan(&roleCount) + group.RoleCount = roleCount + + return &group, nil +} + +// GetGroupByName retrieves a group by name +func GetGroupByName(db *database.DB, name string) (*Group, error) { + query := ` + SELECT id, name, description, is_system, created_at, updated_at + FROM groups + WHERE name = $1 + ` + + var group Group + err := db.QueryRow(query, name).Scan( + &group.ID, &group.Name, &group.Description, &group.IsSystem, + &group.CreatedAt, &group.UpdatedAt, + ) + if err != nil { + return nil, err + } + + return &group, nil +} + +// GetUserGroups retrieves all groups for a user +func GetUserGroups(db *database.DB, userID string) ([]string, error) { + query := ` + SELECT g.name + FROM groups g + INNER JOIN user_groups ug ON g.id = ug.group_id + WHERE ug.user_id = $1 + ORDER BY g.name + ` + + rows, err := db.Query(query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var groups []string + for rows.Next() { + var groupName string + if err := rows.Scan(&groupName); err != nil { + return nil, err + } + groups = append(groups, groupName) + } + + return groups, rows.Err() +} + +// GetGroupUsers retrieves all users in a group +func GetGroupUsers(db *database.DB, groupID string) ([]string, error) { + query := ` + SELECT u.id + FROM users u + INNER JOIN user_groups ug ON u.id = ug.user_id + WHERE ug.group_id = $1 + ORDER BY u.username + ` + + rows, err := db.Query(query, groupID) + 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() +} + +// GetGroupRoles retrieves all roles for a group +func GetGroupRoles(db *database.DB, groupID string) ([]string, error) { + query := ` + SELECT r.name + FROM roles r + INNER JOIN group_roles gr ON r.id = gr.role_id + WHERE gr.group_id = $1 + ORDER BY r.name + ` + + rows, err := db.Query(query, groupID) + if err != nil { + return nil, err + } + defer rows.Close() + + var roles []string + for rows.Next() { + var role string + if err := rows.Scan(&role); err != nil { + return nil, err + } + roles = append(roles, role) + } + + return roles, rows.Err() +} + +// AddUserToGroup adds a user to a group +func AddUserToGroup(db *database.DB, userID, groupID, assignedBy string) error { + query := ` + INSERT INTO user_groups (user_id, group_id, assigned_by) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, group_id) DO NOTHING + ` + _, err := db.Exec(query, userID, groupID, assignedBy) + return err +} + +// RemoveUserFromGroup removes a user from a group +func RemoveUserFromGroup(db *database.DB, userID, groupID string) error { + query := `DELETE FROM user_groups WHERE user_id = $1 AND group_id = $2` + _, err := db.Exec(query, userID, groupID) + return err +} + +// AddRoleToGroup adds a role to a group +func AddRoleToGroup(db *database.DB, groupID, roleID string) error { + query := ` + INSERT INTO group_roles (group_id, role_id) + VALUES ($1, $2) + ON CONFLICT (group_id, role_id) DO NOTHING + ` + _, err := db.Exec(query, groupID, roleID) + return err +} + +// RemoveRoleFromGroup removes a role from a group +func RemoveRoleFromGroup(db *database.DB, groupID, roleID string) error { + query := `DELETE FROM group_roles WHERE group_id = $1 AND role_id = $2` + _, err := db.Exec(query, groupID, roleID) + return err +} + +// GetUserRolesFromGroups retrieves all roles for a user via groups +func GetUserRolesFromGroups(db *database.DB, userID string) ([]string, error) { + query := ` + SELECT DISTINCT r.name + FROM roles r + INNER JOIN group_roles gr ON r.id = gr.role_id + INNER JOIN user_groups ug ON gr.group_id = ug.group_id + WHERE ug.user_id = $1 + ORDER BY r.name + ` + + rows, err := db.Query(query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var roles []string + for rows.Next() { + var role string + if err := rows.Scan(&role); err != nil { + return nil, err + } + roles = append(roles, role) + } + + return roles, rows.Err() +} diff --git a/backend/internal/iam/handler.go b/backend/internal/iam/handler.go index 226e8ab..cac12de 100644 --- a/backend/internal/iam/handler.go +++ b/backend/internal/iam/handler.go @@ -1,6 +1,7 @@ package iam import ( + "database/sql" "fmt" "net/http" "strings" @@ -64,15 +65,22 @@ func (h *Handler) ListUsers(c *gin.Context) { continue } + roles, _ := GetUserRoles(h.db, u.ID) + permissions, _ := GetUserPermissions(h.db, u.ID) + groups, _ := GetUserGroups(h.db, u.ID) + users = append(users, map[string]interface{}{ - "id": u.ID, - "username": u.Username, - "email": u.Email, - "full_name": u.FullName, - "is_active": u.IsActive, - "is_system": u.IsSystem, - "created_at": u.CreatedAt, - "updated_at": u.UpdatedAt, + "id": u.ID, + "username": u.Username, + "email": u.Email, + "full_name": u.FullName, + "is_active": u.IsActive, + "is_system": u.IsSystem, + "roles": roles, + "permissions": permissions, + "groups": groups, + "created_at": u.CreatedAt, + "updated_at": u.UpdatedAt, "last_login_at": u.LastLoginAt, }) } @@ -81,9 +89,45 @@ func (h *Handler) ListUsers(c *gin.Context) { } // GetUser retrieves a single user +// Permission: User can view their own profile, or admin can view any profile func (h *Handler) GetUser(c *gin.Context) { userID := c.Param("id") + // Get current authenticated user from context + authUser, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + currentUser, ok := authUser.(*User) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user context"}) + return + } + + // Check permission: user can view own profile, or admin can view any profile + canView := false + if currentUser.ID == userID { + canView = true + } else { + // Check if current user is admin + roles, err := GetUserRoles(h.db, currentUser.ID) + if err == nil { + for _, role := range roles { + if role == "admin" { + canView = true + break + } + } + } + } + + if !canView { + c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"}) + return + } + user, err := GetUserByID(h.db, userID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) @@ -92,18 +136,21 @@ func (h *Handler) GetUser(c *gin.Context) { roles, _ := GetUserRoles(h.db, userID) permissions, _ := GetUserPermissions(h.db, userID) + groups, _ := GetUserGroups(h.db, userID) c.JSON(http.StatusOK, gin.H{ - "id": user.ID, - "username": user.Username, - "email": user.Email, - "full_name": user.FullName, - "is_active": user.IsActive, - "is_system": user.IsSystem, - "roles": roles, - "permissions": permissions, - "created_at": user.CreatedAt, - "updated_at": user.UpdatedAt, + "id": user.ID, + "username": user.Username, + "email": user.Email, + "full_name": user.FullName, + "is_active": user.IsActive, + "is_system": user.IsSystem, + "roles": roles, + "permissions": permissions, + "groups": groups, + "created_at": user.CreatedAt, + "updated_at": user.UpdatedAt, + "last_login_at": user.LastLoginAt, }) } @@ -230,3 +277,432 @@ func (h *Handler) DeleteUser(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "user deleted successfully"}) } +// ListGroups lists all groups +func (h *Handler) ListGroups(c *gin.Context) { + query := ` + SELECT g.id, g.name, g.description, g.is_system, g.created_at, g.updated_at, + COUNT(DISTINCT ug.user_id) as user_count, + COUNT(DISTINCT gr.role_id) as role_count + FROM groups g + LEFT JOIN user_groups ug ON g.id = ug.group_id + LEFT JOIN group_roles gr ON g.id = gr.group_id + GROUP BY g.id, g.name, g.description, g.is_system, g.created_at, g.updated_at + ORDER BY g.name + ` + + rows, err := h.db.Query(query) + if err != nil { + h.logger.Error("Failed to list groups", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list groups"}) + return + } + defer rows.Close() + + var groups []map[string]interface{} + for rows.Next() { + var g struct { + ID string + Name string + Description sql.NullString + IsSystem bool + CreatedAt string + UpdatedAt string + UserCount int + RoleCount int + } + if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.IsSystem, + &g.CreatedAt, &g.UpdatedAt, &g.UserCount, &g.RoleCount); err != nil { + h.logger.Error("Failed to scan group", "error", err) + continue + } + + groups = append(groups, map[string]interface{}{ + "id": g.ID, + "name": g.Name, + "description": g.Description.String, + "is_system": g.IsSystem, + "user_count": g.UserCount, + "role_count": g.RoleCount, + "created_at": g.CreatedAt, + "updated_at": g.UpdatedAt, + }) + } + + c.JSON(http.StatusOK, gin.H{"groups": groups}) +} + +// GetGroup retrieves a single group +func (h *Handler) GetGroup(c *gin.Context) { + groupID := c.Param("id") + + group, err := GetGroupByID(h.db, groupID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) + return + } + + users, _ := GetGroupUsers(h.db, groupID) + roles, _ := GetGroupRoles(h.db, groupID) + + c.JSON(http.StatusOK, gin.H{ + "id": group.ID, + "name": group.Name, + "description": group.Description, + "is_system": group.IsSystem, + "user_count": group.UserCount, + "role_count": group.RoleCount, + "users": users, + "roles": roles, + "created_at": group.CreatedAt, + "updated_at": group.UpdatedAt, + }) +} + +// CreateGroup creates a new group +func (h *Handler) CreateGroup(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 group", "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 = "" + } + + query := ` + INSERT INTO groups (name, description) + VALUES ($1, $2) + RETURNING id + ` + + var groupID string + err := h.db.QueryRow(query, req.Name, description).Scan(&groupID) + 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("Group name already exists", "name", req.Name, "error", err) + c.JSON(http.StatusConflict, gin.H{"error": "group name already exists"}) + return + } + h.logger.Error("Failed to create group", "error", err, "name", req.Name) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group: " + err.Error()}) + return + } + + h.logger.Info("Group created successfully", "group_id", groupID, "name", req.Name) + c.JSON(http.StatusCreated, gin.H{"id": groupID, "name": req.Name}) +} + +// UpdateGroup updates an existing group +func (h *Handler) UpdateGroup(c *gin.Context) { + groupID := c.Param("id") + + // Check if group is system group + var isSystem bool + err := h.db.QueryRow("SELECT is_system FROM groups WHERE id = $1", groupID).Scan(&isSystem) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) + 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 + var updates []string + var args []interface{} + argIndex := 1 + + if req.Name != "" { + updates = append(updates, fmt.Sprintf("name = $%d", argIndex)) + args = append(args, req.Name) + argIndex++ + } + + if req.Description != "" { + updates = append(updates, fmt.Sprintf("description = $%d", argIndex)) + args = append(args, req.Description) + argIndex++ + } + + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + updates = append(updates, "updated_at = NOW()") + args = append(args, groupID) + + query := fmt.Sprintf("UPDATE groups SET %s WHERE id = $%d", strings.Join(updates, ", "), argIndex) + + _, err = h.db.Exec(query, args...) + if err != nil { + h.logger.Error("Failed to update group", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update group"}) + return + } + + h.logger.Info("Group updated", "group_id", groupID) + c.JSON(http.StatusOK, gin.H{"message": "group updated successfully"}) +} + +// DeleteGroup deletes a group +func (h *Handler) DeleteGroup(c *gin.Context) { + groupID := c.Param("id") + + // Check if group is system group + var isSystem bool + err := h.db.QueryRow("SELECT is_system FROM groups WHERE id = $1", groupID).Scan(&isSystem) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) + return + } + + if isSystem { + c.JSON(http.StatusForbidden, gin.H{"error": "cannot delete system group"}) + return + } + + _, err = h.db.Exec("DELETE FROM groups WHERE id = $1", groupID) + if err != nil { + h.logger.Error("Failed to delete group", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete group"}) + return + } + + h.logger.Info("Group deleted", "group_id", groupID) + c.JSON(http.StatusOK, gin.H{"message": "group deleted successfully"}) +} + +// AddUserToGroup adds a user to a group +func (h *Handler) AddUserToGroup(c *gin.Context) { + groupID := c.Param("id") + var req struct { + UserID string `json:"user_id" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + + // Get current user ID from context + authUser, _ := c.Get("user") + currentUser := authUser.(*User) + + err := AddUserToGroup(h.db, req.UserID, groupID, currentUser.ID) + if err != nil { + h.logger.Error("Failed to add user to group", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add user to group"}) + return + } + + h.logger.Info("User added to group", "user_id", req.UserID, "group_id", groupID) + c.JSON(http.StatusOK, gin.H{"message": "user added to group successfully"}) +} + +// RemoveUserFromGroup removes a user from a group +func (h *Handler) RemoveUserFromGroup(c *gin.Context) { + groupID := c.Param("id") + userID := c.Param("user_id") + + err := RemoveUserFromGroup(h.db, userID, groupID) + if err != nil { + h.logger.Error("Failed to remove user from group", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove user from group"}) + return + } + + h.logger.Info("User removed from group", "user_id", userID, "group_id", groupID) + c.JSON(http.StatusOK, gin.H{"message": "user removed from group successfully"}) +} + +// AssignRoleToUser assigns a role to a user +func (h *Handler) AssignRoleToUser(c *gin.Context) { + userID := c.Param("id") + var req struct { + RoleName string `json:"role_name" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + + // Get role ID by name + roleID, err := GetRoleIDByName(h.db, req.RoleName) + 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 assign role"}) + return + } + + // Get current user ID from context + authUser, _ := c.Get("user") + currentUser := authUser.(*User) + + err = AddUserRole(h.db, userID, roleID, currentUser.ID) + if err != nil { + h.logger.Error("Failed to assign role to user", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to assign role"}) + return + } + + h.logger.Info("Role assigned to user", "user_id", userID, "role", req.RoleName) + c.JSON(http.StatusOK, gin.H{"message": "role assigned successfully"}) +} + +// RemoveRoleFromUser removes a role from a user +func (h *Handler) RemoveRoleFromUser(c *gin.Context) { + userID := c.Param("id") + var req struct { + RoleName string `json:"role_name" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + + // Get role ID by name + roleID, err := GetRoleIDByName(h.db, req.RoleName) + 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 remove role"}) + return + } + + err = RemoveUserRole(h.db, userID, roleID) + if err != nil { + h.logger.Error("Failed to remove role from user", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove role"}) + return + } + + h.logger.Info("Role removed from user", "user_id", userID, "role", req.RoleName) + c.JSON(http.StatusOK, gin.H{"message": "role removed successfully"}) +} + +// AssignGroupToUser assigns a group to a user +func (h *Handler) AssignGroupToUser(c *gin.Context) { + userID := c.Param("id") + var req struct { + GroupName string `json:"group_name" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + + // Get group ID by name + group, err := GetGroupByName(h.db, req.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 == 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 + authUser, _ := c.Get("user") + currentUser := authUser.(*User) + + err = AddUserToGroup(h.db, userID, groupID, currentUser.ID) + if err != nil { + h.logger.Error("Failed to assign group to user", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to assign group"}) + return + } + + h.logger.Info("Group assigned to user", "user_id", userID, "group", req.GroupName) + c.JSON(http.StatusOK, gin.H{"message": "group assigned successfully"}) +} + +// RemoveGroupFromUser removes a group from a user +func (h *Handler) RemoveGroupFromUser(c *gin.Context) { + userID := c.Param("id") + var req struct { + GroupName string `json:"group_name" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + + // Get group ID by name + group, err := GetGroupByName(h.db, req.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 == 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 remove group"}) + return + } + + err = RemoveUserFromGroup(h.db, userID, groupID) + if err != nil { + h.logger.Error("Failed to remove group from user", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove group"}) + return + } + + h.logger.Info("Group removed from user", "user_id", userID, "group", req.GroupName) + c.JSON(http.StatusOK, gin.H{"message": "group removed successfully"}) +} diff --git a/backend/internal/iam/user.go b/backend/internal/iam/user.go index 6512166..8bea90e 100644 --- a/backend/internal/iam/user.go +++ b/backend/internal/iam/user.go @@ -126,3 +126,27 @@ func GetUserPermissions(db *database.DB, userID string) ([]string, error) { return permissions, rows.Err() } +// AddUserRole assigns a role to a user +func AddUserRole(db *database.DB, userID, roleID, assignedBy string) error { + query := ` + INSERT INTO user_roles (user_id, role_id, assigned_by) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, role_id) DO NOTHING + ` + _, err := db.Exec(query, userID, roleID, assignedBy) + return err +} + +// RemoveUserRole removes a role from a user +func RemoveUserRole(db *database.DB, userID, roleID string) error { + query := `DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2` + _, err := db.Exec(query, userID, roleID) + return err +} + +// GetRoleIDByName retrieves a role ID by name +func GetRoleIDByName(db *database.DB, roleName string) (string, error) { + var roleID string + err := db.QueryRow("SELECT id FROM roles WHERE name = $1", roleName).Scan(&roleID) + return roleID, err +} diff --git a/backend/internal/tape_vtl/mhvtl_monitor.go b/backend/internal/tape_vtl/mhvtl_monitor.go index 117ac41..166b4cc 100644 --- a/backend/internal/tape_vtl/mhvtl_monitor.go +++ b/backend/internal/tape_vtl/mhvtl_monitor.go @@ -67,7 +67,7 @@ func (m *MHVTLMonitor) Stop() { // syncMHVTL parses mhvtl configuration and syncs to database func (m *MHVTLMonitor) syncMHVTL(ctx context.Context) { - m.logger.Debug("Running MHVTL configuration sync") + m.logger.Info("Running MHVTL configuration sync") deviceConfPath := filepath.Join(m.configPath, "device.conf") if _, err := os.Stat(deviceConfPath); os.IsNotExist(err) { @@ -84,6 +84,11 @@ func (m *MHVTLMonitor) syncMHVTL(ctx context.Context) { m.logger.Info("Parsed MHVTL configuration", "libraries", len(libraries), "drives", len(drives)) + // Log parsed drives for debugging + for _, drive := range drives { + m.logger.Debug("Parsed drive", "drive_id", drive.DriveID, "library_id", drive.LibraryID, "slot", drive.Slot) + } + // Sync libraries to database for _, lib := range libraries { if err := m.syncLibrary(ctx, lib); err != nil { @@ -94,7 +99,9 @@ func (m *MHVTLMonitor) syncMHVTL(ctx context.Context) { // Sync drives to database for _, drive := range drives { if err := m.syncDrive(ctx, drive); err != nil { - m.logger.Error("Failed to sync drive", "drive_id", drive.DriveID, "error", err) + m.logger.Error("Failed to sync drive", "drive_id", drive.DriveID, "library_id", drive.LibraryID, "slot", drive.Slot, "error", err) + } else { + m.logger.Debug("Synced drive", "drive_id", drive.DriveID, "library_id", drive.LibraryID, "slot", drive.Slot) } } @@ -106,7 +113,7 @@ func (m *MHVTLMonitor) syncMHVTL(ctx context.Context) { } } - m.logger.Debug("MHVTL configuration sync completed") + m.logger.Info("MHVTL configuration sync completed") } // LibraryInfo represents a library from device.conf @@ -189,6 +196,7 @@ func (m *MHVTLMonitor) parseDeviceConf(ctx context.Context, path string) ([]Libr Target: matches[3], LUN: matches[4], } + // Library ID and Slot might be on the same line or next line if matches := libraryIDRegex.FindStringSubmatch(line); matches != nil { libID, _ := strconv.Atoi(matches[1]) slot, _ := strconv.Atoi(matches[2]) @@ -198,34 +206,63 @@ func (m *MHVTLMonitor) parseDeviceConf(ctx context.Context, path string) ([]Libr continue } - // Parse library fields - if currentLibrary != nil { - if strings.HasPrefix(line, "Vendor identification:") { - currentLibrary.Vendor = strings.TrimSpace(strings.TrimPrefix(line, "Vendor identification:")) - } else if strings.HasPrefix(line, "Product identification:") { - currentLibrary.Product = strings.TrimSpace(strings.TrimPrefix(line, "Product identification:")) - } else if strings.HasPrefix(line, "Unit serial number:") { - currentLibrary.SerialNumber = strings.TrimSpace(strings.TrimPrefix(line, "Unit serial number:")) - } else if strings.HasPrefix(line, "Home directory:") { - currentLibrary.HomeDirectory = strings.TrimSpace(strings.TrimPrefix(line, "Home directory:")) + // Parse library fields (only if we're in a library section and not in a drive section) + if currentLibrary != nil && currentDrive == nil { + // Handle both "Vendor identification:" and " Vendor identification:" (with leading space) + if strings.Contains(line, "Vendor identification:") { + parts := strings.Split(line, "Vendor identification:") + if len(parts) > 1 { + currentLibrary.Vendor = strings.TrimSpace(parts[1]) + m.logger.Debug("Parsed vendor", "vendor", currentLibrary.Vendor, "library_id", currentLibrary.LibraryID) + } + } else if strings.Contains(line, "Product identification:") { + parts := strings.Split(line, "Product identification:") + if len(parts) > 1 { + currentLibrary.Product = strings.TrimSpace(parts[1]) + m.logger.Info("Parsed library product", "product", currentLibrary.Product, "library_id", currentLibrary.LibraryID) + } + } else if strings.Contains(line, "Unit serial number:") { + parts := strings.Split(line, "Unit serial number:") + if len(parts) > 1 { + currentLibrary.SerialNumber = strings.TrimSpace(parts[1]) + } + } else if strings.Contains(line, "Home directory:") { + parts := strings.Split(line, "Home directory:") + if len(parts) > 1 { + currentLibrary.HomeDirectory = strings.TrimSpace(parts[1]) + } } } // Parse drive fields if currentDrive != nil { - if strings.HasPrefix(line, "Vendor identification:") { - currentDrive.Vendor = strings.TrimSpace(strings.TrimPrefix(line, "Vendor identification:")) - } else if strings.HasPrefix(line, "Product identification:") { - currentDrive.Product = strings.TrimSpace(strings.TrimPrefix(line, "Product identification:")) - } else if strings.HasPrefix(line, "Unit serial number:") { - currentDrive.SerialNumber = strings.TrimSpace(strings.TrimPrefix(line, "Unit serial number:")) - } else if strings.HasPrefix(line, "Library ID:") && strings.Contains(line, "Slot:") { + // Check for Library ID and Slot first (can be on separate line) + if strings.Contains(line, "Library ID:") && strings.Contains(line, "Slot:") { matches := libraryIDRegex.FindStringSubmatch(line) if matches != nil { libID, _ := strconv.Atoi(matches[1]) slot, _ := strconv.Atoi(matches[2]) currentDrive.LibraryID = libID currentDrive.Slot = slot + m.logger.Debug("Parsed drive Library ID and Slot", "drive_id", currentDrive.DriveID, "library_id", libID, "slot", slot) + continue + } + } + // Handle both "Vendor identification:" and " Vendor identification:" (with leading space) + if strings.Contains(line, "Vendor identification:") { + parts := strings.Split(line, "Vendor identification:") + if len(parts) > 1 { + currentDrive.Vendor = strings.TrimSpace(parts[1]) + } + } else if strings.Contains(line, "Product identification:") { + parts := strings.Split(line, "Product identification:") + if len(parts) > 1 { + currentDrive.Product = strings.TrimSpace(parts[1]) + } + } else if strings.Contains(line, "Unit serial number:") { + parts := strings.Split(line, "Unit serial number:") + if len(parts) > 1 { + currentDrive.SerialNumber = strings.TrimSpace(parts[1]) } } } @@ -255,9 +292,17 @@ func (m *MHVTLMonitor) syncLibrary(ctx context.Context, libInfo LibraryInfo) err libInfo.LibraryID, ).Scan(&existingID) + m.logger.Debug("Syncing library", "library_id", libInfo.LibraryID, "vendor", libInfo.Vendor, "product", libInfo.Product) + + // Use product identification for library name (without library ID) libraryName := fmt.Sprintf("VTL-%d", libInfo.LibraryID) if libInfo.Product != "" { - libraryName = fmt.Sprintf("%s-%d", libInfo.Product, libInfo.LibraryID) + // Use only product name, without library ID + libraryName = libInfo.Product + m.logger.Info("Using product for library name", "product", libInfo.Product, "library_id", libInfo.LibraryID, "name", libraryName) + } else if libInfo.Vendor != "" { + libraryName = libInfo.Vendor + m.logger.Info("Using vendor for library name (product not available)", "vendor", libInfo.Vendor, "library_id", libInfo.LibraryID) } if err == sql.ErrNoRows { @@ -284,13 +329,31 @@ func (m *MHVTLMonitor) syncLibrary(ctx context.Context, libInfo LibraryInfo) err } m.logger.Info("Created virtual library from MHVTL", "library_id", libInfo.LibraryID, "name", libraryName) } else if err == nil { - // Update existing library + // Update existing library - also update name if product is available + updateName := libraryName + // If product exists and current name doesn't match, update it + if libInfo.Product != "" { + var currentName string + err := m.service.db.QueryRowContext(ctx, + "SELECT name FROM virtual_tape_libraries WHERE id = $1", existingID, + ).Scan(¤tName) + if err == nil { + // Use only product name, without library ID + expectedName := libInfo.Product + if currentName != expectedName { + updateName = expectedName + m.logger.Info("Updating library name", "old", currentName, "new", updateName, "product", libInfo.Product) + } + } + } + + m.logger.Info("Updating existing library", "library_id", libInfo.LibraryID, "product", libInfo.Product, "vendor", libInfo.Vendor, "old_name", libraryName, "new_name", updateName) _, err = m.service.db.ExecContext(ctx, ` UPDATE virtual_tape_libraries SET name = $1, description = $2, backing_store_path = $3, vendor = $4, is_active = $5, updated_at = NOW() WHERE id = $6 - `, libraryName, fmt.Sprintf("MHVTL Library %d (%s)", libInfo.LibraryID, libInfo.Product), + `, updateName, fmt.Sprintf("MHVTL Library %d (%s)", libInfo.LibraryID, libInfo.Product), libInfo.HomeDirectory, libInfo.Vendor, true, existingID) if err != nil { return fmt.Errorf("failed to update library: %w", err) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4c7c1bc..6aa4acc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,9 @@ import VTLDetailPage from '@/pages/VTLDetail' import ISCSITargetsPage from '@/pages/ISCSITargets' import ISCSITargetDetailPage from '@/pages/ISCSITargetDetail' import SystemPage from '@/pages/System' +import BackupManagementPage from '@/pages/BackupManagement' +import IAMPage from '@/pages/IAM' +import ProfilePage from '@/pages/Profile' import Layout from '@/components/Layout' // Create a client @@ -55,8 +58,12 @@ function App() { } /> } /> } /> + } /> } /> } /> + } /> + } /> + } /> diff --git a/frontend/src/api/iam.ts b/frontend/src/api/iam.ts new file mode 100644 index 0000000..7187d46 --- /dev/null +++ b/frontend/src/api/iam.ts @@ -0,0 +1,151 @@ +import apiClient from './client' + +export interface User { + id: string + username: string + email: string + full_name: string + is_active: boolean + is_system: boolean + created_at: string + updated_at: string + last_login_at: string | null + roles?: string[] + permissions?: string[] + groups?: string[] +} + +export interface Group { + id: string + name: string + description?: string + is_system: boolean + user_count: number + role_count: number + created_at: string + updated_at: string + users?: string[] + roles?: string[] +} + +export interface CreateGroupRequest { + name: string + description?: string +} + +export interface UpdateGroupRequest { + name?: string + description?: string +} + +export interface AddUserToGroupRequest { + user_id: string +} + +export interface CreateUserRequest { + username: string + email: string + password: string + full_name?: string +} + +export interface UpdateUserRequest { + email?: string + full_name?: string + is_active?: boolean +} + +export const iamApi = { + listUsers: async (): Promise => { + const response = await apiClient.get<{ users: User[] }>('/iam/users') + return response.data.users || [] + }, + + getUser: async (id: string): Promise => { + const response = await apiClient.get<{ + id: string + username: string + email: string + full_name: string + is_active: boolean + is_system: boolean + roles: string[] + permissions: string[] + groups: string[] + created_at: string + updated_at: string + last_login_at: string | null + }>(`/iam/users/${id}`) + return response.data + }, + + createUser: async (data: CreateUserRequest): Promise<{ id: string; username: string }> => { + const response = await apiClient.post<{ id: string; username: string }>('/iam/users', data) + return response.data + }, + + updateUser: async (id: string, data: UpdateUserRequest): Promise => { + await apiClient.put(`/iam/users/${id}`, data) + }, + + deleteUser: async (id: string): Promise => { + await apiClient.delete(`/iam/users/${id}`) + }, + + // Groups API + listGroups: async (): Promise => { + const response = await apiClient.get<{ groups: Group[] }>('/iam/groups') + return response.data.groups || [] + }, + + getGroup: async (id: string): Promise => { + const response = await apiClient.get(`/iam/groups/${id}`) + return response.data + }, + + createGroup: async (data: CreateGroupRequest): Promise<{ id: string; name: string }> => { + const response = await apiClient.post<{ id: string; name: string }>('/iam/groups', data) + return response.data + }, + + updateGroup: async (id: string, data: UpdateGroupRequest): Promise => { + await apiClient.put(`/iam/groups/${id}`, data) + }, + + deleteGroup: async (id: string): Promise => { + await apiClient.delete(`/iam/groups/${id}`) + }, + + addUserToGroup: async (groupId: string, userId: string): Promise => { + await apiClient.post(`/iam/groups/${groupId}/users`, { user_id: userId }) + }, + + removeUserFromGroup: async (groupId: string, userId: string): Promise => { + await apiClient.delete(`/iam/groups/${groupId}/users/${userId}`) + }, + + // User role assignment + assignRoleToUser: async (userId: string, roleName: string): Promise => { + await apiClient.post(`/iam/users/${userId}/roles`, { role_name: roleName }) + }, + + removeRoleFromUser: async (userId: string, roleName: string): Promise => { + await apiClient.delete(`/iam/users/${userId}/roles?role_name=${encodeURIComponent(roleName)}`) + }, + + // User group assignment + assignGroupToUser: async (userId: string, groupName: string): Promise => { + await apiClient.post(`/iam/users/${userId}/groups`, { group_name: groupName }) + }, + + removeGroupFromUser: async (userId: string, groupName: string): Promise => { + await apiClient.delete(`/iam/users/${userId}/groups?group_name=${encodeURIComponent(groupName)}`) + }, + + // List all available roles + listRoles: async (): Promise> => { + const response = await apiClient.get<{ roles: Array<{ name: string; description?: string; is_system: boolean }> }>('/iam/roles') + return response.data.roles + }, +} + diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index e5285c1..0f5710d 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -10,7 +10,8 @@ import { Settings, Bell, Server, - Users + Users, + Archive } from 'lucide-react' import { useState, useEffect } from 'react' @@ -44,14 +45,15 @@ export default function Layout() { { name: 'Dashboard', href: '/', icon: LayoutDashboard }, { name: 'Storage', href: '/storage', icon: HardDrive }, { name: 'Tape Libraries', href: '/tape', icon: Database }, - { name: 'iSCSI Targets', href: '/iscsi', icon: Network }, + { name: 'iSCSI Management', href: '/iscsi', icon: Network }, + { name: 'Backup Management', href: '/backup', icon: Archive }, { name: 'Tasks', href: '/tasks', icon: Settings }, { name: 'Alerts', href: '/alerts', icon: Bell }, { name: 'System', href: '/system', icon: Server }, ] if (user?.roles.includes('admin')) { - navigation.push({ name: 'IAM', href: '/iam', icon: Users }) + navigation.push({ name: 'User Management', href: '/iam', icon: Users }) } const isActive = (href: string) => { @@ -62,7 +64,7 @@ export default function Layout() { } return ( -
+
{/* Mobile backdrop overlay */} {sidebarOpen && (
-
+

{user?.username}

{user?.roles.join(', ').toUpperCase()}

-
+
{/* Page content */} -
+
diff --git a/frontend/src/index.css b/frontend/src/index.css index 2a75742..b08b0ae 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -69,6 +69,7 @@ .custom-scrollbar::-webkit-scrollbar-track { background: #111a22; + border-radius: 4px; } .custom-scrollbar::-webkit-scrollbar-thumb { @@ -80,6 +81,24 @@ background: #476685; } +.custom-scrollbar { + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + scroll-behavior: smooth; +} + +/* Ensure mouse wheel scrolling works */ +.custom-scrollbar, +.custom-scrollbar * { + touch-action: pan-y; +} + +/* Firefox scrollbar */ +.custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: #324d67 #111a22; +} + /* Electric glow animation for buttons */ @keyframes electric-glow { 0%, 100% { diff --git a/frontend/src/pages/BackupManagement.tsx b/frontend/src/pages/BackupManagement.tsx new file mode 100644 index 0000000..ebf3e68 --- /dev/null +++ b/frontend/src/pages/BackupManagement.tsx @@ -0,0 +1,315 @@ +import { useState } from 'react' + +export default function BackupManagement() { + const [activeTab, setActiveTab] = useState<'dashboard' | 'jobs' | 'clients' | 'storage' | 'restore'>('dashboard') + + return ( +
+ {/* Page Heading */} +
+
+
+
+

+ Calypso Backup Manager +

+ +
+

+ Manage backup jobs, configure clients, and monitor storage pools from a central director console. +

+
+
+ + +
+
+
+ + {/* Scrollable Content */} +
+
+ {/* Navigation Tabs */} +
+
+ + + + + +
+
+ + {/* Stats Dashboard */} +
+ {/* Service Status Card */} +
+
+ health_and_safety +
+
+

Director Status

+
+ check_circle +

Active

+
+

Uptime: 14d 2h 12m

+
+
+ + {/* Last Backup Card */} +
+
+ schedule +
+
+

Last Job

+
+

Success

+
+

DailyBackup • 2h 15m ago

+
+
+ + {/* Active Jobs Card */} +
+
+ pending_actions +
+
+

Active Jobs

+
+

3 Running

+
+
+
+
+
+
+ + {/* Storage Pool Card */} +
+
+ hard_drive +
+
+
+

Default Pool

+ 78% +
+
+

9.4 TB

+

/ 12 TB

+
+
+
+
+
+
+
+ + {/* Recent Jobs Section */} +
+
+

Recent Job History

+ +
+
+
+ + + + + + + + + + + + + + + + {/* Running Job */} + + + + + + + + + + + + {/* Successful Job */} + + + + + + + + + + + + {/* Failed Job */} + + + + + + + + + + + + {/* Another Success */} + + + + + + + + + + + + +
StatusJob IDJob NameClientTypeLevelDurationBytesActions
+ + + Running + + 10423WeeklyArchivefilesrv-02BackupFull00:45:12142 GB + +
+ + check + OK + + 10422DailyBackupweb-srv-01BackupIncr00:12:054.2 GB + +
+ + error + Error + + 10421DB_Snapshotdb-prod-01BackupDiff00:00:040 B + +
+ + check + OK + + 10420CatalogBackupbackup-srv-01BackupFull00:05:30850 MB + +
+
+ {/* Pagination/Footer */} +
+

Showing 4 of 128 jobs

+
+ + +
+
+
+
+ + {/* Footer Console Widget */} +
+
+
+ Console Log (tail -f) + + Connected + +
+

[14:22:01] bareos-dir: Connected to Storage at backup-srv-01:9103

+

[14:22:02] bareos-sd: Volume "Vol-0012" selected for appending

+

[14:22:05] bareos-fd: Client "filesrv-02" starting backup of /var/www/html

+

[14:23:10] warning: /var/www/html/cache/tmp locked by another process, skipping

+

[14:23:45] bareos-dir: JobId 10423: Sending Accurate information.

+
+
+
+
+
+ ) +} + diff --git a/frontend/src/pages/IAM.tsx b/frontend/src/pages/IAM.tsx new file mode 100644 index 0000000..0fda221 --- /dev/null +++ b/frontend/src/pages/IAM.tsx @@ -0,0 +1,1210 @@ +import { useState } from 'react' +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 { Button } from '@/components/ui/button' +import { iamApi, type User, type Group } from '@/api/iam' + +export default function IAM() { + const [activeTab, setActiveTab] = useState('users') + const [searchQuery, setSearchQuery] = useState('') + const [showCreateUserForm, setShowCreateUserForm] = useState(false) + const [showEditUserForm, setShowEditUserForm] = useState(false) + const [selectedUser, setSelectedUser] = useState(null) + const [openActionMenu, setOpenActionMenu] = useState(null) + const queryClient = useQueryClient() + + const { data: users, isLoading, error } = useQuery({ + queryKey: ['iam-users'], + queryFn: iamApi.listUsers, + refetchOnWindowFocus: true, + }) + + if (error) { + console.error('Failed to load users:', error) + } + + const filteredUsers = (users || []).filter((user: User) => + user.username.toLowerCase().includes(searchQuery.toLowerCase()) || + (user.full_name && user.full_name.toLowerCase().includes(searchQuery.toLowerCase())) || + (user.email && user.email.toLowerCase().includes(searchQuery.toLowerCase())) || + (user.roles && user.roles.some((r: string) => r.toLowerCase().includes(searchQuery.toLowerCase()))) || + (user.groups && user.groups.some((g: string) => g.toLowerCase().includes(searchQuery.toLowerCase()))) + ) + + const getRoleBadge = (roles: string[] | undefined) => { + if (!roles || roles.length === 0) { + return { bg: 'bg-slate-700', text: 'text-slate-300', border: 'border-slate-600', icon: Shield, Icon: Shield, label: 'No Role' } + } + + // Use first role for display + const role = roles[0] + const roleConfig: Record = { + 'admin': { bg: 'bg-purple-500/10', text: 'text-purple-400', border: 'border-purple-500/20', icon: Verified, label: 'Admin' }, + 'operator': { bg: 'bg-blue-500/10', text: 'text-blue-400', border: 'border-blue-500/20', icon: Wrench, label: 'Operator' }, + 'auditor': { bg: 'bg-yellow-500/10', text: 'text-yellow-500', border: 'border-yellow-500/20', icon: Eye, label: 'Auditor' }, + 'storage_admin': { bg: 'bg-teal-500/10', text: 'text-teal-500', border: 'border-teal-500/20', icon: HardDrive, label: 'Storage Admin' }, + 'service': { bg: 'bg-slate-700', text: 'text-slate-300', border: 'border-slate-600', icon: Shield, label: 'Service' } + } + const config = roleConfig[role.toLowerCase()] || { bg: 'bg-slate-700', text: 'text-slate-300', border: 'border-slate-600', icon: Shield, label: role } + const Icon = config.icon + return { ...config, Icon } + } + + const getAvatarBg = (username: string) => { + if (username.toLowerCase() === 'admin') { + return 'bg-gradient-to-br from-blue-500 to-indigo-600' + } + return 'bg-slate-700' + } + + const deleteUserMutation = useMutation({ + mutationFn: iamApi.deleteUser, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['iam-users'] }) + queryClient.refetchQueries({ queryKey: ['iam-users'] }) + }, + onError: (error: any) => { + console.error('Failed to delete user:', error) + const errorMessage = error.response?.data?.error || error.message || 'Failed to delete user' + alert(errorMessage) + }, + }) + + const handleDeleteUser = (userId: string) => { + deleteUserMutation.mutate(userId) + } + + const formatLastLogin = (lastLoginAt: string | null) => { + if (!lastLoginAt) return 'Never' + + const date = new Date(lastLoginAt) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + if (diffMins < 1) return 'Just now' + if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago` + if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago` + if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago` + + return date.toLocaleDateString() + } + + return ( +
+
+ {/* Page Header */} +
+
+ +

User & Access Management

+

+ Manage local accounts, define RBAC roles, and configure directory services (LDAP/AD) integration. +

+
+
+ +
+
+ + {/* Content Container */} +
+ {/* Tabs */} +
+ + + + +
+ + {/* Toolbar Area */} + {activeTab === 'users' && ( + <> +
+ {/* Search & Filter */} +
+
+ + 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" + /> +
+ +
+ {/* Primary Action */} + +
+ + {/* Users Table */} +
+
+ + + + + + + + + + + + + + {isLoading ? ( + + + + ) : error ? ( + + + + ) : filteredUsers.length > 0 ? ( + filteredUsers.map((user: User) => { + const roleBadge = getRoleBadge(user.roles) + const Icon = roleBadge.Icon + const avatarInitials = user.full_name + ? user.full_name.split(' ').map((n: string) => n[0]).join('').substring(0, 2).toUpperCase() + : user.username.substring(0, 2).toUpperCase() + + return ( + + + + + + + + + + ) + }) + ) : ( + + + + )} + +
StatusUsernameFull NameRoleGroupsLast LoginActions
+ Loading users... +
+ Error loading users: {error instanceof Error ? error.message : 'Unknown error'} +
+ {user.is_active ? ( +
+ + Active +
+ ) : ( +
+ + Locked +
+ )} +
+
+
+ {avatarInitials} +
+ {user.username} +
+
{user.full_name || '-'} + {user.roles && user.roles.length > 0 ? ( + + + {roleBadge.label} + + ) : ( + No role + )} + + {user.groups && user.groups.length > 0 ? user.groups.join(', ') : '-'} + {formatLastLogin(user.last_login_at)} +
+ + {openActionMenu === user.id && ( + <> +
setOpenActionMenu(null)} + /> +
+
+ + + {!user.is_system && ( + + )} +
+
+ + )} +
+
+ No users found +
+
+ {/* Pagination */} +
+ + Showing 1-{filteredUsers.length} of{' '} + {filteredUsers.length} users + +
+ + +
+
+
+ + )} + + {/* Create User Form Modal */} + {showCreateUserForm && ( + setShowCreateUserForm(false)} + onSuccess={async () => { + setShowCreateUserForm(false) + // Invalidate and refetch users list immediately + queryClient.invalidateQueries({ queryKey: ['iam-users'] }) + await queryClient.refetchQueries({ queryKey: ['iam-users'] }) + }} + /> + )} + + {/* Edit User Form Modal */} + {showEditUserForm && selectedUser && ( + { + setShowEditUserForm(false) + setSelectedUser(null) + }} + onSuccess={async () => { + setShowEditUserForm(false) + setSelectedUser(null) + queryClient.invalidateQueries({ queryKey: ['iam-users'] }) + await queryClient.refetchQueries({ queryKey: ['iam-users'] }) + }} + /> + )} + + {/* Groups Tab */} + {activeTab === 'groups' && } + + {activeTab !== 'users' && activeTab !== 'groups' && ( +
+ {activeTab === 'directory' && 'Directory Services tab coming soon'} + {activeTab === 'auth' && 'Authentication & SSO tab coming soon'} +
+ )} + + {/* Info Cards */} +
+ {/* Directory Status */} +
+
+
+
+ +
+

Directory Service

+
+ + Inactive + +
+

+ No LDAP or Active Directory server is currently connected. Local authentication is being used. +

+
+ +
+
+ + {/* MFA Status */} +
+
+
+
+ +
+

Security Policy

+
+ + Good + +
+
+
+ Multi-Factor Auth + Enforced +
+
+ Password Rotation + 90 Days +
+
+
+ +
+
+
+
+
+
+ ) +} + +// Create User Form Component +interface CreateUserFormProps { + onClose: () => void + onSuccess: () => void +} + +function CreateUserForm({ onClose, onSuccess }: CreateUserFormProps) { + const [username, setUsername] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [fullName, setFullName] = useState('') + + const createMutation = useMutation({ + mutationFn: iamApi.createUser, + onSuccess: async () => { + // Wait a bit to ensure backend has processed the request + await new Promise(resolve => setTimeout(resolve, 300)) + onSuccess() + }, + onError: (error: any) => { + console.error('Failed to create user:', error) + const errorMessage = error.response?.data?.error || error.message || 'Failed to create user' + alert(errorMessage) + }, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!username.trim() || !email.trim() || !password.trim()) { + alert('Username, email, and password are required') + return + } + + const userData = { + username: username.trim(), + email: email.trim(), + password: password, + full_name: fullName.trim() || undefined, + } + + console.log('Creating user:', { ...userData, password: '***' }) + createMutation.mutate(userData) + } + + return ( +
+
+ {/* Modal Header */} +
+
+

Create User

+

Create a new user account

+
+ +
+ + {/* Modal Content */} +
+
+ + setUsername(e.target.value)} + placeholder="johndoe" + 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 + /> +
+ +
+ + setEmail(e.target.value)} + placeholder="john.doe@example.com" + 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 + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Enter password" + 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 + minLength={8} + /> +

Minimum 8 characters

+
+ +
+ + setFullName(e.target.value)} + placeholder="John Doe" + 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" + /> +
+ + {/* Action Buttons */} +
+ + +
+
+
+
+ ) +} + +// Edit User Form Component +interface EditUserFormProps { + user: User + onClose: () => void + onSuccess: () => void +} + +function EditUserForm({ user, onClose, onSuccess }: EditUserFormProps) { + const [email, setEmail] = useState(user.email || '') + const [fullName, setFullName] = useState(user.full_name || '') + const [isActive, setIsActive] = useState(user.is_active) + const [userRoles, setUserRoles] = useState(user.roles || []) + const [userGroups, setUserGroups] = useState(user.groups || []) + const [selectedRole, setSelectedRole] = useState('') + const [selectedGroup, setSelectedGroup] = useState('') + const queryClient = useQueryClient() + + // Fetch available roles and groups + const { data: availableRoles = [] } = useQuery({ + queryKey: ['iam-roles'], + queryFn: iamApi.listRoles, + }) + + const { data: availableGroups = [] } = useQuery({ + queryKey: ['iam-groups'], + queryFn: iamApi.listGroups, + }) + + // Filter out already assigned roles/groups + const unassignedRoles = availableRoles.filter(r => !userRoles.includes(r.name)) + const unassignedGroups = availableGroups.filter(g => !userGroups.includes(g.name)) + + const updateMutation = useMutation({ + mutationFn: (data: { email?: string; full_name?: string; is_active?: boolean }) => + iamApi.updateUser(user.id, data), + onSuccess: async () => { + onSuccess() + queryClient.invalidateQueries({ queryKey: ['iam-users'] }) + await queryClient.refetchQueries({ queryKey: ['iam-users'] }) + queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] }) + await queryClient.refetchQueries({ queryKey: ['iam-user', user.id] }) + }, + onError: (error: any) => { + console.error('Failed to update user:', error) + const errorMessage = error.response?.data?.error || error.message || 'Failed to update user' + alert(errorMessage) + }, + }) + + const assignRoleMutation = useMutation({ + mutationFn: (roleName: string) => iamApi.assignRoleToUser(user.id, roleName), + onSuccess: async () => { + const updatedUser = await iamApi.getUser(user.id) + setUserRoles(updatedUser.roles || []) + queryClient.invalidateQueries({ queryKey: ['iam-users'] }) + await queryClient.refetchQueries({ queryKey: ['iam-users'] }) + queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] }) + await queryClient.refetchQueries({ queryKey: ['iam-user', user.id] }) + setSelectedRole('') + }, + onError: (error: any) => { + console.error('Failed to assign role:', error) + alert(error.response?.data?.error || error.message || 'Failed to assign role') + }, + }) + + const removeRoleMutation = useMutation({ + mutationFn: (roleName: string) => iamApi.removeRoleFromUser(user.id, roleName), + onSuccess: async () => { + const updatedUser = await iamApi.getUser(user.id) + setUserRoles(updatedUser.roles || []) + queryClient.invalidateQueries({ queryKey: ['iam-users'] }) + await queryClient.refetchQueries({ queryKey: ['iam-users'] }) + queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] }) + await queryClient.refetchQueries({ queryKey: ['iam-user', user.id] }) + }, + onError: (error: any) => { + console.error('Failed to remove role:', error) + alert(error.response?.data?.error || error.message || 'Failed to remove role') + }, + }) + + const assignGroupMutation = useMutation({ + mutationFn: (groupName: string) => iamApi.assignGroupToUser(user.id, groupName), + onSuccess: async () => { + const updatedUser = await iamApi.getUser(user.id) + setUserGroups(updatedUser.groups || []) + queryClient.invalidateQueries({ queryKey: ['iam-users'] }) + await queryClient.refetchQueries({ queryKey: ['iam-users'] }) + queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] }) + await queryClient.refetchQueries({ queryKey: ['iam-user', user.id] }) + setSelectedGroup('') + }, + onError: (error: any) => { + console.error('Failed to assign group:', error) + alert(error.response?.data?.error || error.message || 'Failed to assign group') + }, + }) + + const removeGroupMutation = useMutation({ + mutationFn: (groupName: string) => iamApi.removeGroupFromUser(user.id, groupName), + onSuccess: async () => { + const updatedUser = await iamApi.getUser(user.id) + setUserGroups(updatedUser.groups || []) + queryClient.invalidateQueries({ queryKey: ['iam-users'] }) + await queryClient.refetchQueries({ queryKey: ['iam-users'] }) + queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] }) + await queryClient.refetchQueries({ queryKey: ['iam-user', user.id] }) + }, + onError: (error: any) => { + console.error('Failed to remove group:', error) + alert(error.response?.data?.error || error.message || 'Failed to remove group') + }, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + updateMutation.mutate({ + email: email.trim(), + full_name: fullName.trim() || undefined, + is_active: isActive, + }) + } + + return ( +
+
+ {/* Modal Header */} +
+
+

Edit User

+

Edit user account: {user.username}

+
+ +
+ + {/* Modal Content */} +
+
+ + +

Username cannot be changed

+
+ +
+ + setEmail(e.target.value)} + placeholder="john.doe@example.com" + 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 + /> +
+ +
+ + setFullName(e.target.value)} + placeholder="John Doe" + 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" + /> +
+ + {/* Roles Section */} +
+ +
+ + +
+
+ {userRoles.length > 0 ? ( + userRoles.map((role) => ( +
+ {role} + +
+ )) + ) : ( +

No roles assigned

+ )} +
+
+ + {/* Groups Section */} +
+ +
+ + +
+
+ {userGroups.length > 0 ? ( + userGroups.map((group) => ( +
+ {group} + +
+ )) + ) : ( +

No groups assigned

+ )} +
+
+ +
+ +

+ {isActive ? 'User can log in and access the system' : 'User account is disabled'} +

+
+ + {/* Action Buttons */} +
+ + +
+
+
+
+ ) +} + +// Groups Tab Component +function GroupsTab() { + const queryClient = useQueryClient() + const [searchQuery, setSearchQuery] = useState('') + const [showCreateForm, setShowCreateForm] = useState(false) + + const { data: groups, isLoading } = useQuery({ + queryKey: ['iam-groups'], + queryFn: iamApi.listGroups, + }) + + const filteredGroups = groups?.filter(group => + group.name.toLowerCase().includes(searchQuery.toLowerCase()) || + (group.description && group.description.toLowerCase().includes(searchQuery.toLowerCase())) + ) || [] + + return ( + <> + {/* Toolbar */} +
+
+
+ + 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" + /> +
+ +
+ +
+ + {/* Groups Table */} +
+
+ + + + + + + + + + + + + {isLoading ? ( + + + + ) : filteredGroups.length > 0 ? ( + filteredGroups.map((group) => ( + + + + + + + + + )) + ) : ( + + + + )} + +
NameDescriptionUsersRolesTypeActions
+ Loading groups... +
+ {group.name} + + {group.description || '-'} + + {group.user_count} + + {group.role_count} + + {group.is_system ? ( + + + System + + ) : ( + Custom + )} + + +
+ No groups found +
+
+ {/* Pagination */} +
+ + Showing 1-{filteredGroups.length} of{' '} + {filteredGroups.length} groups + +
+ + +
+
+
+ + {/* Create Group Form Modal */} + {showCreateForm && ( + setShowCreateForm(false)} + onSuccess={() => { + setShowCreateForm(false) + queryClient.invalidateQueries({ queryKey: ['iam-groups'] }) + }} + /> + )} + + ) +} + +interface CreateGroupFormProps { + onClose: () => void + onSuccess: () => void +} + +function CreateGroupForm({ onClose, onSuccess }: CreateGroupFormProps) { + const [name, setName] = useState('') + const [description, setDescription] = useState('') + + const createMutation = useMutation({ + mutationFn: iamApi.createGroup, + onSuccess: () => { + onSuccess() + }, + onError: (error: any) => { + console.error('Failed to create group:', error) + const errorMessage = error.response?.data?.error || error.message || 'Failed to create group' + alert(errorMessage) + }, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!name.trim()) { + alert('Name is required') + return + } + + const groupData = { + name: name.trim(), + description: description.trim() || '', + } + + console.log('Creating group:', groupData) + createMutation.mutate(groupData) + } + + return ( +
+
+ {/* Modal Header */} +
+
+

Create Group

+

Create a new user group

+
+ +
+ + {/* Modal Content */} +
+
+ + setName(e.target.value)} + placeholder="operators" + 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 + /> +
+ +
+ +