working on some code

This commit is contained in:
Warp Agent
2025-12-27 16:58:19 +00:00
parent 8677820864
commit 97659421b5
16 changed files with 3318 additions and 151 deletions

Binary file not shown.

View File

@@ -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;

View File

@@ -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

View File

@@ -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()
}

View File

@@ -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"})
}

View File

@@ -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
}

View File

@@ -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(&currentName)
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)

View File

@@ -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() {
<Route path="tape/vtl/:id" element={<VTLDetailPage />} />
<Route path="iscsi" element={<ISCSITargetsPage />} />
<Route path="iscsi/:id" element={<ISCSITargetDetailPage />} />
<Route path="backup" element={<BackupManagementPage />} />
<Route path="alerts" element={<AlertsPage />} />
<Route path="system" element={<SystemPage />} />
<Route path="iam" element={<IAMPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="profile/:id" element={<ProfilePage />} />
</Route>
</Routes>
<Toaster />

151
frontend/src/api/iam.ts Normal file
View File

@@ -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<User[]> => {
const response = await apiClient.get<{ users: User[] }>('/iam/users')
return response.data.users || []
},
getUser: async (id: string): Promise<User> => {
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<void> => {
await apiClient.put(`/iam/users/${id}`, data)
},
deleteUser: async (id: string): Promise<void> => {
await apiClient.delete(`/iam/users/${id}`)
},
// Groups API
listGroups: async (): Promise<Group[]> => {
const response = await apiClient.get<{ groups: Group[] }>('/iam/groups')
return response.data.groups || []
},
getGroup: async (id: string): Promise<Group> => {
const response = await apiClient.get<Group>(`/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<void> => {
await apiClient.put(`/iam/groups/${id}`, data)
},
deleteGroup: async (id: string): Promise<void> => {
await apiClient.delete(`/iam/groups/${id}`)
},
addUserToGroup: async (groupId: string, userId: string): Promise<void> => {
await apiClient.post(`/iam/groups/${groupId}/users`, { user_id: userId })
},
removeUserFromGroup: async (groupId: string, userId: string): Promise<void> => {
await apiClient.delete(`/iam/groups/${groupId}/users/${userId}`)
},
// User role assignment
assignRoleToUser: async (userId: string, roleName: string): Promise<void> => {
await apiClient.post(`/iam/users/${userId}/roles`, { role_name: roleName })
},
removeRoleFromUser: async (userId: string, roleName: string): Promise<void> => {
await apiClient.delete(`/iam/users/${userId}/roles?role_name=${encodeURIComponent(roleName)}`)
},
// User group assignment
assignGroupToUser: async (userId: string, groupName: string): Promise<void> => {
await apiClient.post(`/iam/users/${userId}/groups`, { group_name: groupName })
},
removeGroupFromUser: async (userId: string, groupName: string): Promise<void> => {
await apiClient.delete(`/iam/users/${userId}/groups?group_name=${encodeURIComponent(groupName)}`)
},
// List all available roles
listRoles: async (): Promise<Array<{ name: string; description?: string; is_system: boolean }>> => {
const response = await apiClient.get<{ roles: Array<{ name: string; description?: string; is_system: boolean }> }>('/iam/roles')
return response.data.roles
},
}

View File

@@ -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 (
<div className="min-h-screen bg-background-dark">
<div className="h-screen bg-background-dark flex overflow-hidden">
{/* Mobile backdrop overlay */}
{sidebarOpen && (
<div
@@ -135,12 +137,15 @@ export default function Layout() {
{/* Footer */}
<div className="p-4 border-t border-border-dark bg-[#0d1419]">
<div className="mb-3 px-2">
<Link
to="/profile"
className="mb-3 px-2 py-2 rounded-lg hover:bg-card-dark transition-colors block"
>
<p className="text-sm font-semibold text-white mb-0.5">{user?.username}</p>
<p className="text-xs text-text-secondary font-mono">
{user?.roles.join(', ').toUpperCase()}
</p>
</div>
</Link>
<button
onClick={handleLogout}
className="w-full flex items-center gap-2 px-4 py-2.5 rounded-lg text-text-secondary hover:bg-card-dark hover:text-white transition-colors border border-border-dark"
@@ -153,9 +158,9 @@ export default function Layout() {
</div>
{/* Main content */}
<div className={`transition-all duration-300 ${sidebarOpen ? 'lg:ml-64' : 'ml-0'} bg-background-dark`}>
<div className={`transition-all duration-300 flex-1 flex flex-col overflow-hidden ${sidebarOpen ? 'lg:ml-64' : 'ml-0'} bg-background-dark`}>
{/* Top bar with burger menu button */}
<div className="sticky top-0 z-30 lg:hidden bg-background-dark border-b border-border-dark px-4 py-3">
<div className="flex-none lg:hidden bg-background-dark border-b border-border-dark px-4 py-3">
<button
onClick={() => setSidebarOpen(true)}
className="text-text-secondary hover:text-white transition-colors"
@@ -166,7 +171,7 @@ export default function Layout() {
</div>
{/* Page content */}
<main className="min-h-screen">
<main className="flex-1 overflow-hidden">
<Outlet />
</main>
</div>

View File

@@ -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% {

View File

@@ -0,0 +1,315 @@
import { useState } from 'react'
export default function BackupManagement() {
const [activeTab, setActiveTab] = useState<'dashboard' | 'jobs' | 'clients' | 'storage' | 'restore'>('dashboard')
return (
<div className="flex-1 flex flex-col h-full overflow-hidden relative bg-background-dark">
{/* Page Heading */}
<header className="flex-none px-6 py-5 border-b border-border-dark bg-background-dark/95 backdrop-blur z-10">
<div className="max-w-[1200px] mx-auto flex flex-wrap justify-between items-end gap-4">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<h1 className="text-white text-3xl md:text-4xl font-black leading-tight tracking-tight">
Calypso Backup Manager
</h1>
<span className="flex h-3 w-3 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]"></span>
</div>
<p className="text-text-secondary text-base font-normal max-w-2xl">
Manage backup jobs, configure clients, and monitor storage pools from a central director console.
</p>
</div>
<div className="flex gap-3">
<button className="flex items-center gap-2 cursor-pointer justify-center rounded-lg h-10 px-4 bg-[#1c2936] border border-border-dark text-white text-sm font-bold hover:bg-[#2a3c50] transition-colors">
<span className="material-symbols-outlined text-base">terminal</span>
<span>Console</span>
</button>
<button className="flex items-center gap-2 cursor-pointer justify-center rounded-lg h-10 px-4 bg-primary text-white text-sm font-bold shadow-lg shadow-primary/20 hover:bg-primary/90 transition-colors">
<span className="material-symbols-outlined text-base">refresh</span>
<span>Restart Director</span>
</button>
</div>
</div>
</header>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto bg-background-dark">
<div className="max-w-[1200px] mx-auto p-6 md:p-8 flex flex-col gap-6">
{/* Navigation Tabs */}
<div className="w-full overflow-x-auto">
<div className="flex border-b border-border-dark gap-8 min-w-max">
<button
onClick={() => 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'
}`}
>
<span className="material-symbols-outlined text-base">dashboard</span>
<p className="text-sm font-bold tracking-wide">Dashboard</p>
</button>
<button
onClick={() => 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'
}`}
>
<span className="material-symbols-outlined text-base">task</span>
<p className="text-sm font-bold tracking-wide">Jobs</p>
</button>
<button
onClick={() => 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'
}`}
>
<span className="material-symbols-outlined text-base">devices</span>
<p className="text-sm font-bold tracking-wide">Clients</p>
</button>
<button
onClick={() => 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'
}`}
>
<span className="material-symbols-outlined text-base">storage</span>
<p className="text-sm font-bold tracking-wide">Storage</p>
</button>
<button
onClick={() => 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'
}`}
>
<span className="material-symbols-outlined text-base">history</span>
<p className="text-sm font-bold tracking-wide">Restore</p>
</button>
</div>
</div>
{/* Stats Dashboard */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Service Status Card */}
<div className="flex flex-col justify-between rounded-lg p-5 bg-[#1c2936] border border-border-dark relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<span className="material-symbols-outlined text-6xl">health_and_safety</span>
</div>
<div className="flex flex-col gap-1 z-10">
<p className="text-text-secondary text-sm font-medium uppercase tracking-wider">Director Status</p>
<div className="flex items-center gap-2 mt-1">
<span className="material-symbols-outlined text-green-500">check_circle</span>
<p className="text-white text-2xl font-bold">Active</p>
</div>
<p className="text-green-500 text-xs font-mono mt-1">Uptime: 14d 2h 12m</p>
</div>
</div>
{/* Last Backup Card */}
<div className="flex flex-col justify-between rounded-lg p-5 bg-[#1c2936] border border-border-dark relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<span className="material-symbols-outlined text-6xl">schedule</span>
</div>
<div className="flex flex-col gap-1 z-10">
<p className="text-text-secondary text-sm font-medium uppercase tracking-wider">Last Job</p>
<div className="flex items-center gap-2 mt-1">
<p className="text-white text-2xl font-bold">Success</p>
</div>
<p className="text-text-secondary text-xs mt-1">DailyBackup 2h 15m ago</p>
</div>
</div>
{/* Active Jobs Card */}
<div className="flex flex-col justify-between rounded-lg p-5 bg-[#1c2936] border border-border-dark relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<span className="material-symbols-outlined text-6xl">pending_actions</span>
</div>
<div className="flex flex-col gap-1 z-10">
<p className="text-text-secondary text-sm font-medium uppercase tracking-wider">Active Jobs</p>
<div className="flex items-center gap-2 mt-1">
<p className="text-primary text-2xl font-bold">3 Running</p>
</div>
<div className="w-full bg-[#111a22] h-1.5 rounded-full mt-3 overflow-hidden">
<div className="bg-primary h-full rounded-full animate-pulse w-2/3"></div>
</div>
</div>
</div>
{/* Storage Pool Card */}
<div className="flex flex-col justify-between rounded-lg p-5 bg-[#1c2936] border border-border-dark relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<span className="material-symbols-outlined text-6xl">hard_drive</span>
</div>
<div className="flex flex-col gap-1 z-10 w-full">
<div className="flex justify-between items-center">
<p className="text-text-secondary text-sm font-medium uppercase tracking-wider">Default Pool</p>
<span className="text-white text-xs font-bold">78%</span>
</div>
<div className="flex items-end gap-1 mt-1">
<p className="text-white text-2xl font-bold">9.4 TB</p>
<p className="text-text-secondary text-sm mb-1">/ 12 TB</p>
</div>
<div className="w-full bg-[#111a22] h-2 rounded-full mt-2 overflow-hidden">
<div className="bg-gradient-to-r from-primary to-blue-400 h-full rounded-full" style={{ width: '78%' }}></div>
</div>
</div>
</div>
</div>
{/* Recent Jobs Section */}
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between px-1">
<h3 className="text-white text-lg font-bold">Recent Job History</h3>
<button className="text-primary text-sm font-bold hover:text-blue-300 transition-colors">
View All History
</button>
</div>
<div className="rounded-lg border border-border-dark bg-[#1c2936] overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-[#111a22] border-b border-border-dark text-text-secondary text-xs uppercase tracking-wider">
<th className="px-6 py-4 font-semibold">Status</th>
<th className="px-6 py-4 font-semibold">Job ID</th>
<th className="px-6 py-4 font-semibold">Job Name</th>
<th className="px-6 py-4 font-semibold">Client</th>
<th className="px-6 py-4 font-semibold">Type</th>
<th className="px-6 py-4 font-semibold">Level</th>
<th className="px-6 py-4 font-semibold">Duration</th>
<th className="px-6 py-4 font-semibold">Bytes</th>
<th className="px-6 py-4 font-semibold text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border-dark text-sm">
{/* Running Job */}
<tr className="hover:bg-[#111a22]/50 transition-colors">
<td className="px-6 py-4">
<span className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium bg-blue-500/10 text-blue-400 border border-blue-500/20">
<span className="block h-1.5 w-1.5 rounded-full bg-blue-400 animate-pulse"></span>
Running
</span>
</td>
<td className="px-6 py-4 text-text-secondary font-mono">10423</td>
<td className="px-6 py-4 text-white font-medium">WeeklyArchive</td>
<td className="px-6 py-4 text-text-secondary">filesrv-02</td>
<td className="px-6 py-4 text-text-secondary">Backup</td>
<td className="px-6 py-4 text-text-secondary">Full</td>
<td className="px-6 py-4 text-text-secondary font-mono">00:45:12</td>
<td className="px-6 py-4 text-text-secondary font-mono">142 GB</td>
<td className="px-6 py-4 text-right">
<button className="text-text-secondary hover:text-white p-1 rounded hover:bg-[#111a22] transition-colors">
<span className="material-symbols-outlined text-[20px]">cancel</span>
</button>
</td>
</tr>
{/* Successful Job */}
<tr className="hover:bg-[#111a22]/50 transition-colors">
<td className="px-6 py-4">
<span className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/20">
<span className="material-symbols-outlined text-[14px]">check</span>
OK
</span>
</td>
<td className="px-6 py-4 text-text-secondary font-mono">10422</td>
<td className="px-6 py-4 text-white font-medium">DailyBackup</td>
<td className="px-6 py-4 text-text-secondary">web-srv-01</td>
<td className="px-6 py-4 text-text-secondary">Backup</td>
<td className="px-6 py-4 text-text-secondary">Incr</td>
<td className="px-6 py-4 text-text-secondary font-mono">00:12:05</td>
<td className="px-6 py-4 text-text-secondary font-mono">4.2 GB</td>
<td className="px-6 py-4 text-right">
<button className="text-text-secondary hover:text-white p-1 rounded hover:bg-[#111a22] transition-colors">
<span className="material-symbols-outlined text-[20px]">more_vert</span>
</button>
</td>
</tr>
{/* Failed Job */}
<tr className="hover:bg-[#111a22]/50 transition-colors">
<td className="px-6 py-4">
<span className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium bg-red-500/10 text-red-400 border border-red-500/20">
<span className="material-symbols-outlined text-[14px]">error</span>
Error
</span>
</td>
<td className="px-6 py-4 text-text-secondary font-mono">10421</td>
<td className="px-6 py-4 text-white font-medium">DB_Snapshot</td>
<td className="px-6 py-4 text-text-secondary">db-prod-01</td>
<td className="px-6 py-4 text-text-secondary">Backup</td>
<td className="px-6 py-4 text-text-secondary">Diff</td>
<td className="px-6 py-4 text-text-secondary font-mono">00:00:04</td>
<td className="px-6 py-4 text-text-secondary font-mono">0 B</td>
<td className="px-6 py-4 text-right">
<button className="text-text-secondary hover:text-white p-1 rounded hover:bg-[#111a22] transition-colors">
<span className="material-symbols-outlined text-[20px]">replay</span>
</button>
</td>
</tr>
{/* Another Success */}
<tr className="hover:bg-[#111a22]/50 transition-colors">
<td className="px-6 py-4">
<span className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/20">
<span className="material-symbols-outlined text-[14px]">check</span>
OK
</span>
</td>
<td className="px-6 py-4 text-text-secondary font-mono">10420</td>
<td className="px-6 py-4 text-white font-medium">CatalogBackup</td>
<td className="px-6 py-4 text-text-secondary">backup-srv-01</td>
<td className="px-6 py-4 text-text-secondary">Backup</td>
<td className="px-6 py-4 text-text-secondary">Full</td>
<td className="px-6 py-4 text-text-secondary font-mono">00:05:30</td>
<td className="px-6 py-4 text-text-secondary font-mono">850 MB</td>
<td className="px-6 py-4 text-right">
<button className="text-text-secondary hover:text-white p-1 rounded hover:bg-[#111a22] transition-colors">
<span className="material-symbols-outlined text-[20px]">more_vert</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
{/* Pagination/Footer */}
<div className="bg-[#111a22] border-t border-border-dark px-6 py-3 flex items-center justify-between">
<p className="text-text-secondary text-xs">Showing 4 of 128 jobs</p>
<div className="flex gap-2">
<button className="p-1 rounded text-text-secondary hover:text-white disabled:opacity-50 hover:bg-[#1c2936]">
<span className="material-symbols-outlined text-base">chevron_left</span>
</button>
<button className="p-1 rounded text-text-secondary hover:text-white hover:bg-[#1c2936]">
<span className="material-symbols-outlined text-base">chevron_right</span>
</button>
</div>
</div>
</div>
</div>
{/* Footer Console Widget */}
<div className="mt-auto pt-8">
<div className="rounded-lg bg-[#0d131a] border border-border-dark p-4 font-mono text-xs text-text-secondary shadow-inner h-32 overflow-y-auto">
<div className="flex items-center justify-between mb-2 text-gray-500 border-b border-white/5 pb-1">
<span>Console Log (tail -f)</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span> Connected
</span>
</div>
<p className="text-blue-400">[14:22:01] bareos-dir: Connected to Storage at backup-srv-01:9103</p>
<p>[14:22:02] bareos-sd: Volume "Vol-0012" selected for appending</p>
<p>[14:22:05] bareos-fd: Client "filesrv-02" starting backup of /var/www/html</p>
<p className="text-yellow-500">[14:23:10] warning: /var/www/html/cache/tmp locked by another process, skipping</p>
<p>[14:23:45] bareos-dir: JobId 10423: Sending Accurate information.</p>
</div>
</div>
</div>
</div>
</div>
)
}

1210
frontend/src/pages/IAM.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const { data: targets, isLoading } = useQuery<SCSTTarget[]>({
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 (
<div className="space-y-6 min-h-screen bg-background-dark p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-white">iSCSI Targets</h1>
<p className="mt-2 text-sm text-text-secondary">
Manage SCST iSCSI targets, LUNs, and initiators
</p>
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-[1200px] mx-auto flex flex-col gap-6">
{/* Breadcrumbs */}
<div className="flex flex-wrap items-center gap-2">
<Link to="/storage" className="text-text-secondary text-sm font-medium hover:text-white transition-colors">
Storage
</Link>
<ChevronRight className="text-text-secondary" size={16} />
<span className="text-white text-sm font-medium">iSCSI Management</span>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => applyConfigMutation.mutate()}
disabled={applyConfigMutation.isPending}
>
<RefreshCw className={`h-4 w-4 mr-2 ${applyConfigMutation.isPending ? 'animate-spin' : ''}`} />
Apply Config
</Button>
<Button onClick={() => setShowCreateForm(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Target
</Button>
{/* Page Heading */}
<div className="flex flex-wrap justify-between items-end gap-4">
<div className="flex flex-col gap-2">
<h1 className="text-white text-3xl font-extrabold leading-tight tracking-tight">iSCSI Management</h1>
<p className="text-text-secondary text-base font-normal">Manage targets, portals, and initiator access control lists.</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
onClick={() => 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"
>
<Settings size={20} />
<span>Global Settings</span>
</Button>
<Button
onClick={() => 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"
>
<Plus size={20} />
<span>Create Target</span>
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="flex flex-col gap-1 rounded-xl p-5 bg-card-dark border border-border-dark shadow-sm">
<div className="flex items-center justify-between mb-2">
<p className="text-text-secondary text-sm font-medium uppercase tracking-wider">Service Status</p>
<CheckCircle className="text-green-500" size={24} />
</div>
<p className="text-white text-2xl font-bold">Running</p>
<p className="text-green-500 text-xs font-medium mt-1">Uptime: 14d 2h</p>
</div>
<div className="flex flex-col gap-1 rounded-xl p-5 bg-card-dark border border-border-dark shadow-sm">
<div className="flex items-center justify-between mb-2">
<p className="text-text-secondary text-sm font-medium uppercase tracking-wider">Port Binding</p>
<Network className="text-text-secondary" size={24} />
</div>
<p className="text-white text-2xl font-bold">3260</p>
<p className="text-text-secondary text-xs font-medium mt-1">Listening on 0.0.0.0</p>
</div>
<div className="flex flex-col gap-1 rounded-xl p-5 bg-card-dark border border-border-dark shadow-sm">
<div className="flex items-center justify-between mb-2">
<p className="text-text-secondary text-sm font-medium uppercase tracking-wider">Active Sessions</p>
<ArrowUpDown className="text-primary" size={24} />
</div>
<div className="flex items-baseline gap-2">
<p className="text-white text-2xl font-bold">12</p>
<span className="text-green-500 text-sm font-medium flex items-center">
<ArrowUp size={16} /> 2
</span>
</div>
<p className="text-text-secondary text-xs font-medium mt-1">Total throughput: 450 MB/s</p>
</div>
</div>
{/* Tabs & Filters */}
<div className="flex flex-col bg-card-dark border border-border-dark rounded-xl overflow-hidden shadow-sm">
{/* Tabs Header */}
<div className="border-b border-border-dark px-6">
<div className="flex gap-8">
<button
onClick={() => 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' && (
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-primary rounded-t-full"></div>
)}
</button>
<button
onClick={() => 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' && (
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-primary rounded-t-full"></div>
)}
</button>
<button
onClick={() => 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' && (
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-primary rounded-t-full"></div>
)}
</button>
<button
onClick={() => 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' && (
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-primary rounded-t-full"></div>
)}
</button>
</div>
</div>
{/* Toolbar */}
<div className="p-4 flex items-center justify-between gap-4 border-b border-border-dark/50 bg-[#141d26]">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" size={20} />
<input
type="text"
placeholder="Search targets by alias or IQN..."
value={searchQuery}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md bg-[#0f161d] border border-border-dark">
<span className="text-xs text-text-secondary font-medium">Filter:</span>
<select className="bg-transparent text-xs text-white font-medium focus:outline-none cursor-pointer">
<option>All Status</option>
<option>Online</option>
<option>Offline</option>
</select>
</div>
</div>
</div>
{/* Targets List */}
{activeTab === 'targets' && (
<div className="flex flex-col">
{isLoading ? (
<div className="p-8 text-center text-text-secondary">Loading targets...</div>
) : filteredTargets.length > 0 ? (
<>
{filteredTargets.map((target, index) => (
<TargetRow
key={target.id}
target={target}
isExpanded={expandedTarget === target.id}
onToggle={() => setExpandedTarget(expandedTarget === target.id ? null : target.id)}
isLast={index === filteredTargets.length - 1}
/>
))}
</>
) : (
<div className="p-12 text-center">
<p className="text-text-secondary">No targets found</p>
</div>
)}
{/* Pagination */}
<div className="p-4 bg-[#141d26] border-t border-border-dark flex items-center justify-between">
<p className="text-xs text-text-secondary">
Showing 1-{filteredTargets.length} of {filteredTargets.length} targets
</p>
<div className="flex gap-2">
<button className="p-1 rounded text-text-secondary hover:text-white hover:bg-white/10 disabled:opacity-50">
<ChevronLeft size={20} />
</button>
<button className="p-1 rounded text-text-secondary hover:text-white hover:bg-white/10">
<ChevronRightIcon size={20} />
</button>
</div>
</div>
</div>
)}
{activeTab !== 'targets' && (
<div className="p-8 text-center text-text-secondary">
{activeTab.charAt(0).toUpperCase() + activeTab.slice(1)} tab coming soon
</div>
)}
</div>
</div>
{/* Create Target Form */}
{/* Create Target Form Modal */}
{showCreateForm && (
<CreateTargetForm
onClose={() => setShowCreateForm(false)}
@@ -58,74 +243,130 @@ export default function ISCSITargets() {
}}
/>
)}
{/* Targets List */}
{isLoading ? (
<p className="text-sm text-text-secondary">Loading targets...</p>
) : targets && targets.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{targets.map((target) => (
<TargetCard key={target.id} target={target} />
))}
</div>
) : (
<Card>
<CardContent className="p-12 text-center">
<Server className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-white mb-2">No iSCSI Targets</h3>
<p className="text-sm text-text-secondary mb-4">
Create your first iSCSI target to start exporting storage
</p>
<Button onClick={() => setShowCreateForm(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Target
</Button>
</CardContent>
</Card>
)}
</div>
)
}
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 (
<Link to={`/iscsi/${target.id}`}>
<Card className="hover:border-blue-500 transition-colors">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-mono text-sm">{target.iqn}</CardTitle>
{target.is_active ? (
<CheckCircle className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-gray-400" />
)}
<div className={`group border-b border-border-dark ${isExpanded ? 'bg-white/[0.02]' : 'bg-transparent'}`}>
{/* Main Row */}
<div
className={`flex items-center p-4 gap-4 hover:bg-white/5 transition-colors cursor-pointer border-l-4 ${
isExpanded ? 'border-primary' : 'border-transparent hover:border-border-dark'
}`}
onClick={onToggle}
>
<div className={`p-2 rounded-md ${isExpanded ? 'bg-primary/10 text-primary' : 'bg-border-dark/50 text-text-secondary'}`}>
<Network size={24} />
</div>
<div className="flex-1 min-w-0 flex flex-col gap-1">
<div className="flex items-center gap-3">
<span className="text-white font-bold text-sm">{target.alias || target.iqn.split(':').pop()}</span>
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide border ${statusColor}`}>
{statusText}
</span>
</div>
{target.alias && (
<CardDescription>{target.alias}</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-text-secondary">Status:</span>
<span className={target.is_active ? 'text-green-400' : 'text-text-secondary'}>
{target.is_active ? 'Active' : 'Inactive'}
</span>
</div>
<div className="flex justify-between">
<span className="text-text-secondary">Created:</span>
<span className="text-white">
{new Date(target.created_at).toLocaleDateString()}
</span>
<div className="flex items-center gap-2 group/iqn">
<span className="text-text-secondary font-mono text-xs truncate">{target.iqn}</span>
<button
className="opacity-0 group-hover/iqn:opacity-100 text-text-secondary hover:text-white transition-opacity"
title="Copy IQN"
onClick={(e) => {
e.stopPropagation()
navigator.clipboard.writeText(target.iqn)
}}
>
<Copy size={14} />
</button>
</div>
</div>
<div className="hidden md:flex items-center gap-8 mr-4">
<div className="flex flex-col items-end">
<span className="text-[10px] uppercase text-text-secondary font-bold tracking-wider">LUNs</span>
<div className="flex items-center gap-1">
<HardDrive className="text-text-secondary" size={16} />
<span className="text-white text-sm font-bold">0</span>
</div>
</div>
</CardContent>
</Card>
</Link>
<div className="flex flex-col items-end">
<span className="text-[10px] uppercase text-text-secondary font-bold tracking-wider">Auth</span>
<span className="text-white text-sm font-medium">None</span>
</div>
</div>
<button
className="p-2 hover:bg-white/10 rounded-full text-text-secondary hover:text-white transition-colors"
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
>
{isExpanded ? <ChevronUp size={24} /> : <ChevronDown size={24} />}
</button>
</div>
{/* Expanded Detail Panel */}
{isExpanded && (
<div className="px-4 pb-4 pt-0">
<div className="bg-[#0f161d] border border-border-dark rounded-lg p-4 grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left: LUNs */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold text-text-secondary uppercase tracking-wider">Attached LUNs</h4>
<button className="text-primary text-xs font-bold hover:underline">+ Add LUN</button>
</div>
<div className="flex flex-col gap-2">
<div className="p-3 rounded bg-card-dark border border-border-dark text-center text-text-secondary text-sm">
No LUNs attached
</div>
</div>
</div>
{/* Right: ACLs & Config */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold text-text-secondary uppercase tracking-wider">Access Control</h4>
<button className="text-primary text-xs font-bold hover:underline">Edit Policy</button>
</div>
<div className="flex flex-col gap-2 h-full">
<div className="p-3 rounded bg-card-dark border border-border-dark flex flex-col gap-2">
<div className="flex justify-between items-center pb-2 border-b border-border-dark/50">
<span className="text-text-secondary text-xs">Auth Method</span>
<span className="text-white text-xs font-bold">None</span>
</div>
<div className="flex justify-between items-center py-1">
<span className="text-text-secondary text-xs">Initiator Group</span>
<span className="text-primary text-xs font-bold cursor-pointer hover:underline">None</span>
</div>
</div>
</div>
</div>
{/* Action Footer */}
<div className="col-span-1 lg:col-span-2 flex justify-end gap-2 mt-2 pt-3 border-t border-border-dark/50">
<Link
to={`/iscsi/${target.id}`}
className="px-3 py-1.5 rounded text-xs font-bold bg-primary text-white hover:bg-blue-600 transition-colors"
>
View Details
</Link>
</div>
</div>
</div>
)}
</div>
)
}
@@ -163,15 +404,15 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) {
}
return (
<Card>
<CardHeader>
<CardTitle>Create iSCSI Target</CardTitle>
<CardDescription>Create a new SCST iSCSI target</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-card-dark border border-border-dark rounded-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-border-dark">
<h2 className="text-xl font-bold text-white">Create iSCSI Target</h2>
<p className="text-sm text-text-secondary mt-1">Create a new SCST iSCSI target</p>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label htmlFor="iqn" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="iqn" className="block text-sm font-medium text-white mb-1">
IQN (iSCSI Qualified Name) *
</label>
<input
@@ -180,7 +421,7 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) {
value={iqn}
onChange={(e) => 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
/>
<p className="mt-1 text-xs text-text-secondary">
@@ -189,7 +430,7 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) {
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="name" className="block text-sm font-medium text-white mb-1">
Name *
</label>
<input
@@ -198,20 +439,20 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) {
value={name}
onChange={(e) => 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
/>
</div>
<div>
<label htmlFor="targetType" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="targetType" className="block text-sm font-medium text-white mb-1">
Target Type *
</label>
<select
id="targetType"
value={targetType}
onChange={(e) => 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
>
<option value="disk">Disk</option>
@@ -221,7 +462,7 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) {
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="description" className="block text-sm font-medium text-white mb-1">
Description (Optional)
</label>
<textarea
@@ -229,12 +470,12 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) {
value={description}
onChange={(e) => 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}
/>
</div>
<div className="flex justify-end gap-2">
<div className="flex justify-end gap-2 pt-4 border-t border-border-dark">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
@@ -243,8 +484,7 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) {
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -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<User>({
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 (
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-[1200px] mx-auto">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-6 text-center">
<p className="text-red-400 font-semibold">Access Denied</p>
<p className="text-text-secondary text-sm mt-2">
You don't have permission to view this profile.
</p>
<Button
variant="outline"
onClick={() => navigate(-1)}
className="mt-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Go Back
</Button>
</div>
</div>
</div>
)
}
if (isLoading) {
return (
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-[1200px] mx-auto">
<p className="text-text-secondary">Loading profile...</p>
</div>
</div>
)
}
if (!profileUser) {
return (
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-[1200px] mx-auto">
<div className="bg-card-dark border border-border-dark rounded-lg p-6 text-center">
<p className="text-text-secondary">User not found</p>
<Button
variant="outline"
onClick={() => navigate(-1)}
className="mt-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Go Back
</Button>
</div>
</div>
</div>
)
}
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 (
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-[1200px] mx-auto flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(-1)}
className="text-text-secondary hover:text-white"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<div>
<h1 className="text-3xl font-black text-white leading-tight">User Profile</h1>
<p className="text-text-secondary text-sm mt-1">
{isOwnProfile ? 'Your profile information' : `Viewing profile for ${profileUser.username}`}
</p>
</div>
</div>
{canEdit && (
<div className="flex gap-2">
{isEditing ? (
<>
<Button
variant="outline"
onClick={() => {
setIsEditing(false)
setEditForm({
email: profileUser.email || '',
full_name: profileUser.full_name || '',
})
}}
>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button
onClick={handleSave}
disabled={updateMutation.isPending}
>
<Save className="h-4 w-4 mr-2" />
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</>
) : (
<Button onClick={() => setIsEditing(true)}>
<Edit2 className="h-4 w-4 mr-2" />
Edit Profile
</Button>
)}
</div>
)}
</div>
{/* Profile Card */}
<div className="bg-card-dark border border-border-dark rounded-xl overflow-hidden">
{/* Profile Header */}
<div className="bg-gradient-to-r from-primary/20 to-blue-600/20 p-8 border-b border-border-dark">
<div className="flex items-center gap-6">
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white text-3xl font-bold">
{getAvatarInitials()}
</div>
<div className="flex-1">
<h2 className="text-2xl font-bold text-white">
{profileUser.full_name || profileUser.username}
</h2>
<p className="text-text-secondary mt-1">@{profileUser.username}</p>
<div className="flex items-center gap-4 mt-3">
<div className={`inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-bold ${
profileUser.is_active
? 'bg-green-500/10 text-green-400 border border-green-500/20'
: 'bg-red-500/10 text-red-400 border border-red-500/20'
}`}>
<span className={`w-2 h-2 rounded-full ${profileUser.is_active ? 'bg-green-400' : 'bg-red-400'}`}></span>
{profileUser.is_active ? 'Active' : 'Inactive'}
</div>
{profileUser.is_system && (
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-bold bg-purple-500/10 text-purple-400 border border-purple-500/20">
<Shield size={12} />
System User
</div>
)}
</div>
</div>
</div>
</div>
{/* Profile Content */}
<div className="p-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Basic Information */}
<div className="space-y-6">
<div>
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
<UserIcon className="h-5 w-5 text-primary" />
Basic Information
</h3>
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-text-secondary uppercase tracking-wider mb-2">
Username
</label>
<div className="bg-[#0f161d] border border-border-dark rounded-lg px-4 py-3 text-white font-mono">
{profileUser.username}
</div>
<p className="text-xs text-text-secondary mt-1">Username cannot be changed</p>
</div>
<div>
<label className="block text-xs font-bold text-text-secondary uppercase tracking-wider mb-2">
Email Address
</label>
{isEditing ? (
<input
type="email"
value={editForm.email}
onChange={(e) => 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"
/>
) : (
<div className="bg-[#0f161d] border border-border-dark rounded-lg px-4 py-3 text-white flex items-center gap-2">
<Mail className="h-4 w-4 text-text-secondary" />
{profileUser.email || '-'}
</div>
)}
</div>
<div>
<label className="block text-xs font-bold text-text-secondary uppercase tracking-wider mb-2">
Full Name
</label>
{isEditing ? (
<input
type="text"
value={editForm.full_name}
onChange={(e) => 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"
/>
) : (
<div className="bg-[#0f161d] border border-border-dark rounded-lg px-4 py-3 text-white">
{profileUser.full_name || '-'}
</div>
)}
</div>
</div>
</div>
</div>
{/* Account Details */}
<div className="space-y-6">
<div>
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
Account Details
</h3>
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-text-secondary uppercase tracking-wider mb-2">
Roles
</label>
<div className="bg-[#0f161d] border border-border-dark rounded-lg px-4 py-3">
{profileUser.roles && profileUser.roles.length > 0 ? (
<div className="flex flex-wrap gap-2">
{profileUser.roles.map((role) => (
<span
key={role}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-primary/10 text-primary text-xs font-medium border border-primary/20"
>
<Shield size={12} />
{role}
</span>
))}
</div>
) : (
<span className="text-text-secondary text-sm">No roles assigned</span>
)}
</div>
</div>
<div>
<label className="block text-xs font-bold text-text-secondary uppercase tracking-wider mb-2">
Permissions
</label>
<div className="bg-[#0f161d] border border-border-dark rounded-lg px-4 py-3">
{profileUser.permissions && profileUser.permissions.length > 0 ? (
<div className="flex flex-wrap gap-2">
{profileUser.permissions.map((perm) => (
<span
key={perm}
className="inline-flex items-center px-2 py-1 rounded-md bg-slate-700 text-slate-300 text-xs font-medium"
>
{perm}
</span>
))}
</div>
) : (
<span className="text-text-secondary text-sm">No permissions assigned</span>
)}
</div>
</div>
<div>
<label className="block text-xs font-bold text-text-secondary uppercase tracking-wider mb-2">
Last Login
</label>
<div className="bg-[#0f161d] border border-border-dark rounded-lg px-4 py-3 text-white flex items-center gap-2">
<Clock className="h-4 w-4 text-text-secondary" />
{formatLastLogin(profileUser.last_login_at)}
</div>
</div>
<div>
<label className="block text-xs font-bold text-text-secondary uppercase tracking-wider mb-2">
Account Created
</label>
<div className="bg-[#0f161d] border border-border-dark rounded-lg px-4 py-3 text-white flex items-center gap-2">
<Calendar className="h-4 w-4 text-text-secondary" />
{formatDate(profileUser.created_at)}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -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"],