Browse Source

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.go
fix-bucket-name-case-7910
Chris Lu 15 hours ago
committed by GitHub
parent
commit
b6d99f1c9e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 7
      weed/admin/dash/admin_server.go
  2. 53
      weed/admin/dash/service_account_helpers.go
  3. 434
      weed/admin/dash/service_account_management.go
  4. 46
      weed/admin/dash/types.go
  5. 57
      weed/admin/handlers/admin_handlers.go
  6. 213
      weed/admin/handlers/service_account_handlers.go
  7. 4
      weed/admin/handlers/user_handlers.go
  8. 22
      weed/admin/view/app/maintenance_queue.templ
  9. 18
      weed/admin/view/app/maintenance_queue_templ.go
  10. 653
      weed/admin/view/app/service_accounts.templ
  11. 283
      weed/admin/view/app/service_accounts_templ.go
  12. 5
      weed/admin/view/layout/layout.templ
  13. 24
      weed/admin/view/layout/layout_templ.go

7
weed/admin/dash/admin_server.go

@ -623,7 +623,7 @@ func (s *AdminServer) DeleteS3Bucket(bucketName string) error {
}
// GetObjectStoreUsers retrieves object store users from identity.json
func (s *AdminServer) GetObjectStoreUsers() ([]ObjectStoreUser, error) {
func (s *AdminServer) GetObjectStoreUsers(ctx context.Context) ([]ObjectStoreUser, error) {
s3cfg := &iam_pb.S3ApiConfiguration{}
// Load IAM configuration from filer
@ -656,6 +656,11 @@ func (s *AdminServer) GetObjectStoreUsers() ([]ObjectStoreUser, error) {
continue
}
// Skip service accounts - they should not be parent users
if strings.HasPrefix(identity.Name, serviceAccountPrefix) {
continue
}
user := ObjectStoreUser{
Username: identity.Name,
Permissions: identity.Actions,

53
weed/admin/dash/service_account_helpers.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
}

434
weed/admin/dash/service_account_management.go

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

46
weed/admin/dash/types.go

@ -552,3 +552,49 @@ type CollectionDetailsData struct {
SortBy string `json:"sort_by"`
SortOrder string `json:"sort_order"`
}
// Service Account management structures
type ServiceAccount struct {
ID string `json:"id"`
ParentUser string `json:"parent_user"`
Description string `json:"description,omitempty"`
AccessKeyId string `json:"access_key_id,omitempty"`
SecretAccessKey string `json:"secret_access_key,omitempty"` // Only returned on creation
Status string `json:"status"`
CreateDate time.Time `json:"create_date"`
Expiration time.Time `json:"expiration,omitempty"`
// ServiceAccountId is used when returning a single ID in some API responses
ServiceAccountId string `json:"service_account_id,omitempty"`
// ServiceAccountIds is used when returning a list of IDs owned by a user
ServiceAccountIds []string `json:"service_account_ids,omitempty"`
}
type ServiceAccountsData struct {
Username string `json:"username"`
ServiceAccounts []ServiceAccount `json:"service_accounts"`
TotalAccounts int `json:"total_accounts"`
ActiveAccounts int `json:"active_accounts"`
AvailableUsers []string `json:"available_users"` // For parent user dropdown
LastUpdated time.Time `json:"last_updated"`
}
type CreateServiceAccountRequest struct {
ParentUser string `json:"parent_user"`
Description string `json:"description,omitempty"`
Expiration string `json:"expiration,omitempty"` // RFC3339 format
}
type UpdateServiceAccountRequest struct {
Status string `json:"status,omitempty"` // Active, Inactive
Description string `json:"description,omitempty"`
Expiration string `json:"expiration,omitempty"`
}
// STS Configuration display types
type STSConfigData struct {
Enabled bool `json:"enabled"`
Issuer string `json:"issuer,omitempty"`
TokenDuration string `json:"token_duration,omitempty"`
Providers []string `json:"providers,omitempty"`
LastUpdated time.Time `json:"last_updated"`
}

57
weed/admin/handlers/admin_handlers.go

@ -14,14 +14,15 @@ import (
// AdminHandlers contains all the HTTP handlers for the admin interface
type AdminHandlers struct {
adminServer *dash.AdminServer
authHandlers *AuthHandlers
clusterHandlers *ClusterHandlers
fileBrowserHandlers *FileBrowserHandlers
userHandlers *UserHandlers
policyHandlers *PolicyHandlers
maintenanceHandlers *MaintenanceHandlers
mqHandlers *MessageQueueHandlers
adminServer *dash.AdminServer
authHandlers *AuthHandlers
clusterHandlers *ClusterHandlers
fileBrowserHandlers *FileBrowserHandlers
userHandlers *UserHandlers
policyHandlers *PolicyHandlers
maintenanceHandlers *MaintenanceHandlers
mqHandlers *MessageQueueHandlers
serviceAccountHandlers *ServiceAccountHandlers
}
// NewAdminHandlers creates a new instance of AdminHandlers
@ -33,15 +34,17 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers {
policyHandlers := NewPolicyHandlers(adminServer)
maintenanceHandlers := NewMaintenanceHandlers(adminServer)
mqHandlers := NewMessageQueueHandlers(adminServer)
serviceAccountHandlers := NewServiceAccountHandlers(adminServer)
return &AdminHandlers{
adminServer: adminServer,
authHandlers: authHandlers,
clusterHandlers: clusterHandlers,
fileBrowserHandlers: fileBrowserHandlers,
userHandlers: userHandlers,
policyHandlers: policyHandlers,
maintenanceHandlers: maintenanceHandlers,
mqHandlers: mqHandlers,
adminServer: adminServer,
authHandlers: authHandlers,
clusterHandlers: clusterHandlers,
fileBrowserHandlers: fileBrowserHandlers,
userHandlers: userHandlers,
policyHandlers: policyHandlers,
maintenanceHandlers: maintenanceHandlers,
mqHandlers: mqHandlers,
serviceAccountHandlers: serviceAccountHandlers,
}
}
@ -77,6 +80,7 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser,
protected.GET("/object-store/buckets/:bucket", h.ShowBucketDetails)
protected.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers)
protected.GET("/object-store/policies", h.policyHandlers.ShowPolicies)
protected.GET("/object-store/service-accounts", h.serviceAccountHandlers.ShowServiceAccounts)
// File browser routes
protected.GET("/files", h.fileBrowserHandlers.ShowFileBrowser)
@ -143,6 +147,16 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser,
usersApi.PUT("/:username/policies", dash.RequireWriteAccess(), h.userHandlers.UpdateUserPolicies)
}
// Service Account management API routes
saApi := api.Group("/service-accounts")
{
saApi.GET("", h.serviceAccountHandlers.GetServiceAccounts)
saApi.POST("", dash.RequireWriteAccess(), h.serviceAccountHandlers.CreateServiceAccount)
saApi.GET("/:id", h.serviceAccountHandlers.GetServiceAccountDetails)
saApi.PUT("/:id", dash.RequireWriteAccess(), h.serviceAccountHandlers.UpdateServiceAccount)
saApi.DELETE("/:id", dash.RequireWriteAccess(), h.serviceAccountHandlers.DeleteServiceAccount)
}
// Object Store Policy management API routes
objectStorePoliciesApi := api.Group("/object-store/policies")
{
@ -207,6 +221,7 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser,
r.GET("/object-store/buckets/:bucket", h.ShowBucketDetails)
r.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers)
r.GET("/object-store/policies", h.policyHandlers.ShowPolicies)
r.GET("/object-store/service-accounts", h.serviceAccountHandlers.ShowServiceAccounts)
// File browser routes
r.GET("/files", h.fileBrowserHandlers.ShowFileBrowser)
@ -272,6 +287,16 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser,
usersApi.PUT("/:username/policies", h.userHandlers.UpdateUserPolicies)
}
// Service Account management API routes
saApi := api.Group("/service-accounts")
{
saApi.GET("", h.serviceAccountHandlers.GetServiceAccounts)
saApi.POST("", h.serviceAccountHandlers.CreateServiceAccount)
saApi.GET("/:id", h.serviceAccountHandlers.GetServiceAccountDetails)
saApi.PUT("/:id", h.serviceAccountHandlers.UpdateServiceAccount)
saApi.DELETE("/:id", h.serviceAccountHandlers.DeleteServiceAccount)
}
// Object Store Policy management API routes
objectStorePoliciesApi := api.Group("/object-store/policies")
{

213
weed/admin/handlers/service_account_handlers.go

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

4
weed/admin/handlers/user_handlers.go

@ -41,7 +41,7 @@ func (h *UserHandlers) ShowObjectStoreUsers(c *gin.Context) {
// GetUsers returns the list of users as JSON
func (h *UserHandlers) GetUsers(c *gin.Context) {
users, err := h.adminServer.GetObjectStoreUsers()
users, err := h.adminServer.GetObjectStoreUsers(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get users: " + err.Error()})
return
@ -234,7 +234,7 @@ func (h *UserHandlers) getObjectStoreUsersData(c *gin.Context) dash.ObjectStoreU
}
// Get object store users
users, err := h.adminServer.GetObjectStoreUsers()
users, err := h.adminServer.GetObjectStoreUsers(c.Request.Context())
if err != nil {
glog.Errorf("Failed to get object store users: %v", err)
// Return empty data on error

22
weed/admin/view/app/maintenance_queue.templ

@ -320,14 +320,14 @@ templ MaintenanceQueue(data *maintenance.MaintenanceQueueData) {
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Maintenance scan triggered successfully');
showToast('Success', 'Maintenance scan triggered successfully', 'success');
setTimeout(() => window.location.reload(), 2000);
} else {
alert('Failed to trigger scan: ' + (data.error || 'Unknown error'));
showToast('Error', 'Failed to trigger scan: ' + (data.error || 'Unknown error'), 'danger');
}
})
.catch(error => {
alert('Error: ' + error.message);
showToast('Error', 'Error: ' + error.message, 'danger');
});
};
@ -412,18 +412,4 @@ func formatDuration(d time.Duration) string {
}
}
func formatTimeAgo(t time.Time) string {
duration := time.Since(t)
if duration < time.Minute {
return "just now"
} else if duration < time.Hour {
minutes := int(duration.Minutes())
return fmt.Sprintf("%dm ago", minutes)
} else if duration < 24*time.Hour {
hours := int(duration.Hours())
return fmt.Sprintf("%dh ago", hours)
} else {
days := int(duration.Hours() / 24)
return fmt.Sprintf("%dd ago", days)
}
}

18
weed/admin/view/app/maintenance_queue_templ.go

@ -610,7 +610,7 @@ func MaintenanceQueue(data *maintenance.MaintenanceQueueData) templ.Component {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</div></div></div></div></div><script>\n // Debug output to browser console\n console.log(\"DEBUG: Maintenance Queue Template loaded\");\n \n // Auto-refresh every 10 seconds\n setInterval(function() {\n if (!document.hidden) {\n window.location.reload();\n }\n }, 10000);\n\n window.triggerScan = function() {\n console.log(\"triggerScan called\");\n fetch('/api/maintenance/scan', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n }\n })\n .then(response => response.json())\n .then(data => {\n if (data.success) {\n alert('Maintenance scan triggered successfully');\n setTimeout(() => window.location.reload(), 2000);\n } else {\n alert('Failed to trigger scan: ' + (data.error || 'Unknown error'));\n }\n })\n .catch(error => {\n alert('Error: ' + error.message);\n });\n };\n\n window.refreshPage = function() {\n console.log(\"refreshPage called\");\n window.location.reload();\n };\n\n window.navigateToTask = function(element) {\n const taskId = element.getAttribute('data-task-id');\n if (taskId) {\n window.location.href = '/maintenance/tasks/' + taskId;\n }\n };\n </script>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</div></div></div></div></div><script>\n // Debug output to browser console\n console.log(\"DEBUG: Maintenance Queue Template loaded\");\n \n // Auto-refresh every 10 seconds\n setInterval(function() {\n if (!document.hidden) {\n window.location.reload();\n }\n }, 10000);\n\n window.triggerScan = function() {\n console.log(\"triggerScan called\");\n fetch('/api/maintenance/scan', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n }\n })\n .then(response => response.json())\n .then(data => {\n if (data.success) {\n showToast('Success', 'Maintenance scan triggered successfully', 'success');\n setTimeout(() => window.location.reload(), 2000);\n } else {\n showToast('Error', 'Failed to trigger scan: ' + (data.error || 'Unknown error'), 'danger');\n }\n })\n .catch(error => {\n showToast('Error', 'Error: ' + error.message, 'danger');\n });\n };\n\n window.refreshPage = function() {\n console.log(\"refreshPage called\");\n window.location.reload();\n };\n\n window.navigateToTask = function(element) {\n const taskId = element.getAttribute('data-task-id');\n if (taskId) {\n window.location.href = '/maintenance/tasks/' + taskId;\n }\n };\n </script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -857,20 +857,4 @@ func formatDuration(d time.Duration) string {
}
}
func formatTimeAgo(t time.Time) string {
duration := time.Since(t)
if duration < time.Minute {
return "just now"
} else if duration < time.Hour {
minutes := int(duration.Minutes())
return fmt.Sprintf("%dm ago", minutes)
} else if duration < 24*time.Hour {
hours := int(duration.Hours())
return fmt.Sprintf("%dh ago", hours)
} else {
days := int(duration.Hours() / 24)
return fmt.Sprintf("%dd ago", days)
}
}
var _ = templruntime.GeneratedTemplate

653
weed/admin/view/app/service_accounts.templ

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

5
weed/admin/view/layout/layout.templ

@ -168,6 +168,11 @@ templ Layout(c *gin.Context, content templ.Component) {
<i class="fas fa-users me-2"></i>Users
</a>
</li>
<li class="nav-item">
<a class="nav-link py-2" href="/object-store/service-accounts">
<i class="fas fa-robot me-2"></i>Service Accounts
</a>
</li>
<li class="nav-item">
<a class="nav-link py-2" href="/object-store/policies">
<i class="fas fa-shield-alt me-2"></i>Policies

24
weed/admin/view/layout/layout_templ.go

@ -181,7 +181,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" id=\"storageSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/storage/volumes\"><i class=\"fas fa-database me-2\"></i>Volumes</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/storage/ec-shards\"><i class=\"fas fa-th-large me-2\"></i>EC Volumes</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/storage/collections\"><i class=\"fas fa-layer-group me-2\"></i>Collections</a></li></ul></div></li></ul><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MANAGEMENT</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/files\"><i class=\"fas fa-folder me-2\"></i>File Browser</a></li><li class=\"nav-item\"><a class=\"nav-link collapsed\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#objectStoreSubmenu\" aria-expanded=\"false\" aria-controls=\"objectStoreSubmenu\"><i class=\"fas fa-cloud me-2\"></i>Object Store <i class=\"fas fa-chevron-down ms-auto\"></i></a><div class=\"collapse\" id=\"objectStoreSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/buckets\"><i class=\"fas fa-cube me-2\"></i>Buckets</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/users\"><i class=\"fas fa-users me-2\"></i>Users</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/policies\"><i class=\"fas fa-shield-alt me-2\"></i>Policies</a></li></ul></div></li><li class=\"nav-item\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" id=\"storageSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/storage/volumes\"><i class=\"fas fa-database me-2\"></i>Volumes</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/storage/ec-shards\"><i class=\"fas fa-th-large me-2\"></i>EC Volumes</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/storage/collections\"><i class=\"fas fa-layer-group me-2\"></i>Collections</a></li></ul></div></li></ul><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MANAGEMENT</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/files\"><i class=\"fas fa-folder me-2\"></i>File Browser</a></li><li class=\"nav-item\"><a class=\"nav-link collapsed\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#objectStoreSubmenu\" aria-expanded=\"false\" aria-controls=\"objectStoreSubmenu\"><i class=\"fas fa-cloud me-2\"></i>Object Store <i class=\"fas fa-chevron-down ms-auto\"></i></a><div class=\"collapse\" id=\"objectStoreSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/buckets\"><i class=\"fas fa-cube me-2\"></i>Buckets</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/users\"><i class=\"fas fa-users me-2\"></i>Users</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/service-accounts\"><i class=\"fas fa-robot me-2\"></i>Service Accounts</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/policies\"><i class=\"fas fa-shield-alt me-2\"></i>Policies</a></li></ul></div></li><li class=\"nav-item\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -271,7 +271,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var13 templ.SafeURL
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 277, Col: 117}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 282, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@ -306,7 +306,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 278, Col: 109}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 283, Col: 109}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
@ -324,7 +324,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var17 templ.SafeURL
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 281, Col: 110}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 286, Col: 110}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
@ -359,7 +359,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 282, Col: 109}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 287, Col: 109}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
@ -392,7 +392,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var21 templ.SafeURL
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 294, Col: 106}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 299, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
@ -427,7 +427,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 295, Col: 105}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 300, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
@ -488,7 +488,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", time.Now().Year()))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 342, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 347, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
@ -501,7 +501,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(version.VERSION_NUMBER)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 342, Col: 102}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 347, Col: 102}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
@ -553,7 +553,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 366, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 371, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
@ -566,7 +566,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 380, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 385, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
@ -584,7 +584,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 387, Col: 45}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 392, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {

Loading…
Cancel
Save