Browse Source
admin: add group management page to admin UI
admin: add group management page to admin UI
Add groups page with CRUD operations, member management, policy attachment, and enable/disable toggle. Register routes in admin handlers and add Groups entry to sidebar navigation.pull/8560/head
10 changed files with 1199 additions and 15 deletions
-
206weed/admin/dash/group_management.go
-
24weed/admin/dash/types.go
-
17weed/admin/handlers/admin_handlers.go
-
235weed/admin/handlers/group_handlers.go
-
23weed/admin/static/js/iam-utils.js
-
396weed/admin/view/app/groups.templ
-
256weed/admin/view/app/groups_templ.go
-
5weed/admin/view/layout/layout.templ
-
30weed/admin/view/layout/layout_templ.go
-
22weed/credential/credential_manager.go
@ -0,0 +1,206 @@ |
|||||
|
package dash |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/glog" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" |
||||
|
) |
||||
|
|
||||
|
func (s *AdminServer) GetGroups(ctx context.Context) ([]GroupData, error) { |
||||
|
if s.credentialManager == nil { |
||||
|
return nil, fmt.Errorf("credential manager not available") |
||||
|
} |
||||
|
|
||||
|
groupNames, err := s.credentialManager.ListGroups(ctx) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to list groups: %w", err) |
||||
|
} |
||||
|
|
||||
|
var groups []GroupData |
||||
|
for _, name := range groupNames { |
||||
|
g, err := s.credentialManager.GetGroup(ctx, name) |
||||
|
if err != nil { |
||||
|
glog.V(1).Infof("Failed to get group %s: %v", name, err) |
||||
|
continue |
||||
|
} |
||||
|
status := "enabled" |
||||
|
if g.Disabled { |
||||
|
status = "disabled" |
||||
|
} |
||||
|
groups = append(groups, GroupData{ |
||||
|
Name: g.Name, |
||||
|
MemberCount: len(g.Members), |
||||
|
PolicyCount: len(g.PolicyNames), |
||||
|
Status: status, |
||||
|
Members: g.Members, |
||||
|
PolicyNames: g.PolicyNames, |
||||
|
}) |
||||
|
} |
||||
|
return groups, nil |
||||
|
} |
||||
|
|
||||
|
func (s *AdminServer) GetGroupDetails(ctx context.Context, name string) (*GroupData, error) { |
||||
|
if s.credentialManager == nil { |
||||
|
return nil, fmt.Errorf("credential manager not available") |
||||
|
} |
||||
|
|
||||
|
g, err := s.credentialManager.GetGroup(ctx, name) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to get group: %w", err) |
||||
|
} |
||||
|
status := "enabled" |
||||
|
if g.Disabled { |
||||
|
status = "disabled" |
||||
|
} |
||||
|
return &GroupData{ |
||||
|
Name: g.Name, |
||||
|
MemberCount: len(g.Members), |
||||
|
PolicyCount: len(g.PolicyNames), |
||||
|
Status: status, |
||||
|
Members: g.Members, |
||||
|
PolicyNames: g.PolicyNames, |
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
func (s *AdminServer) CreateGroup(ctx context.Context, name string) (*GroupData, error) { |
||||
|
if s.credentialManager == nil { |
||||
|
return nil, fmt.Errorf("credential manager not available") |
||||
|
} |
||||
|
|
||||
|
group := &iam_pb.Group{Name: name} |
||||
|
if err := s.credentialManager.CreateGroup(ctx, group); err != nil { |
||||
|
return nil, fmt.Errorf("failed to create group: %w", err) |
||||
|
} |
||||
|
glog.V(1).Infof("Created group %s", name) |
||||
|
return &GroupData{ |
||||
|
Name: name, |
||||
|
Status: "enabled", |
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
func (s *AdminServer) DeleteGroup(ctx context.Context, name string) error { |
||||
|
if s.credentialManager == nil { |
||||
|
return fmt.Errorf("credential manager not available") |
||||
|
} |
||||
|
if err := s.credentialManager.DeleteGroup(ctx, name); err != nil { |
||||
|
return fmt.Errorf("failed to delete group: %w", err) |
||||
|
} |
||||
|
glog.V(1).Infof("Deleted group %s", name) |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (s *AdminServer) AddGroupMember(ctx context.Context, groupName, username string) error { |
||||
|
if s.credentialManager == nil { |
||||
|
return fmt.Errorf("credential manager not available") |
||||
|
} |
||||
|
g, err := s.credentialManager.GetGroup(ctx, groupName) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to get group: %w", err) |
||||
|
} |
||||
|
for _, m := range g.Members { |
||||
|
if m == username { |
||||
|
return nil // already a member
|
||||
|
} |
||||
|
} |
||||
|
g.Members = append(g.Members, username) |
||||
|
if err := s.credentialManager.UpdateGroup(ctx, g); err != nil { |
||||
|
return fmt.Errorf("failed to update group: %w", err) |
||||
|
} |
||||
|
glog.V(1).Infof("Added user %s to group %s", username, groupName) |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (s *AdminServer) RemoveGroupMember(ctx context.Context, groupName, username string) error { |
||||
|
if s.credentialManager == nil { |
||||
|
return fmt.Errorf("credential manager not available") |
||||
|
} |
||||
|
g, err := s.credentialManager.GetGroup(ctx, groupName) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to get group: %w", err) |
||||
|
} |
||||
|
found := false |
||||
|
var newMembers []string |
||||
|
for _, m := range g.Members { |
||||
|
if m == username { |
||||
|
found = true |
||||
|
} else { |
||||
|
newMembers = append(newMembers, m) |
||||
|
} |
||||
|
} |
||||
|
if !found { |
||||
|
return fmt.Errorf("user %s is not a member of group %s", username, groupName) |
||||
|
} |
||||
|
g.Members = newMembers |
||||
|
if err := s.credentialManager.UpdateGroup(ctx, g); err != nil { |
||||
|
return fmt.Errorf("failed to update group: %w", err) |
||||
|
} |
||||
|
glog.V(1).Infof("Removed user %s from group %s", username, groupName) |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (s *AdminServer) AttachGroupPolicy(ctx context.Context, groupName, policyName string) error { |
||||
|
if s.credentialManager == nil { |
||||
|
return fmt.Errorf("credential manager not available") |
||||
|
} |
||||
|
g, err := s.credentialManager.GetGroup(ctx, groupName) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to get group: %w", err) |
||||
|
} |
||||
|
for _, p := range g.PolicyNames { |
||||
|
if p == policyName { |
||||
|
return nil // already attached
|
||||
|
} |
||||
|
} |
||||
|
g.PolicyNames = append(g.PolicyNames, policyName) |
||||
|
if err := s.credentialManager.UpdateGroup(ctx, g); err != nil { |
||||
|
return fmt.Errorf("failed to update group: %w", err) |
||||
|
} |
||||
|
glog.V(1).Infof("Attached policy %s to group %s", policyName, groupName) |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (s *AdminServer) DetachGroupPolicy(ctx context.Context, groupName, policyName string) error { |
||||
|
if s.credentialManager == nil { |
||||
|
return fmt.Errorf("credential manager not available") |
||||
|
} |
||||
|
g, err := s.credentialManager.GetGroup(ctx, groupName) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to get group: %w", err) |
||||
|
} |
||||
|
found := false |
||||
|
var newPolicies []string |
||||
|
for _, p := range g.PolicyNames { |
||||
|
if p == policyName { |
||||
|
found = true |
||||
|
} else { |
||||
|
newPolicies = append(newPolicies, p) |
||||
|
} |
||||
|
} |
||||
|
if !found { |
||||
|
return fmt.Errorf("policy %s is not attached to group %s", policyName, groupName) |
||||
|
} |
||||
|
g.PolicyNames = newPolicies |
||||
|
if err := s.credentialManager.UpdateGroup(ctx, g); err != nil { |
||||
|
return fmt.Errorf("failed to update group: %w", err) |
||||
|
} |
||||
|
glog.V(1).Infof("Detached policy %s from group %s", policyName, groupName) |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (s *AdminServer) SetGroupStatus(ctx context.Context, groupName string, enabled bool) error { |
||||
|
if s.credentialManager == nil { |
||||
|
return fmt.Errorf("credential manager not available") |
||||
|
} |
||||
|
g, err := s.credentialManager.GetGroup(ctx, groupName) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to get group: %w", err) |
||||
|
} |
||||
|
g.Disabled = !enabled |
||||
|
if err := s.credentialManager.UpdateGroup(ctx, g); err != nil { |
||||
|
return fmt.Errorf("failed to update group: %w", err) |
||||
|
} |
||||
|
glog.V(1).Infof("Set group %s status to enabled=%v", groupName, enabled) |
||||
|
return nil |
||||
|
} |
||||
@ -0,0 +1,235 @@ |
|||||
|
package handlers |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
"net/http" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/gorilla/mux" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/admin/dash" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/admin/view/app" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/glog" |
||||
|
) |
||||
|
|
||||
|
type GroupHandlers struct { |
||||
|
adminServer *dash.AdminServer |
||||
|
} |
||||
|
|
||||
|
func NewGroupHandlers(adminServer *dash.AdminServer) *GroupHandlers { |
||||
|
return &GroupHandlers{adminServer: adminServer} |
||||
|
} |
||||
|
|
||||
|
func (h *GroupHandlers) ShowGroups(w http.ResponseWriter, r *http.Request) { |
||||
|
data := h.getGroupsPageData(r) |
||||
|
|
||||
|
var buf bytes.Buffer |
||||
|
component := app.Groups(data) |
||||
|
viewCtx := layout.NewViewContext(r, dash.UsernameFromContext(r.Context()), dash.CSRFTokenFromContext(r.Context())) |
||||
|
layoutComponent := layout.Layout(viewCtx, component) |
||||
|
if err := layoutComponent.Render(r.Context(), &buf); err != nil { |
||||
|
glog.Errorf("Failed to render groups template: %v", err) |
||||
|
w.WriteHeader(http.StatusInternalServerError) |
||||
|
return |
||||
|
} |
||||
|
w.Header().Set("Content-Type", "text/html") |
||||
|
_, _ = w.Write(buf.Bytes()) |
||||
|
} |
||||
|
|
||||
|
func (h *GroupHandlers) GetGroups(w http.ResponseWriter, r *http.Request) { |
||||
|
groups, err := h.adminServer.GetGroups(r.Context()) |
||||
|
if err != nil { |
||||
|
glog.Errorf("Failed to get groups: %v", err) |
||||
|
writeJSONError(w, http.StatusInternalServerError, "Failed to get groups") |
||||
|
return |
||||
|
} |
||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"groups": groups}) |
||||
|
} |
||||
|
|
||||
|
func (h *GroupHandlers) CreateGroup(w http.ResponseWriter, r *http.Request) { |
||||
|
var req dash.CreateGroupRequest |
||||
|
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { |
||||
|
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) |
||||
|
return |
||||
|
} |
||||
|
if req.Name == "" { |
||||
|
writeJSONError(w, http.StatusBadRequest, "Group name is required") |
||||
|
return |
||||
|
} |
||||
|
group, err := h.adminServer.CreateGroup(r.Context(), req.Name) |
||||
|
if err != nil { |
||||
|
glog.Errorf("Failed to create group: %v", err) |
||||
|
writeJSONError(w, http.StatusInternalServerError, "Failed to create group: "+err.Error()) |
||||
|
return |
||||
|
} |
||||
|
writeJSON(w, http.StatusOK, group) |
||||
|
} |
||||
|
|
||||
|
func (h *GroupHandlers) GetGroupDetails(w http.ResponseWriter, r *http.Request) { |
||||
|
name := mux.Vars(r)["name"] |
||||
|
group, err := h.adminServer.GetGroupDetails(r.Context(), name) |
||||
|
if err != nil { |
||||
|
glog.Errorf("Failed to get group details: %v", err) |
||||
|
writeJSONError(w, http.StatusNotFound, "Group not found") |
||||
|
return |
||||
|
} |
||||
|
writeJSON(w, http.StatusOK, group) |
||||
|
} |
||||
|
|
||||
|
func (h *GroupHandlers) DeleteGroup(w http.ResponseWriter, r *http.Request) { |
||||
|
name := mux.Vars(r)["name"] |
||||
|
if err := h.adminServer.DeleteGroup(r.Context(), name); err != nil { |
||||
|
glog.Errorf("Failed to delete group: %v", err) |
||||
|
writeJSONError(w, http.StatusInternalServerError, "Failed to delete group: "+err.Error()) |
||||
|
return |
||||
|
} |
||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "Group deleted successfully"}) |
||||
|
} |
||||
|
|
||||
|
func (h *GroupHandlers) GetGroupMembers(w http.ResponseWriter, r *http.Request) { |
||||
|
name := mux.Vars(r)["name"] |
||||
|
group, err := h.adminServer.GetGroupDetails(r.Context(), name) |
||||
|
if err != nil { |
||||
|
writeJSONError(w, http.StatusNotFound, "Group not found") |
||||
|
return |
||||
|
} |
||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"members": group.Members}) |
||||
|
} |
||||
|
|
||||
|
func (h *GroupHandlers) AddGroupMember(w http.ResponseWriter, r *http.Request) { |
||||
|
name := mux.Vars(r)["name"] |
||||
|
var req struct { |
||||
|
Username string `json:"username"` |
||||
|
} |
||||
|
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { |
||||
|
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) |
||||
|
return |
||||
|
} |
||||
|
if req.Username == "" { |
||||
|
writeJSONError(w, http.StatusBadRequest, "Username is required") |
||||
|
return |
||||
|
} |
||||
|
if err := h.adminServer.AddGroupMember(r.Context(), name, req.Username); err != nil { |
||||
|
writeJSONError(w, http.StatusInternalServerError, "Failed to add member: "+err.Error()) |
||||
|
return |
||||
|
} |
||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "Member added successfully"}) |
||||
|
} |
||||
|
|
||||
|
func (h *GroupHandlers) RemoveGroupMember(w http.ResponseWriter, r *http.Request) { |
||||
|
name := mux.Vars(r)["name"] |
||||
|
username := mux.Vars(r)["username"] |
||||
|
if err := h.adminServer.RemoveGroupMember(r.Context(), name, username); err != nil { |
||||
|
writeJSONError(w, http.StatusInternalServerError, "Failed to remove member: "+err.Error()) |
||||
|
return |
||||
|
} |
||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "Member removed successfully"}) |
||||
|
} |
||||
|
|
||||
|
func (h *GroupHandlers) GetGroupPolicies(w http.ResponseWriter, r *http.Request) { |
||||
|
name := mux.Vars(r)["name"] |
||||
|
group, err := h.adminServer.GetGroupDetails(r.Context(), name) |
||||
|
if err != nil { |
||||
|
writeJSONError(w, http.StatusNotFound, "Group not found") |
||||
|
return |
||||
|
} |
||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"policies": group.PolicyNames}) |
||||
|
} |
||||
|
|
||||
|
func (h *GroupHandlers) AttachGroupPolicy(w http.ResponseWriter, r *http.Request) { |
||||
|
name := mux.Vars(r)["name"] |
||||
|
var req struct { |
||||
|
PolicyName string `json:"policy_name"` |
||||
|
} |
||||
|
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { |
||||
|
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) |
||||
|
return |
||||
|
} |
||||
|
if req.PolicyName == "" { |
||||
|
writeJSONError(w, http.StatusBadRequest, "Policy name is required") |
||||
|
return |
||||
|
} |
||||
|
if err := h.adminServer.AttachGroupPolicy(r.Context(), name, req.PolicyName); err != nil { |
||||
|
writeJSONError(w, http.StatusInternalServerError, "Failed to attach policy: "+err.Error()) |
||||
|
return |
||||
|
} |
||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "Policy attached successfully"}) |
||||
|
} |
||||
|
|
||||
|
func (h *GroupHandlers) DetachGroupPolicy(w http.ResponseWriter, r *http.Request) { |
||||
|
name := mux.Vars(r)["name"] |
||||
|
policyName := mux.Vars(r)["policyName"] |
||||
|
if err := h.adminServer.DetachGroupPolicy(r.Context(), name, policyName); err != nil { |
||||
|
writeJSONError(w, http.StatusInternalServerError, "Failed to detach policy: "+err.Error()) |
||||
|
return |
||||
|
} |
||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "Policy detached successfully"}) |
||||
|
} |
||||
|
|
||||
|
func (h *GroupHandlers) SetGroupStatus(w http.ResponseWriter, r *http.Request) { |
||||
|
name := mux.Vars(r)["name"] |
||||
|
var req struct { |
||||
|
Enabled bool `json:"enabled"` |
||||
|
} |
||||
|
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { |
||||
|
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) |
||||
|
return |
||||
|
} |
||||
|
if err := h.adminServer.SetGroupStatus(r.Context(), name, req.Enabled); err != nil { |
||||
|
writeJSONError(w, http.StatusInternalServerError, "Failed to update group status: "+err.Error()) |
||||
|
return |
||||
|
} |
||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "Group status updated"}) |
||||
|
} |
||||
|
|
||||
|
func (h *GroupHandlers) getGroupsPageData(r *http.Request) dash.GroupsPageData { |
||||
|
username := dash.UsernameFromContext(r.Context()) |
||||
|
if username == "" { |
||||
|
username = "admin" |
||||
|
} |
||||
|
|
||||
|
groups, err := h.adminServer.GetGroups(r.Context()) |
||||
|
if err != nil { |
||||
|
glog.Errorf("Failed to get groups: %v", err) |
||||
|
return dash.GroupsPageData{ |
||||
|
Username: username, |
||||
|
Groups: []dash.GroupData{}, |
||||
|
LastUpdated: time.Now(), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
activeCount := 0 |
||||
|
for _, g := range groups { |
||||
|
if g.Status == "enabled" { |
||||
|
activeCount++ |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Get available users for dropdown
|
||||
|
var availableUsers []string |
||||
|
users, err := h.adminServer.GetObjectStoreUsers(r.Context()) |
||||
|
if err == nil { |
||||
|
for _, user := range users { |
||||
|
availableUsers = append(availableUsers, user.Username) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Get available policies for dropdown
|
||||
|
var availablePolicies []string |
||||
|
policies, err := h.adminServer.GetPolicies() |
||||
|
if err == nil { |
||||
|
for _, p := range policies { |
||||
|
availablePolicies = append(availablePolicies, p.Name) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return dash.GroupsPageData{ |
||||
|
Username: username, |
||||
|
Groups: groups, |
||||
|
TotalGroups: len(groups), |
||||
|
ActiveGroups: activeCount, |
||||
|
AvailableUsers: availableUsers, |
||||
|
AvailablePolicies: availablePolicies, |
||||
|
LastUpdated: time.Now(), |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,396 @@ |
|||||
|
package app |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/admin/dash" |
||||
|
) |
||||
|
|
||||
|
templ Groups(data dash.GroupsPageData) { |
||||
|
<div class="container-fluid"> |
||||
|
<!-- Page Header --> |
||||
|
<div class="d-sm-flex align-items-center justify-content-between mb-4"> |
||||
|
<div> |
||||
|
<h1 class="h3 mb-0 text-gray-800"> |
||||
|
<i class="fas fa-users-cog me-2"></i>Groups |
||||
|
</h1> |
||||
|
<p class="mb-0 text-muted">Manage IAM groups for organizing users and policies</p> |
||||
|
</div> |
||||
|
<div class="d-flex gap-2"> |
||||
|
<button type="button" class="btn btn-primary" |
||||
|
data-bs-toggle="modal" |
||||
|
data-bs-target="#createGroupModal"> |
||||
|
<i class="fas fa-plus me-1"></i>Create Group |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Summary Cards --> |
||||
|
<div class="row mb-4"> |
||||
|
<div class="col-xl-3 col-md-6 mb-4"> |
||||
|
<div class="card border-left-primary shadow h-100 py-2"> |
||||
|
<div class="card-body"> |
||||
|
<div class="row no-gutters align-items-center"> |
||||
|
<div class="col mr-2"> |
||||
|
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1"> |
||||
|
Total Groups |
||||
|
</div> |
||||
|
<div class="h5 mb-0 font-weight-bold text-gray-800"> |
||||
|
{fmt.Sprintf("%d", data.TotalGroups)} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col-auto"> |
||||
|
<i class="fas fa-users-cog fa-2x text-gray-300"></i> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col-xl-3 col-md-6 mb-4"> |
||||
|
<div class="card border-left-success shadow h-100 py-2"> |
||||
|
<div class="card-body"> |
||||
|
<div class="row no-gutters align-items-center"> |
||||
|
<div class="col mr-2"> |
||||
|
<div class="text-xs font-weight-bold text-success text-uppercase mb-1"> |
||||
|
Active Groups |
||||
|
</div> |
||||
|
<div class="h5 mb-0 font-weight-bold text-gray-800"> |
||||
|
{fmt.Sprintf("%d", data.ActiveGroups)} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col-auto"> |
||||
|
<i class="fas fa-check-circle fa-2x text-gray-300"></i> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Groups Table --> |
||||
|
<div class="card shadow mb-4"> |
||||
|
<div class="card-header py-3"> |
||||
|
<h6 class="m-0 font-weight-bold text-primary">Groups</h6> |
||||
|
</div> |
||||
|
<div class="card-body"> |
||||
|
if len(data.Groups) == 0 { |
||||
|
<div class="text-center py-5 text-muted"> |
||||
|
<i class="fas fa-users-cog fa-3x mb-3"></i> |
||||
|
<p>No groups found. Create a group to get started.</p> |
||||
|
</div> |
||||
|
} else { |
||||
|
<div class="table-responsive"> |
||||
|
<table class="table table-bordered table-hover" id="groupsTable" width="100%" cellspacing="0"> |
||||
|
<thead> |
||||
|
<tr> |
||||
|
<th>Name</th> |
||||
|
<th>Members</th> |
||||
|
<th>Policies</th> |
||||
|
<th>Status</th> |
||||
|
<th>Actions</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
for _, group := range data.Groups { |
||||
|
<tr> |
||||
|
<td> |
||||
|
<strong>{group.Name}</strong> |
||||
|
</td> |
||||
|
<td> |
||||
|
<span class="badge bg-info">{fmt.Sprintf("%d", group.MemberCount)}</span> |
||||
|
</td> |
||||
|
<td> |
||||
|
<span class="badge bg-secondary">{fmt.Sprintf("%d", group.PolicyCount)}</span> |
||||
|
</td> |
||||
|
<td> |
||||
|
if group.Status == "enabled" { |
||||
|
<span class="badge bg-success">Enabled</span> |
||||
|
} else { |
||||
|
<span class="badge bg-danger">Disabled</span> |
||||
|
} |
||||
|
</td> |
||||
|
<td> |
||||
|
<button class="btn btn-sm btn-outline-primary me-1" |
||||
|
onclick={ templ.ComponentScript{Call: fmt.Sprintf("viewGroup('%s')", group.Name)} }> |
||||
|
<i class="fas fa-eye"></i> |
||||
|
</button> |
||||
|
<button class="btn btn-sm btn-outline-danger" |
||||
|
onclick={ templ.ComponentScript{Call: fmt.Sprintf("deleteGroup('%s')", group.Name)} }> |
||||
|
<i class="fas fa-trash"></i> |
||||
|
</button> |
||||
|
</td> |
||||
|
</tr> |
||||
|
} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Create Group Modal --> |
||||
|
<div class="modal fade" id="createGroupModal" tabindex="-1"> |
||||
|
<div class="modal-dialog"> |
||||
|
<div class="modal-content"> |
||||
|
<div class="modal-header"> |
||||
|
<h5 class="modal-title">Create Group</h5> |
||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> |
||||
|
</div> |
||||
|
<div class="modal-body"> |
||||
|
<form id="createGroupForm"> |
||||
|
<div class="mb-3"> |
||||
|
<label for="groupName" class="form-label">Group Name</label> |
||||
|
<input type="text" class="form-control" id="groupName" name="name" required |
||||
|
placeholder="Enter group name"/> |
||||
|
</div> |
||||
|
</form> |
||||
|
</div> |
||||
|
<div class="modal-footer"> |
||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> |
||||
|
<button type="button" class="btn btn-primary" onclick="createGroup()">Create</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- View Group Modal --> |
||||
|
<div class="modal fade" id="viewGroupModal" tabindex="-1"> |
||||
|
<div class="modal-dialog modal-lg"> |
||||
|
<div class="modal-content"> |
||||
|
<div class="modal-header"> |
||||
|
<h5 class="modal-title" id="viewGroupTitle">Group Details</h5> |
||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> |
||||
|
</div> |
||||
|
<div class="modal-body"> |
||||
|
<ul class="nav nav-tabs" id="groupTabs" role="tablist"> |
||||
|
<li class="nav-item"> |
||||
|
<a class="nav-link active" id="members-tab" data-bs-toggle="tab" href="#membersPane" role="tab">Members</a> |
||||
|
</li> |
||||
|
<li class="nav-item"> |
||||
|
<a class="nav-link" id="policies-tab" data-bs-toggle="tab" href="#policiesPane" role="tab">Policies</a> |
||||
|
</li> |
||||
|
<li class="nav-item"> |
||||
|
<a class="nav-link" id="settings-tab" data-bs-toggle="tab" href="#settingsPane" role="tab">Settings</a> |
||||
|
</li> |
||||
|
</ul> |
||||
|
<div class="tab-content mt-3" id="groupTabContent"> |
||||
|
<!-- Members Tab --> |
||||
|
<div class="tab-pane fade show active" id="membersPane" role="tabpanel"> |
||||
|
<div class="mb-3"> |
||||
|
<div class="input-group"> |
||||
|
<select class="form-select" id="addMemberSelect"> |
||||
|
<option value="">Select user to add...</option> |
||||
|
for _, user := range data.AvailableUsers { |
||||
|
<option value={user}>{user}</option> |
||||
|
} |
||||
|
</select> |
||||
|
<button class="btn btn-outline-primary" type="button" onclick="addMemberToGroup()"> |
||||
|
<i class="fas fa-plus"></i> Add |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div id="membersList"></div> |
||||
|
</div> |
||||
|
<!-- Policies Tab --> |
||||
|
<div class="tab-pane fade" id="policiesPane" role="tabpanel"> |
||||
|
<div class="mb-3"> |
||||
|
<div class="input-group"> |
||||
|
<select class="form-select" id="attachPolicySelect"> |
||||
|
<option value="">Select policy to attach...</option> |
||||
|
for _, policy := range data.AvailablePolicies { |
||||
|
<option value={policy}>{policy}</option> |
||||
|
} |
||||
|
</select> |
||||
|
<button class="btn btn-outline-primary" type="button" onclick="attachPolicyToGroup()"> |
||||
|
<i class="fas fa-plus"></i> Attach |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div id="policiesList"></div> |
||||
|
</div> |
||||
|
<!-- Settings Tab --> |
||||
|
<div class="tab-pane fade" id="settingsPane" role="tabpanel"> |
||||
|
<div class="form-check form-switch mb-3"> |
||||
|
<input class="form-check-input" type="checkbox" id="groupEnabledSwitch" checked |
||||
|
onchange="toggleGroupStatus()"/> |
||||
|
<label class="form-check-label" for="groupEnabledSwitch">Group Enabled</label> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="modal-footer"> |
||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<script src="/static/js/iam-utils.js"></script> |
||||
|
<script> |
||||
|
// Groups page JavaScript |
||||
|
let currentGroupName = ''; |
||||
|
|
||||
|
async function createGroup() { |
||||
|
const name = document.getElementById('groupName').value.trim(); |
||||
|
if (!name) { |
||||
|
showAlert('Group name is required', 'error'); |
||||
|
return; |
||||
|
} |
||||
|
try { |
||||
|
const response = await fetch('/api/groups', { |
||||
|
method: 'POST', |
||||
|
headers: { 'Content-Type': 'application/json' }, |
||||
|
body: JSON.stringify({ name: name }) |
||||
|
}); |
||||
|
if (response.ok) { |
||||
|
showAlert('Group created successfully', 'success'); |
||||
|
setTimeout(() => window.location.reload(), 1000); |
||||
|
} else { |
||||
|
const error = await response.json().catch(() => ({})); |
||||
|
showAlert('Failed to create group: ' + (error.error || 'Unknown error'), 'error'); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
showAlert('Failed to create group: ' + error.message, 'error'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function viewGroup(name) { |
||||
|
currentGroupName = name; |
||||
|
document.getElementById('viewGroupTitle').textContent = 'Group: ' + name; |
||||
|
await refreshGroupDetails(); |
||||
|
new bootstrap.Modal(document.getElementById('viewGroupModal')).show(); |
||||
|
} |
||||
|
|
||||
|
async function refreshGroupDetails() { |
||||
|
try { |
||||
|
const response = await fetch('/api/groups/' + encodeURIComponent(currentGroupName)); |
||||
|
if (!response.ok) throw new Error('Failed to fetch group'); |
||||
|
const group = await response.json(); |
||||
|
|
||||
|
// Render members |
||||
|
let membersHtml = '<table class="table table-sm"><tbody>'; |
||||
|
if (group.members && group.members.length > 0) { |
||||
|
for (const member of group.members) { |
||||
|
membersHtml += '<tr><td>' + member + '</td><td><button class="btn btn-sm btn-outline-danger" onclick="removeMember(\'' + member + '\')"><i class="fas fa-times"></i></button></td></tr>'; |
||||
|
} |
||||
|
} else { |
||||
|
membersHtml += '<tr><td class="text-muted">No members</td></tr>'; |
||||
|
} |
||||
|
membersHtml += '</tbody></table>'; |
||||
|
document.getElementById('membersList').innerHTML = membersHtml; |
||||
|
|
||||
|
// Render policies |
||||
|
let policiesHtml = '<table class="table table-sm"><tbody>'; |
||||
|
if (group.policy_names && group.policy_names.length > 0) { |
||||
|
for (const policy of group.policy_names) { |
||||
|
policiesHtml += '<tr><td>' + policy + '</td><td><button class="btn btn-sm btn-outline-danger" onclick="detachPolicy(\'' + policy + '\')"><i class="fas fa-times"></i></button></td></tr>'; |
||||
|
} |
||||
|
} else { |
||||
|
policiesHtml += '<tr><td class="text-muted">No policies attached</td></tr>'; |
||||
|
} |
||||
|
policiesHtml += '</tbody></table>'; |
||||
|
document.getElementById('policiesList').innerHTML = policiesHtml; |
||||
|
|
||||
|
// Update status toggle |
||||
|
document.getElementById('groupEnabledSwitch').checked = (group.status === 'enabled'); |
||||
|
} catch (error) { |
||||
|
console.error('Error fetching group details:', error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function addMemberToGroup() { |
||||
|
const username = document.getElementById('addMemberSelect').value; |
||||
|
if (!username) return; |
||||
|
try { |
||||
|
const response = await fetch('/api/groups/' + encodeURIComponent(currentGroupName) + '/members', { |
||||
|
method: 'POST', |
||||
|
headers: { 'Content-Type': 'application/json' }, |
||||
|
body: JSON.stringify({ username: username }) |
||||
|
}); |
||||
|
if (response.ok) { |
||||
|
await refreshGroupDetails(); |
||||
|
showAlert('Member added', 'success'); |
||||
|
} else { |
||||
|
const error = await response.json().catch(() => ({})); |
||||
|
showAlert('Failed to add member: ' + (error.error || 'Unknown error'), 'error'); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
showAlert('Failed to add member: ' + error.message, 'error'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function removeMember(username) { |
||||
|
try { |
||||
|
const response = await fetch('/api/groups/' + encodeURIComponent(currentGroupName) + '/members/' + encodeURIComponent(username), { |
||||
|
method: 'DELETE' |
||||
|
}); |
||||
|
if (response.ok) { |
||||
|
await refreshGroupDetails(); |
||||
|
showAlert('Member removed', 'success'); |
||||
|
} else { |
||||
|
const error = await response.json().catch(() => ({})); |
||||
|
showAlert('Failed to remove member: ' + (error.error || 'Unknown error'), 'error'); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
showAlert('Failed to remove member: ' + error.message, 'error'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function attachPolicyToGroup() { |
||||
|
const policyName = document.getElementById('attachPolicySelect').value; |
||||
|
if (!policyName) return; |
||||
|
try { |
||||
|
const response = await fetch('/api/groups/' + encodeURIComponent(currentGroupName) + '/policies', { |
||||
|
method: 'POST', |
||||
|
headers: { 'Content-Type': 'application/json' }, |
||||
|
body: JSON.stringify({ policy_name: policyName }) |
||||
|
}); |
||||
|
if (response.ok) { |
||||
|
await refreshGroupDetails(); |
||||
|
showAlert('Policy attached', 'success'); |
||||
|
} else { |
||||
|
const error = await response.json().catch(() => ({})); |
||||
|
showAlert('Failed to attach policy: ' + (error.error || 'Unknown error'), 'error'); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
showAlert('Failed to attach policy: ' + error.message, 'error'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function detachPolicy(policyName) { |
||||
|
try { |
||||
|
const response = await fetch('/api/groups/' + encodeURIComponent(currentGroupName) + '/policies/' + encodeURIComponent(policyName), { |
||||
|
method: 'DELETE' |
||||
|
}); |
||||
|
if (response.ok) { |
||||
|
await refreshGroupDetails(); |
||||
|
showAlert('Policy detached', 'success'); |
||||
|
} else { |
||||
|
const error = await response.json().catch(() => ({})); |
||||
|
showAlert('Failed to detach policy: ' + (error.error || 'Unknown error'), 'error'); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
showAlert('Failed to detach policy: ' + error.message, 'error'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function toggleGroupStatus() { |
||||
|
const enabled = document.getElementById('groupEnabledSwitch').checked; |
||||
|
try { |
||||
|
const response = await fetch('/api/groups/' + encodeURIComponent(currentGroupName) + '/status', { |
||||
|
method: 'PUT', |
||||
|
headers: { 'Content-Type': 'application/json' }, |
||||
|
body: JSON.stringify({ enabled: enabled }) |
||||
|
}); |
||||
|
if (response.ok) { |
||||
|
showAlert('Group status updated', 'success'); |
||||
|
} else { |
||||
|
const error = await response.json().catch(() => ({})); |
||||
|
showAlert('Failed to update status: ' + (error.error || 'Unknown error'), 'error'); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
showAlert('Failed to update status: ' + error.message, 'error'); |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
} |
||||
256
weed/admin/view/app/groups_templ.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
Write
Preview
Loading…
Cancel
Save
Reference in new issue