From 4656d1bce9d9a445ba0f67e2fd38bd57d0772dd7 Mon Sep 17 00:00:00 2001 From: Noste <83548733+Noooste@users.noreply.github.com> Date: Sat, 20 Dec 2025 12:24:53 +0100 Subject: [PATCH] feat: add functionality to create and reveal secret access keys for users Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com> --- .gitignore | 3 +- Dockerfile | 2 +- README.md | 647 ++++++++++++++++++++++ backend/internal/handlers/buckets.go | 17 +- backend/internal/handlers/users.go | 35 ++ backend/internal/models/garage_admin.go | 2 +- backend/internal/routes/routes.go | 5 +- backend/internal/services/garage_admin.go | 2 +- docker-compose.yml | 2 +- frontend/package-lock.json | 24 +- frontend/src/lib/api.ts | 5 + frontend/src/pages/AccessControl.tsx | 426 ++++++++++---- frontend/src/types/index.ts | 1 + 13 files changed, 1033 insertions(+), 138 deletions(-) create mode 100644 README.md diff --git a/.gitignore b/.gitignore index b82b1be..3d57463 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,5 @@ data/ meta/ garage.toml -backend/docs/ \ No newline at end of file +backend/docs/ +config.yaml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 7aa3002..3de9bca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /app/frontend COPY frontend/package.json frontend/package-lock.json* ./ -RUN npm ci +RUN npm install COPY frontend/ . diff --git a/README.md b/README.md new file mode 100644 index 0000000..953fc16 --- /dev/null +++ b/README.md @@ -0,0 +1,647 @@ +# Garage UI + +A modern, full-stack web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) distributed object storage systems. Built with React, TypeScript, Go, and Fiber for production-ready S3-compatible storage management. + +[![Docker Build](https://github.com/Noooste/garage-ui/actions/workflows/build.yml/badge.svg)](https://github.com/Noooste/garage-ui/actions/workflows/build.yml) +[![Helm Chart](https://github.com/Noooste/garage-ui/actions/workflows/release.yml/badge.svg)](https://github.com/Noooste/garage-ui/actions/workflows/release.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Go Version](https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go)](https://go.dev/) +[![Node Version](https://img.shields.io/badge/Node-25%2B-339933?logo=node.js)](https://nodejs.org/) +[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/garage-ui)](https://artifacthub.io/packages/search?repo=garage-ui) + +Garage UI provides a comprehensive dashboard for managing your Garage S3 storage cluster, featuring bucket management, object operations, user access control, and real-time cluster monitoring—all through an intuitive web interface. + +--- + +## Features + +- **Bucket Management** - Create, configure, and delete S3 buckets with visual controls +- **Object Operations** - Upload, download, and manage objects with drag-and-drop support +- **User & Access Control** - Manage access keys, permissions, and user credentials +- **Cluster Monitoring** - Real-time cluster health metrics, node status, and performance statistics +- **Multi-Auth Support** - Flexible authentication with None, Basic Auth, and OIDC/OAuth2 modes +- **Modern UI/UX** - Responsive React interface with dark mode, built on Tailwind CSS and shadcn/ui +- **Production Ready** - Docker/Kubernetes deployment, health checks, and Prometheus metrics +- **API-First Design** - RESTful API with Swagger/OpenAPI documentation + +--- + +## Quick Start + +### Prerequisites + +- **Garage S3** storage cluster running (v2.0.0+) +- **Docker** & Docker Compose (recommended) OR +- **Go** 1.25+ and **Node.js** 25+ (for development) + +### Using Docker Compose (Recommended) + +1. **Clone the repository:** + +```bash +git clone https://github.com/Noooste/garage-ui.git +cd garage-ui +``` + +2. **Configure the application:** + +```bash +cp config.yaml.example config.yaml +# Edit config.yaml with your Garage endpoints and credentials +``` + +3. **Start the services:** + +```bash +docker-compose up -d +``` + +4. **Access the UI:** + +Open your browser to [http://localhost:8080](http://localhost:8080) + +> The Docker Compose setup includes both Garage and Garage UI services for quick local development. + +### Using Docker + +```bash +docker run -d \ + -p 8080:8080 \ + -v $(pwd)/config.yaml:/app/config.yaml \ + -e GARAGE_UI_GARAGE_ENDPOINT=http://your-garage:3900 \ + -e GARAGE_UI_GARAGE_ADMIN_ENDPOINT=http://your-garage:3903 \ + noooste/garage-ui:latest +``` + +--- + +## Installation + +### Docker Deployment + +**Pull the latest image:** + +```bash +docker pull noooste/garage-ui:latest +``` + +**Available tags:** +- `latest` - Latest stable release +- `v0.0.4` - Specific version +- `0.0` - Minor version track + +The Docker image is multi-platform, supporting `linux/amd64` and `linux/arm64`. + +### Kubernetes Deployment with Helm + +```bash +# Add the Helm repository (if available) +helm repo add garage-ui https://helm.noste.dev/ + +# Install the chart +helm install garage-ui garage-ui/garage-ui \ + --set garage.endpoint=http://garage:3900 \ + --set garage.adminEndpoint=http://garage:3903 \ + --set garage.adminToken=your-admin-token +``` + +Or use the local Helm chart: + +```bash +helm install garage-ui ./helm/garage-ui -f custom-values.yaml +``` + +### Building from Source + +**Backend:** + +```bash +cd backend +go mod download +swag init # Generate API docs +go build -o garage-ui . +``` + +**Frontend:** + +```bash +cd frontend +npm install +npm run build +``` + +**Run:** + +```bash +# From project root +./backend/garage-ui --config config.yaml +``` + +--- + +## Configuration + +Garage UI can be configured via YAML file or environment variables. + +### Configuration File + +Create a `config.yaml` based on the provided example: + +```yaml +# Server configuration +server: + host: "0.0.0.0" + port: 8080 + environment: "production" + +# Garage S3 Configuration +garage: + endpoint: "http://garage:3900" + region: "eu-west-1" + admin_endpoint: "http://garage:3903" + admin_token: "your-admin-token-here" + +# Authentication (none, basic, oidc) +auth: + mode: "none" + + # Basic auth example + basic: + username: "admin" + password: "secure-password" + + # OIDC example (Keycloak, Auth0, etc.) + oidc: + enabled: true + provider_name: "Keycloak" + client_id: "garage-ui" + client_secret: "your-client-secret" + issuer_url: "https://auth.example.com/realms/master" + auth_url: "https://auth.example.com/realms/master/protocol/openid-connect/auth" + token_url: "https://auth.example.com/realms/master/protocol/openid-connect/token" +``` + +See [`config.yaml.example`](config.yaml.example) for all available options. + +### Environment Variables + +All configuration can be set via environment variables with the prefix `GARAGE_UI_`: + +```bash +GARAGE_UI_SERVER_PORT=8080 +GARAGE_UI_GARAGE_ENDPOINT=http://garage:3900 +GARAGE_UI_GARAGE_ADMIN_ENDPOINT=http://garage:3903 +GARAGE_UI_GARAGE_ADMIN_TOKEN=your-token +GARAGE_UI_AUTH_MODE=basic +GARAGE_UI_AUTH_BASIC_USERNAME=admin +GARAGE_UI_AUTH_BASIC_PASSWORD=password +GARAGE_UI_LOGGING_LEVEL=info +``` + +**Configuration Priority:** Environment variables override YAML file settings. + +--- + +## Project Structure + +``` +garage-ui/ +├── backend/ # Go backend (Fiber framework) +│ ├── internal/ +│ │ ├── auth/ # Authentication services (JWT, OIDC) +│ │ ├── config/ # Configuration loading +│ │ ├── handlers/ # HTTP request handlers +│ │ ├── middleware/ # CORS, auth middleware +│ │ ├── models/ # Data models and types +│ │ ├── routes/ # API route definitions +│ │ └── services/ # Business logic (S3, Garage Admin) +│ ├── pkg/ # Shared packages +│ ├── main.go # Application entry point +│ └── go.mod # Go dependencies +│ +├── frontend/ # React + TypeScript frontend +│ ├── src/ +│ │ ├── components/ # Reusable UI components +│ │ │ ├── buckets/ # Bucket-specific components +│ │ │ ├── charts/ # Data visualization +│ │ │ ├── layout/ # App layout and navigation +│ │ │ └── ui/ # shadcn/ui component library +│ │ ├── hooks/ # Custom React hooks +│ │ ├── lib/ # API client and utilities +│ │ ├── pages/ # Page components (Dashboard, Buckets, etc.) +│ │ ├── types/ # TypeScript type definitions +│ │ └── App.tsx # Main application component +│ ├── package.json # Node dependencies +│ └── vite.config.ts # Vite build configuration +│ +├── helm/ # Kubernetes Helm chart +│ └── garage-ui/ +│ ├── Chart.yaml # Chart metadata +│ ├── values.yaml # Default values +│ └── templates/ # K8s resource templates +│ +├── .github/workflows/ # CI/CD pipelines +│ ├── build.yml # Docker build and push +│ └── release.yml # Helm chart releases +│ +├── Dockerfile # Multi-stage build +├── docker-compose.yml # Local development stack +└── config.yaml.example # Configuration template +``` + +**Key Directories:** + +- **`backend/internal/handlers/`** - REST API endpoints for buckets, objects, users, cluster, and monitoring +- **`frontend/src/pages/`** - Main application views (Dashboard, Buckets, Cluster, Access Control) +- **`backend/internal/services/`** - Core business logic interfacing with Garage Admin API and S3 +- **`frontend/src/lib/api.ts`** - Axios-based API client with automatic error handling + +--- + +## Development + +### Local Development Setup + +**1. Start Garage (using Docker Compose):** + +```bash +docker-compose up garage -d +``` + +**2. Run the backend:** + +```bash +cd backend +go run main.go --config ../config.yaml +``` + +The backend will start on `http://localhost:8080` with hot-reload (use [air](https://github.com/cosmtrek/air) for auto-reload). + +**3. Run the frontend:** + +```bash +cd frontend +npm install +npm run dev +``` + +The frontend dev server starts on `http://localhost:3000` with API proxy to backend. + +### Available Scripts + +**Backend:** + +```bash +go run main.go # Start server +go test ./... # Run tests +swag init # Regenerate API docs +go build -o garage-ui . # Build binary +``` + +**Frontend:** + +```bash +npm run dev # Development server (Vite) +npm run build # Production build +npm run lint # ESLint checks +npm run preview # Preview production build +``` + +### Testing + +```bash +# Backend tests +cd backend && go test ./internal/... + +# Frontend tests (if available) +cd frontend && npm test +``` + +### Building Docker Image + +```bash +docker build -t garage-ui:dev . +``` + +The Dockerfile uses a multi-stage build: +1. **Frontend build** - Node.js Alpine to compile React app +2. **Backend build** - Go Alpine to compile binary +3. **Runtime** - Minimal Alpine with ca-certificates + +--- + +## API Documentation + +Once the backend is running, access the Swagger UI documentation at: + +**http://localhost:8080/api/v1/** + +### API Endpoints Overview + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/health` | GET | Health check endpoint | +| `/api/v1/buckets` | GET | List all buckets | +| `/api/v1/buckets` | POST | Create a new bucket | +| `/api/v1/buckets/{name}` | GET | Get bucket details | +| `/api/v1/buckets/{name}` | DELETE | Delete a bucket | +| `/api/v1/buckets/{name}/objects` | GET | List objects in bucket | +| `/api/v1/objects/upload` | POST | Upload object to bucket | +| `/api/v1/objects/download` | GET | Download object | +| `/api/v1/objects/delete` | DELETE | Delete object | +| `/api/v1/users` | GET | List access keys | +| `/api/v1/users` | POST | Create access key | +| `/api/v1/users/{id}` | DELETE | Delete access key | +| `/api/v1/cluster/status` | GET | Cluster node status | +| `/api/v1/cluster/health` | GET | Cluster health metrics | +| `/api/v1/cluster/statistics` | GET | Storage statistics | +| `/api/v1/metrics` | GET | Prometheus metrics | + +**Authentication:** Bearer token in `Authorization` header when using OIDC or session-based auth. + +--- + +## Authentication Modes + +Garage UI supports three authentication modes: + +### 1. None (Default) + +No authentication required - suitable for private networks or development. + +```yaml +auth: + mode: "none" +``` + +### 2. Basic Authentication + +Simple username/password authentication. + +```yaml +auth: + mode: "basic" + basic: + username: "admin" + password: "your-secure-password" +``` + +### 3. OIDC (OpenID Connect) + +Enterprise-grade authentication with providers like Keycloak, Auth0, Okta, etc. + +```yaml +auth: + mode: "oidc" + oidc: + enabled: true + provider_name: "Keycloak" + client_id: "garage-ui" + client_secret: "your-client-secret" + issuer_url: "https://auth.example.com/realms/master" + auth_url: "https://auth.example.com/realms/master/protocol/openid-connect/auth" + token_url: "https://auth.example.com/realms/master/protocol/openid-connect/token" + userinfo_url: "https://auth.example.com/realms/master/protocol/openid-connect/userinfo" + session_max_age: 86400 # 24 hours + cookie_secure: true # Enable in production with HTTPS +``` + +**Role-Based Access (Optional):** + +```yaml +auth: + oidc: + role_attribute_path: "resource_access.garage-ui.roles" + admin_role: "admin" +``` + +--- + +## Deployment Best Practices + +### Production Considerations + +1. **Use HTTPS** - Always enable TLS in production: + ```yaml + auth: + oidc: + cookie_secure: true + ``` + +2. **Set Strong Admin Token** - Generate a secure token for Garage Admin API: + ```bash + openssl rand -base64 32 + ``` + +3. **Configure CORS** - Restrict allowed origins: + ```yaml + cors: + allowed_origins: + - "https://garage-ui.yourdomain.com" + ``` + +4. **Enable Monitoring** - Expose metrics endpoint for Prometheus: + ```yaml + # Metrics available at /api/v1/metrics + ``` + +5. **Resource Limits** - Set appropriate CPU/memory limits in Kubernetes/Docker + +### Health Checks + +The application provides a health check endpoint: + +```bash +curl http://localhost:8080/health +# Response: {"status":"ok","version":"0.1.0"} +``` + +Docker health check is configured automatically (every 30s). + +### Kubernetes Deployment + +The Helm chart includes: + +- **Deployment** with configurable replicas +- **Service** (ClusterIP/LoadBalancer) +- **Ingress** with TLS support +- **ConfigMap** for configuration +- **NetworkPolicy** for security +- **ServiceMonitor** for Prometheus (optional) + +Example custom values: + +```yaml +# custom-values.yaml +replicaCount: 2 + +image: + repository: noooste/garage-ui + tag: v0.0.4 + +garage: + endpoint: "http://garage.storage.svc.cluster.local:3900" + adminEndpoint: "http://garage.storage.svc.cluster.local:3903" + adminToken: "your-secure-token" + +ingress: + enabled: true + className: nginx + hosts: + - host: garage-ui.example.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: garage-ui-tls + hosts: + - garage-ui.example.com + +auth: + mode: oidc + oidc: + clientId: garage-ui + clientSecret: secret + issuerUrl: https://auth.example.com/realms/master +``` + +Install with: + +```bash +helm install garage-ui ./helm/garage-ui -f custom-values.yaml +``` + +--- + +## Technology Stack + +### Backend + +- **[Go](https://go.dev/)** 1.25+ - High-performance backend runtime +- **[Fiber v3](https://gofiber.io/)** - Express-inspired web framework +- **[Viper](https://github.com/spf13/viper)** - Configuration management +- **[Minio Go SDK](https://github.com/minio/minio-go)** - S3 client library +- **[go-oidc](https://github.com/coreos/go-oidc)** - OpenID Connect support +- **[Zerolog](https://github.com/rs/zerolog)** - Structured logging +- **[Swagger/Swag](https://github.com/swaggo/swag)** - API documentation + +### Frontend + +- **[React](https://react.dev/)** 19 - UI framework +- **[TypeScript](https://www.typescriptlang.org/)** 5.9+ - Type safety +- **[Vite](https://vitejs.dev/)** 7 - Build tool and dev server +- **[React Router](https://reactrouter.com/)** 7 - Client-side routing +- **[TanStack Query](https://tanstack.com/query)** - Server state management +- **[Zustand](https://github.com/pmndrs/zustand)** - Client state management +- **[React Hook Form](https://react-hook-form.com/)** + [Zod](https://zod.dev/) - Form validation +- **[Tailwind CSS](https://tailwindcss.com/)** 4 - Utility-first styling +- **[shadcn/ui](https://ui.shadcn.com/)** - Component library +- **[Lucide Icons](https://lucide.dev/)** - Icon library +- **[Recharts](https://recharts.org/)** - Data visualization +- **[Axios](https://axios-http.com/)** - HTTP client + +--- + +## Contributing + +Contributions are welcome! Whether it's bug reports, feature requests, or code contributions, your input helps improve Garage UI. + +### How to Contribute + +1. **Fork the repository** +2. **Create a feature branch** (`git checkout -b feature/amazing-feature`) +3. **Make your changes** +4. **Commit your changes** (`git commit -m 'Add amazing feature'`) +5. **Push to the branch** (`git push origin feature/amazing-feature`) +6. **Open a Pull Request** + +### Development Guidelines + +- Follow existing code style (use `gofmt` for Go, ESLint for TypeScript) +- Add tests for new features when possible +- Update documentation for API changes +- Keep commits atomic and descriptive +- Ensure all tests pass before submitting PR + +### Reporting Issues + +Please open an issue on GitHub with: +- Clear description of the problem +- Steps to reproduce +- Expected vs actual behavior +- Environment details (OS, Docker version, etc.) + +--- + +## Troubleshooting + +### Common Issues + +**Issue: "Failed to connect to Garage Admin API"** + +- **Solution:** Verify `garage.admin_endpoint` and `garage.admin_token` in config.yaml +- Check Garage Admin API is accessible: `curl http://garage:3903/status -H "Authorization: Bearer your-token"` + +**Issue: "CORS errors in browser console"** + +- **Solution:** Configure CORS in config.yaml to allow frontend origin: + ```yaml + cors: + allowed_origins: + - "http://localhost:3000" # For dev + ``` + +**Issue: "401 Unauthorized with OIDC"** + +- **Solution:** Verify OIDC configuration, especially `issuer_url`, `client_id`, and `client_secret` +- Check provider configuration allows the redirect URL: `http://your-app/auth/callback` + +**Issue: "Objects not appearing after upload"** + +- **Solution:** Check S3 endpoint connectivity and bucket permissions +- Verify Garage bucket exists: `garage bucket list` + +### Debug Mode + +Enable debug logging for troubleshooting: + +```yaml +logging: + level: "debug" + format: "json" +``` + +Or via environment variable: + +```bash +GARAGE_UI_LOGGING_LEVEL=debug +``` + +--- + +## License + +This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details. + +Copyright (c) 2025 Noste + +--- + +## Acknowledgments + +- [Garage](https://garagehq.deuxfleurs.fr/) - The lightweight, geo-distributed S3 storage system +- [shadcn/ui](https://ui.shadcn.com/) - Beautiful, accessible React components +- [Fiber](https://gofiber.io/) - Fast and lightweight Go web framework +- All open-source contributors who made this project possible + +--- + +## Support + +- **Documentation:** [GitHub Wiki](https://github.com/Noooste/garage-ui/wiki) (coming soon) +- **Issues:** [GitHub Issues](https://github.com/Noooste/garage-ui/issues) +- **Discussions:** [GitHub Discussions](https://github.com/Noooste/garage-ui/discussions) + +--- + +**Made with ❤️ for the Garage community** diff --git a/backend/internal/handlers/buckets.go b/backend/internal/handlers/buckets.go index 425eb79..f44288b 100644 --- a/backend/internal/handlers/buckets.go +++ b/backend/internal/handlers/buckets.go @@ -111,25 +111,12 @@ func (h *BucketHandler) CreateBucket(c fiber.Ctx) error { ) } - // Check if bucket already exists - bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, req.Name) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON( - models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()), - ) - } - - if bucketInfo != nil { - return c.Status(fiber.StatusConflict).JSON( - models.ErrorResponse(models.ErrCodeBucketExists, "Bucket already exists"), - ) - } - // Create the bucket createBucketReq := models.CreateBucketAdminRequest{ GlobalAlias: &req.Name, } - if bucketInfo, err = h.adminService.CreateBucket(ctx, createBucketReq); err != nil { + + if _, err := h.adminService.CreateBucket(ctx, createBucketReq); err != nil { return c.Status(fiber.StatusInternalServerError).JSON( models.ErrorResponse(models.ErrCodeInternalError, "Failed to create bucket: "+err.Error()), ) diff --git a/backend/internal/handlers/users.go b/backend/internal/handlers/users.go index 0f4babf..e3e9cfb 100644 --- a/backend/internal/handlers/users.go +++ b/backend/internal/handlers/users.go @@ -251,6 +251,41 @@ func (h *UserHandler) GetUser(c fiber.Ctx) error { return c.JSON(models.SuccessResponse(userInfo)) } +// GetUserSecretKey retrieves the secret key for a specific user/access key +// +// @Summary Get user secret key +// @Description Retrieves the secret access key for a specific user/access key +// @Tags Users +// @Produce json +// @Param access_key path string true "Access key of the user to retrieve secret for" +// @Success 200 {object} models.APIResponse{data=map[string]string} "Secret key retrieved successfully" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get secret key" +// @Router /api/v1/users/{access_key}/secret [get] +func (h *UserHandler) GetUserSecretKey(c fiber.Ctx) error { + ctx := c.Context() + accessKey := c.Params("access_key") + + if accessKey == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"), + ) + } + + // Get key information WITH secret key + keyInfo, err := h.adminService.GetKeyInfo(ctx, accessKey, true) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to get secret key: "+err.Error()), + ) + } + + // Return only the secret key + return c.JSON(models.SuccessResponse(map[string]string{ + "secretKey": *keyInfo.SecretAccessKey, + })) +} + // UpdateUserPermissions updates user permissions // // @Summary Update user permissions diff --git a/backend/internal/models/garage_admin.go b/backend/internal/models/garage_admin.go index 7ca63fd..3957fc6 100644 --- a/backend/internal/models/garage_admin.go +++ b/backend/internal/models/garage_admin.go @@ -194,7 +194,7 @@ type ClusterHealth struct { KnownNodes int `json:"knownNodes"` ConnectedNodes int `json:"connectedNodes"` StorageNodes int `json:"storageNodes"` - StorageNodesUp int `json:"storageNodesUp"` + StorageNodesUp int `json:"storageNodesOk"` Partitions int `json:"partitions"` PartitionsQuorum int `json:"partitionsQuorum"` PartitionsAllOk int `json:"partitionsAllOk"` diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go index 1657694..6b295aa 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -74,6 +74,7 @@ func SetupRoutes( users.Get("/", userHandler.ListUsers) // List all users/keys users.Post("/", userHandler.CreateUser) // Create new user/key users.Get("/:access_key", userHandler.GetUser) // Get user info + users.Get("/:access_key/secret", userHandler.GetUserSecretKey) // Get user secret key users.Delete("/:access_key", userHandler.DeleteUser) // Delete user/key users.Patch("/:access_key", userHandler.UpdateUserPermissions) // Update user permissions } @@ -217,10 +218,10 @@ func SetupRoutes( } } + cfg.Server.FrontendPath = "./frontend/dist" + // Check if frontend path exists if _, err := os.Stat(cfg.Server.FrontendPath); err == nil { - fmt.Println("Serving frontend from:", cfg.Server.FrontendPath) - // SPA fallback - serve index.html for all non-API routes app.Use(func(c fiber.Ctx) error { path := c.Path() diff --git a/backend/internal/services/garage_admin.go b/backend/internal/services/garage_admin.go index 1e8f44b..9b7920e 100644 --- a/backend/internal/services/garage_admin.go +++ b/backend/internal/services/garage_admin.go @@ -210,7 +210,7 @@ func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAli } var result models.GarageBucketInfo - if err := decodeResponse(resp, &result); err != nil { + if err = decodeResponse(resp, &result); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } diff --git a/docker-compose.yml b/docker-compose.yml index 2f6ade4..163f16e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,7 @@ services: - garage environment: # Garage S3 Configuration - GARAGE_UI_GARAGE_ENDPOINT: "http://garage:3900" + GARAGE_UI_GARAGE_ENDPOINT: "garage:3900" GARAGE_UI_GARAGE_ADMIN_ENDPOINT: "http://garage:3903" # Server Configuration diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 84686bb..be27754 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -91,6 +91,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2344,6 +2345,7 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2354,6 +2356,7 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2364,6 +2367,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2420,6 +2424,7 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -2671,6 +2676,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2847,6 +2853,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -3352,6 +3359,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4655,6 +4663,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4732,6 +4741,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4741,6 +4751,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4770,6 +4781,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz", "integrity": "sha512-2KnjpgG2Rhbi+CIiIBQQ9Df6sMGH5ExNyFl4Hw9qO7pIqMBR8Bvu9RQyjl3JM4vehzCh9soiNUM/xYMswb2EiA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -4785,13 +4797,15 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -4893,7 +4907,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -5161,6 +5176,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5218,6 +5234,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5334,6 +5351,7 @@ "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -5427,6 +5445,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5482,6 +5501,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fabd04d..dccf7c7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -222,6 +222,11 @@ export const accessApi = { return response.data.data; }, + getSecretKey: async (accessKey: string): Promise => { + const response = await api.get(`/v1/users/${accessKey}/secret`); + return response.data.data.secretKey; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any createKey: async (name: string, permissions?: any[]): Promise => { const response = await api.post('/v1/users', { name, permissions }); diff --git a/frontend/src/pages/AccessControl.tsx b/frontend/src/pages/AccessControl.tsx index 8146de7..5a8753a 100644 --- a/frontend/src/pages/AccessControl.tsx +++ b/frontend/src/pages/AccessControl.tsx @@ -46,6 +46,7 @@ export function AccessControl() { const [createPermissionWrite, setCreatePermissionWrite] = useState(false); const [createPermissionOwner, setCreatePermissionOwner] = useState(false); const [createGrantPermissions, setCreateGrantPermissions] = useState(false); + const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); // Edit permissions state const [editPermissionsDialogOpen, setEditPermissionsDialogOpen] = useState(false); @@ -69,6 +70,11 @@ export function AccessControl() { const [expirationDate, setExpirationDate] = useState(''); const [neverExpires, setNeverExpires] = useState(true); + // Secret key dialog state + const [secretKeyDialogOpen, setSecretKeyDialogOpen] = useState(false); + const [revealedSecretKey, setRevealedSecretKey] = useState(''); + const [isLoadingSecretKey, setIsLoadingSecretKey] = useState(false); + useEffect(() => { const fetchKeys = async () => { try { @@ -120,13 +126,8 @@ export function AccessControl() { } } - setCreateDialogOpen(false); - setNewKeyName(''); - setCreateSelectedBucket(''); - setCreatePermissionRead(false); - setCreatePermissionWrite(false); - setCreatePermissionOwner(false); - setCreateGrantPermissions(false); + // Store the newly created key to show the secret key + setNewlyCreatedKey(newKey); // Refresh keys list const data = await accessApi.listKeys(); @@ -138,6 +139,17 @@ export function AccessControl() { } }; + const handleCloseCreateDialog = () => { + setCreateDialogOpen(false); + setNewKeyName(''); + setCreateSelectedBucket(''); + setCreatePermissionRead(false); + setCreatePermissionWrite(false); + setCreatePermissionOwner(false); + setCreateGrantPermissions(false); + setNewlyCreatedKey(null); + }; + const handleOpenCreateDialog = async () => { setCreateDialogOpen(true); setNewKeyName(''); @@ -222,6 +234,23 @@ export function AccessControl() { } }; + const handleRevealSecretKey = async (key: AccessKey) => { + setSelectedKey(key); + setIsLoadingSecretKey(true); + setSecretKeyDialogOpen(true); + setRevealedSecretKey(''); + + try { + const secretKey = await accessApi.getSecretKey(key.accessKeyId); + setRevealedSecretKey(secretKey); + } catch (error) { + console.error('Failed to fetch secret key:', error); + setSecretKeyDialogOpen(false); + } finally { + setIsLoadingSecretKey(false); + } + }; + const handleOpenEditPermissions = async (key: AccessKey) => { setEditingKey(key); setEditPermissionsDialogOpen(true); @@ -466,6 +495,11 @@ export function AccessControl() { + handleRevealSecretKey(key)}> + + View Secret Key + + handleOpenEditPermissions(key)}> Edit Permissions @@ -525,122 +559,200 @@ export function AccessControl() { {/* Create Key Dialog */} - + - - Create API Key - - Create a new API key with optional bucket permissions - - -
-
- - setNewKeyName(e.target.value)} - /> -

- A friendly name to identify this API key -

-
- - {/* Optional: Grant permissions during creation */} -
- -

- You can also grant permissions later from the Edit Permissions menu -

- - {createGrantPermissions && ( -
- {/* Bucket Selection */} -
- - + +
+
+
+ +
+ + {newlyCreatedKey.secretKey} + + +
+
+
+
+ +
+

+ Important: Save This Key Now +

+

+ This is the only time you'll see the secret access key. Make sure to copy and save it securely. + If you lose it, you'll need to create a new key. +

+
+
+
+
+ + + + + ) : ( + // Creation form + <> + + Create API Key + + Create a new API key with optional bucket permissions + + +
+
+ + setNewKeyName(e.target.value)} + /> +

+ A friendly name to identify this API key +

+
- {/* Permissions */} - {createSelectedBucket && ( -
- -
- + {/* Optional: Grant permissions during creation */} +
+ +

+ You can also grant permissions later from the Edit Permissions menu +

- - - + {createGrantPermissions && ( +
+ {/* Bucket Selection */} +
+ +
+ + {/* Permissions */} + {createSelectedBucket && ( +
+ +
+ + + + + +
+
+ )}
)}
- )} -
-
- - - - +
+ + + + + + )}
@@ -665,6 +777,92 @@ export function AccessControl() {
+ {/* Secret Key Dialog */} + + + + Secret Access Key + + Copy your secret access key now. For security reasons, it cannot be viewed again. + + +
+
+ +
{selectedKey?.name}
+
+
+ +
+ + {selectedKey?.accessKeyId} + + +
+
+
+ +
+ {isLoadingSecretKey ? ( +
+ + Loading secret key... +
+ ) : ( + <> + + {revealedSecretKey} + + + + )} +
+
+
+
+ +
+

+ Security Warning +

+

+ Keep this secret key secure. Anyone with access to it can perform operations on your behalf. +

+
+
+
+
+ + + +
+
+ {/* Key Settings Dialog */} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c03a67a..49f7be6 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -60,6 +60,7 @@ export interface ObjectMetadata { export interface AccessKey { accessKeyId: string; name: string; + secretKey?: string; createdAt: string; lastUsed?: string; status: 'active' | 'inactive';