Browse Source
Admin: Add Service Account Management UI (#7902)
Admin: Add Service Account Management UI (#7902)
* admin: add Service Account management UI Add admin UI for managing service accounts: New files: - handlers/service_account_handlers.go - HTTP handlers - dash/service_account_management.go - CRUD operations - view/app/service_accounts.templ - UI template Changes: - dash/types.go - Add ServiceAccount and related types - handlers/admin_handlers.go - Register routes and handlers - view/layout/layout.templ - Add sidebar navigation link Service accounts are stored as special identities with "sa:" prefix in their name, using ABIA access key prefix. They can be created, listed, enabled/disabled, and deleted through the admin UI. Features: - Create service accounts linked to parent users - View and manage service account status - Delete service accounts - Service accounts inherit parent user permissions Note: STS configuration is read-only (configured via JSON file). Full STS integration requires changes from PR #7901. * admin: use dropdown for parent user selection Change the Parent User field from text input to dropdown when creating a service account. The dropdown is populated with all existing Object Store users. Changes: - Add AvailableUsers field to ServiceAccountsData type - Populate available users in getServiceAccountsData handler - Update template to use <select> element with user options * admin: show secret access key on service account creation Display both access key and secret access key when creating a service account, with proper AWS CLI usage instructions. Changes: - Add SecretAccessKey field to ServiceAccount type (only populated on creation) - Return secret key from CreateServiceAccount - Add credentials modal with copy-to-clipboard buttons - Show AWS CLI usage example with actual credentials - Modal is non-dismissible until user confirms they saved credentials The secret key is only shown once during creation for security. After creation, only the access key ID is visible in the list. * admin: address code review comments for service account management - Persist creation dates in identity actions (createdAt:timestamp) - Replace magic number slicing with len(accessKeyPrefix) - Add bounds checking after strings.SplitN - Use accessKeyPrefix constant instead of hardcoded "ABIA" Creation dates are now stored as actions (e.g., "createdAt:1735473600") and will persist across restarts. Helper functions getCreationDate() and setCreationDate() manage the timestamp storage. Addresses review comments from gemini-code-assist[bot] and coderabbitai[bot] * admin: fix XSS vulnerabilities in service account details Replace innerHTML with template literals with safe DOM creation. The createSADetailsContent function now uses createElement and textContent to prevent XSS attacks from malicious service account data (id, description, parent_user, etc.). Also added try-catch for date parsing to prevent exceptions on malformed input. Addresses security review comments from coderabbitai[bot] * admin: add context.Context to service account management methods Addressed PR #7902 review feedback: 1. All service account management methods now accept context.Context as first parameter to enable cancellation, deadlines, and tracing 2. Removed all context.Background() calls 3. Updated handlers to pass c.Request.Context() from HTTP requests Methods updated: - GetServiceAccounts - GetServiceAccountDetails - CreateServiceAccount - UpdateServiceAccount - DeleteServiceAccount - GetServiceAccountByAccessKey Note: Creation date persistence was already implemented using the createdAt:<timestamp> action pattern as suggested in the review. * admin: fix render flow to prevent partial HTML writes Fixed ShowServiceAccounts handler to render template to an in-memory buffer first before writing to the response. This prevents partial HTML writes followed by JSON error responses, which would result in invalid mixed content. Changes: - Render to bytes.Buffer first - Only write to c.Writer if render succeeds - Use c.AbortWithStatus on error instead of attempting JSON response - Prevents any additional headers/body writes after partial write * admin: fix error handling, date validation, and event parameters Addressed multiple code review issues: 1. Proper 404 vs 500 error handling: - Added ErrServiceAccountNotFound sentinel error - GetServiceAccountDetails now wraps errors with sentinel - Handler uses errors.Is() to distinguish not-found from internal errors - Returns 404 only for missing resources, 500 for other errors - Logs internal errors before returning 500 2. Date validation in JavaScript: - Validate expiration date before using it - Check !isNaN(date.getTime()) to ensure valid date - Return validation error if date is invalid - Prevents invalid Date construction 3. Event parameter handling: - copyToClipboard now accepts event parameter - Updated onclick attributes to pass event object - Prevents reliance on window.event - More explicit and reliable event handling * admin: replace deprecated execCommand with Clipboard API Replaced deprecated document.execCommand('copy') with modern navigator.clipboard.writeText() API for better security and UX. Changes: - Made copyToClipboard async to support Clipboard API - Use navigator.clipboard.writeText() as primary method - Fallback to execCommand if Clipboard API fails (older browsers) - Added console warning when fallback is used - Maintains same visual feedback behavior * admin: improve security and UX for error handling Addressed code review feedback: 1. Security: Remove sensitive error details from API responses - CreateServiceAccount: Return generic error message - UpdateServiceAccount: Return generic error message - DeleteServiceAccount: Return generic error message - Detailed errors still logged server-side via glog.Errorf() - Prevents exposure of internal system details to clients 2. UX: Replace alert() with Bootstrap toast notifications - Implemented showToast() function using Bootstrap 5 toasts - Non-blocking, modern notification system - Auto-dismiss after 5 seconds - Proper HTML escaping to prevent XSS - Toast container positioned at top-right - Success (green) and error (red) variants * admin: complete error handling improvements Addressed remaining security review feedback: 1. GetServiceAccounts: Remove error details from response - Log errors server-side via glog.Errorf() - Return generic error message to client 2. UpdateServiceAccount & DeleteServiceAccount: - Wrap not-found errors with ErrServiceAccountNotFound sentinel - Enables proper 404 vs 500 distinction in handlers 3. Update & Delete handlers: - Added errors.Is() check for ErrServiceAccountNotFound - Return 404 for missing resources - Return 500 for internal errors with logging - Consistent with GetServiceAccountDetails behavior All handlers now properly distinguish not-found (404) from internal errors (500) and never expose sensitive error details to clients. * admin: implement expiration support and improve code quality Addressed final code review feedback: 1. Expiration Support: - Added expiration helper functions (getExpiration, setExpiration) - Implemented expiration in CreateServiceAccount - Implemented expiration in UpdateServiceAccount - Added Expiration field to ServiceAccount struct - Parse and validate RFC3339 expiration dates 2. Constants for Magic Strings: - Added StatusActive, StatusInactive constants - Added disabledAction, serviceAccountPrefix constants - Replaced all magic strings with constants throughout - Improves maintainability and prevents typos 3. Helper Function to Reduce Duplication: - Created identityToServiceAccount() helper - Reduces code duplication across Get/Update/Delete methods - Centralizes ServiceAccount struct building logic 4. Fixed time.Now() Fallback: - Changed from time.Now() to time.Time{} for legacy accounts - Prevents creation date from changing on each fetch - UI can display zero time as "N/A" or blank All code quality issues addressed! * admin: fix StatusActive reference in handler Use dash.StatusActive to properly reference the constant from the dash package. * admin: regenerate templ files Regenerated all templ Go files after recent template changes. The AWS CLI usage example already uses proper <pre><code> formatting which preserves line breaks for better readability. * admin: add explicit white-space CSS to AWS CLI example Added style="white-space: pre-wrap;" to the pre tag to ensure line breaks are preserved and displayed correctly in all browsers. This forces the browser to respect the newlines in the code block. * admin: fix AWS CLI example to display on separate lines Replaced pre/code block with individual div elements for each line. This ensures each command displays on its own line regardless of how templ processes whitespace. Each line is now a separate div with font-monospace styling for code appearance. * make * admin: filter service accounts from parent user dropdown Service accounts should not appear as selectable parent users when creating new service accounts. Added filter to GetObjectStoreUsers() to skip identities with "sa:" prefix, ensuring only actual IAM users are shown in the parent user dropdown. * admin: address code review feedback - Use constants for magic strings in service account management - Add Expiration field to service account responses - Add nil checks and context propagation - Improve templates (date validation, async clipboard, toast notifications) * Update service_accounts_templ.gofix-bucket-name-case-7910
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1753 additions and 66 deletions
-
7weed/admin/dash/admin_server.go
-
53weed/admin/dash/service_account_helpers.go
-
434weed/admin/dash/service_account_management.go
-
46weed/admin/dash/types.go
-
57weed/admin/handlers/admin_handlers.go
-
213weed/admin/handlers/service_account_handlers.go
-
4weed/admin/handlers/user_handlers.go
-
22weed/admin/view/app/maintenance_queue.templ
-
18weed/admin/view/app/maintenance_queue_templ.go
-
653weed/admin/view/app/service_accounts.templ
-
283weed/admin/view/app/service_accounts_templ.go
-
5weed/admin/view/layout/layout.templ
-
24weed/admin/view/layout/layout_templ.go
@ -0,0 +1,53 @@ |
|||
package dash |
|||
|
|||
import ( |
|||
"fmt" |
|||
"strings" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" |
|||
) |
|||
|
|||
// identityToServiceAccount converts an IAM identity to a ServiceAccount struct
|
|||
// This helper reduces code duplication across GetServiceAccounts, GetServiceAccountDetails,
|
|||
// UpdateServiceAccount, and GetServiceAccountByAccessKey
|
|||
func identityToServiceAccount(identity *iam_pb.Identity) (*ServiceAccount, error) { |
|||
if identity == nil { |
|||
return nil, fmt.Errorf("identity cannot be nil") |
|||
} |
|||
if !strings.HasPrefix(identity.GetName(), serviceAccountPrefix) { |
|||
return nil, fmt.Errorf("not a service account: %s", identity.GetName()) |
|||
} |
|||
|
|||
parts := strings.SplitN(identity.GetName(), ":", 3) |
|||
if len(parts) < 3 { |
|||
return nil, fmt.Errorf("invalid service account ID format") |
|||
} |
|||
|
|||
sa := &ServiceAccount{ |
|||
ID: identity.GetName(), |
|||
ParentUser: parts[1], |
|||
Status: StatusActive, |
|||
CreateDate: getCreationDate(identity.GetActions()), |
|||
Expiration: getExpiration(identity.GetActions()), |
|||
} |
|||
|
|||
// Get description from account display name
|
|||
if identity.Account != nil { |
|||
sa.Description = identity.Account.GetDisplayName() |
|||
} |
|||
|
|||
// Get access key from credentials
|
|||
if len(identity.Credentials) > 0 { |
|||
sa.AccessKeyId = identity.Credentials[0].GetAccessKey() |
|||
} |
|||
|
|||
// Check if disabled
|
|||
for _, action := range identity.GetActions() { |
|||
if action == disabledAction { |
|||
sa.Status = StatusInactive |
|||
break |
|||
} |
|||
} |
|||
|
|||
return sa, nil |
|||
} |
|||
@ -0,0 +1,434 @@ |
|||
package dash |
|||
|
|||
import ( |
|||
"context" |
|||
"errors" |
|||
"fmt" |
|||
"strconv" |
|||
"strings" |
|||
"time" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/glog" |
|||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" |
|||
) |
|||
|
|||
var ( |
|||
// ErrServiceAccountNotFound is returned when a service account is not found
|
|||
ErrServiceAccountNotFound = errors.New("service account not found") |
|||
) |
|||
|
|||
const ( |
|||
createdAtActionPrefix = "createdAt:" |
|||
expirationActionPrefix = "expiresAt:" |
|||
disabledAction = "__disabled__" |
|||
serviceAccountPrefix = "sa:" |
|||
accessKeyPrefix = "ABIA" // Service account access keys use ABIA prefix
|
|||
|
|||
// Status constants
|
|||
StatusActive = "Active" |
|||
StatusInactive = "Inactive" |
|||
) |
|||
|
|||
// Helper functions for managing creation timestamps in actions
|
|||
func getCreationDate(actions []string) time.Time { |
|||
for _, action := range actions { |
|||
if strings.HasPrefix(action, createdAtActionPrefix) { |
|||
timestampStr := strings.TrimPrefix(action, createdAtActionPrefix) |
|||
if timestamp, err := strconv.ParseInt(timestampStr, 10, 64); err == nil { |
|||
return time.Unix(timestamp, 0) |
|||
} |
|||
} |
|||
} |
|||
return time.Time{} // Return zero time for legacy service accounts without stored creation date
|
|||
} |
|||
|
|||
func setCreationDate(actions []string, createDate time.Time) []string { |
|||
// Remove any existing createdAt action
|
|||
filtered := make([]string, 0, len(actions)+1) |
|||
for _, action := range actions { |
|||
if !strings.HasPrefix(action, createdAtActionPrefix) { |
|||
filtered = append(filtered, action) |
|||
} |
|||
} |
|||
// Add new createdAt action
|
|||
filtered = append(filtered, fmt.Sprintf("%s%d", createdAtActionPrefix, createDate.Unix())) |
|||
return filtered |
|||
} |
|||
|
|||
// Helper functions for managing expiration timestamps in actions
|
|||
func getExpiration(actions []string) time.Time { |
|||
for _, action := range actions { |
|||
if strings.HasPrefix(action, expirationActionPrefix) { |
|||
timestampStr := strings.TrimPrefix(action, expirationActionPrefix) |
|||
if timestamp, err := strconv.ParseInt(timestampStr, 10, 64); err == nil { |
|||
return time.Unix(timestamp, 0) |
|||
} |
|||
} |
|||
} |
|||
return time.Time{} // No expiration set
|
|||
} |
|||
|
|||
func setExpiration(actions []string, expiration time.Time) []string { |
|||
// Remove any existing expiration action
|
|||
filtered := make([]string, 0, len(actions)+1) |
|||
for _, action := range actions { |
|||
if !strings.HasPrefix(action, expirationActionPrefix) { |
|||
filtered = append(filtered, action) |
|||
} |
|||
} |
|||
// Add new expiration action if not zero
|
|||
if !expiration.IsZero() { |
|||
filtered = append(filtered, fmt.Sprintf("%s%d", expirationActionPrefix, expiration.Unix())) |
|||
} |
|||
return filtered |
|||
} |
|||
|
|||
// GetServiceAccounts returns all service accounts, optionally filtered by parent user
|
|||
// NOTE: Service accounts are stored as special identities with "sa:" prefix
|
|||
func (s *AdminServer) GetServiceAccounts(ctx context.Context, parentUser string) ([]ServiceAccount, error) { |
|||
if s.credentialManager == nil { |
|||
return nil, fmt.Errorf("credential manager not available") |
|||
} |
|||
|
|||
// Load the current configuration to find service account identities
|
|||
config, err := s.credentialManager.LoadConfiguration(ctx) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("failed to load configuration: %w", err) |
|||
} |
|||
|
|||
var accounts []ServiceAccount |
|||
|
|||
// Service accounts are stored as identities with "sa:" prefix in their name
|
|||
// Format: "sa:<parent_user>:<uuid>"
|
|||
for _, identity := range config.GetIdentities() { |
|||
if !strings.HasPrefix(identity.GetName(), serviceAccountPrefix) { |
|||
continue |
|||
} |
|||
|
|||
parts := strings.SplitN(identity.GetName(), ":", 3) |
|||
if len(parts) < 3 { |
|||
continue |
|||
} |
|||
|
|||
parent := parts[1] |
|||
saId := identity.GetName() |
|||
|
|||
// Filter by parent user if specified
|
|||
if parentUser != "" && parent != parentUser { |
|||
continue |
|||
} |
|||
|
|||
// Extract description from account display name if available
|
|||
description := "" |
|||
status := StatusActive |
|||
if identity.Account != nil { |
|||
description = identity.Account.GetDisplayName() |
|||
} |
|||
|
|||
// Get access key from credentials
|
|||
accessKey := "" |
|||
if len(identity.Credentials) > 0 { |
|||
accessKey = identity.Credentials[0].GetAccessKey() |
|||
// Service accounts use ABIA prefix
|
|||
if !strings.HasPrefix(accessKey, accessKeyPrefix) { |
|||
continue // Not a service account
|
|||
} |
|||
} |
|||
|
|||
// Check if disabled (stored in actions)
|
|||
for _, action := range identity.GetActions() { |
|||
if action == disabledAction { |
|||
status = StatusInactive |
|||
break |
|||
} |
|||
} |
|||
|
|||
accounts = append(accounts, ServiceAccount{ |
|||
ID: saId, |
|||
ParentUser: parent, |
|||
Description: description, |
|||
AccessKeyId: accessKey, |
|||
Status: status, |
|||
CreateDate: getCreationDate(identity.GetActions()), |
|||
Expiration: getExpiration(identity.GetActions()), |
|||
}) |
|||
} |
|||
|
|||
return accounts, nil |
|||
} |
|||
|
|||
// GetServiceAccountDetails returns detailed information about a specific service account
|
|||
func (s *AdminServer) GetServiceAccountDetails(ctx context.Context, id string) (*ServiceAccount, error) { |
|||
if s.credentialManager == nil { |
|||
return nil, fmt.Errorf("credential manager not available") |
|||
} |
|||
|
|||
// Get the identity
|
|||
identity, err := s.credentialManager.GetUser(ctx, id) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("%w: %s", ErrServiceAccountNotFound, id) |
|||
} |
|||
|
|||
if !strings.HasPrefix(identity.GetName(), serviceAccountPrefix) { |
|||
return nil, fmt.Errorf("%w: not a service account: %s", ErrServiceAccountNotFound, id) |
|||
} |
|||
|
|||
parts := strings.SplitN(identity.GetName(), ":", 3) |
|||
if len(parts) < 3 { |
|||
return nil, fmt.Errorf("invalid service account ID format") |
|||
} |
|||
|
|||
account := &ServiceAccount{ |
|||
ID: id, |
|||
ParentUser: parts[1], |
|||
Status: StatusActive, |
|||
CreateDate: getCreationDate(identity.GetActions()), |
|||
Expiration: getExpiration(identity.GetActions()), |
|||
} |
|||
|
|||
if identity.Account != nil { |
|||
account.Description = identity.Account.GetDisplayName() |
|||
} |
|||
|
|||
if len(identity.Credentials) > 0 { |
|||
account.AccessKeyId = identity.Credentials[0].GetAccessKey() |
|||
} |
|||
|
|||
// Check if disabled
|
|||
for _, action := range identity.GetActions() { |
|||
if action == disabledAction { |
|||
account.Status = StatusInactive |
|||
break |
|||
} |
|||
} |
|||
|
|||
return account, nil |
|||
} |
|||
|
|||
// CreateServiceAccount creates a new service account for a parent user
|
|||
func (s *AdminServer) CreateServiceAccount(ctx context.Context, req CreateServiceAccountRequest) (*ServiceAccount, error) { |
|||
if s.credentialManager == nil { |
|||
return nil, fmt.Errorf("credential manager not available") |
|||
} |
|||
|
|||
// Validate parent user exists
|
|||
_, err := s.credentialManager.GetUser(ctx, req.ParentUser) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("parent user not found: %s", req.ParentUser) |
|||
} |
|||
|
|||
// Generate unique ID and credentials
|
|||
uuid := generateAccountId() |
|||
saId := fmt.Sprintf("sa:%s:%s", req.ParentUser, uuid) |
|||
accessKey := accessKeyPrefix + generateAccessKey()[len(accessKeyPrefix):] // Use ABIA prefix for service accounts
|
|||
secretKey := generateSecretKey() |
|||
|
|||
// Create the service account as a special identity
|
|||
now := time.Now() |
|||
|
|||
// Parse expiration if provided
|
|||
var expiration time.Time |
|||
if req.Expiration != "" { |
|||
var err error |
|||
expiration, err = time.Parse(time.RFC3339, req.Expiration) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("invalid expiration format: %w", err) |
|||
} |
|||
} |
|||
|
|||
identity := &iam_pb.Identity{ |
|||
Name: saId, |
|||
Account: &iam_pb.Account{ |
|||
Id: uuid, |
|||
DisplayName: req.Description, |
|||
}, |
|||
Credentials: []*iam_pb.Credential{ |
|||
{ |
|||
AccessKey: accessKey, |
|||
SecretKey: secretKey, |
|||
}, |
|||
}, |
|||
// Store creation date and expiration in actions
|
|||
Actions: setExpiration(setCreationDate([]string{}, now), expiration), |
|||
} |
|||
|
|||
// Create the service account
|
|||
err = s.credentialManager.CreateUser(ctx, identity) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("failed to create service account: %w", err) |
|||
} |
|||
|
|||
glog.V(1).Infof("Created service account %s for user %s", saId, req.ParentUser) |
|||
|
|||
return &ServiceAccount{ |
|||
ID: saId, |
|||
ParentUser: req.ParentUser, |
|||
Description: req.Description, |
|||
AccessKeyId: accessKey, |
|||
SecretAccessKey: secretKey, // Only returned on creation
|
|||
Status: StatusActive, |
|||
CreateDate: now, |
|||
Expiration: expiration, |
|||
}, nil |
|||
} |
|||
|
|||
// UpdateServiceAccount updates an existing service account
|
|||
func (s *AdminServer) UpdateServiceAccount(ctx context.Context, id string, req UpdateServiceAccountRequest) (*ServiceAccount, error) { |
|||
if s.credentialManager == nil { |
|||
return nil, fmt.Errorf("credential manager not available") |
|||
} |
|||
|
|||
// Get existing identity
|
|||
identity, err := s.credentialManager.GetUser(ctx, id) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("%w: %s", ErrServiceAccountNotFound, id) |
|||
} |
|||
|
|||
if !strings.HasPrefix(identity.GetName(), serviceAccountPrefix) { |
|||
return nil, fmt.Errorf("%w: not a service account: %s", ErrServiceAccountNotFound, id) |
|||
} |
|||
|
|||
// Update description if provided
|
|||
if req.Description != "" { |
|||
if identity.Account == nil { |
|||
identity.Account = &iam_pb.Account{} |
|||
} |
|||
identity.Account.DisplayName = req.Description |
|||
} |
|||
|
|||
// Update status by adding/removing disabled action
|
|||
if req.Status != "" { |
|||
// Remove existing disabled marker
|
|||
newActions := make([]string, 0, len(identity.Actions)) |
|||
for _, action := range identity.Actions { |
|||
if action != disabledAction { |
|||
newActions = append(newActions, action) |
|||
} |
|||
} |
|||
// Add disabled action if setting to Inactive
|
|||
if req.Status == StatusInactive { |
|||
newActions = append(newActions, disabledAction) |
|||
} |
|||
identity.Actions = newActions |
|||
} |
|||
|
|||
// Update expiration if provided
|
|||
if req.Expiration != "" { |
|||
var expiration time.Time |
|||
var err error |
|||
expiration, err = time.Parse(time.RFC3339, req.Expiration) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("invalid expiration format: %w", err) |
|||
} |
|||
identity.Actions = setExpiration(identity.Actions, expiration) |
|||
} |
|||
|
|||
// Update the identity
|
|||
err = s.credentialManager.UpdateUser(ctx, id, identity) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("failed to update service account: %w", err) |
|||
} |
|||
|
|||
glog.V(1).Infof("Updated service account %s", id) |
|||
|
|||
// Build response
|
|||
parts := strings.SplitN(id, ":", 3) |
|||
if len(parts) < 3 { |
|||
return nil, fmt.Errorf("invalid service account ID format") |
|||
} |
|||
|
|||
result := &ServiceAccount{ |
|||
ID: id, |
|||
ParentUser: parts[1], |
|||
Description: identity.Account.GetDisplayName(), |
|||
Status: StatusActive, |
|||
CreateDate: getCreationDate(identity.Actions), |
|||
} |
|||
|
|||
if len(identity.Credentials) > 0 { |
|||
result.AccessKeyId = identity.Credentials[0].GetAccessKey() |
|||
} |
|||
|
|||
for _, action := range identity.Actions { |
|||
if action == disabledAction { |
|||
result.Status = StatusInactive |
|||
break |
|||
} |
|||
} |
|||
|
|||
return result, nil |
|||
} |
|||
|
|||
// DeleteServiceAccount deletes a service account
|
|||
func (s *AdminServer) DeleteServiceAccount(ctx context.Context, id string) error { |
|||
if s.credentialManager == nil { |
|||
return fmt.Errorf("credential manager not available") |
|||
} |
|||
|
|||
// Verify it's a service account
|
|||
identity, err := s.credentialManager.GetUser(ctx, id) |
|||
if err != nil { |
|||
return fmt.Errorf("%w: %s", ErrServiceAccountNotFound, id) |
|||
} |
|||
|
|||
if !strings.HasPrefix(identity.GetName(), serviceAccountPrefix) { |
|||
return fmt.Errorf("%w: not a service account: %s", ErrServiceAccountNotFound, id) |
|||
} |
|||
|
|||
// Delete the identity
|
|||
err = s.credentialManager.DeleteUser(ctx, id) |
|||
if err != nil { |
|||
return fmt.Errorf("failed to delete service account: %w", err) |
|||
} |
|||
|
|||
glog.V(1).Infof("Deleted service account %s", id) |
|||
return nil |
|||
} |
|||
|
|||
// GetServiceAccountByAccessKey finds a service account by its access key
|
|||
func (s *AdminServer) GetServiceAccountByAccessKey(ctx context.Context, accessKey string) (*ServiceAccount, error) { |
|||
if !strings.HasPrefix(accessKey, accessKeyPrefix) { |
|||
return nil, fmt.Errorf("not a service account access key") |
|||
} |
|||
|
|||
if s.credentialManager == nil { |
|||
return nil, fmt.Errorf("credential manager not available") |
|||
} |
|||
|
|||
// Find identity by access key
|
|||
identity, err := s.credentialManager.GetUserByAccessKey(ctx, accessKey) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("service account not found for access key: %s", accessKey) |
|||
} |
|||
|
|||
if !strings.HasPrefix(identity.GetName(), serviceAccountPrefix) { |
|||
return nil, fmt.Errorf("not a service account") |
|||
} |
|||
|
|||
parts := strings.SplitN(identity.GetName(), ":", 3) |
|||
if len(parts) < 3 { |
|||
return nil, fmt.Errorf("invalid service account ID format") |
|||
} |
|||
|
|||
account := &ServiceAccount{ |
|||
ID: identity.GetName(), |
|||
ParentUser: parts[1], |
|||
AccessKeyId: accessKey, |
|||
Status: StatusActive, |
|||
CreateDate: getCreationDate(identity.GetActions()), |
|||
Expiration: getExpiration(identity.GetActions()), |
|||
} |
|||
|
|||
if identity.Account != nil { |
|||
account.Description = identity.Account.GetDisplayName() |
|||
} |
|||
|
|||
for _, action := range identity.GetActions() { |
|||
if action == disabledAction { |
|||
account.Status = StatusInactive |
|||
break |
|||
} |
|||
} |
|||
|
|||
return account, nil |
|||
} |
|||
@ -0,0 +1,213 @@ |
|||
package handlers |
|||
|
|||
import ( |
|||
"bytes" |
|||
"errors" |
|||
"net/http" |
|||
"time" |
|||
|
|||
"github.com/gin-gonic/gin" |
|||
"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" |
|||
) |
|||
|
|||
// ServiceAccountHandlers contains HTTP handlers for service account management
|
|||
type ServiceAccountHandlers struct { |
|||
adminServer *dash.AdminServer |
|||
} |
|||
|
|||
// NewServiceAccountHandlers creates a new instance of ServiceAccountHandlers
|
|||
func NewServiceAccountHandlers(adminServer *dash.AdminServer) *ServiceAccountHandlers { |
|||
return &ServiceAccountHandlers{ |
|||
adminServer: adminServer, |
|||
} |
|||
} |
|||
|
|||
// ShowServiceAccounts renders the service accounts management page
|
|||
func (h *ServiceAccountHandlers) ShowServiceAccounts(c *gin.Context) { |
|||
data := h.getServiceAccountsData(c) |
|||
|
|||
// Render to buffer first to avoid partial writes on error
|
|||
var buf bytes.Buffer |
|||
component := app.ServiceAccounts(data) |
|||
layoutComponent := layout.Layout(c, component) |
|||
err := layoutComponent.Render(c.Request.Context(), &buf) |
|||
if err != nil { |
|||
glog.Errorf("Failed to render service accounts template: %v", err) |
|||
c.AbortWithStatus(http.StatusInternalServerError) |
|||
return |
|||
} |
|||
|
|||
// Only write to response if render succeeded
|
|||
c.Header("Content-Type", "text/html") |
|||
c.Writer.Write(buf.Bytes()) |
|||
} |
|||
|
|||
// GetServiceAccounts returns the list of service accounts as JSON
|
|||
func (h *ServiceAccountHandlers) GetServiceAccounts(c *gin.Context) { |
|||
parentUser := c.Query("parent_user") |
|||
|
|||
accounts, err := h.adminServer.GetServiceAccounts(c.Request.Context(), parentUser) |
|||
if err != nil { |
|||
glog.Errorf("Failed to get service accounts: %v", err) |
|||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get service accounts"}) |
|||
return |
|||
} |
|||
c.JSON(http.StatusOK, gin.H{"service_accounts": accounts}) |
|||
} |
|||
|
|||
// CreateServiceAccount handles service account creation
|
|||
func (h *ServiceAccountHandlers) CreateServiceAccount(c *gin.Context) { |
|||
var req dash.CreateServiceAccountRequest |
|||
if err := c.ShouldBindJSON(&req); err != nil { |
|||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) |
|||
return |
|||
} |
|||
|
|||
if req.ParentUser == "" { |
|||
c.JSON(http.StatusBadRequest, gin.H{"error": "ParentUser is required"}) |
|||
return |
|||
} |
|||
|
|||
sa, err := h.adminServer.CreateServiceAccount(c.Request.Context(), req) |
|||
if err != nil { |
|||
glog.Errorf("Failed to create service account for user %s: %v", req.ParentUser, err) |
|||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service account"}) |
|||
return |
|||
} |
|||
|
|||
c.JSON(http.StatusCreated, gin.H{ |
|||
"message": "Service account created successfully", |
|||
"service_account": sa, |
|||
}) |
|||
} |
|||
|
|||
// GetServiceAccountDetails returns detailed information about a service account
|
|||
func (h *ServiceAccountHandlers) GetServiceAccountDetails(c *gin.Context) { |
|||
id := c.Param("id") |
|||
if id == "" { |
|||
c.JSON(http.StatusBadRequest, gin.H{"error": "Service account ID is required"}) |
|||
return |
|||
} |
|||
|
|||
sa, err := h.adminServer.GetServiceAccountDetails(c.Request.Context(), id) |
|||
if err != nil { |
|||
// Distinguish not-found errors from internal errors
|
|||
if errors.Is(err, dash.ErrServiceAccountNotFound) { |
|||
c.JSON(http.StatusNotFound, gin.H{"error": "Service account not found: " + err.Error()}) |
|||
} else { |
|||
glog.Errorf("Failed to get service account details for %s: %v", id, err) |
|||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get service account details"}) |
|||
} |
|||
return |
|||
} |
|||
|
|||
c.JSON(http.StatusOK, sa) |
|||
} |
|||
|
|||
// UpdateServiceAccount handles service account updates
|
|||
func (h *ServiceAccountHandlers) UpdateServiceAccount(c *gin.Context) { |
|||
id := c.Param("id") |
|||
if id == "" { |
|||
c.JSON(http.StatusBadRequest, gin.H{"error": "Service account ID is required"}) |
|||
return |
|||
} |
|||
|
|||
var req dash.UpdateServiceAccountRequest |
|||
if err := c.ShouldBindJSON(&req); err != nil { |
|||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) |
|||
return |
|||
} |
|||
|
|||
sa, err := h.adminServer.UpdateServiceAccount(c.Request.Context(), id, req) |
|||
if err != nil { |
|||
// Distinguish not-found errors from internal errors
|
|||
if errors.Is(err, dash.ErrServiceAccountNotFound) { |
|||
c.JSON(http.StatusNotFound, gin.H{"error": "Service account not found"}) |
|||
} else { |
|||
glog.Errorf("Failed to update service account %s: %v", id, err) |
|||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service account"}) |
|||
} |
|||
return |
|||
} |
|||
|
|||
c.JSON(http.StatusOK, gin.H{ |
|||
"message": "Service account updated successfully", |
|||
"service_account": sa, |
|||
}) |
|||
} |
|||
|
|||
// DeleteServiceAccount handles service account deletion
|
|||
func (h *ServiceAccountHandlers) DeleteServiceAccount(c *gin.Context) { |
|||
id := c.Param("id") |
|||
if id == "" { |
|||
c.JSON(http.StatusBadRequest, gin.H{"error": "Service account ID is required"}) |
|||
return |
|||
} |
|||
|
|||
err := h.adminServer.DeleteServiceAccount(c.Request.Context(), id) |
|||
if err != nil { |
|||
// Distinguish not-found errors from internal errors
|
|||
if errors.Is(err, dash.ErrServiceAccountNotFound) { |
|||
c.JSON(http.StatusNotFound, gin.H{"error": "Service account not found"}) |
|||
} else { |
|||
glog.Errorf("Failed to delete service account %s: %v", id, err) |
|||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete service account"}) |
|||
} |
|||
return |
|||
} |
|||
|
|||
c.JSON(http.StatusOK, gin.H{ |
|||
"message": "Service account deleted successfully", |
|||
}) |
|||
} |
|||
|
|||
// getServiceAccountsData retrieves service accounts data for the template
|
|||
func (h *ServiceAccountHandlers) getServiceAccountsData(c *gin.Context) dash.ServiceAccountsData { |
|||
username := c.GetString("username") |
|||
if username == "" { |
|||
username = "admin" |
|||
} |
|||
|
|||
// Get all service accounts
|
|||
accounts, err := h.adminServer.GetServiceAccounts(c.Request.Context(), "") |
|||
if err != nil { |
|||
glog.Errorf("Failed to get service accounts: %v", err) |
|||
return dash.ServiceAccountsData{ |
|||
Username: username, |
|||
ServiceAccounts: []dash.ServiceAccount{}, |
|||
TotalAccounts: 0, |
|||
LastUpdated: time.Now(), |
|||
} |
|||
} |
|||
|
|||
// Count active accounts
|
|||
activeCount := 0 |
|||
for _, sa := range accounts { |
|||
if sa.Status == dash.StatusActive { |
|||
activeCount++ |
|||
} |
|||
} |
|||
|
|||
// Get available users for dropdown
|
|||
var availableUsers []string |
|||
users, err := h.adminServer.GetObjectStoreUsers(c.Request.Context()) |
|||
if err != nil { |
|||
glog.Errorf("Failed to get users for dropdown: %v", err) |
|||
} else { |
|||
for _, user := range users { |
|||
availableUsers = append(availableUsers, user.Username) |
|||
} |
|||
} |
|||
|
|||
return dash.ServiceAccountsData{ |
|||
Username: username, |
|||
ServiceAccounts: accounts, |
|||
TotalAccounts: len(accounts), |
|||
ActiveAccounts: activeCount, |
|||
AvailableUsers: availableUsers, |
|||
LastUpdated: time.Now(), |
|||
} |
|||
} |
|||
@ -0,0 +1,653 @@ |
|||
package app |
|||
|
|||
import ( |
|||
"fmt" |
|||
"github.com/seaweedfs/seaweedfs/weed/admin/dash" |
|||
) |
|||
|
|||
templ ServiceAccounts(data dash.ServiceAccountsData) { |
|||
<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-robot me-2"></i>Service Accounts |
|||
</h1> |
|||
<p class="mb-0 text-muted">Manage application credentials for automated processes</p> |
|||
</div> |
|||
<div class="d-flex gap-2"> |
|||
<button type="button" class="btn btn-primary" |
|||
data-bs-toggle="modal" |
|||
data-bs-target="#createServiceAccountModal"> |
|||
<i class="fas fa-plus me-1"></i>Create Service Account |
|||
</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 Service Accounts |
|||
</div> |
|||
<div class="h5 mb-0 font-weight-bold text-gray-800"> |
|||
{fmt.Sprintf("%d", data.TotalAccounts)} |
|||
</div> |
|||
</div> |
|||
<div class="col-auto"> |
|||
<i class="fas fa-id-card 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 Accounts |
|||
</div> |
|||
<div class="h5 mb-0 font-weight-bold text-gray-800"> |
|||
{fmt.Sprintf("%d", data.ActiveAccounts)} |
|||
</div> |
|||
</div> |
|||
<div class="col-auto"> |
|||
<i class="fas fa-check-circle 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-info 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-info text-uppercase mb-1"> |
|||
Last Updated |
|||
</div> |
|||
<div class="h6 mb-0 font-weight-bold text-gray-800"> |
|||
{data.LastUpdated.Format("15:04")} |
|||
</div> |
|||
</div> |
|||
<div class="col-auto"> |
|||
<i class="fas fa-clock fa-2x text-gray-300"></i> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Service Accounts Table --> |
|||
<div class="row"> |
|||
<div class="col-12"> |
|||
<div class="card shadow mb-4"> |
|||
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between"> |
|||
<h6 class="m-0 font-weight-bold text-primary"> |
|||
<i class="fas fa-robot me-2"></i>Service Accounts |
|||
</h6> |
|||
</div> |
|||
<div class="card-body"> |
|||
<div class="table-responsive"> |
|||
<table class="table table-hover" width="100%" cellspacing="0" id="serviceAccountsTable"> |
|||
<thead> |
|||
<tr> |
|||
<th>ID</th> |
|||
<th>Parent User</th> |
|||
<th>Access Key</th> |
|||
<th>Status</th> |
|||
<th>Created</th> |
|||
<th>Actions</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
for _, sa := range data.ServiceAccounts { |
|||
<tr> |
|||
<td> |
|||
<div class="d-flex align-items-center"> |
|||
<i class="fas fa-robot me-2 text-muted"></i> |
|||
<code>{sa.ID}</code> |
|||
</div> |
|||
</td> |
|||
<td> |
|||
<i class="fas fa-user me-1 text-muted"></i> |
|||
{sa.ParentUser} |
|||
</td> |
|||
<td> |
|||
<code class="text-muted">{sa.AccessKeyId}</code> |
|||
</td> |
|||
<td> |
|||
if sa.Status == "Active" { |
|||
<span class="badge bg-success">Active</span> |
|||
} else { |
|||
<span class="badge bg-secondary">Inactive</span> |
|||
} |
|||
</td> |
|||
<td>{sa.CreateDate.Format("2006-01-02")}</td> |
|||
<td> |
|||
<div class="btn-group btn-group-sm" role="group"> |
|||
<button type="button" class="btn btn-outline-info" |
|||
data-action="show-sa-details" data-sa-id={ sa.ID }> |
|||
<i class="fas fa-info-circle"></i> |
|||
</button> |
|||
<button type="button" class="btn btn-outline-primary" |
|||
data-action="toggle-sa-status" data-sa-id={ sa.ID } data-current-status={ sa.Status }> |
|||
if sa.Status == "Active" { |
|||
<i class="fas fa-pause"></i> |
|||
} else { |
|||
<i class="fas fa-play"></i> |
|||
} |
|||
</button> |
|||
<button type="button" class="btn btn-outline-danger" |
|||
data-action="delete-sa" data-sa-id={ sa.ID }> |
|||
<i class="fas fa-trash"></i> |
|||
</button> |
|||
</div> |
|||
</td> |
|||
</tr> |
|||
} |
|||
if len(data.ServiceAccounts) == 0 { |
|||
<tr> |
|||
<td colspan="6" class="text-center text-muted py-4"> |
|||
<i class="fas fa-robot fa-3x mb-3 text-muted"></i> |
|||
<div> |
|||
<h5>No service accounts found</h5> |
|||
<p>Create your first service account for automated processes.</p> |
|||
</div> |
|||
</td> |
|||
</tr> |
|||
} |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Last Updated --> |
|||
<div class="row"> |
|||
<div class="col-12"> |
|||
<small class="text-muted"> |
|||
<i class="fas fa-clock me-1"></i> |
|||
Last updated: {data.LastUpdated.Format("2006-01-02 15:04:05")} |
|||
</small> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Create Service Account Modal --> |
|||
<div class="modal fade" id="createServiceAccountModal" tabindex="-1" role="dialog"> |
|||
<div class="modal-dialog" role="document"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header"> |
|||
<h5 class="modal-title"> |
|||
<i class="fas fa-plus me-2"></i>Create Service Account |
|||
</h5> |
|||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> |
|||
</div> |
|||
<div class="modal-body"> |
|||
<form id="createSAForm"> |
|||
<div class="mb-3"> |
|||
<label for="parentUser" class="form-label">Parent User *</label> |
|||
<select class="form-select" id="parentUser" name="parent_user" required> |
|||
<option value="">-- Select a user --</option> |
|||
for _, user := range data.AvailableUsers { |
|||
<option value={ user }>{ user }</option> |
|||
} |
|||
</select> |
|||
<small class="form-text text-muted">The service account will inherit permissions from this user</small> |
|||
</div> |
|||
<div class="mb-3"> |
|||
<label for="description" class="form-label">Description</label> |
|||
<textarea class="form-control" id="description" name="description" rows="2" |
|||
placeholder="What is this service account used for?"></textarea> |
|||
</div> |
|||
<div class="mb-3"> |
|||
<label for="expiration" class="form-label">Expiration (optional)</label> |
|||
<input type="datetime-local" class="form-control" id="expiration" name="expiration"> |
|||
<small class="form-text text-muted">Leave empty for no expiration</small> |
|||
</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="handleCreateServiceAccount()">Create</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Service Account Details Modal --> |
|||
<div class="modal fade" id="saDetailsModal" tabindex="-1" role="dialog"> |
|||
<div class="modal-dialog modal-lg" role="document"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header"> |
|||
<h5 class="modal-title"> |
|||
<i class="fas fa-robot me-2"></i>Service Account Details |
|||
</h5> |
|||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> |
|||
</div> |
|||
<div class="modal-body" id="saDetailsContent"> |
|||
<!-- Content will be loaded dynamically --> |
|||
</div> |
|||
<div class="modal-footer"> |
|||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Credentials Display Modal --> |
|||
<div class="modal fade" id="credentialsModal" tabindex="-1" role="dialog" data-bs-backdrop="static" data-bs-keyboard="false"> |
|||
<div class="modal-dialog modal-lg" role="document"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header bg-success text-white"> |
|||
<h5 class="modal-title"> |
|||
<i class="fas fa-check-circle me-2"></i>Service Account Created Successfully |
|||
</h5> |
|||
</div> |
|||
<div class="modal-body"> |
|||
<div class="alert alert-warning"> |
|||
<i class="fas fa-exclamation-triangle me-2"></i> |
|||
<strong>Important:</strong> This is the only time you will see the secret access key. Please save it securely. |
|||
</div> |
|||
|
|||
<div class="mb-4"> |
|||
<h6 class="text-muted mb-3">AWS CLI Configuration</h6> |
|||
<p class="text-muted small">Use these credentials to configure AWS CLI or SDKs:</p> |
|||
|
|||
<div class="mb-3"> |
|||
<label class="form-label fw-bold">AWS_ACCESS_KEY_ID</label> |
|||
<div class="input-group"> |
|||
<input type="text" class="form-control font-monospace" id="displayAccessKey" readonly> |
|||
<button class="btn btn-outline-secondary" type="button" onclick="copyToClipboard(event, 'displayAccessKey')"> |
|||
<i class="fas fa-copy"></i> Copy |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="mb-3"> |
|||
<label class="form-label fw-bold">AWS_SECRET_ACCESS_KEY</label> |
|||
<div class="input-group"> |
|||
<input type="text" class="form-control font-monospace" id="displaySecretKey" readonly> |
|||
<button class="btn btn-outline-secondary" type="button" onclick="copyToClipboard(event, 'displaySecretKey')"> |
|||
<i class="fas fa-copy"></i> Copy |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="bg-light p-3 rounded"> |
|||
<h6 class="text-muted mb-2">Example AWS CLI Usage:</h6> |
|||
<div class="font-monospace small"> |
|||
<div>export AWS_ACCESS_KEY_ID=<span id="exampleAccessKey"></span></div> |
|||
<div>export AWS_SECRET_ACCESS_KEY=<span id="exampleSecretKey"></span></div> |
|||
<div>export AWS_ENDPOINT_URL=http://localhost:8333</div> |
|||
<div class="mt-2"># List buckets</div> |
|||
<div>aws s3 ls</div> |
|||
<div class="mt-2"># Upload a file</div> |
|||
<div>aws s3 cp myfile.txt s3://mybucket/</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="modal-footer"> |
|||
<button type="button" class="btn btn-primary" onclick="closeCredentialsModal()"> |
|||
<i class="fas fa-check me-1"></i>I have saved the credentials |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- JavaScript for service account management --> |
|||
<script> |
|||
document.addEventListener('DOMContentLoaded', function() { |
|||
document.addEventListener('click', function(e) { |
|||
const button = e.target.closest('[data-action]'); |
|||
if (!button) return; |
|||
|
|||
const action = button.getAttribute('data-action'); |
|||
const saId = button.getAttribute('data-sa-id'); |
|||
|
|||
switch (action) { |
|||
case 'show-sa-details': |
|||
showSADetails(saId); |
|||
break; |
|||
case 'toggle-sa-status': |
|||
toggleSAStatus(saId, button.getAttribute('data-current-status')); |
|||
break; |
|||
case 'delete-sa': |
|||
deleteSA(saId); |
|||
break; |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
async function showSADetails(id) { |
|||
try { |
|||
const response = await fetch(`/api/service-accounts/${id}`); |
|||
if (response.ok) { |
|||
const sa = await response.json(); |
|||
document.getElementById('saDetailsContent').innerHTML = createSADetailsContent(sa); |
|||
const modal = new bootstrap.Modal(document.getElementById('saDetailsModal')); |
|||
modal.show(); |
|||
} else { |
|||
showErrorMessage('Failed to load service account details'); |
|||
} |
|||
} catch (error) { |
|||
console.error('Error loading service account details:', error); |
|||
showErrorMessage('Failed to load service account details'); |
|||
} |
|||
} |
|||
|
|||
async function toggleSAStatus(id, currentStatus) { |
|||
const newStatus = currentStatus === 'Active' ? 'Inactive' : 'Active'; |
|||
try { |
|||
const response = await fetch(`/api/service-accounts/${id}`, { |
|||
method: 'PUT', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify({ status: newStatus }) |
|||
}); |
|||
|
|||
if (response.ok) { |
|||
showSuccessMessage(`Service account ${newStatus === 'Active' ? 'activated' : 'deactivated'}`); |
|||
setTimeout(() => window.location.reload(), 1000); |
|||
} else { |
|||
const error = await response.json(); |
|||
showErrorMessage('Failed to update status: ' + (error.error || 'Unknown error')); |
|||
} |
|||
} catch (error) { |
|||
console.error('Error updating status:', error); |
|||
showErrorMessage('Failed to update status: ' + error.message); |
|||
} |
|||
} |
|||
|
|||
async function deleteSA(id) { |
|||
if (confirm('Are you sure you want to delete this service account? This action cannot be undone.')) { |
|||
try { |
|||
const response = await fetch(`/api/service-accounts/${id}`, { |
|||
method: 'DELETE' |
|||
}); |
|||
|
|||
if (response.ok) { |
|||
showSuccessMessage('Service account deleted successfully'); |
|||
setTimeout(() => window.location.reload(), 1000); |
|||
} else { |
|||
const error = await response.json(); |
|||
showErrorMessage('Failed to delete: ' + (error.error || 'Unknown error')); |
|||
} |
|||
} catch (error) { |
|||
console.error('Error deleting service account:', error); |
|||
showErrorMessage('Failed to delete: ' + error.message); |
|||
} |
|||
} |
|||
} |
|||
|
|||
async function handleCreateServiceAccount() { |
|||
const form = document.getElementById('createSAForm'); |
|||
const formData = new FormData(form); |
|||
|
|||
const saData = { |
|||
parent_user: formData.get('parent_user'), |
|||
description: formData.get('description') |
|||
}; |
|||
|
|||
// Handle expiration if set |
|||
const expiration = formData.get('expiration'); |
|||
if (expiration) { |
|||
// Validate the date before using it |
|||
const date = new Date(expiration); |
|||
const now = new Date(); |
|||
|
|||
if (isNaN(date.getTime())) { |
|||
showErrorMessage('Invalid expiration date format'); |
|||
return; |
|||
} |
|||
|
|||
// Ensure expiration is in the future |
|||
if (date <= now) { |
|||
showErrorMessage('Expiration date must be in the future'); |
|||
return; |
|||
} |
|||
|
|||
saData.expiration = date.toISOString(); |
|||
} |
|||
|
|||
try { |
|||
const response = await fetch('/api/service-accounts', { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify(saData) |
|||
}); |
|||
|
|||
if (response.ok) { |
|||
const result = await response.json(); |
|||
|
|||
// Hide create modal |
|||
const createModal = bootstrap.Modal.getInstance(document.getElementById('createServiceAccountModal')); |
|||
createModal.hide(); |
|||
form.reset(); |
|||
|
|||
// Show credentials if returned |
|||
if (result.service_account && result.service_account.secret_access_key) { |
|||
showCredentials(result.service_account); |
|||
} else { |
|||
showSuccessMessage('Service account created successfully'); |
|||
setTimeout(() => window.location.reload(), 1000); |
|||
} |
|||
} else { |
|||
const error = await response.json(); |
|||
showErrorMessage('Failed to create service account: ' + (error.error || 'Unknown error')); |
|||
} |
|||
} catch (error) { |
|||
console.error('Error creating service account:', error); |
|||
showErrorMessage('Failed to create service account: ' + error.message); |
|||
} |
|||
} |
|||
|
|||
function showCredentials(serviceAccount) { |
|||
// Populate the credentials modal |
|||
document.getElementById('displayAccessKey').value = serviceAccount.access_key_id; |
|||
document.getElementById('displaySecretKey').value = serviceAccount.secret_access_key; |
|||
document.getElementById('exampleAccessKey').textContent = serviceAccount.access_key_id; |
|||
document.getElementById('exampleSecretKey').textContent = serviceAccount.secret_access_key; |
|||
|
|||
// Show the modal |
|||
const credentialsModal = new bootstrap.Modal(document.getElementById('credentialsModal')); |
|||
credentialsModal.show(); |
|||
} |
|||
|
|||
function closeCredentialsModal() { |
|||
const modal = bootstrap.Modal.getInstance(document.getElementById('credentialsModal')); |
|||
modal.hide(); |
|||
// Reload to show the new service account in the list |
|||
setTimeout(() => window.location.reload(), 500); |
|||
} |
|||
|
|||
function copyToClipboard(event, elementId) { |
|||
const element = document.getElementById(elementId); |
|||
|
|||
// Use modern Clipboard API if available |
|||
if (navigator.clipboard) { |
|||
navigator.clipboard.writeText(element.value).then(() => { |
|||
// Success feedback could be added here |
|||
}).catch(err => { |
|||
console.warn('Clipboard API failed:', err); |
|||
// Fallback |
|||
element.select(); |
|||
document.execCommand('copy'); |
|||
}); |
|||
} else { |
|||
// Fallback for older browsers |
|||
element.select(); |
|||
document.execCommand('copy'); |
|||
} |
|||
|
|||
// Visual feedback |
|||
const button = event.target.closest('button'); |
|||
const originalHTML = button.innerHTML; |
|||
|
|||
button.innerHTML = '<i class="fas fa-check"></i>'; |
|||
button.classList.remove('btn-outline-secondary'); |
|||
button.classList.add('btn-success'); |
|||
|
|||
setTimeout(() => { |
|||
button.innerHTML = originalHTML; |
|||
button.classList.remove('btn-success'); |
|||
button.classList.add('btn-outline-secondary'); |
|||
}, 1000); |
|||
} |
|||
|
|||
|
|||
function createSADetailsContent(sa) { |
|||
// Create DOM elements safely to prevent XSS |
|||
const container = document.createElement('div'); |
|||
container.className = 'row'; |
|||
|
|||
// Basic Information column |
|||
const col1 = document.createElement('div'); |
|||
col1.className = 'col-md-6'; |
|||
|
|||
const h6_1 = document.createElement('h6'); |
|||
h6_1.className = 'text-muted'; |
|||
h6_1.textContent = 'Basic Information'; |
|||
col1.appendChild(h6_1); |
|||
|
|||
const table1 = document.createElement('table'); |
|||
table1.className = 'table table-sm'; |
|||
|
|||
// ID row |
|||
const idRow = document.createElement('tr'); |
|||
idRow.innerHTML = '<td><strong>ID:</strong></td><td><code></code></td>'; |
|||
idRow.querySelector('code').textContent = sa.id || ''; |
|||
table1.appendChild(idRow); |
|||
|
|||
// Parent User row |
|||
const parentRow = document.createElement('tr'); |
|||
parentRow.innerHTML = '<td><strong>Parent User:</strong></td><td></td>'; |
|||
parentRow.querySelectorAll('td')[1].textContent = sa.parent_user || ''; |
|||
table1.appendChild(parentRow); |
|||
|
|||
// Access Key row |
|||
const keyRow = document.createElement('tr'); |
|||
keyRow.innerHTML = '<td><strong>Access Key:</strong></td><td><code></code></td>'; |
|||
keyRow.querySelector('code').textContent = sa.access_key_id || ''; |
|||
table1.appendChild(keyRow); |
|||
|
|||
// Status row |
|||
const statusRow = document.createElement('tr'); |
|||
const statusTd1 = document.createElement('td'); |
|||
statusTd1.innerHTML = '<strong>Status:</strong>'; |
|||
const statusTd2 = document.createElement('td'); |
|||
const statusBadge = document.createElement('span'); |
|||
statusBadge.className = sa.status === 'Active' ? 'badge bg-success' : 'badge bg-secondary'; |
|||
statusBadge.textContent = sa.status || 'Unknown'; |
|||
statusTd2.appendChild(statusBadge); |
|||
statusRow.appendChild(statusTd1); |
|||
statusRow.appendChild(statusTd2); |
|||
table1.appendChild(statusRow); |
|||
|
|||
col1.appendChild(table1); |
|||
container.appendChild(col1); |
|||
|
|||
// Details column |
|||
const col2 = document.createElement('div'); |
|||
col2.className = 'col-md-6'; |
|||
|
|||
const h6_2 = document.createElement('h6'); |
|||
h6_2.className = 'text-muted'; |
|||
h6_2.textContent = 'Details'; |
|||
col2.appendChild(h6_2); |
|||
|
|||
const table2 = document.createElement('table'); |
|||
table2.className = 'table table-sm'; |
|||
|
|||
// Description row |
|||
const descRow = document.createElement('tr'); |
|||
descRow.innerHTML = '<td><strong>Description:</strong></td><td></td>'; |
|||
descRow.querySelectorAll('td')[1].textContent = sa.description || 'Not set'; |
|||
table2.appendChild(descRow); |
|||
|
|||
// Created row |
|||
const createdRow = document.createElement('tr'); |
|||
createdRow.innerHTML = '<td><strong>Created:</strong></td><td></td>'; |
|||
try { |
|||
createdRow.querySelectorAll('td')[1].textContent = new Date(sa.create_date).toLocaleString(); |
|||
} catch (e) { |
|||
createdRow.querySelectorAll('td')[1].textContent = 'Invalid date'; |
|||
} |
|||
table2.appendChild(createdRow); |
|||
|
|||
// Expiration row |
|||
const expRow = document.createElement('tr'); |
|||
expRow.innerHTML = '<td><strong>Expires:</strong></td><td></td>'; |
|||
expRow.querySelectorAll('td')[1].textContent = sa.expiration || 'Never'; |
|||
table2.appendChild(expRow); |
|||
|
|||
col2.appendChild(table2); |
|||
container.appendChild(col2); |
|||
|
|||
return container.outerHTML; |
|||
} |
|||
|
|||
|
|||
function showSuccessMessage(message) { |
|||
showToast(message, 'success'); |
|||
} |
|||
|
|||
function showErrorMessage(message) { |
|||
showToast(message, 'danger'); |
|||
} |
|||
|
|||
function showToast(message, type) { |
|||
// Create toast container if it doesn't exist |
|||
let toastContainer = document.getElementById('toastContainer'); |
|||
if (!toastContainer) { |
|||
toastContainer = document.createElement('div'); |
|||
toastContainer.id = 'toastContainer'; |
|||
toastContainer.className = 'toast-container position-fixed top-0 end-0 p-3'; |
|||
toastContainer.style.zIndex = '9999'; |
|||
document.body.appendChild(toastContainer); |
|||
} |
|||
|
|||
// Create toast element |
|||
const toastId = 'toast-' + Date.now(); |
|||
const toastHTML = ` |
|||
<div id="${toastId}" class="toast align-items-center text-white bg-${type} border-0" role="alert" aria-live="assertive" aria-atomic="true"> |
|||
<div class="d-flex"> |
|||
<div class="toast-body"> |
|||
${escapeHtml(message)} |
|||
</div> |
|||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button> |
|||
</div> |
|||
</div> |
|||
`; |
|||
|
|||
toastContainer.insertAdjacentHTML('beforeend', toastHTML); |
|||
const toastElement = document.getElementById(toastId); |
|||
const toast = new bootstrap.Toast(toastElement, { autohide: true, delay: 5000 }); |
|||
toast.show(); |
|||
|
|||
// Remove toast element after it's hidden |
|||
toastElement.addEventListener('hidden.bs.toast', () => { |
|||
toastElement.remove(); |
|||
}); |
|||
} |
|||
|
|||
function escapeHtml(text) { |
|||
const div = document.createElement('div'); |
|||
div.textContent = text; |
|||
return div.innerHTML; |
|||
} |
|||
</script> |
|||
} |
|||
283
weed/admin/view/app/service_accounts_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