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()} - + {/* Main content */} - + {/* Top bar with burger menu button */} - + setSidebarOpen(true)} className="text-text-secondary hover:text-white transition-colors" @@ -166,7 +171,7 @@ export default function Layout() { {/* 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. + + + + + terminal + Console + + + refresh + Restart Director + + + + + + {/* Scrollable Content */} + + + {/* Navigation Tabs */} + + + setActiveTab('dashboard')} + className={`flex items-center gap-2 border-b-[3px] pb-3 pt-2 transition-colors ${ + activeTab === 'dashboard' + ? 'border-primary text-white' + : 'border-transparent text-text-secondary hover:text-white' + }`} + > + dashboard + Dashboard + + setActiveTab('jobs')} + className={`flex items-center gap-2 border-b-[3px] pb-3 pt-2 transition-colors ${ + activeTab === 'jobs' + ? 'border-primary text-white' + : 'border-transparent text-text-secondary hover:text-white' + }`} + > + task + Jobs + + setActiveTab('clients')} + className={`flex items-center gap-2 border-b-[3px] pb-3 pt-2 transition-colors ${ + activeTab === 'clients' + ? 'border-primary text-white' + : 'border-transparent text-text-secondary hover:text-white' + }`} + > + devices + Clients + + setActiveTab('storage')} + className={`flex items-center gap-2 border-b-[3px] pb-3 pt-2 transition-colors ${ + activeTab === 'storage' + ? 'border-primary text-white' + : 'border-transparent text-text-secondary hover:text-white' + }`} + > + storage + Storage + + setActiveTab('restore')} + className={`flex items-center gap-2 border-b-[3px] pb-3 pt-2 transition-colors ${ + activeTab === 'restore' + ? 'border-primary text-white' + : 'border-transparent text-text-secondary hover:text-white' + }`} + > + history + Restore + + + + + {/* 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 + + View All History + + + + + + + + Status + Job ID + Job Name + Client + Type + Level + Duration + Bytes + Actions + + + + {/* Running Job */} + + + + + Running + + + 10423 + WeeklyArchive + filesrv-02 + Backup + Full + 00:45:12 + 142 GB + + + cancel + + + + {/* Successful Job */} + + + + check + OK + + + 10422 + DailyBackup + web-srv-01 + Backup + Incr + 00:12:05 + 4.2 GB + + + more_vert + + + + {/* Failed Job */} + + + + error + Error + + + 10421 + DB_Snapshot + db-prod-01 + Backup + Diff + 00:00:04 + 0 B + + + replay + + + + {/* Another Success */} + + + + check + OK + + + 10420 + CatalogBackup + backup-srv-01 + Backup + Full + 00:05:30 + 850 MB + + + more_vert + + + + + + + {/* Pagination/Footer */} + + Showing 4 of 128 jobs + + + chevron_left + + + chevron_right + + + + + + + {/* 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 */} + + + + System + + Access Control + + User & Access Management + + Manage local accounts, define RBAC roles, and configure directory services (LDAP/AD) integration. + + + + + + Audit Log + + + + + {/* Content Container */} + + {/* Tabs */} + + setActiveTab('users')} + className={`flex items-center gap-2 pb-3 border-b-[3px] transition-colors ${ + activeTab === 'users' + ? 'border-primary text-white' + : 'border-transparent text-text-secondary hover:text-white hover:border-slate-600' + }`} + > + + Local Users + + setActiveTab('groups')} + className={`flex items-center gap-2 pb-3 border-b-[3px] transition-colors ${ + activeTab === 'groups' + ? 'border-primary text-white' + : 'border-transparent text-text-secondary hover:text-white hover:border-slate-600' + }`} + > + + Groups + + setActiveTab('directory')} + className={`flex items-center gap-2 pb-3 border-b-[3px] transition-colors ${ + activeTab === 'directory' + ? 'border-primary text-white' + : 'border-transparent text-text-secondary hover:text-white hover:border-slate-600' + }`} + > + + Directory Services + + setActiveTab('auth')} + className={`flex items-center gap-2 pb-3 border-b-[3px] transition-colors ${ + activeTab === 'auth' + ? 'border-primary text-white' + : 'border-transparent text-text-secondary hover:text-white hover:border-slate-600' + }`} + > + + Authentication & SSO + + + + {/* 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" + /> + + + + Filter + + + {/* Primary Action */} + setShowCreateUserForm(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" + > + + Create User + + + + {/* Users Table */} + + + + + + Status + Username + Full Name + Role + Groups + Last Login + Actions + + + + {isLoading ? ( + + + Loading users... + + + ) : error ? ( + + + Error loading users: {error instanceof Error ? error.message : 'Unknown 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 ( + + + {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)} + + + { + e.stopPropagation() + setOpenActionMenu(openActionMenu === user.id ? null : user.id) + }} + className="p-2 text-text-secondary hover:text-white hover:bg-border-dark rounded-lg transition-colors" + > + + + {openActionMenu === user.id && ( + <> + setOpenActionMenu(null)} + /> + + + { + e.stopPropagation() + setSelectedUser(user) + setShowEditUserForm(true) + setOpenActionMenu(null) + }} + className="w-full px-4 py-2 text-left text-sm text-white hover:bg-[#233648] flex items-center gap-2 transition-colors" + > + + Edit User + + { + e.stopPropagation() + window.location.href = `/profile/${user.id}` + }} + className="w-full px-4 py-2 text-left text-sm text-white hover:bg-[#233648] flex items-center gap-2 transition-colors" + > + + View Profile + + {!user.is_system && ( + { + e.stopPropagation() + if (confirm(`Are you sure you want to delete user "${user.username}"? This action cannot be undone.`)) { + handleDeleteUser(user.id) + } + setOpenActionMenu(null) + }} + 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" + > + + Delete User + + )} + + + > + )} + + + + ) + }) + ) : ( + + + 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. + + + + Configure Directory + + + + + + {/* MFA Status */} + + + + + + + Security Policy + + + Good + + + + + Multi-Factor Auth + Enforced + + + Password Rotation + 90 Days + + + + + Manage Policies + + + + + + + + + ) +} + +// 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 */} + + + + Username * + + 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 + /> + + + + + Email * + + 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 + /> + + + + + Password * + + 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 + + + + + Full Name (Optional) + + 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 */} + + + Cancel + + + {createMutation.isPending ? 'Creating...' : 'Create User'} + + + + + + ) +} + +// 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 + + + Username cannot be changed + + + + + Email * + + 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 + /> + + + + + Full Name (Optional) + + 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 */} + + + Roles + + + setSelectedRole(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" + > + Select a role... + {unassignedRoles.map((role) => ( + + {role.name} {role.description ? `- ${role.description}` : ''} + + ))} + + { + if (selectedRole) { + assignRoleMutation.mutate(selectedRole) + } + }} + disabled={!selectedRole || assignRoleMutation.isPending} + className="px-4 bg-primary hover:bg-blue-600" + > + + + + + {userRoles.length > 0 ? ( + userRoles.map((role) => ( + + {role} + removeRoleMutation.mutate(role)} + disabled={removeRoleMutation.isPending} + className="text-red-400 hover:text-red-300 transition-colors" + > + + + + )) + ) : ( + No roles assigned + )} + + + + {/* Groups Section */} + + + Groups + + + setSelectedGroup(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" + > + Select a group... + {unassignedGroups.map((group) => ( + + {group.name} {group.description ? `- ${group.description}` : ''} + + ))} + + { + if (selectedGroup) { + assignGroupMutation.mutate(selectedGroup) + } + }} + disabled={!selectedGroup || assignGroupMutation.isPending} + className="px-4 bg-primary hover:bg-blue-600" + > + + + + + {userGroups.length > 0 ? ( + userGroups.map((group) => ( + + {group} + removeGroupMutation.mutate(group)} + disabled={removeGroupMutation.isPending} + className="text-red-400 hover:text-red-300 transition-colors" + > + + + + )) + ) : ( + No groups assigned + )} + + + + + + setIsActive(e.target.checked)} + className="w-4 h-4 rounded bg-[#0f161d] border-border-dark text-primary focus:ring-2 focus:ring-primary" + /> + Active Account + + + {isActive ? 'User can log in and access the system' : 'User account is disabled'} + + + + {/* Action Buttons */} + + + Cancel + + + {updateMutation.isPending ? 'Saving...' : 'Save Changes'} + + + + + + ) +} + +// 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" + /> + + + + Filter + + + 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" + > + + Create Group + + + + {/* Groups Table */} + + + + + + Name + Description + Users + Roles + Type + Actions + + + + {isLoading ? ( + + + Loading groups... + + + ) : filteredGroups.length > 0 ? ( + filteredGroups.map((group) => ( + + + {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 */} + + + + Group Name * + + 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 + /> + + + + + Description (Optional) + + setDescription(e.target.value)} + placeholder="Group description" + 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" + rows={4} + /> + + + {/* Action Buttons */} + + + Cancel + + + {createMutation.isPending ? 'Creating...' : 'Create Group'} + + + + + + ) +} + diff --git a/frontend/src/pages/ISCSITargets.tsx b/frontend/src/pages/ISCSITargets.tsx index 6e644f3..b3234eb 100644 --- a/frontend/src/pages/ISCSITargets.tsx +++ b/frontend/src/pages/ISCSITargets.tsx @@ -1,14 +1,16 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { scstAPI, type SCSTTarget } from '@/api/scst' -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { Plus, RefreshCw, Server, CheckCircle, XCircle } from 'lucide-react' +import { Plus, Settings, ChevronRight, Search, ChevronLeft, ChevronRight as ChevronRightIcon, CheckCircle, HardDrive, ArrowUpDown, ArrowUp, ChevronUp, ChevronDown, Copy, Network } from 'lucide-react' import { Link } from 'react-router-dom' export default function ISCSITargets() { const queryClient = useQueryClient() const [showCreateForm, setShowCreateForm] = useState(false) + const [activeTab, setActiveTab] = useState('targets') + const [expandedTarget, setExpandedTarget] = useState(null) + const [searchQuery, setSearchQuery] = useState('') const { data: targets, isLoading } = useQuery({ queryKey: ['scst-targets'], @@ -23,32 +25,215 @@ export default function ISCSITargets() { }, }) + const filteredTargets = targets?.filter(target => + target.iqn.toLowerCase().includes(searchQuery.toLowerCase()) || + (target.alias && target.alias.toLowerCase().includes(searchQuery.toLowerCase())) + ) || [] + return ( - - - - iSCSI Targets - - Manage SCST iSCSI targets, LUNs, and initiators - + + + {/* Breadcrumbs */} + + + Storage + + + iSCSI Management - - applyConfigMutation.mutate()} - disabled={applyConfigMutation.isPending} - > - - Apply Config - - setShowCreateForm(true)}> - - Create Target - + + {/* Page Heading */} + + + iSCSI Management + Manage targets, portals, and initiator access control lists. + + + applyConfigMutation.mutate()} + disabled={applyConfigMutation.isPending} + className="flex items-center gap-2 px-4 h-10 rounded-lg bg-card-dark border border-border-dark hover:bg-white/5 text-white text-sm font-semibold" + > + + Global Settings + + setShowCreateForm(true)} + className="flex items-center gap-2 px-4 h-10 rounded-lg bg-primary hover:bg-blue-600 text-white text-sm font-bold shadow-lg shadow-blue-900/20" + > + + Create Target + + + + + {/* Stats Cards */} + + + + Service Status + + + Running + Uptime: 14d 2h + + + + Port Binding + + + 3260 + Listening on 0.0.0.0 + + + + Active Sessions + + + + 12 + + 2 + + + Total throughput: 450 MB/s + + + + {/* Tabs & Filters */} + + {/* Tabs Header */} + + + setActiveTab('targets')} + className={`relative py-4 text-sm tracking-wide transition-colors ${ + activeTab === 'targets' + ? 'text-primary font-bold' + : 'text-text-secondary hover:text-white font-medium' + }`} + > + Targets + {activeTab === 'targets' && ( + + )} + + setActiveTab('portals')} + className={`relative py-4 text-sm tracking-wide transition-colors ${ + activeTab === 'portals' + ? 'text-primary font-bold' + : 'text-text-secondary hover:text-white font-medium' + }`} + > + Portals + {activeTab === 'portals' && ( + + )} + + setActiveTab('initiators')} + className={`relative py-4 text-sm tracking-wide transition-colors ${ + activeTab === 'initiators' + ? 'text-primary font-bold' + : 'text-text-secondary hover:text-white font-medium' + }`} + > + Initiators + {activeTab === 'initiators' && ( + + )} + + setActiveTab('extents')} + className={`relative py-4 text-sm tracking-wide transition-colors ${ + activeTab === 'extents' + ? 'text-primary font-bold' + : 'text-text-secondary hover:text-white font-medium' + }`} + > + Extents + {activeTab === 'extents' && ( + + )} + + + + + {/* Toolbar */} + + + + setSearchQuery(e.target.value)} + className="w-full bg-[#0f161d] border border-border-dark rounded-lg pl-10 pr-4 py-2 text-sm text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all placeholder-text-secondary/50" + /> + + + + Filter: + + All Status + Online + Offline + + + + + + {/* Targets List */} + {activeTab === 'targets' && ( + + {isLoading ? ( + Loading targets... + ) : filteredTargets.length > 0 ? ( + <> + {filteredTargets.map((target, index) => ( + setExpandedTarget(expandedTarget === target.id ? null : target.id)} + isLast={index === filteredTargets.length - 1} + /> + ))} + > + ) : ( + + No targets found + + )} + + {/* Pagination */} + + + Showing 1-{filteredTargets.length} of {filteredTargets.length} targets + + + + + + + + + + + + )} + + {activeTab !== 'targets' && ( + + {activeTab.charAt(0).toUpperCase() + activeTab.slice(1)} tab coming soon + + )} - {/* Create Target Form */} + {/* Create Target Form Modal */} {showCreateForm && ( setShowCreateForm(false)} @@ -58,74 +243,130 @@ export default function ISCSITargets() { }} /> )} - - {/* Targets List */} - {isLoading ? ( - Loading targets... - ) : targets && targets.length > 0 ? ( - - {targets.map((target) => ( - - ))} - - ) : ( - - - - No iSCSI Targets - - Create your first iSCSI target to start exporting storage - - setShowCreateForm(true)}> - - Create Target - - - - )} ) } -interface TargetCardProps { +interface TargetRowProps { target: SCSTTarget + isExpanded: boolean + onToggle: () => void + isLast?: boolean } -function TargetCard({ target }: TargetCardProps) { +function TargetRow({ target, isExpanded, onToggle }: TargetRowProps) { + const statusColor = target.is_active + ? 'bg-green-500/20 text-green-400 border-green-500/20' + : 'bg-red-500/20 text-red-400 border-red-500/20' + const statusText = target.is_active ? 'Online' : 'Offline' + return ( - - - - - {target.iqn} - {target.is_active ? ( - - ) : ( - - )} + + {/* Main Row */} + + + + + + + {target.alias || target.iqn.split(':').pop()} + + {statusText} + - {target.alias && ( - {target.alias} - )} - - - - - Status: - - {target.is_active ? 'Active' : 'Inactive'} - - - - Created: - - {new Date(target.created_at).toLocaleDateString()} - + + {target.iqn} + { + e.stopPropagation() + navigator.clipboard.writeText(target.iqn) + }} + > + + + + + + + LUNs + + + 0 - - - + + Auth + None + + + { + e.stopPropagation() + onToggle() + }} + > + {isExpanded ? : } + + + + {/* Expanded Detail Panel */} + {isExpanded && ( + + + {/* Left: LUNs */} + + + Attached LUNs + + Add LUN + + + + No LUNs attached + + + + + {/* Right: ACLs & Config */} + + + Access Control + Edit Policy + + + + + Auth Method + None + + + Initiator Group + None + + + + + + {/* Action Footer */} + + + View Details + + + + + )} + ) } @@ -163,15 +404,15 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) { } return ( - - - Create iSCSI Target - Create a new SCST iSCSI target - - - + + + + Create iSCSI Target + Create a new SCST iSCSI target + + - + IQN (iSCSI Qualified Name) * setIqn(e.target.value)} placeholder="iqn.2024-01.com.example:target1" - className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary" required /> @@ -189,7 +430,7 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) { - + Name * setName(e.target.value)} placeholder="My Target" - className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary" required /> - + Target Type * setTargetType(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary" required > Disk @@ -221,7 +462,7 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) { - + Description (Optional) setDescription(e.target.value)} placeholder="Target description" - className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary" rows={3} /> - + Cancel @@ -243,8 +484,7 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) { - - + + ) } - diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx new file mode 100644 index 0000000..438ec1b --- /dev/null +++ b/frontend/src/pages/Profile.tsx @@ -0,0 +1,376 @@ +import { useState, useEffect } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useAuthStore } from '@/store/auth' +import { iamApi, type User, type UpdateUserRequest } from '@/api/iam' +import { Button } from '@/components/ui/button' +import { ArrowLeft, Save, Mail, User as UserIcon, Shield, Calendar, Clock, Edit2, X } from 'lucide-react' + +export default function Profile() { + const { id } = useParams<{ id?: string }>() + const navigate = useNavigate() + const { user: currentUser } = useAuthStore() + const queryClient = useQueryClient() + const [isEditing, setIsEditing] = useState(false) + const [editForm, setEditForm] = useState({ + email: '', + full_name: '', + }) + + // Determine which user to show + const targetUserId = id || currentUser?.id + + // Check permission: only allow if viewing own profile or user is admin + const canView = !!currentUser && !!targetUserId && ( + targetUserId === currentUser.id || + currentUser.roles.includes('admin') + ) + + const { data: profileUser, isLoading } = useQuery({ + queryKey: ['iam-user', targetUserId], + queryFn: () => iamApi.getUser(targetUserId!), + enabled: canView, + }) + + const updateMutation = useMutation({ + mutationFn: (data: UpdateUserRequest) => iamApi.updateUser(targetUserId!, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['iam-user', targetUserId] }) + queryClient.invalidateQueries({ queryKey: ['iam-users'] }) + setIsEditing(false) + // If updating own profile, refresh auth store + if (targetUserId === currentUser?.id) { + queryClient.invalidateQueries({ queryKey: ['auth-me'] }) + } + }, + }) + + useEffect(() => { + if (profileUser) { + setEditForm({ + email: profileUser.email || '', + full_name: profileUser.full_name || '', + }) + } + }, [profileUser]) + + if (!canView) { + return ( + + + + Access Denied + + You don't have permission to view this profile. + + navigate(-1)} + className="mt-4" + > + + Go Back + + + + + ) + } + + if (isLoading) { + return ( + + + Loading profile... + + + ) + } + + if (!profileUser) { + return ( + + + + User not found + navigate(-1)} + className="mt-4" + > + + Go Back + + + + + ) + } + + const isOwnProfile = targetUserId === currentUser?.id + const canEdit = isOwnProfile || currentUser?.roles.includes('admin') + + const handleSave = () => { + updateMutation.mutate({ + email: editForm.email, + full_name: editForm.full_name, + }) + } + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString() + } + + const formatLastLogin = (lastLoginAt: string | null) => { + if (!lastLoginAt) return 'Never' + return formatDate(lastLoginAt) + } + + const getAvatarInitials = () => { + if (profileUser?.full_name) { + return profileUser.full_name + .split(' ') + .map((n: string) => n[0]) + .join('') + .substring(0, 2) + .toUpperCase() + } + return profileUser?.username?.substring(0, 2).toUpperCase() || 'U' + } + + return ( + + + {/* Header */} + + + navigate(-1)} + className="text-text-secondary hover:text-white" + > + + Back + + + User Profile + + {isOwnProfile ? 'Your profile information' : `Viewing profile for ${profileUser.username}`} + + + + {canEdit && ( + + {isEditing ? ( + <> + { + setIsEditing(false) + setEditForm({ + email: profileUser.email || '', + full_name: profileUser.full_name || '', + }) + }} + > + + Cancel + + + + {updateMutation.isPending ? 'Saving...' : 'Save Changes'} + + > + ) : ( + setIsEditing(true)}> + + Edit Profile + + )} + + )} + + + {/* Profile Card */} + + {/* Profile Header */} + + + + {getAvatarInitials()} + + + + {profileUser.full_name || profileUser.username} + + @{profileUser.username} + + + + {profileUser.is_active ? 'Active' : 'Inactive'} + + {profileUser.is_system && ( + + + System User + + )} + + + + + + {/* Profile Content */} + + + {/* Basic Information */} + + + + + Basic Information + + + + + Username + + + {profileUser.username} + + Username cannot be changed + + + + + Email Address + + {isEditing ? ( + setEditForm({ ...editForm, email: e.target.value })} + className="w-full bg-[#0f161d] border border-border-dark rounded-lg px-4 py-3 text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + placeholder="email@example.com" + /> + ) : ( + + + {profileUser.email || '-'} + + )} + + + + + Full Name + + {isEditing ? ( + setEditForm({ ...editForm, full_name: e.target.value })} + className="w-full bg-[#0f161d] border border-border-dark rounded-lg px-4 py-3 text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + placeholder="Full Name" + /> + ) : ( + + {profileUser.full_name || '-'} + + )} + + + + + + {/* Account Details */} + + + + + Account Details + + + + + Roles + + + {profileUser.roles && profileUser.roles.length > 0 ? ( + + {profileUser.roles.map((role) => ( + + + {role} + + ))} + + ) : ( + No roles assigned + )} + + + + + + Permissions + + + {profileUser.permissions && profileUser.permissions.length > 0 ? ( + + {profileUser.permissions.map((perm) => ( + + {perm} + + ))} + + ) : ( + No permissions assigned + )} + + + + + + Last Login + + + + {formatLastLogin(profileUser.last_login_at)} + + + + + + Account Created + + + + {formatDate(profileUser.created_at)} + + + + + + + + + + + ) +} + diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 7620187..b621929 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -46,6 +46,8 @@ export default { "card-dark": "#192633", "border-dark": "#233648", "text-secondary": "#92adc9", + "surface-dark": "#111a22", + "surface-highlight": "#1c2936", }, fontFamily: { display: ["Manrope", "sans-serif"],
{user?.username}
{user?.roles.join(', ').toUpperCase()}
+ Manage backup jobs, configure clients, and monitor storage pools from a central director console. +
Dashboard
Jobs
Clients
Storage
Restore
Director Status
Active
Uptime: 14d 2h 12m
Last Job
Success
DailyBackup • 2h 15m ago
Active Jobs
3 Running
Default Pool
9.4 TB
/ 12 TB
Showing 4 of 128 jobs
[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.
+ Manage local accounts, define RBAC roles, and configure directory services (LDAP/AD) integration. +
+ No LDAP or Active Directory server is currently connected. Local authentication is being used. +
Create a new user account
Minimum 8 characters
Edit user account: {user.username}
Username cannot be changed
No roles assigned
No groups assigned
+ {isActive ? 'User can log in and access the system' : 'User account is disabled'} +
Create a new user group
- Manage SCST iSCSI targets, LUNs, and initiators -
Manage targets, portals, and initiator access control lists.
Service Status
Running
Uptime: 14d 2h
Port Binding
3260
Listening on 0.0.0.0
Active Sessions
12
Total throughput: 450 MB/s
No targets found
+ Showing 1-{filteredTargets.length} of {filteredTargets.length} targets +
Loading targets...
- Create your first iSCSI target to start exporting storage -
Create a new SCST iSCSI target
@@ -189,7 +430,7 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) {
Access Denied
+ You don't have permission to view this profile. +
Loading profile...
User not found
+ {isOwnProfile ? 'Your profile information' : `Viewing profile for ${profileUser.username}`} +
@{profileUser.username}