Add auth and tenant function

This commit is contained in:
Aluisco Ricardo 2025-09-25 17:35:50 -03:00
parent ee420fbf29
commit 15a350370c
43 changed files with 4502 additions and 71 deletions

10
.env.development Normal file
View File

@ -0,0 +1,10 @@
# Development environment variables
VITE_API_URL=http://localhost:3909
# Development mode settings
VITE_MODE=development
VITE_DEBUG=true
# Hot reload settings
VITE_HMR_PORT=5173
VITE_HMR_HOST=localhost

48
.gitignore vendored
View File

@ -23,10 +23,56 @@ dist-ssr
*.sln
*.sw?
# Environment files
.env*
!.env.example
docker-compose.*.yml
!.env.development
# Docker compose development files (keep only production)
# docker-compose.*.yml # Commented out to allow dev files
# Development data
data/
meta/
dev-data/
garage.toml
# Backend specific
backend/main
backend/tmp/
backend/data/
backend/*.log
backend/.air_tmp
backend/build-errors.log
# Go build artifacts
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
vendor/
tmp/
# Database files
*.db
*.sqlite
*.sqlite3
# Backup files
*.bak
*.backup
# Development certificates
*.pem
*.crt
*.key
# Cache and runtime
.cache/
.vite/
runtime/
logs/
.claude/

48
Dockerfile.dev Normal file
View File

@ -0,0 +1,48 @@
# Development Dockerfile - Single stage with both frontend and backend
FROM node:20-slim
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
# Install Go
RUN curl -L https://go.dev/dl/go1.25.1.linux-amd64.tar.gz | tar -C /usr/local -xzf -
ENV PATH="/usr/local/go/bin:$PATH"
# Install Air for Go hot reload
RUN go install github.com/air-verse/air@latest
ENV PATH="/root/go/bin:$PATH"
# Enable corepack for pnpm
RUN npm install -g corepack@latest && corepack use pnpm@latest
RUN npm install -g concurrently
WORKDIR /app
# Install frontend dependencies
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
# Install backend dependencies
COPY backend/go.mod backend/go.sum ./backend/
WORKDIR /app/backend
RUN go mod download
WORKDIR /app
# Copy all source code (will be overridden by volume in dev)
COPY . .
# Create tmp directory for Go builds
RUN mkdir -p backend/tmp
# Expose ports
EXPOSE 5173 3909
# Development command with both frontend and backend
CMD ["concurrently", \
"--names", "FRONTEND,BACKEND", \
"--prefix-colors", "blue,green", \
"pnpm run dev:client", \
"cd backend && air -c .air.toml"]

234
Makefile Normal file
View File

@ -0,0 +1,234 @@
# Garage WebUI Development Makefile
# Variables
DOCKER_COMPOSE_DEV = docker-compose -f docker-compose.dev.yml
DOCKER_COMPOSE_PROD = docker-compose -f docker-compose.yml
# Colors for output
RED=\033[0;31m
GREEN=\033[0;32m
YELLOW=\033[1;33m
BLUE=\033[0;34m
NC=\033[0m # No Color
.PHONY: help dev dev-docker dev-frontend dev-backend dev-fullstack build clean test lint install
# Default target
help: ## Show this help message
@echo "${BLUE}Garage WebUI Development Commands${NC}"
@echo ""
@echo "Usage: make [command]"
@echo ""
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "${GREEN}%-20s${NC} %s\n", $$1, $$2}'
# Development Commands
dev: ## Start local development (requires local Garage)
@echo "${BLUE}Starting local development...${NC}"
pnpm run dev
dev-docker: ## Start Docker development environment
@echo "${BLUE}Starting Docker development environment...${NC}"
$(DOCKER_COMPOSE_DEV) up --build
dev-logs: ## Show development logs
@echo "${BLUE}Showing development logs...${NC}"
$(DOCKER_COMPOSE_DEV) logs -f
dev-stop: ## Stop development environment
@echo "${YELLOW}Stopping development environment...${NC}"
$(DOCKER_COMPOSE_DEV) down
dev-clean: ## Clean development environment and volumes
@echo "${RED}Cleaning development environment...${NC}"
$(DOCKER_COMPOSE_DEV) down -v
docker system prune -f
# Build Commands
build: ## Build for production
@echo "${BLUE}Building for production...${NC}"
pnpm run build
build-dev: ## Build for development
@echo "${BLUE}Building for development...${NC}"
pnpm run build:dev
build-backend: ## Build backend only
@echo "${BLUE}Building backend...${NC}"
cd backend && go build -o main .
build-docker: ## Build production Docker images
@echo "${BLUE}Building production Docker images...${NC}"
$(DOCKER_COMPOSE_PROD) build
# Install Commands
install: ## Install all dependencies
@echo "${BLUE}Installing dependencies...${NC}"
pnpm install
$(MAKE) install-backend
install-backend: ## Install Go dependencies
@echo "${BLUE}Installing Go dependencies...${NC}"
cd backend && go mod download
# Test Commands
test: ## Run all tests
@echo "${BLUE}Running tests...${NC}"
$(MAKE) test-backend
$(MAKE) type-check
test-backend: ## Run Go tests
@echo "${BLUE}Running Go tests...${NC}"
cd backend && go test ./...
test-frontend: ## Run frontend tests (if they exist)
@echo "${BLUE}Running frontend tests...${NC}"
pnpm test
type-check: ## Check TypeScript types
@echo "${BLUE}Checking TypeScript types...${NC}"
pnpm run type-check
# Lint Commands
lint: ## Run linters
@echo "${BLUE}Running linters...${NC}"
pnpm run lint
$(MAKE) lint-backend
lint-fix: ## Fix linting issues automatically
@echo "${BLUE}Fixing linting issues...${NC}"
pnpm run lint:fix
lint-backend: ## Lint Go code
@echo "${BLUE}Linting Go code...${NC}"
cd backend && go fmt ./...
cd backend && go vet ./...
# Clean Commands
clean: ## Clean build artifacts and cache
@echo "${YELLOW}Cleaning build artifacts...${NC}"
rm -rf dist
rm -rf node_modules/.vite
rm -rf backend/tmp
rm -rf backend/main
clean-all: ## Clean everything including node_modules
@echo "${RED}Cleaning everything...${NC}"
$(MAKE) clean
rm -rf node_modules
cd backend && go clean -cache -modcache -i -r
# Database Commands
db-backup: ## Backup development database
@echo "${BLUE}Backing up development database...${NC}"
mkdir -p dev-data/backups
$(DOCKER_COMPOSE_DEV) exec webui-backend cp /app/data/database.json /data/backups/database-backup-$$(date +%Y%m%d-%H%M%S).json
@echo "${GREEN}Database backup created in dev-data/backups/${NC}"
db-restore: ## Restore development database from backup (specify BACKUP_FILE)
@echo "${BLUE}Restoring development database...${NC}"
@if [ -z "$(BACKUP_FILE)" ]; then echo "${RED}Please specify BACKUP_FILE. Example: make db-restore BACKUP_FILE=database-backup-20231201-120000.json${NC}"; exit 1; fi
$(DOCKER_COMPOSE_DEV) exec webui-backend cp /data/backups/$(BACKUP_FILE) /app/data/database.json
@echo "${GREEN}Database restored from $(BACKUP_FILE)${NC}"
db-reset: ## Reset development database (creates fresh admin user)
@echo "${YELLOW}Resetting development database...${NC}"
$(DOCKER_COMPOSE_DEV) exec webui-backend rm -f /app/data/database.json
$(DOCKER_COMPOSE_DEV) restart webui-backend
@echo "${GREEN}Database reset. Default admin user created (admin/admin)${NC}"
# Debug Commands
debug-frontend: ## Access frontend container shell
@echo "${BLUE}Accessing frontend container...${NC}"
$(DOCKER_COMPOSE_DEV) exec webui-frontend sh
debug-backend: ## Access backend container shell
@echo "${BLUE}Accessing backend container...${NC}"
$(DOCKER_COMPOSE_DEV) exec webui-backend sh
debug-garage: ## Access garage container shell
@echo "${BLUE}Accessing garage container...${NC}"
$(DOCKER_COMPOSE_DEV) exec garage sh
debug-logs-frontend: ## Show frontend logs only
$(DOCKER_COMPOSE_DEV) logs -f webui-frontend
debug-logs-backend: ## Show backend logs only
$(DOCKER_COMPOSE_DEV) logs -f webui-backend
debug-logs-garage: ## Show garage logs only
$(DOCKER_COMPOSE_DEV) logs -f garage
# Status Commands
status: ## Show development environment status
@echo "${BLUE}Development environment status:${NC}"
$(DOCKER_COMPOSE_DEV) ps
health: ## Check health of all services
@echo "${BLUE}Checking service health...${NC}"
@echo "Frontend: http://localhost:5173"
@curl -s -o /dev/null -w "Frontend: %{http_code}\n" http://localhost:5173 || echo "Frontend: ${RED}DOWN${NC}"
@curl -s -o /dev/null -w "Backend: %{http_code}\n" http://localhost:3909/api/auth/status || echo "Backend: ${RED}DOWN${NC}"
@curl -s -o /dev/null -w "Garage: %{http_code}\n" http://localhost:3903/status || echo "Garage: ${RED}DOWN${NC}"
# Production Commands
prod-build: ## Build production images
@echo "${BLUE}Building production images...${NC}"
$(DOCKER_COMPOSE_PROD) build
prod-up: ## Start production environment
@echo "${BLUE}Starting production environment...${NC}"
$(DOCKER_COMPOSE_PROD) up -d
prod-down: ## Stop production environment
@echo "${YELLOW}Stopping production environment...${NC}"
$(DOCKER_COMPOSE_PROD) down
prod-logs: ## Show production logs
$(DOCKER_COMPOSE_PROD) logs -f
# Utility Commands
ports: ## Show all used ports
@echo "${BLUE}Development ports:${NC}"
@echo "Frontend (Vite): http://localhost:5173"
@echo "Backend (API): http://localhost:3909"
@echo "Garage (S3): http://localhost:3900"
@echo "Garage (Admin): http://localhost:3903"
@echo "Garage (RPC): http://localhost:3901"
@echo "Garage (Web): http://localhost:3902"
@echo ""
@echo "${BLUE}Fullstack alternative ports:${NC}"
@echo "Frontend: http://localhost:5174"
@echo "Backend: http://localhost:3910"
urls: ## Show all useful URLs
@echo "${BLUE}Development URLs:${NC}"
@echo "WebUI: http://localhost:5173"
@echo "Admin Dashboard: http://localhost:5173/admin"
@echo "Login: http://localhost:5173/auth/login"
@echo "API Status: http://localhost:3909/api/auth/status"
@echo "Garage Status: http://localhost:3903/status"
# First time setup
setup: ## First time setup (install deps + start dev environment)
@echo "${GREEN}Setting up Garage WebUI development environment...${NC}"
$(MAKE) install
@echo "${YELLOW}Creating garage.toml if it doesn't exist...${NC}"
@if [ ! -f garage.toml ]; then \
echo "Creating garage.toml from template..."; \
cp garage.toml.example garage.toml 2>/dev/null || echo "Please create garage.toml manually"; \
fi
@echo "${GREEN}Setup complete! Run 'make dev-docker' to start development.${NC}"
# Quick commands for daily development
quick-start: dev-docker ## Quick start development environment
quick-stop: dev-stop ## Quick stop development environment
quick-restart: ## Quick restart development environment
$(MAKE) dev-stop
$(MAKE) dev-docker
# Git hooks (optional)
install-hooks: ## Install git pre-commit hooks
@echo "${BLUE}Installing git hooks...${NC}"
@echo '#!/bin/sh\nmake lint && make type-check' > .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
@echo "${GREEN}Pre-commit hooks installed${NC}"

459
README.dev.md Normal file
View File

@ -0,0 +1,459 @@
# Garage Web UI - Desarrollo
Esta guía te ayudará a configurar el entorno de desarrollo para Garage Web UI con hot reload y todas las funcionalidades avanzadas.
## 🚀 Quick Start
### Opción 1: Docker (Recomendado)
```bash
# Clona el repositorio
git clone https://github.com/khairul169/garage-webui.git
cd garage-webui
# Inicia el entorno completo de desarrollo
npm run dev:docker
# O por separado:
npm run dev:docker:frontend # Solo frontend
npm run dev:docker:backend # Solo backend
npm run dev:docker:fullstack # Todo en un contenedor
```
### Opción 2: Local
```bash
# Instalar dependencias
pnpm install
npm run install:backend
# Desarrollo local (requiere Garage corriendo)
npm run dev
```
## 📁 Estructura del Proyecto
```
garage-webui/
├── src/ # Frontend React + TypeScript
│ ├── components/ # Componentes reutilizables
│ ├── pages/ # Páginas principales
│ │ ├── admin/ # Dashboard de administración
│ │ ├── auth/ # Autenticación
│ │ ├── buckets/ # Gestión de buckets
│ │ ├── cluster/ # Gestión del clúster
│ │ └── keys/ # Gestión de keys
│ ├── hooks/ # Custom hooks
│ ├── types/ # TypeScript types
│ └── lib/ # Utilidades
├── backend/ # Backend Go
│ ├── middleware/ # Middleware de seguridad
│ ├── router/ # Endpoints API
│ ├── schema/ # Modelos de datos
│ └── utils/ # Utilidades del servidor
├── docker-compose.dev.yml # Entorno de desarrollo
├── Dockerfile.dev # Dockerfile para desarrollo
└── README.dev.md # Esta documentación
```
## 🛠️ Configuración del Entorno
### Variables de Entorno
Crea un archivo `garage.toml` para Garage (ejemplo mínimo):
```toml
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "127.0.0.1:3901"
rpc_secret = "1799bccfd7411abbccc9a3f8a0ccc314f5d0d9690e9a2cc4de5ba8faa24a3ee2"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage.localhost"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "admin-token-change-me"
metrics_token = "metrics-token-change-me"
```
### Variables de Entorno para Desarrollo
El proyecto incluye configuración automática para desarrollo:
**Frontend (.env.development):**
```env
VITE_API_URL=http://localhost:3909
VITE_MODE=development
VITE_DEBUG=true
```
**Backend (docker-compose.dev.yml):**
```env
CONFIG_PATH=/etc/garage.toml
API_BASE_URL=http://garage:3903
S3_ENDPOINT_URL=http://garage:3900
DATA_DIR=/app/data
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
RATE_LIMIT_REQUESTS=1000
RATE_LIMIT_WINDOW=1m
```
## 🔥 Hot Reload
### Frontend (React)
- **Puerto**: 5173
- **Hot Module Replacement**: Activado automáticamente
- **Proxy API**: `/api/*``http://localhost:3909`
- **File Watching**: Optimizado para Docker con polling
### Backend (Go)
- **Puerto**: 3909
- **Herramienta**: Air (similar a nodemon para Node.js)
- **Auto-rebuild**: Al cambiar archivos `.go`
- **Configuración**: `backend/.air.toml`
## 🐳 Opciones de Docker
### 1. Frontend + Backend Separados (Recomendado)
```bash
# Inicia Garage + Frontend + Backend en contenedores separados
npm run dev:docker
# Accede a:
# - Frontend: http://localhost:5173 (con HMR)
# - Backend API: http://localhost:3909
# - Garage S3: http://localhost:3900
# - Garage Admin: http://localhost:3903
```
**Ventajas:**
- ✅ Mejor aislamiento
- ✅ Hot reload independiente
- ✅ Fácil debugging
- ✅ Logs separados
### 2. Frontend Solo
```bash
npm run dev:docker:frontend
```
Útil cuando quieres desarrollar solo el frontend con un backend en producción.
### 3. Backend Solo
```bash
npm run dev:docker:backend
```
Útil para desarrollo de API con frontend en producción.
### 4. Fullstack (Un Solo Contenedor)
```bash
npm run dev:docker:fullstack
```
**Puertos alternativos:** Frontend: 5174, Backend: 3910
## 📝 Scripts Disponibles
### Desarrollo
```bash
npm run dev # Local: Frontend + Backend
npm run dev:client # Solo frontend local
npm run dev:server # Solo backend local
npm run dev:docker # Docker: Todo el entorno
npm run dev:docker:frontend # Docker: Solo frontend
npm run dev:docker:backend # Docker: Solo backend
npm run dev:docker:fullstack # Docker: Fullstack
```
### Build y Testing
```bash
npm run build # Build de producción
npm run build:dev # Build de desarrollo
npm run type-check # Verificar tipos TypeScript
npm run lint # Linter
npm run lint:fix # Fix automático del linter
npm run test:backend # Tests del backend Go
```
### Backend
```bash
npm run install:backend # Instalar dependencias Go
npm run build:backend # Build del backend
```
### Limpieza
```bash
npm run clean # Limpiar cache y builds
npm run dev:docker:clean # Limpiar contenedores y volúmenes
```
## 🔐 Sistema de Autenticación
### Usuario por Defecto
Al iniciar por primera vez, se crea automáticamente:
- **Usuario**: `admin`
- **Contraseña**: `admin`
- **Rol**: Administrador
**⚠️ IMPORTANTE**: Cambia la contraseña después del primer login.
### Roles Disponibles
- **Admin**: Acceso completo al sistema
- **Tenant Admin**: Administración de su tenant
- **User**: Usuario básico con permisos limitados
- **ReadOnly**: Solo lectura
## 🎯 Funcionalidades de Desarrollo
### Dashboard de Administración
- ✅ Gestión completa de usuarios
- ✅ Sistema de tenants (multi-tenancy)
- ✅ Roles y permisos granulares
- ✅ Configuración dinámica de S3
- ✅ Monitoreo del sistema
### Seguridad Implementada
- ✅ Autenticación JWT con sessiones
- ✅ Rate limiting configurable
- ✅ Headers de seguridad (CORS, XSS, etc.)
- ✅ Cifrado bcrypt para contraseñas
- ✅ Middleware de autorización
### Base de Datos
- ✅ Persistencia en JSON local
- ✅ Thread-safe operations
- ✅ Backup automático
- ✅ Migration desde configuración legacy
## 🐛 Debugging
### Logs del Frontend
```bash
# Ver logs del frontend
docker-compose -f docker-compose.dev.yml logs -f webui-frontend
# O desde el navegador
# Abre DevTools → Console
```
### Logs del Backend
```bash
# Ver logs del backend
docker-compose -f docker-compose.dev.yml logs -f webui-backend
# Ver logs de Garage
docker-compose -f docker-compose.dev.yml logs -f garage
```
### Debugging del Backend Go
```bash
# Ejecutar en contenedor para debugging
docker-compose -f docker-compose.dev.yml exec webui-backend sh
# Ver estado de la base de datos
cat /app/data/database.json
# Logs de compilación
cat /app/build-errors.log
```
## 📊 Monitoreo
### Endpoints Útiles para Desarrollo
- `GET /api/auth/status` - Estado de autenticación
- `GET /api/s3/status` - Estado del sistema S3
- `GET /api/s3/config` - Configuración actual
- `POST /api/s3/test` - Test de conectividad
- `GET /api/users` - Lista de usuarios (admin)
- `GET /api/tenants` - Lista de tenants (admin)
### Health Checks
- Garage: `http://localhost:3903/status`
- WebUI Backend: `http://localhost:3909/api/s3/status`
## 🚨 Troubleshooting
### El frontend no se actualiza automáticamente
```bash
# Verificar que el polling esté habilitado
# En vite.config.ts debe estar:
watch: {
usePolling: true,
interval: 100,
}
```
### El backend no se recarga
```bash
# Verificar que Air esté corriendo
docker-compose -f docker-compose.dev.yml logs webui-backend
# Debe mostrar: "watching .go files"
```
### Problemas de conectividad
```bash
# Verificar que todos los servicios estén corriendo
docker-compose -f docker-compose.dev.yml ps
# Verificar conectividad a Garage
curl http://localhost:3903/status
```
### Limpiar estado corrupto
```bash
# Limpiar todo y empezar de nuevo
npm run dev:docker:clean
docker system prune -a
npm run dev:docker
```
### Base de datos corrupta
```bash
# Backup automático en dev-data/
cp dev-data/backup-database.json backend/data/database.json
# O eliminar para recrear usuario admin
rm backend/data/database.json
```
## 🎨 Desarrollo del Frontend
### Estructura de Componentes
- `src/components/ui/` - Componentes base (Button, Input, etc.)
- `src/components/containers/` - Contenedores (Sidebar, Theme, etc.)
- `src/components/layouts/` - Layouts de página
- `src/pages/` - Páginas específicas
### Estado Global
- **React Query**: Cache de API y estado servidor
- **Zustand**: Estado global mínimo (theme, etc.)
- **React Hook Form**: Formularios con validación
### Estilos
- **Tailwind CSS**: Utility-first CSS
- **DaisyUI**: Componentes pre-diseñados
- **CSS Modules**: Estilos específicos cuando es necesario
## 🔧 Desarrollo del Backend
### Arquitectura
```
backend/
├── main.go # Entry point
├── router/ # Endpoints HTTP
│ ├── auth.go # Autenticación
│ ├── users.go # Gestión usuarios
│ ├── tenants.go # Gestión tenants
│ └── s3config.go # Configuración S3
├── middleware/ # Middleware HTTP
│ ├── auth.go # Autenticación
│ └── security.go # Seguridad (CORS, Rate limiting)
├── schema/ # Modelos de datos
├── utils/ # Utilidades
└── .air.toml # Configuración hot reload
```
### Adding New Endpoints
```go
// 1. Agregar al router (router/router.go)
users := &Users{}
router.HandleFunc("GET /users", users.GetAll)
// 2. Implementar handler (router/users.go)
func (u *Users) GetAll(w http.ResponseWriter, r *http.Request) {
// Verificar permisos
if !u.checkPermission(r, schema.PermissionReadUsers) {
utils.ResponseErrorStatus(w, nil, http.StatusForbidden)
return
}
// Lógica del endpoint
users, err := utils.DB.ListUsers()
if err != nil {
utils.ResponseError(w, err)
return
}
utils.ResponseSuccess(w, users)
}
```
## 🔒 Seguridad en Desarrollo
### HTTPS Local (Opcional)
Para testing de características que requieren HTTPS:
```bash
# Generar certificados locales
mkcert localhost 127.0.0.1
# Actualizar vite.config.ts para usar HTTPS
server: {
https: {
key: fs.readFileSync('localhost-key.pem'),
cert: fs.readFileSync('localhost.pem'),
},
// ... resto de config
}
```
### Variables de Entorno Sensibles
```bash
# NO commitear archivos con secretos reales
# Usar valores de desarrollo como:
rpc_secret = "dev-secret-not-for-production"
admin_token = "dev-admin-token"
```
## 📋 Checklist Pre-Commit
- [ ] `npm run type-check` pasa sin errores
- [ ] `npm run lint` pasa sin errores
- [ ] `npm run test:backend` pasa todos los tests
- [ ] Hot reload funciona en frontend y backend
- [ ] Dashboard de admin funciona correctamente
- [ ] No hay secrets hardcodeados en el código
## 🚀 Deployment
Una vez que el desarrollo esté listo, usa el docker-compose.yml original para producción:
```bash
# Build de producción
npm run build
# Deploy con el docker-compose.yml original
docker-compose up --build
```
## 💡 Tips de Desarrollo
### VS Code Extensions Recomendadas
- TypeScript Importer
- Tailwind CSS IntelliSense
- Go Extension
- Docker Extension
- Thunder Client (para testing de APIs)
### Chrome Extensions Útiles
- React Developer Tools
- TanStack Query DevTools
---
**¿Problemas?** Abre un issue en el repositorio con:
1. Comando que causó el problema
2. Logs completos (`docker-compose logs`)
3. Sistema operativo y versión de Docker
4. Pasos para reproducir

View File

@ -7,7 +7,7 @@ tmp_dir = "tmp"
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ."
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_dir = ["assets", "tmp", "vendor", "testdata", "ui"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
@ -20,12 +20,10 @@ tmp_dir = "tmp"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
stop_on_root = false
[color]
app = ""
@ -41,11 +39,6 @@ tmp_dir = "tmp"
[misc]
clean_on_exit = false
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = false
keep_scroll = true
keep_scroll = true

View File

@ -2,6 +2,7 @@ package main
import (
"fmt"
"khairul169/garage-webui/middleware"
"khairul169/garage-webui/router"
"khairul169/garage-webui/ui"
"khairul169/garage-webui/utils"
@ -18,6 +19,11 @@ func main() {
utils.InitCacheManager()
sessionMgr := utils.InitSessionManager()
// Initialize database
if err := utils.InitDatabase(); err != nil {
log.Fatal("Failed to initialize database:", err)
}
if err := utils.Garage.LoadConfig(); err != nil {
log.Println("Cannot load garage config!", err)
}
@ -27,7 +33,8 @@ func main() {
// Serve API
apiPrefix := basePath + "/api"
mux.Handle(apiPrefix+"/", http.StripPrefix(apiPrefix, router.HandleApiRouter()))
apiHandler := http.StripPrefix(apiPrefix, router.HandleApiRouter())
mux.Handle(apiPrefix+"/", apiHandler)
// Static files
ui.ServeUI(mux)
@ -37,13 +44,23 @@ func main() {
mux.Handle("/", http.RedirectHandler(basePath, http.StatusMovedPermanently))
}
// Apply security middleware
handler := sessionMgr.LoadAndSave(mux)
handler = middleware.CORSMiddleware(handler)
handler = middleware.SecurityHeadersMiddleware(handler)
handler = middleware.RateLimitMiddleware(handler)
host := utils.GetEnv("HOST", "0.0.0.0")
port := utils.GetEnv("PORT", "3909")
addr := fmt.Sprintf("%s:%s", host, port)
log.Printf("Starting server on http://%s", addr)
log.Printf("Starting secure server on http://%s", addr)
log.Printf("Authentication: enabled")
log.Printf("Rate limiting: %s requests per %s",
utils.GetEnv("RATE_LIMIT_REQUESTS", "100"),
utils.GetEnv("RATE_LIMIT_WINDOW", "1m"))
if err := http.ListenAndServe(addr, sessionMgr.LoadAndSave(mux)); err != nil {
if err := http.ListenAndServe(addr, handler); err != nil {
log.Fatal(err)
}
}

View File

@ -7,17 +7,21 @@ import (
)
func AuthMiddleware(next http.Handler) http.Handler {
authData := utils.GetEnv("AUTH_USER_PASS", "")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := utils.Session.Get(r, "authenticated")
userID := utils.Session.Get(r, "user_id")
if authData == "" {
next.ServeHTTP(w, r)
// Check if user is authenticated
if auth == nil || !auth.(bool) || userID == nil {
utils.ResponseErrorStatus(w, errors.New("unauthorized"), http.StatusUnauthorized)
return
}
if auth == nil || !auth.(bool) {
// Verify user still exists and is enabled
user, err := utils.DB.GetUser(userID.(string))
if err != nil || !user.Enabled {
// Clear invalid session
utils.Session.Clear(r)
utils.ResponseErrorStatus(w, errors.New("unauthorized"), http.StatusUnauthorized)
return
}

View File

@ -0,0 +1,200 @@
package middleware
import (
"khairul169/garage-webui/utils"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
// CORSMiddleware adds CORS headers to responses
func CORSMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get allowed origins from environment or use default
allowedOrigins := utils.GetEnv("CORS_ALLOWED_ORIGINS", "http://localhost:*,http://127.0.0.1:*")
origins := strings.Split(allowedOrigins, ",")
origin := r.Header.Get("Origin")
allowed := false
// Check if origin is allowed
for _, allowedOrigin := range origins {
if matchOrigin(strings.TrimSpace(allowedOrigin), origin) {
allowed = true
break
}
}
if allowed || len(origin) == 0 {
w.Header().Set("Access-Control-Allow-Origin", origin)
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "86400") // 24 hours
// Handle preflight requests
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
// matchOrigin checks if an origin matches the pattern (supports wildcard *)
func matchOrigin(pattern, origin string) bool {
if pattern == "*" {
return true
}
if !strings.Contains(pattern, "*") {
return pattern == origin
}
// Simple wildcard matching for ports
if strings.HasSuffix(pattern, ":*") {
basePattern := strings.TrimSuffix(pattern, ":*")
return strings.HasPrefix(origin, basePattern)
}
return pattern == origin
}
// SecurityHeadersMiddleware adds security headers
func SecurityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Security headers
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}
// Rate limiting
type RateLimiter struct {
requests map[string][]time.Time
mutex sync.RWMutex
limit int
window time.Duration
}
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
return &RateLimiter{
requests: make(map[string][]time.Time),
limit: limit,
window: window,
}
}
func (rl *RateLimiter) Allow(ip string) bool {
rl.mutex.Lock()
defer rl.mutex.Unlock()
now := time.Now()
// Get requests for this IP
requests := rl.requests[ip]
// Remove old requests outside the window
var validRequests []time.Time
for _, reqTime := range requests {
if now.Sub(reqTime) <= rl.window {
validRequests = append(validRequests, reqTime)
}
}
// Check if limit exceeded
if len(validRequests) >= rl.limit {
rl.requests[ip] = validRequests
return false
}
// Add current request
validRequests = append(validRequests, now)
rl.requests[ip] = validRequests
return true
}
func (rl *RateLimiter) Cleanup() {
ticker := time.NewTicker(rl.window)
go func() {
for range ticker.C {
rl.mutex.Lock()
now := time.Now()
for ip, requests := range rl.requests {
var validRequests []time.Time
for _, reqTime := range requests {
if now.Sub(reqTime) <= rl.window {
validRequests = append(validRequests, reqTime)
}
}
if len(validRequests) == 0 {
delete(rl.requests, ip)
} else {
rl.requests[ip] = validRequests
}
}
rl.mutex.Unlock()
}
}()
}
var defaultRateLimiter *RateLimiter
func init() {
// Default: 100 requests per minute per IP
limit, _ := strconv.Atoi(utils.GetEnv("RATE_LIMIT_REQUESTS", "100"))
window, _ := time.ParseDuration(utils.GetEnv("RATE_LIMIT_WINDOW", "1m"))
defaultRateLimiter = NewRateLimiter(limit, window)
defaultRateLimiter.Cleanup()
}
// RateLimitMiddleware applies rate limiting
func RateLimitMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get client IP
ip := getClientIP(r)
// Check rate limit
if !defaultRateLimiter.Allow(ip) {
w.Header().Set("Retry-After", "60")
utils.ResponseErrorStatus(w, nil, http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
// getClientIP extracts the real client IP from request
func getClientIP(r *http.Request) string {
// Check X-Forwarded-For header
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
ips := strings.Split(xff, ",")
return strings.TrimSpace(ips[0])
}
// Check X-Real-IP header
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return xri
}
// Use remote address
ip := r.RemoteAddr
if colon := strings.LastIndex(ip, ":"); colon != -1 {
ip = ip[:colon]
}
return ip
}

View File

@ -2,63 +2,100 @@ package router
import (
"encoding/json"
"errors"
"fmt"
"khairul169/garage-webui/schema"
"khairul169/garage-webui/utils"
"net/http"
"strings"
"golang.org/x/crypto/bcrypt"
)
type Auth struct{}
func (c *Auth) Login(w http.ResponseWriter, r *http.Request) {
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
fmt.Println("Login attempt started")
var body schema.LoginRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
fmt.Printf("Failed to decode request body: %v\n", err)
utils.ResponseError(w, err)
return
}
fmt.Printf("Login request for user: %s\n", body.Username)
userPass := strings.Split(utils.GetEnv("AUTH_USER_PASS", ""), ":")
if len(userPass) < 2 {
utils.ResponseErrorStatus(w, errors.New("AUTH_USER_PASS not set"), 500)
// Authenticate user
user, err := utils.DB.AuthenticateUser(body.Username, body.Password)
if err != nil {
fmt.Printf("Authentication failed: %v\n", err)
utils.ResponseErrorStatus(w, err, 401)
return
}
fmt.Println("User authenticated successfully")
if strings.TrimSpace(body.Username) != userPass[0] || bcrypt.CompareHashAndPassword([]byte(userPass[1]), []byte(body.Password)) != nil {
utils.ResponseErrorStatus(w, errors.New("invalid username or password"), 401)
// Create session
session, err := utils.DB.CreateSession(user.ID)
if err != nil {
fmt.Printf("Failed to create session: %v\n", err)
utils.ResponseError(w, err)
return
}
fmt.Println("Session created successfully")
// Set session in cookie/session store
utils.Session.Set(r, "user_id", user.ID)
utils.Session.Set(r, "session_id", session.ID)
utils.Session.Set(r, "authenticated", true)
utils.ResponseSuccess(w, map[string]bool{
"authenticated": true,
})
fmt.Println("Session data set")
response := schema.LoginResponse{
User: *user,
Token: session.Token,
ExpiresAt: session.ExpiresAt,
}
fmt.Println("Sending login response")
utils.ResponseSuccess(w, response)
}
func (c *Auth) Logout(w http.ResponseWriter, r *http.Request) {
// Get session ID from session store
sessionID := utils.Session.Get(r, "session_id")
if sessionID != nil {
// Delete session from database
utils.DB.DeleteSession(sessionID.(string))
}
utils.Session.Clear(r)
utils.ResponseSuccess(w, true)
utils.ResponseSuccess(w, map[string]bool{"success": true})
}
func (c *Auth) GetStatus(w http.ResponseWriter, r *http.Request) {
isAuthenticated := true
fmt.Println("GetStatus: Checking authentication status")
enabled := true // Authentication is always enabled now
authenticated := false
var user *schema.User
authSession := utils.Session.Get(r, "authenticated")
enabled := false
userID := utils.Session.Get(r, "user_id")
if utils.GetEnv("AUTH_USER_PASS", "") != "" {
enabled = true
fmt.Printf("GetStatus: authSession=%v, userID=%v\n", authSession, userID)
if authSession != nil && authSession.(bool) && userID != nil {
authenticated = true
fmt.Println("GetStatus: User is authenticated")
// Get user details
if u, err := utils.DB.GetUser(userID.(string)); err == nil {
user = u
fmt.Printf("GetStatus: User found: %s\n", user.Username)
} else {
fmt.Printf("GetStatus: Failed to get user: %v\n", err)
}
} else {
fmt.Println("GetStatus: User is not authenticated")
}
if authSession != nil && authSession.(bool) {
isAuthenticated = true
response := schema.AuthStatusResponse{
Enabled: enabled,
Authenticated: authenticated,
User: user,
}
utils.ResponseSuccess(w, map[string]bool{
"enabled": enabled,
"authenticated": isAuthenticated,
})
}
utils.ResponseSuccess(w, response)
}

View File

@ -27,6 +27,30 @@ func HandleApiRouter() *http.ServeMux {
router.HandleFunc("PUT /browse/{bucket}/{key...}", browse.PutObject)
router.HandleFunc("DELETE /browse/{bucket}/{key...}", browse.DeleteObject)
// User management routes
users := &Users{}
router.HandleFunc("GET /users", users.GetAll)
router.HandleFunc("GET /users/{id}", users.GetOne)
router.HandleFunc("POST /users", users.Create)
router.HandleFunc("PUT /users/{id}", users.Update)
router.HandleFunc("DELETE /users/{id}", users.Delete)
// Tenant management routes
tenants := &Tenants{}
router.HandleFunc("GET /tenants", tenants.GetAll)
router.HandleFunc("GET /tenants/{id}", tenants.GetOne)
router.HandleFunc("POST /tenants", tenants.Create)
router.HandleFunc("PUT /tenants/{id}", tenants.Update)
router.HandleFunc("DELETE /tenants/{id}", tenants.Delete)
router.HandleFunc("GET /tenants/{id}/stats", tenants.GetStats)
// S3 Configuration routes
s3config := &S3Config{}
router.HandleFunc("GET /s3/config", s3config.GetConfig)
router.HandleFunc("PUT /s3/config", s3config.UpdateConfig)
router.HandleFunc("POST /s3/test", s3config.TestConnection)
router.HandleFunc("GET /s3/status", s3config.GetStatus)
// Proxy request to garage api endpoint
router.HandleFunc("/", ProxyHandler)

164
backend/router/s3config.go Normal file
View File

@ -0,0 +1,164 @@
package router
import (
"encoding/json"
"khairul169/garage-webui/schema"
"khairul169/garage-webui/utils"
"net/http"
)
type S3Config struct{}
// S3ConfigResponse represents S3 configuration response
type S3ConfigResponse struct {
Region string `json:"region"`
Endpoint string `json:"endpoint"`
AdminAPI string `json:"admin_api"`
AdminToken string `json:"admin_token,omitempty"`
WebEndpoint string `json:"web_endpoint,omitempty"`
}
// UpdateS3ConfigRequest represents S3 config update request
type UpdateS3ConfigRequest struct {
Region *string `json:"region,omitempty"`
Endpoint *string `json:"endpoint,omitempty"`
AdminAPI *string `json:"admin_api,omitempty"`
AdminToken *string `json:"admin_token,omitempty"`
WebEndpoint *string `json:"web_endpoint,omitempty"`
}
func (s *S3Config) GetConfig(w http.ResponseWriter, r *http.Request) {
// Check permissions
if !s.checkPermission(r, schema.PermissionSystemAdmin) {
utils.ResponseErrorStatus(w, nil, http.StatusForbidden)
return
}
response := S3ConfigResponse{
Region: utils.Garage.GetS3Region(),
Endpoint: utils.Garage.GetS3Endpoint(),
AdminAPI: utils.Garage.GetAdminEndpoint(),
WebEndpoint: utils.Garage.GetWebEndpoint(),
// Don't send admin token for security
}
utils.ResponseSuccess(w, response)
}
func (s *S3Config) UpdateConfig(w http.ResponseWriter, r *http.Request) {
// Check permissions
if !s.checkPermission(r, schema.PermissionSystemAdmin) {
utils.ResponseErrorStatus(w, nil, http.StatusForbidden)
return
}
var req UpdateS3ConfigRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.ResponseError(w, err)
return
}
// Update configuration values
if req.Region != nil {
utils.SetEnv("S3_REGION", *req.Region)
}
if req.Endpoint != nil {
utils.SetEnv("S3_ENDPOINT_URL", *req.Endpoint)
}
if req.AdminAPI != nil {
utils.SetEnv("API_BASE_URL", *req.AdminAPI)
}
if req.AdminToken != nil {
utils.SetEnv("API_ADMIN_KEY", *req.AdminToken)
}
// Reload garage configuration
if err := utils.Garage.LoadConfig(); err != nil {
utils.ResponseError(w, err)
return
}
// Return updated config
response := S3ConfigResponse{
Region: utils.Garage.GetS3Region(),
Endpoint: utils.Garage.GetS3Endpoint(),
AdminAPI: utils.Garage.GetAdminEndpoint(),
WebEndpoint: utils.Garage.GetWebEndpoint(),
}
utils.ResponseSuccess(w, response)
}
func (s *S3Config) TestConnection(w http.ResponseWriter, r *http.Request) {
// Check permissions
if !s.checkPermission(r, schema.PermissionSystemAdmin) {
utils.ResponseErrorStatus(w, nil, http.StatusForbidden)
return
}
// Test Garage API connection
_, err := utils.Garage.Fetch("/status", &utils.FetchOptions{
Method: "GET",
})
if err != nil {
utils.ResponseErrorStatus(w, err, http.StatusServiceUnavailable)
return
}
utils.ResponseSuccess(w, map[string]interface{}{
"status": "connected",
"message": "Connection to Garage API successful",
})
}
func (s *S3Config) GetStatus(w http.ResponseWriter, r *http.Request) {
// Check permissions
if !s.checkPermission(r, schema.PermissionReadCluster) {
utils.ResponseErrorStatus(w, nil, http.StatusForbidden)
return
}
// Get status from Garage API
data, err := utils.Garage.Fetch("/status", &utils.FetchOptions{
Method: "GET",
})
if err != nil {
utils.ResponseError(w, err)
return
}
// Parse response
var status map[string]interface{}
if err := json.Unmarshal(data, &status); err != nil {
utils.ResponseError(w, err)
return
}
// Add our own status info
response := map[string]interface{}{
"garage": status,
"webui_version": "1.1.0",
"authentication": true,
"users_count": len(utils.DB.Users),
"tenants_count": len(utils.DB.Tenants),
"sessions_count": len(utils.DB.Sessions),
}
utils.ResponseSuccess(w, response)
}
func (s *S3Config) checkPermission(r *http.Request, permission schema.Permission) bool {
userID := utils.Session.Get(r, "user_id")
if userID == nil {
return false
}
user, err := utils.DB.GetUser(userID.(string))
if err != nil {
return false
}
return user.HasPermission(permission)
}

190
backend/router/tenants.go Normal file
View File

@ -0,0 +1,190 @@
package router
import (
"encoding/json"
"khairul169/garage-webui/schema"
"khairul169/garage-webui/utils"
"net/http"
)
type Tenants struct{}
func (t *Tenants) GetAll(w http.ResponseWriter, r *http.Request) {
// Check permissions
if !t.checkPermission(r, schema.PermissionReadTenants) {
utils.ResponseErrorStatus(w, nil, http.StatusForbidden)
return
}
tenants, err := utils.DB.ListTenants()
if err != nil {
utils.ResponseError(w, err)
return
}
utils.ResponseSuccess(w, tenants)
}
func (t *Tenants) GetOne(w http.ResponseWriter, r *http.Request) {
tenantID := r.PathValue("id")
if tenantID == "" {
utils.ResponseErrorStatus(w, nil, http.StatusBadRequest)
return
}
// Check permissions
if !t.checkPermission(r, schema.PermissionReadTenants) {
utils.ResponseErrorStatus(w, nil, http.StatusForbidden)
return
}
tenant, err := utils.DB.GetTenant(tenantID)
if err != nil {
utils.ResponseErrorStatus(w, err, http.StatusNotFound)
return
}
utils.ResponseSuccess(w, tenant)
}
func (t *Tenants) Create(w http.ResponseWriter, r *http.Request) {
// Check permissions
if !t.checkPermission(r, schema.PermissionWriteTenants) {
utils.ResponseErrorStatus(w, nil, http.StatusForbidden)
return
}
var req schema.CreateTenantRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.ResponseError(w, err)
return
}
// Validate request
if req.Name == "" {
utils.ResponseErrorStatus(w, nil, http.StatusBadRequest)
return
}
tenant, err := utils.DB.CreateTenant(&req)
if err != nil {
utils.ResponseError(w, err)
return
}
utils.ResponseSuccess(w, tenant)
}
func (t *Tenants) Update(w http.ResponseWriter, r *http.Request) {
tenantID := r.PathValue("id")
if tenantID == "" {
utils.ResponseErrorStatus(w, nil, http.StatusBadRequest)
return
}
// Check permissions
if !t.checkPermission(r, schema.PermissionWriteTenants) {
utils.ResponseErrorStatus(w, nil, http.StatusForbidden)
return
}
var req schema.UpdateTenantRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.ResponseError(w, err)
return
}
tenant, err := utils.DB.UpdateTenant(tenantID, &req)
if err != nil {
utils.ResponseError(w, err)
return
}
utils.ResponseSuccess(w, tenant)
}
func (t *Tenants) Delete(w http.ResponseWriter, r *http.Request) {
tenantID := r.PathValue("id")
if tenantID == "" {
utils.ResponseErrorStatus(w, nil, http.StatusBadRequest)
return
}
// Check permissions
if !t.checkPermission(r, schema.PermissionDeleteTenants) {
utils.ResponseErrorStatus(w, nil, http.StatusForbidden)
return
}
err := utils.DB.DeleteTenant(tenantID)
if err != nil {
utils.ResponseError(w, err)
return
}
utils.ResponseSuccess(w, map[string]bool{"success": true})
}
func (t *Tenants) GetStats(w http.ResponseWriter, r *http.Request) {
tenantID := r.PathValue("id")
if tenantID == "" {
utils.ResponseErrorStatus(w, nil, http.StatusBadRequest)
return
}
// Check permissions
if !t.checkPermission(r, schema.PermissionReadTenants) {
utils.ResponseErrorStatus(w, nil, http.StatusForbidden)
return
}
// Get tenant
tenant, err := utils.DB.GetTenant(tenantID)
if err != nil {
utils.ResponseErrorStatus(w, err, http.StatusNotFound)
return
}
// Get stats from Garage API
// This would need to be implemented to get actual usage statistics
// For now, return basic info
stats := map[string]interface{}{
"tenant": tenant,
"bucket_count": 0,
"key_count": 0,
"total_size": 0,
"user_count": t.getUserCountForTenant(tenantID),
}
utils.ResponseSuccess(w, stats)
}
func (t *Tenants) checkPermission(r *http.Request, permission schema.Permission) bool {
userID := utils.Session.Get(r, "user_id")
if userID == nil {
return false
}
user, err := utils.DB.GetUser(userID.(string))
if err != nil {
return false
}
return user.HasPermission(permission)
}
func (t *Tenants) getUserCountForTenant(tenantID string) int {
users, err := utils.DB.ListUsers()
if err != nil {
return 0
}
count := 0
for _, user := range users {
if user.TenantID != nil && *user.TenantID == tenantID {
count++
}
}
return count
}

147
backend/router/users.go Normal file
View File

@ -0,0 +1,147 @@
package router
import (
"encoding/json"
"khairul169/garage-webui/schema"
"khairul169/garage-webui/utils"
"net/http"
)
type Users struct{}
func (u *Users) GetAll(w http.ResponseWriter, r *http.Request) {
// Check permissions
if !u.checkPermission(r, schema.PermissionReadUsers) {
utils.ResponseErrorStatus(w, nil, http.StatusForbidden)
return
}
users, err := utils.DB.ListUsers()
if err != nil {
utils.ResponseError(w, err)
return
}
utils.ResponseSuccess(w, users)
}
func (u *Users) GetOne(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
if userID == "" {
utils.ResponseErrorStatus(w, nil, http.StatusBadRequest)
return
}
// Check permissions
if !u.checkPermission(r, schema.PermissionReadUsers) {
utils.ResponseErrorStatus(w, nil, http.StatusForbidden)
return
}
user, err := utils.DB.GetUser(userID)
if err != nil {
utils.ResponseErrorStatus(w, err, http.StatusNotFound)
return
}
utils.ResponseSuccess(w, user)
}
func (u *Users) Create(w http.ResponseWriter, r *http.Request) {
// Check permissions
if !u.checkPermission(r, schema.PermissionWriteUsers) {
utils.ResponseErrorStatus(w, nil, http.StatusForbidden)
return
}
var req schema.CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.ResponseError(w, err)
return
}
// Validate request
if req.Username == "" || req.Email == "" || req.Password == "" {
utils.ResponseErrorStatus(w, nil, http.StatusBadRequest)
return
}
user, err := utils.DB.CreateUser(&req)
if err != nil {
utils.ResponseError(w, err)
return
}
utils.ResponseSuccess(w, user)
}
func (u *Users) Update(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
if userID == "" {
utils.ResponseErrorStatus(w, nil, http.StatusBadRequest)
return
}
// Check permissions
if !u.checkPermission(r, schema.PermissionWriteUsers) {
utils.ResponseErrorStatus(w, nil, http.StatusForbidden)
return
}
var req schema.UpdateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.ResponseError(w, err)
return
}
user, err := utils.DB.UpdateUser(userID, &req)
if err != nil {
utils.ResponseError(w, err)
return
}
utils.ResponseSuccess(w, user)
}
func (u *Users) Delete(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
if userID == "" {
utils.ResponseErrorStatus(w, nil, http.StatusBadRequest)
return
}
// Check permissions
if !u.checkPermission(r, schema.PermissionDeleteUsers) {
utils.ResponseErrorStatus(w, nil, http.StatusForbidden)
return
}
// Prevent self-deletion
currentUserID := utils.Session.Get(r, "user_id")
if currentUserID != nil && currentUserID.(string) == userID {
utils.ResponseErrorStatus(w, nil, http.StatusBadRequest)
return
}
err := utils.DB.DeleteUser(userID)
if err != nil {
utils.ResponseError(w, err)
return
}
utils.ResponseSuccess(w, map[string]bool{"success": true})
}
func (u *Users) checkPermission(r *http.Request, permission schema.Permission) bool {
userID := utils.Session.Get(r, "user_id")
if userID == nil {
return false
}
user, err := utils.DB.GetUser(userID.(string))
if err != nil {
return false
}
return user.HasPermission(permission)
}

View File

@ -26,3 +26,33 @@ type S3Web struct {
Index string `json:"index" toml:"index"`
RootDomain string `json:"root_domain" toml:"root_domain"`
}
// S3Configuration represents the S3 configuration that can be modified at runtime
type S3Configuration struct {
Region string `json:"region"`
Endpoint string `json:"endpoint"`
AdminAPI string `json:"admin_api"`
WebEndpoint string `json:"web_endpoint"`
MaxBuckets int `json:"max_buckets"`
MaxKeys int `json:"max_keys"`
DefaultQuota int64 `json:"default_quota"`
AllowBucketCRUD bool `json:"allow_bucket_crud"`
AllowKeysCRUD bool `json:"allow_keys_crud"`
RequireAuth bool `json:"require_auth"`
}
// GetDefaultS3Config returns default S3 configuration
func GetDefaultS3Config() *S3Configuration {
return &S3Configuration{
Region: "garage",
Endpoint: "http://localhost:3900",
AdminAPI: "http://localhost:3903",
WebEndpoint: "",
MaxBuckets: 10,
MaxKeys: 100,
DefaultQuota: 0, // No limit
AllowBucketCRUD: true,
AllowKeysCRUD: true,
RequireAuth: true,
}
}

172
backend/schema/user.go Normal file
View File

@ -0,0 +1,172 @@
package schema
import (
"time"
)
type Role string
const (
RoleAdmin Role = "admin"
RoleUser Role = "user"
RoleReadOnly Role = "readonly"
RoleTenantAdmin Role = "tenant_admin"
)
type Permission string
const (
PermissionReadBuckets Permission = "read_buckets"
PermissionWriteBuckets Permission = "write_buckets"
PermissionDeleteBuckets Permission = "delete_buckets"
PermissionReadKeys Permission = "read_keys"
PermissionWriteKeys Permission = "write_keys"
PermissionDeleteKeys Permission = "delete_keys"
PermissionReadCluster Permission = "read_cluster"
PermissionWriteCluster Permission = "write_cluster"
PermissionReadUsers Permission = "read_users"
PermissionWriteUsers Permission = "write_users"
PermissionDeleteUsers Permission = "delete_users"
PermissionReadTenants Permission = "read_tenants"
PermissionWriteTenants Permission = "write_tenants"
PermissionDeleteTenants Permission = "delete_tenants"
PermissionSystemAdmin Permission = "system_admin"
)
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Role Role `json:"role"`
TenantID *string `json:"tenant_id"`
Enabled bool `json:"enabled"`
LastLogin *time.Time `json:"last_login"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Tenant struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
MaxBuckets int `json:"max_buckets"`
MaxKeys int `json:"max_keys"`
QuotaBytes *int64 `json:"quota_bytes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Session struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Token string `json:"-"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
// CreateUserRequest represents the request to create a new user
type CreateUserRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Role Role `json:"role"`
TenantID *string `json:"tenant_id"`
Enabled bool `json:"enabled"`
}
// UpdateUserRequest represents the request to update a user
type UpdateUserRequest struct {
Username *string `json:"username,omitempty"`
Email *string `json:"email,omitempty"`
Password *string `json:"password,omitempty"`
Role *Role `json:"role,omitempty"`
TenantID *string `json:"tenant_id,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
// CreateTenantRequest represents the request to create a new tenant
type CreateTenantRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
MaxBuckets int `json:"max_buckets"`
MaxKeys int `json:"max_keys"`
QuotaBytes *int64 `json:"quota_bytes"`
}
// UpdateTenantRequest represents the request to update a tenant
type UpdateTenantRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
MaxBuckets *int `json:"max_buckets,omitempty"`
MaxKeys *int `json:"max_keys,omitempty"`
QuotaBytes *int64 `json:"quota_bytes,omitempty"`
}
// LoginRequest represents the login request
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// LoginResponse represents the login response
type LoginResponse struct {
User User `json:"user"`
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
// AuthStatusResponse represents the auth status response
type AuthStatusResponse struct {
Enabled bool `json:"enabled"`
Authenticated bool `json:"authenticated"`
User *User `json:"user,omitempty"`
}
// GetRolePermissions returns the permissions for a given role
func GetRolePermissions(role Role) []Permission {
switch role {
case RoleAdmin:
return []Permission{
PermissionSystemAdmin,
PermissionReadBuckets, PermissionWriteBuckets, PermissionDeleteBuckets,
PermissionReadKeys, PermissionWriteKeys, PermissionDeleteKeys,
PermissionReadCluster, PermissionWriteCluster,
PermissionReadUsers, PermissionWriteUsers, PermissionDeleteUsers,
PermissionReadTenants, PermissionWriteTenants, PermissionDeleteTenants,
}
case RoleTenantAdmin:
return []Permission{
PermissionReadBuckets, PermissionWriteBuckets, PermissionDeleteBuckets,
PermissionReadKeys, PermissionWriteKeys, PermissionDeleteKeys,
PermissionReadUsers, PermissionWriteUsers, PermissionDeleteUsers,
}
case RoleUser:
return []Permission{
PermissionReadBuckets, PermissionWriteBuckets,
PermissionReadKeys, PermissionWriteKeys,
}
case RoleReadOnly:
return []Permission{
PermissionReadBuckets,
PermissionReadKeys,
PermissionReadCluster,
}
default:
return []Permission{}
}
}
// HasPermission checks if a user has a specific permission
func (u *User) HasPermission(permission Permission) bool {
permissions := GetRolePermissions(u.Role)
for _, p := range permissions {
if p == permission {
return true
}
}
return false
}

500
backend/utils/database.go Normal file
View File

@ -0,0 +1,500 @@
package utils
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"khairul169/garage-webui/schema"
"os"
"path/filepath"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
type Database struct {
Users map[string]*schema.User `json:"users"`
Tenants map[string]*schema.Tenant `json:"tenants"`
Sessions map[string]*schema.Session `json:"sessions"`
mutex sync.RWMutex
}
var DB = &Database{
Users: make(map[string]*schema.User),
Tenants: make(map[string]*schema.Tenant),
Sessions: make(map[string]*schema.Session),
}
func InitDatabase() error {
// Create data directory if it doesn't exist
dataDir := GetEnv("DATA_DIR", "./data")
if err := os.MkdirAll(dataDir, 0755); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
// Load existing data
if err := DB.Load(); err != nil {
return fmt.Errorf("failed to load database: %w", err)
}
// Create default admin user if no users exist
if len(DB.Users) == 0 {
if err := DB.CreateDefaultAdmin(); err != nil {
return fmt.Errorf("failed to create default admin: %w", err)
}
}
return nil
}
func (db *Database) Load() error {
db.mutex.Lock()
defer db.mutex.Unlock()
dataPath := filepath.Join(GetEnv("DATA_DIR", "./data"), "database.json")
// If file doesn't exist, start with empty database
if _, err := os.Stat(dataPath); os.IsNotExist(err) {
return nil
}
data, err := os.ReadFile(dataPath)
if err != nil {
return err
}
return json.Unmarshal(data, db)
}
func (db *Database) Save() error {
fmt.Println("Save: Attempting to acquire lock")
db.mutex.Lock()
defer db.mutex.Unlock()
fmt.Println("Save: Lock acquired, marshaling data")
dataPath := filepath.Join(GetEnv("DATA_DIR", "./data"), "database.json")
data, err := json.MarshalIndent(db, "", " ")
if err != nil {
fmt.Printf("Save: Marshal failed: %v\n", err)
return err
}
fmt.Println("Save: Data marshaled, writing to file")
return os.WriteFile(dataPath, data, 0600)
}
// saveUnsafe saves without acquiring locks (for use when lock is already held)
func (db *Database) saveUnsafe() error {
fmt.Println("saveUnsafe: Marshaling data without lock")
dataPath := filepath.Join(GetEnv("DATA_DIR", "./data"), "database.json")
data, err := json.MarshalIndent(db, "", " ")
if err != nil {
fmt.Printf("saveUnsafe: Marshal failed: %v\n", err)
return err
}
fmt.Println("saveUnsafe: Data marshaled, writing to file")
return os.WriteFile(dataPath, data, 0600)
}
func (db *Database) CreateDefaultAdmin() error {
// Check if we should create from environment variables (legacy support)
userPass := strings.Split(GetEnv("AUTH_USER_PASS", ""), ":")
if len(userPass) >= 2 {
return db.createUserFromEnv(userPass[0], userPass[1])
}
// Create default admin user
defaultPassword := "admin"
fmt.Printf("Creating default admin user with password: %s\n", defaultPassword)
fmt.Println("IMPORTANT: Change this password after first login!")
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(defaultPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
admin := &schema.User{
ID: GenerateID(),
Username: "admin",
Email: "admin@localhost",
PasswordHash: string(hashedPassword),
Role: schema.RoleAdmin,
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
db.Users[admin.ID] = admin
return db.Save()
}
func (db *Database) createUserFromEnv(username, passwordHash string) error {
admin := &schema.User{
ID: GenerateID(),
Username: username,
Email: username + "@localhost",
PasswordHash: passwordHash,
Role: schema.RoleAdmin,
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
db.Users[admin.ID] = admin
return db.Save()
}
// User operations
func (db *Database) CreateUser(req *schema.CreateUserRequest) (*schema.User, error) {
db.mutex.Lock()
defer db.mutex.Unlock()
// Check if username already exists
for _, user := range db.Users {
if user.Username == req.Username {
return nil, errors.New("username already exists")
}
if user.Email == req.Email {
return nil, errors.New("email already exists")
}
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
user := &schema.User{
ID: GenerateID(),
Username: req.Username,
Email: req.Email,
PasswordHash: string(hashedPassword),
Role: req.Role,
TenantID: req.TenantID,
Enabled: req.Enabled,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
db.Users[user.ID] = user
if err := db.Save(); err != nil {
return nil, err
}
return user, nil
}
func (db *Database) GetUser(id string) (*schema.User, error) {
db.mutex.RLock()
defer db.mutex.RUnlock()
user, exists := db.Users[id]
if !exists {
return nil, errors.New("user not found")
}
return user, nil
}
func (db *Database) GetUserByUsername(username string) (*schema.User, error) {
db.mutex.RLock()
defer db.mutex.RUnlock()
for _, user := range db.Users {
if user.Username == username {
return user, nil
}
}
return nil, errors.New("user not found")
}
func (db *Database) UpdateUser(id string, req *schema.UpdateUserRequest) (*schema.User, error) {
db.mutex.Lock()
defer db.mutex.Unlock()
user, exists := db.Users[id]
if !exists {
return nil, errors.New("user not found")
}
if req.Username != nil {
user.Username = *req.Username
}
if req.Email != nil {
user.Email = *req.Email
}
if req.Password != nil {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
user.PasswordHash = string(hashedPassword)
}
if req.Role != nil {
user.Role = *req.Role
}
if req.TenantID != nil {
user.TenantID = req.TenantID
}
if req.Enabled != nil {
user.Enabled = *req.Enabled
}
user.UpdatedAt = time.Now()
if err := db.Save(); err != nil {
return nil, err
}
return user, nil
}
func (db *Database) DeleteUser(id string) error {
db.mutex.Lock()
defer db.mutex.Unlock()
if _, exists := db.Users[id]; !exists {
return errors.New("user not found")
}
delete(db.Users, id)
return db.Save()
}
func (db *Database) ListUsers() ([]*schema.User, error) {
db.mutex.RLock()
defer db.mutex.RUnlock()
users := make([]*schema.User, 0, len(db.Users))
for _, user := range db.Users {
users = append(users, user)
}
return users, nil
}
// Tenant operations
func (db *Database) CreateTenant(req *schema.CreateTenantRequest) (*schema.Tenant, error) {
db.mutex.Lock()
defer db.mutex.Unlock()
// Check if name already exists
for _, tenant := range db.Tenants {
if tenant.Name == req.Name {
return nil, errors.New("tenant name already exists")
}
}
tenant := &schema.Tenant{
ID: GenerateID(),
Name: req.Name,
Description: req.Description,
Enabled: req.Enabled,
MaxBuckets: req.MaxBuckets,
MaxKeys: req.MaxKeys,
QuotaBytes: req.QuotaBytes,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
db.Tenants[tenant.ID] = tenant
if err := db.Save(); err != nil {
return nil, err
}
return tenant, nil
}
func (db *Database) GetTenant(id string) (*schema.Tenant, error) {
db.mutex.RLock()
defer db.mutex.RUnlock()
tenant, exists := db.Tenants[id]
if !exists {
return nil, errors.New("tenant not found")
}
return tenant, nil
}
func (db *Database) UpdateTenant(id string, req *schema.UpdateTenantRequest) (*schema.Tenant, error) {
db.mutex.Lock()
defer db.mutex.Unlock()
tenant, exists := db.Tenants[id]
if !exists {
return nil, errors.New("tenant not found")
}
if req.Name != nil {
tenant.Name = *req.Name
}
if req.Description != nil {
tenant.Description = *req.Description
}
if req.Enabled != nil {
tenant.Enabled = *req.Enabled
}
if req.MaxBuckets != nil {
tenant.MaxBuckets = *req.MaxBuckets
}
if req.MaxKeys != nil {
tenant.MaxKeys = *req.MaxKeys
}
if req.QuotaBytes != nil {
tenant.QuotaBytes = req.QuotaBytes
}
tenant.UpdatedAt = time.Now()
if err := db.Save(); err != nil {
return nil, err
}
return tenant, nil
}
func (db *Database) DeleteTenant(id string) error {
db.mutex.Lock()
defer db.mutex.Unlock()
if _, exists := db.Tenants[id]; !exists {
return errors.New("tenant not found")
}
delete(db.Tenants, id)
return db.Save()
}
func (db *Database) ListTenants() ([]*schema.Tenant, error) {
db.mutex.RLock()
defer db.mutex.RUnlock()
tenants := make([]*schema.Tenant, 0, len(db.Tenants))
for _, tenant := range db.Tenants {
tenants = append(tenants, tenant)
}
return tenants, nil
}
// Session operations
func (db *Database) CreateSession(userID string) (*schema.Session, error) {
fmt.Println("CreateSession: Starting session creation")
db.mutex.Lock()
defer db.mutex.Unlock()
fmt.Println("CreateSession: Generating token")
token, err := GenerateToken()
if err != nil {
fmt.Printf("CreateSession: Token generation failed: %v\n", err)
return nil, err
}
fmt.Println("CreateSession: Token generated successfully")
session := &schema.Session{
ID: GenerateID(),
UserID: userID,
Token: token,
ExpiresAt: time.Now().Add(24 * time.Hour), // 24 hours expiry
CreatedAt: time.Now(),
}
db.Sessions[session.ID] = session
fmt.Println("CreateSession: Session stored in memory")
fmt.Println("CreateSession: Saving to database")
if err := db.saveUnsafe(); err != nil {
fmt.Printf("CreateSession: Database save failed: %v\n", err)
return nil, err
}
fmt.Println("CreateSession: Database saved successfully")
return session, nil
}
func (db *Database) GetSessionByToken(token string) (*schema.Session, error) {
db.mutex.RLock()
defer db.mutex.RUnlock()
for _, session := range db.Sessions {
if session.Token == token {
if time.Now().After(session.ExpiresAt) {
return nil, errors.New("session expired")
}
return session, nil
}
}
return nil, errors.New("session not found")
}
func (db *Database) DeleteSession(id string) error {
db.mutex.Lock()
defer db.mutex.Unlock()
delete(db.Sessions, id)
return db.saveUnsafe()
}
func (db *Database) CleanupExpiredSessions() error {
db.mutex.Lock()
defer db.mutex.Unlock()
now := time.Now()
for id, session := range db.Sessions {
if now.After(session.ExpiresAt) {
delete(db.Sessions, id)
}
}
return db.saveUnsafe()
}
// Utility functions
func GenerateID() string {
bytes := make([]byte, 16)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}
func GenerateToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
// AuthenticateUser validates credentials and returns user
func (db *Database) AuthenticateUser(username, password string) (*schema.User, error) {
user, err := db.GetUserByUsername(username)
if err != nil {
return nil, errors.New("invalid credentials")
}
if !user.Enabled {
return nil, errors.New("user account is disabled")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return nil, errors.New("invalid credentials")
}
// Update last login
user.LastLogin = &[]time.Time{time.Now()}[0]
// Note: last login time will be saved when session is created
return user, nil
}

View File

@ -85,6 +85,27 @@ func (g *garage) GetS3Region() string {
return g.Config.S3API.S3Region
}
func (g *garage) GetWebEndpoint() string {
endpoint := os.Getenv("S3_WEB_ENDPOINT_URL")
if len(endpoint) > 0 {
return endpoint
}
if len(g.Config.S3Web.BindAddr) == 0 {
return ""
}
host := strings.Split(g.Config.RPCPublicAddr, ":")[0]
port := LastString(strings.Split(g.Config.S3Web.BindAddr, ":"))
endpoint = fmt.Sprintf("%s:%s", host, port)
if !strings.HasPrefix(endpoint, "http") {
endpoint = fmt.Sprintf("http://%s", endpoint)
}
return endpoint
}
func (g *garage) GetAdminKey() string {
key := os.Getenv("API_ADMIN_KEY")
if len(key) > 0 {

View File

@ -2,10 +2,15 @@ package utils
import (
"encoding/json"
"log"
"net/http"
"os"
"strings"
"sync"
)
var envMutex sync.RWMutex
func GetEnv(key, defaultValue string) string {
value := os.Getenv(key)
if len(value) == 0 {
@ -14,22 +19,72 @@ func GetEnv(key, defaultValue string) string {
return value
}
// SetEnv sets an environment variable (thread-safe)
func SetEnv(key, value string) error {
envMutex.Lock()
defer envMutex.Unlock()
return os.Setenv(key, value)
}
// GetAllEnv returns all environment variables as a map
func GetAllEnv() map[string]string {
envMutex.RLock()
defer envMutex.RUnlock()
result := make(map[string]string)
for _, env := range os.Environ() {
parts := strings.SplitN(env, "=", 2)
if len(parts) == 2 {
result[parts[0]] = parts[1]
}
}
return result
}
func LastString(str []string) string {
return str[len(str)-1]
}
func ResponseError(w http.ResponseWriter, err error) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
ResponseErrorStatus(w, err, http.StatusInternalServerError)
}
func ResponseErrorStatus(w http.ResponseWriter, err error, status int) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.WriteHeader(status)
w.Write([]byte(err.Error()))
message := "Internal server error"
if err != nil {
message = err.Error()
}
response := map[string]interface{}{
"success": false,
"message": message,
}
// Add error details for development (remove in production)
if status >= 500 && err != nil {
log.Printf("Server error: %v", err)
}
json.NewEncoder(w).Encode(response)
}
func ResponseSuccess(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(data)
response := map[string]interface{}{
"success": true,
"data": data,
}
json.NewEncoder(w).Encode(response)
}

41
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,41 @@
services:
# Garage - unchanged, uses official image
garage:
image: dxflrs/garage:v2.0.0
container_name: garage-dev
volumes:
- ./garage.toml:/etc/garage.toml
- ./dev-data/garage/meta:/var/lib/garage/meta
- ./dev-data/garage/data:/var/lib/garage/data
ports:
- "3900:3900" # S3 API
- "3901:3901" # RPC
- "3902:3902" # S3 Web
- "3903:3903" # Admin API
# WebUI - Single service for development
webui:
build:
context: .
dockerfile: Dockerfile.dev
container_name: garage-webui-dev
ports:
- "5173:5173" # Frontend dev server
- "3909:3909" # Backend API
volumes:
- .:/app
- ./garage.toml:/etc/garage.toml:ro
- /app/node_modules
- /app/backend/tmp
environment:
- VITE_API_URL=http://127.0.0.1:3909
- CONFIG_PATH=/etc/garage.toml
- API_BASE_URL=http://garage:3903
- S3_ENDPOINT_URL=http://garage:3900
- DATA_DIR=/app/data
- CORS_ALLOWED_ORIGINS=http://localhost:5173
- RATE_LIMIT_REQUESTS=1000
- RATE_LIMIT_WINDOW=1m
- CHOKIDAR_USEPOLLING=true
depends_on:
- garage

58
garage.toml.example Normal file
View File

@ -0,0 +1,58 @@
# Garage Configuration Example for Development
# Copy this file to garage.toml and modify as needed
# Data storage locations
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
# Database engine (sqlite for development, lmdb for production)
db_engine = "sqlite"
# Automatic metadata snapshots (optional)
metadata_auto_snapshot_interval = "6h"
# Replication settings
replication_factor = 1 # Use 3 for production clusters
# Compression level (1-6, higher = better compression but slower)
compression_level = 2
# RPC Configuration
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "127.0.0.1:3901" # Change to your public IP for production
rpc_secret = "1799bccfd7411abbccc9a3f8a0ccc314f5d0d9690e9a2cc4de5ba8faa24a3ee2" # CHANGE THIS
# S3 API Configuration
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage.localhost" # Change for production
# S3 Web Interface (optional)
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage.localhost" # Change for production
index = "index.html"
# Admin API Configuration (required for WebUI)
[admin]
api_bind_addr = "[::]:3903"
admin_token = "dev-admin-token-change-for-production" # CHANGE THIS
metrics_token = "dev-metrics-token-change-for-production" # CHANGE THIS
# Examples of production configurations:
# [s3_api]
# s3_region = "us-east-1"
# api_bind_addr = "[::]:3900"
# root_domain = ".s3.yourdomain.com"
# [s3_web]
# bind_addr = "[::]:3902"
# root_domain = ".web.yourdomain.com"
# index = "index.html"
# [admin]
# api_bind_addr = "127.0.0.1:3903" # Bind only to localhost for security
# admin_token = "your-secure-admin-token-here"
# metrics_token = "your-secure-metrics-token-here"

View File

@ -5,11 +5,21 @@
"type": "module",
"scripts": {
"dev:client": "vite",
"dev:client:host": "vite --host",
"build": "tsc -b && vite build",
"build:dev": "tsc -b && vite build --mode development",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"preview": "vite preview",
"dev:server": "cd backend && air",
"dev": "concurrently \"npm run dev:client\" \"npm run dev:server\""
"dev": "concurrently \"npm run dev:client\" \"npm run dev:server\"",
"dev:docker": "docker-compose -f docker-compose.dev.yml up --build",
"dev:docker:clean": "docker-compose -f docker-compose.dev.yml down -v && docker system prune -f",
"type-check": "tsc --noEmit",
"clean": "rm -rf dist node_modules/.vite",
"install:backend": "cd backend && go mod download",
"build:backend": "cd backend && go build -o main .",
"test:backend": "cd backend && go test ./..."
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",

View File

@ -10,6 +10,7 @@ const HomePage = lazy(() => import("@/pages/home/page"));
const BucketsPage = lazy(() => import("@/pages/buckets/page"));
const ManageBucketPage = lazy(() => import("@/pages/buckets/manage/page"));
const KeysPage = lazy(() => import("@/pages/keys/page"));
const AdminPage = lazy(() => import("@/pages/admin/page"));
const router = createBrowserRouter(
[
@ -46,6 +47,10 @@ const router = createBrowserRouter(
path: "keys",
Component: KeysPage,
},
{
path: "admin",
Component: AdminPage,
},
],
},
],

View File

@ -6,18 +6,19 @@ import {
LayoutDashboard,
LogOut,
Palette,
Settings,
} from "lucide-react";
import { Dropdown, Menu } from "react-daisyui";
import { Link, useLocation } from "react-router-dom";
import { Link, useLocation, useNavigate } from "react-router-dom";
import Button from "../ui/button";
import { themes } from "@/app/themes";
import appStore from "@/stores/app-store";
import garageLogo from "@/assets/garage-logo.svg";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/lib/api";
import * as utils from "@/lib/utils";
import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
import { useAuth, usePermissions } from "@/hooks/useAuth";
const pages = [
{ icon: LayoutDashboard, title: "Dashboard", path: "/", exact: true },
@ -29,6 +30,9 @@ const pages = [
const Sidebar = () => {
const { pathname } = useLocation();
const auth = useAuth();
const { hasAnyPermission } = usePermissions();
const showAdminLink = hasAnyPermission(["system_admin", "read_users", "read_tenants"]);
return (
<aside className="bg-base-100 border-r border-base-300/30 w-[80%] md:w-[250px] flex flex-col items-stretch overflow-hidden h-full">
@ -39,6 +43,18 @@ const Sidebar = () => {
className="w-full max-w-[100px] mx-auto"
/>
<p className="text-sm font-medium text-center">WebUI</p>
{/* User info */}
{auth.user && (
<div className="mt-2 p-2 bg-base-200 rounded-lg">
<p className="text-xs text-center text-base-content/60">
{auth.user.username}
</p>
<p className="text-xs text-center text-base-content/40">
{auth.user.role}
</p>
</div>
)}
</div>
<Menu className="gap-y-1 flex-1 overflow-y-auto">
@ -62,6 +78,23 @@ const Sidebar = () => {
</Menu.Item>
);
})}
{/* Admin link */}
{showAdminLink && (
<Menu.Item>
<Link
to="/admin"
className={cn(
"h-12 flex items-center px-6",
pathname.startsWith("/admin") &&
"bg-primary text-primary-content hover:bg-primary/60 focus:bg-primary focus:text-primary-content"
)}
>
<Settings size={18} />
<p>Administración</p>
</Link>
</Menu.Item>
)}
</Menu>
<div className="py-2 px-4 flex items-center gap-2">
@ -91,10 +124,16 @@ const Sidebar = () => {
};
const LogoutButton = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const logout = useMutation({
mutationFn: () => api.post("/auth/logout"),
onSuccess: () => {
window.location.href = utils.url("/auth/login");
// Clear auth queries
queryClient.removeQueries({ queryKey: ["auth"] });
// Navigate to login page
navigate("/auth/login", { replace: true });
},
onError: (err) => {
toast.error(err?.message || "Unknown error");

View File

@ -4,14 +4,23 @@ import { Navigate, Outlet } from "react-router-dom";
const AuthLayout = () => {
const auth = useAuth();
console.log("AuthLayout render:", {
isLoading: auth.isLoading,
isAuthenticated: auth.isAuthenticated,
user: auth.user
});
if (auth.isLoading) {
console.log("AuthLayout: Loading...");
return null;
}
if (auth.isAuthenticated) {
console.log("AuthLayout: User authenticated, redirecting to /");
return <Navigate to="/" replace />;
}
console.log("AuthLayout: User not authenticated, showing login");
return (
<div className="min-h-svh flex items-center justify-center">
<Outlet />

View File

@ -56,3 +56,4 @@ export const ToggleField = <T extends FieldValues>({
};
export default Toggle;
export { Toggle };

171
src/hooks/useAdmin.ts Normal file
View File

@ -0,0 +1,171 @@
import api from "@/lib/api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
User,
Tenant,
CreateUserRequest,
UpdateUserRequest,
CreateTenantRequest,
UpdateTenantRequest,
TenantStats
} from "@/types/admin";
import { toast } from "sonner";
// User hooks
export const useUsers = () => {
return useQuery({
queryKey: ["users"],
queryFn: async () => {
try {
const response = await api.get<User[]>("/users");
return response?.data || [];
} catch (error) {
console.error("Failed to fetch users:", error);
return [];
}
},
retry: 2,
});
};
export const useUser = (id: string) => {
return useQuery({
queryKey: ["users", id],
queryFn: async () => {
const response = await api.get<User>(`/users/${id}`);
return response?.data;
},
enabled: !!id,
});
};
export const useCreateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateUserRequest) => api.post<User>("/users", { body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
toast.success("Usuario creado exitosamente");
},
onError: (error: any) => {
toast.error(error.message || "Error al crear usuario");
},
});
};
export const useUpdateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateUserRequest }) =>
api.put<User>(`/users/${id}`, { body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
toast.success("Usuario actualizado exitosamente");
},
onError: (error: any) => {
toast.error(error.message || "Error al actualizar usuario");
},
});
};
export const useDeleteUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.delete(`/users/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
toast.success("Usuario eliminado exitosamente");
},
onError: (error: any) => {
toast.error(error.message || "Error al eliminar usuario");
},
});
};
// Tenant hooks
export const useTenants = () => {
return useQuery({
queryKey: ["tenants"],
queryFn: async () => {
try {
const response = await api.get<Tenant[]>("/tenants");
return response?.data || [];
} catch (error) {
console.error("Failed to fetch tenants:", error);
return [];
}
},
retry: 2,
});
};
export const useTenant = (id: string) => {
return useQuery({
queryKey: ["tenants", id],
queryFn: async () => {
const response = await api.get<Tenant>(`/tenants/${id}`);
return response?.data;
},
enabled: !!id,
});
};
export const useTenantStats = (id: string) => {
return useQuery({
queryKey: ["tenants", id, "stats"],
queryFn: async () => {
const response = await api.get<TenantStats>(`/tenants/${id}/stats`);
return response?.data;
},
enabled: !!id,
});
};
export const useCreateTenant = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateTenantRequest) => api.post<Tenant>("/tenants", { body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants"] });
toast.success("Tenant creado exitosamente");
},
onError: (error: any) => {
toast.error(error.message || "Error al crear tenant");
},
});
};
export const useUpdateTenant = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateTenantRequest }) =>
api.put<Tenant>(`/tenants/${id}`, { body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants"] });
toast.success("Tenant actualizado exitosamente");
},
onError: (error: any) => {
toast.error(error.message || "Error al actualizar tenant");
},
});
};
export const useDeleteTenant = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.delete(`/tenants/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants"] });
toast.success("Tenant eliminado exitosamente");
},
onError: (error: any) => {
toast.error(error.message || "Error al eliminar tenant");
},
});
};

View File

@ -1,20 +1,69 @@
import api from "@/lib/api";
import { useQuery } from "@tanstack/react-query";
type AuthResponse = {
enabled: boolean;
authenticated: boolean;
};
import { AuthStatusResponse, Permission, User } from "@/types/admin";
export const useAuth = () => {
const { data, isLoading } = useQuery({
queryKey: ["auth"],
queryFn: () => api.get<AuthResponse>("/auth/status"),
queryFn: () => api.get<AuthStatusResponse>("/auth/status"),
retry: false,
});
console.log("useAuth data:", data);
return {
isLoading,
isEnabled: data?.enabled,
isAuthenticated: data?.authenticated,
isEnabled: data?.data?.enabled,
isAuthenticated: data?.data?.authenticated,
user: data?.data?.user,
};
};
// Role permissions mapping
const rolePermissions: Record<string, Permission[]> = {
admin: [
"system_admin",
"read_buckets", "write_buckets", "delete_buckets",
"read_keys", "write_keys", "delete_keys",
"read_cluster", "write_cluster",
"read_users", "write_users", "delete_users",
"read_tenants", "write_tenants", "delete_tenants",
],
tenant_admin: [
"read_buckets", "write_buckets", "delete_buckets",
"read_keys", "write_keys", "delete_keys",
"read_users", "write_users", "delete_users",
],
user: [
"read_buckets", "write_buckets",
"read_keys", "write_keys",
],
readonly: [
"read_buckets",
"read_keys",
"read_cluster",
],
};
export const usePermissions = () => {
const { user } = useAuth();
const hasPermission = (permission: Permission): boolean => {
if (!user) return false;
const permissions = rolePermissions[user.role] || [];
return permissions.includes(permission);
};
const hasAnyPermission = (permissions: Permission[]): boolean => {
return permissions.some(permission => hasPermission(permission));
};
return {
hasPermission,
hasAnyPermission,
isAdmin: hasPermission("system_admin"),
isTenantAdmin: user?.role === "tenant_admin",
isUser: user?.role === "user",
isReadOnly: user?.role === "readonly",
};
};

View File

@ -0,0 +1,160 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Modal, Button } from "react-daisyui";
import { InputField } from "@/components/ui/input";
import { useCreateTenant } from "@/hooks/useAdmin";
import { Toggle } from "@/components/ui/toggle";
const createTenantSchema = z.object({
name: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
description: z.string(),
enabled: z.boolean(),
max_buckets: z.number().min(0, "Debe ser un número positivo"),
max_keys: z.number().min(0, "Debe ser un número positivo"),
quota_bytes: z.number().min(0, "Debe ser un número positivo").optional(),
});
type CreateTenantForm = z.infer<typeof createTenantSchema>;
interface CreateTenantDialogProps {
open: boolean;
onClose: () => void;
}
export default function CreateTenantDialog({ open, onClose }: CreateTenantDialogProps) {
const createTenant = useCreateTenant();
const form = useForm<CreateTenantForm>({
resolver: zodResolver(createTenantSchema),
defaultValues: {
name: "",
description: "",
enabled: true,
max_buckets: 10,
max_keys: 100,
quota_bytes: undefined,
},
});
const handleSubmit = async (data: CreateTenantForm) => {
try {
await createTenant.mutateAsync({
...data,
quota_bytes: data.quota_bytes || undefined,
});
onClose();
form.reset();
} catch (error) {
// Error is handled by the mutation
}
};
const formatBytesInput = (value: string) => {
const num = parseFloat(value);
if (isNaN(num)) return 0;
// Assume input is in GB, convert to bytes
return Math.floor(num * 1024 * 1024 * 1024);
};
return (
<Modal open={open} onClickBackdrop={onClose}>
<Modal.Header className="font-bold">
Crear Nuevo Tenant
</Modal.Header>
<Modal.Body>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<InputField
form={form}
name="name"
title="Nombre del Tenant"
placeholder="Ej: Empresa ABC"
required
/>
<div className="form-control w-full">
<label className="label">
<span className="label-text">Descripción</span>
</label>
<textarea
{...form.register("description")}
className="textarea textarea-bordered"
placeholder="Descripción opcional del tenant"
rows={3}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InputField
form={form}
name="max_buckets"
title="Máximo de Buckets"
type="number"
min={0}
placeholder="10"
required
/>
<InputField
form={form}
name="max_keys"
title="Máximo de Keys"
type="number"
min={0}
placeholder="100"
required
/>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">Cuota de Almacenamiento (GB)</span>
<span className="label-text-alt">Opcional</span>
</label>
<input
type="number"
step="0.1"
min={0}
className="input input-bordered"
placeholder="Ej: 100 (para 100GB)"
onChange={(e) => {
const bytes = formatBytesInput(e.target.value);
form.setValue("quota_bytes", bytes > 0 ? bytes : undefined);
}}
/>
<label className="label">
<span className="label-text-alt">
Dejar vacío para sin límite
</span>
</label>
</div>
<div className="form-control">
<label className="label cursor-pointer justify-start space-x-3">
<Toggle
{...form.register("enabled")}
color="success"
/>
<span className="label-text">Tenant habilitado</span>
</label>
</div>
</form>
</Modal.Body>
<Modal.Actions>
<Button onClick={onClose} disabled={createTenant.isPending}>
Cancelar
</Button>
<Button
color="primary"
loading={createTenant.isPending}
onClick={form.handleSubmit(handleSubmit)}
>
Crear Tenant
</Button>
</Modal.Actions>
</Modal>
);
}

View File

@ -0,0 +1,169 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Modal, Button, Select } from "react-daisyui";
import { InputField } from "@/components/ui/input";
import { useCreateUser, useTenants } from "@/hooks/useAdmin";
import { usePermissions } from "@/hooks/useAuth";
import { Role } from "@/types/admin";
import { Toggle } from "@/components/ui/toggle";
const createUserSchema = z.object({
username: z.string().min(3, "El nombre de usuario debe tener al menos 3 caracteres"),
email: z.string().email("Email inválido"),
password: z.string().min(6, "La contraseña debe tener al menos 6 caracteres"),
role: z.enum(["admin", "tenant_admin", "user", "readonly"] as const),
tenant_id: z.string().optional(),
enabled: z.boolean(),
});
type CreateUserForm = z.infer<typeof createUserSchema>;
interface CreateUserDialogProps {
open: boolean;
onClose: () => void;
}
export default function CreateUserDialog({ open, onClose }: CreateUserDialogProps) {
const createUser = useCreateUser();
const { data: tenants } = useTenants();
const { isAdmin } = usePermissions();
const form = useForm<CreateUserForm>({
resolver: zodResolver(createUserSchema),
defaultValues: {
username: "",
email: "",
password: "",
role: "user",
tenant_id: "",
enabled: true,
},
});
const selectedRole = form.watch("role");
const handleSubmit = async (data: CreateUserForm) => {
try {
await createUser.mutateAsync({
...data,
tenant_id: data.tenant_id || undefined,
});
onClose();
form.reset();
} catch (error) {
// Error is handled by the mutation
}
};
const roleOptions = [
...(isAdmin ? [{ value: "admin", label: "Administrador" }] : []),
{ value: "tenant_admin", label: "Administrador de Tenant" },
{ value: "user", label: "Usuario" },
{ value: "readonly", label: "Solo Lectura" },
];
return (
<Modal open={open} onClickBackdrop={onClose}>
<Modal.Header className="font-bold">
Crear Nuevo Usuario
</Modal.Header>
<Modal.Body>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<InputField
form={form}
name="username"
title="Nombre de Usuario"
placeholder="Ingresa el nombre de usuario"
required
/>
<InputField
form={form}
name="email"
title="Email"
type="email"
placeholder="Ingresa el email"
required
/>
<InputField
form={form}
name="password"
title="Contraseña"
type="password"
placeholder="Ingresa la contraseña"
required
/>
<div className="form-control w-full">
<label className="label">
<span className="label-text">Rol</span>
</label>
<Select
{...form.register("role")}
className="select-bordered"
>
{roleOptions.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
{form.formState.errors.role && (
<label className="label">
<span className="label-text-alt text-error">
{form.formState.errors.role.message}
</span>
</label>
)}
</div>
{/* Show tenant selector for tenant_admin and user roles */}
{(selectedRole === "tenant_admin" || selectedRole === "user") && (
<div className="form-control w-full">
<label className="label">
<span className="label-text">Tenant</span>
</label>
<Select
{...form.register("tenant_id")}
className="select-bordered"
>
<Select.Option value="">Seleccionar tenant (opcional)</Select.Option>
{tenants?.map((tenant) => (
<Select.Option key={tenant.id} value={tenant.id}>
{tenant.name}
</Select.Option>
))}
</Select>
</div>
)}
<div className="form-control">
<label className="label cursor-pointer justify-start space-x-3">
<Toggle
{...form.register("enabled")}
color="success"
/>
<span className="label-text">Usuario habilitado</span>
</label>
</div>
</form>
</Modal.Body>
<Modal.Actions>
<Button onClick={onClose} disabled={createUser.isPending}>
Cancelar
</Button>
<Button
color="primary"
loading={createUser.isPending}
onClick={form.handleSubmit(handleSubmit)}
>
Crear Usuario
</Button>
</Modal.Actions>
</Modal>
);
}

View File

@ -0,0 +1,187 @@
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Modal, Button } from "react-daisyui";
import { InputField } from "@/components/ui/input";
import { useUpdateTenant } from "@/hooks/useAdmin";
import { Tenant } from "@/types/admin";
import { Toggle } from "@/components/ui/toggle";
const updateTenantSchema = z.object({
name: z.string().min(2, "El nombre debe tener al menos 2 caracteres").optional(),
description: z.string().optional(),
enabled: z.boolean().optional(),
max_buckets: z.number().min(0, "Debe ser un número positivo").optional(),
max_keys: z.number().min(0, "Debe ser un número positivo").optional(),
quota_bytes: z.number().min(0, "Debe ser un número positivo").optional(),
});
type UpdateTenantForm = z.infer<typeof updateTenantSchema>;
interface EditTenantDialogProps {
open: boolean;
tenant: Tenant | null;
onClose: () => void;
}
export default function EditTenantDialog({ open, tenant, onClose }: EditTenantDialogProps) {
const updateTenant = useUpdateTenant();
const [quotaGB, setQuotaGB] = useState<string>("");
const form = useForm<UpdateTenantForm>({
resolver: zodResolver(updateTenantSchema),
defaultValues: {
name: "",
description: "",
enabled: true,
max_buckets: 10,
max_keys: 100,
quota_bytes: undefined,
},
});
useEffect(() => {
if (tenant && open) {
form.reset({
name: tenant.name,
description: tenant.description,
enabled: tenant.enabled,
max_buckets: tenant.max_buckets,
max_keys: tenant.max_keys,
quota_bytes: tenant.quota_bytes,
});
// Convert bytes to GB for display
if (tenant.quota_bytes) {
const gb = tenant.quota_bytes / (1024 * 1024 * 1024);
setQuotaGB(gb.toString());
} else {
setQuotaGB("");
}
}
}, [tenant, open, form]);
const handleSubmit = async (data: UpdateTenantForm) => {
if (!tenant) return;
try {
await updateTenant.mutateAsync({
id: tenant.id,
data,
});
onClose();
} catch (error) {
// Error is handled by the mutation
}
};
const formatBytesInput = (value: string) => {
const num = parseFloat(value);
if (isNaN(num) || num <= 0) return undefined;
// Assume input is in GB, convert to bytes
return Math.floor(num * 1024 * 1024 * 1024);
};
const handleQuotaChange = (value: string) => {
setQuotaGB(value);
const bytes = formatBytesInput(value);
form.setValue("quota_bytes", bytes);
};
if (!tenant) return null;
return (
<Modal open={open} onClickBackdrop={onClose}>
<Modal.Header className="font-bold">
Editar Tenant: {tenant.name}
</Modal.Header>
<Modal.Body>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<InputField
form={form}
name="name"
title="Nombre del Tenant"
placeholder="Ej: Empresa ABC"
/>
<div className="form-control w-full">
<label className="label">
<span className="label-text">Descripción</span>
</label>
<textarea
{...form.register("description")}
className="textarea textarea-bordered"
placeholder="Descripción opcional del tenant"
rows={3}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InputField
form={form}
name="max_buckets"
title="Máximo de Buckets"
type="number"
min={0}
/>
<InputField
form={form}
name="max_keys"
title="Máximo de Keys"
type="number"
min={0}
/>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">Cuota de Almacenamiento (GB)</span>
<span className="label-text-alt">Opcional</span>
</label>
<input
type="number"
step="0.1"
min={0}
className="input input-bordered"
placeholder="Ej: 100 (para 100GB)"
value={quotaGB}
onChange={(e) => handleQuotaChange(e.target.value)}
/>
<label className="label">
<span className="label-text-alt">
Dejar vacío para sin límite
</span>
</label>
</div>
<div className="form-control">
<label className="label cursor-pointer justify-start space-x-3">
<Toggle
{...form.register("enabled")}
color="success"
/>
<span className="label-text">Tenant habilitado</span>
</label>
</div>
</form>
</Modal.Body>
<Modal.Actions>
<Button onClick={onClose} disabled={updateTenant.isPending}>
Cancelar
</Button>
<Button
color="primary"
loading={updateTenant.isPending}
onClick={form.handleSubmit(handleSubmit)}
>
Actualizar Tenant
</Button>
</Modal.Actions>
</Modal>
);
}

View File

@ -0,0 +1,188 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Modal, Button, Select } from "react-daisyui";
import { InputField } from "@/components/ui/input";
import { useUpdateUser, useTenants } from "@/hooks/useAdmin";
import { usePermissions } from "@/hooks/useAuth";
import { User } from "@/types/admin";
import { Toggle } from "@/components/ui/toggle";
const updateUserSchema = z.object({
username: z.string().min(3, "El nombre de usuario debe tener al menos 3 caracteres").optional(),
email: z.string().email("Email inválido").optional(),
password: z.string().min(6, "La contraseña debe tener al menos 6 caracteres").optional(),
role: z.enum(["admin", "tenant_admin", "user", "readonly"] as const).optional(),
tenant_id: z.string().optional(),
enabled: z.boolean().optional(),
});
type UpdateUserForm = z.infer<typeof updateUserSchema>;
interface EditUserDialogProps {
open: boolean;
user: User | null;
onClose: () => void;
}
export default function EditUserDialog({ open, user, onClose }: EditUserDialogProps) {
const updateUser = useUpdateUser();
const { data: tenants } = useTenants();
const { isAdmin } = usePermissions();
const form = useForm<UpdateUserForm>({
resolver: zodResolver(updateUserSchema),
defaultValues: {
username: "",
email: "",
password: "",
role: "user",
tenant_id: "",
enabled: true,
},
});
const selectedRole = form.watch("role");
useEffect(() => {
if (user && open) {
form.reset({
username: user.username,
email: user.email,
password: "",
role: user.role,
tenant_id: user.tenant_id || "",
enabled: user.enabled,
});
}
}, [user, open, form]);
const handleSubmit = async (data: UpdateUserForm) => {
if (!user) return;
try {
// Remove empty password field
const updateData = { ...data };
if (!updateData.password) {
delete updateData.password;
}
// Convert empty tenant_id to undefined
if (updateData.tenant_id === "") {
updateData.tenant_id = undefined;
}
await updateUser.mutateAsync({
id: user.id,
data: updateData,
});
onClose();
} catch (error) {
// Error is handled by the mutation
}
};
const roleOptions = [
...(isAdmin ? [{ value: "admin", label: "Administrador" }] : []),
{ value: "tenant_admin", label: "Administrador de Tenant" },
{ value: "user", label: "Usuario" },
{ value: "readonly", label: "Solo Lectura" },
];
if (!user) return null;
return (
<Modal open={open} onClickBackdrop={onClose}>
<Modal.Header className="font-bold">
Editar Usuario: {user.username}
</Modal.Header>
<Modal.Body>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<InputField
form={form}
name="username"
title="Nombre de Usuario"
placeholder="Ingresa el nombre de usuario"
/>
<InputField
form={form}
name="email"
title="Email"
type="email"
placeholder="Ingresa el email"
/>
<InputField
form={form}
name="password"
title="Nueva Contraseña"
type="password"
placeholder="Dejar vacío para mantener la actual"
/>
<div className="form-control w-full">
<label className="label">
<span className="label-text">Rol</span>
</label>
<Select
{...form.register("role")}
className="select-bordered"
>
{roleOptions.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
</div>
{/* Show tenant selector for tenant_admin and user roles */}
{(selectedRole === "tenant_admin" || selectedRole === "user") && (
<div className="form-control w-full">
<label className="label">
<span className="label-text">Tenant</span>
</label>
<Select
{...form.register("tenant_id")}
className="select-bordered"
>
<Select.Option value="">Sin tenant</Select.Option>
{tenants?.map((tenant) => (
<Select.Option key={tenant.id} value={tenant.id}>
{tenant.name}
</Select.Option>
))}
</Select>
</div>
)}
<div className="form-control">
<label className="label cursor-pointer justify-start space-x-3">
<Toggle
{...form.register("enabled")}
color="success"
/>
<span className="label-text">Usuario habilitado</span>
</label>
</div>
</form>
</Modal.Body>
<Modal.Actions>
<Button onClick={onClose} disabled={updateUser.isPending}>
Cancelar
</Button>
<Button
color="primary"
loading={updateUser.isPending}
onClick={form.handleSubmit(handleSubmit)}
>
Actualizar Usuario
</Button>
</Modal.Actions>
</Modal>
);
}

View File

@ -0,0 +1,110 @@
import { usePermissions } from "@/hooks/useAuth";
import { useUsers, useTenants } from "@/hooks/useAdmin";
import { Card, Stats } from "react-daisyui";
import { Users, Building2, ShieldCheck, Database } from "lucide-react";
import TabView from "@/components/containers/tab-view";
import UsersTab from "./tabs/users-tab";
import TenantsTab from "./tabs/tenants-tab";
import SystemTab from "./tabs/system-tab";
export default function AdminDashboard() {
const { hasPermission, isAdmin } = usePermissions();
const { data: users } = useUsers();
const { data: tenants } = useTenants();
const tabs = [
...(hasPermission("read_users") ? [{
name: "users",
title: "Usuarios",
icon: Users,
Component: UsersTab
}] : []),
...(hasPermission("read_tenants") ? [{
name: "tenants",
title: "Tenants",
icon: Building2,
Component: TenantsTab
}] : []),
...(isAdmin ? [{
name: "system",
title: "Sistema",
icon: ShieldCheck,
Component: SystemTab
}] : []),
];
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Panel de Administración</h1>
<p className="text-base-content/60 mt-2">
Gestiona usuarios, tenants y configuraciones del sistema
</p>
</div>
</div>
{/* Stats Overview */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{hasPermission("read_users") && (
<Card className="bg-base-100 shadow-sm">
<Card.Body className="flex flex-row items-center justify-between p-4">
<div>
<div className="text-2xl font-bold">
{users?.length || 0}
</div>
<div className="text-sm text-base-content/60">Usuarios Totales</div>
</div>
<Users className="h-8 w-8 text-primary" />
</Card.Body>
</Card>
)}
{hasPermission("read_users") && (
<Card className="bg-base-100 shadow-sm">
<Card.Body className="flex flex-row items-center justify-between p-4">
<div>
<div className="text-2xl font-bold">
{users?.filter(u => u.enabled).length || 0}
</div>
<div className="text-sm text-base-content/60">Usuarios Activos</div>
</div>
<ShieldCheck className="h-8 w-8 text-success" />
</Card.Body>
</Card>
)}
{hasPermission("read_tenants") && (
<Card className="bg-base-100 shadow-sm">
<Card.Body className="flex flex-row items-center justify-between p-4">
<div>
<div className="text-2xl font-bold">
{tenants?.length || 0}
</div>
<div className="text-sm text-base-content/60">Tenants Totales</div>
</div>
<Building2 className="h-8 w-8 text-info" />
</Card.Body>
</Card>
)}
{hasPermission("read_tenants") && (
<Card className="bg-base-100 shadow-sm">
<Card.Body className="flex flex-row items-center justify-between p-4">
<div>
<div className="text-2xl font-bold">
{tenants?.filter(t => t.enabled).length || 0}
</div>
<div className="text-sm text-base-content/60">Tenants Activos</div>
</div>
<Database className="h-8 w-8 text-warning" />
</Card.Body>
</Card>
)}
</div>
{/* Tabs */}
<TabView tabs={tabs} />
</div>
);
}

14
src/pages/admin/page.tsx Normal file
View File

@ -0,0 +1,14 @@
import { usePermissions } from "@/hooks/useAuth";
import { Navigate } from "react-router-dom";
import AdminDashboard from "./dashboard";
export default function AdminPage() {
const { hasPermission } = usePermissions();
// Check if user has admin permissions
if (!hasPermission("system_admin") && !hasPermission("read_users") && !hasPermission("read_tenants")) {
return <Navigate to="/" replace />;
}
return <AdminDashboard />;
}

View File

@ -0,0 +1,201 @@
import { useAuth } from "@/hooks/useAuth";
import { Card, Button, Stats } from "react-daisyui";
import { Shield, Database, Server, Settings, AlertTriangle } from "lucide-react";
export default function SystemTab() {
const { user } = useAuth();
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-xl font-semibold">Configuración del Sistema</h2>
<p className="text-sm text-base-content/60">
Configuraciones avanzadas y estado del sistema
</p>
</div>
{/* System Status */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="bg-base-100">
<Card.Body>
<Card.Title className="flex items-center gap-2">
<Shield size={20} />
Seguridad
</Card.Title>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span>Autenticación</span>
<div className="badge badge-success">Activa</div>
</div>
<div className="flex justify-between items-center">
<span>Sistema de Roles</span>
<div className="badge badge-success">Activo</div>
</div>
<div className="flex justify-between items-center">
<span>Sesiones Seguras</span>
<div className="badge badge-success">Activas</div>
</div>
<div className="flex justify-between items-center">
<span>Usuario Actual</span>
<div className="badge badge-info">{user?.role}</div>
</div>
</div>
</Card.Body>
</Card>
<Card className="bg-base-100">
<Card.Body>
<Card.Title className="flex items-center gap-2">
<Database size={20} />
Base de Datos
</Card.Title>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span>Estado</span>
<div className="badge badge-success">Conectada</div>
</div>
<div className="flex justify-between items-center">
<span>Tipo</span>
<div className="badge badge-info">JSON Local</div>
</div>
<div className="flex justify-between items-center">
<span>Backups</span>
<div className="badge badge-warning">Manual</div>
</div>
<Button size="sm" color="primary" outline>
Crear Backup
</Button>
</div>
</Card.Body>
</Card>
</div>
{/* Configuration Sections */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="bg-base-100">
<Card.Body>
<Card.Title className="flex items-center gap-2">
<Server size={20} />
Configuración Garage
</Card.Title>
<div className="space-y-4">
<div>
<label className="label">
<span className="label-text">Endpoint Admin API</span>
</label>
<input
type="text"
className="input input-bordered w-full input-sm"
placeholder="Configurado desde archivo garage.toml"
disabled
/>
</div>
<div>
<label className="label">
<span className="label-text">Endpoint S3</span>
</label>
<input
type="text"
className="input input-bordered w-full input-sm"
placeholder="Configurado desde archivo garage.toml"
disabled
/>
</div>
<div className="alert alert-info">
<AlertTriangle size={16} />
<span className="text-sm">
La configuración se lee desde el archivo garage.toml
</span>
</div>
</div>
</Card.Body>
</Card>
<Card className="bg-base-100">
<Card.Body>
<Card.Title className="flex items-center gap-2">
<Settings size={20} />
Configuración Aplicación
</Card.Title>
<div className="space-y-4">
<div>
<label className="label">
<span className="label-text">Puerto</span>
</label>
<input
type="text"
className="input input-bordered w-full input-sm"
value="3909"
disabled
/>
</div>
<div>
<label className="label">
<span className="label-text">Directorio de Datos</span>
</label>
<input
type="text"
className="input input-bordered w-full input-sm"
placeholder="./data"
disabled
/>
</div>
<div>
<label className="label">
<span className="label-text">Modo</span>
</label>
<div className="badge badge-success">Producción</div>
</div>
</div>
</Card.Body>
</Card>
</div>
{/* Actions */}
<Card className="bg-base-100">
<Card.Body>
<Card.Title>Acciones del Sistema</Card.Title>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Button color="info" outline>
<Database size={16} className="mr-2" />
Crear Backup
</Button>
<Button color="warning" outline>
<Settings size={16} className="mr-2" />
Limpiar Cache
</Button>
<Button color="error" outline>
<AlertTriangle size={16} className="mr-2" />
Reiniciar Servicio
</Button>
</div>
<div className="alert alert-warning mt-4">
<AlertTriangle size={16} />
<span className="text-sm">
Estas acciones requieren privilegios de administrador del sistema
</span>
</div>
</Card.Body>
</Card>
</div>
);
}

View File

@ -0,0 +1,238 @@
import { useState } from "react";
import { useTenants, useDeleteTenant } from "@/hooks/useAdmin";
import { usePermissions } from "@/hooks/useAuth";
import { Card, Table, Button, Badge, Modal } from "react-daisyui";
import { Plus, Edit, Trash2, Eye, Building, Building2 } from "lucide-react";
import CreateTenantDialog from "../components/create-tenant-dialog";
import EditTenantDialog from "../components/edit-tenant-dialog";
import { Tenant } from "@/types/admin";
import dayjs from "dayjs";
export default function TenantsTab() {
const { hasPermission } = usePermissions();
const { data: tenants, isLoading } = useTenants();
const deleteTenant = useDeleteTenant();
const [selectedTenant, setSelectedTenant] = useState<Tenant | null>(null);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [showEditDialog, setShowEditDialog] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const handleDelete = async () => {
if (!selectedTenant) return;
try {
await deleteTenant.mutateAsync(selectedTenant.id);
setShowDeleteConfirm(false);
setSelectedTenant(null);
} catch (error) {
// Error is handled by the mutation
}
};
const formatBytes = (bytes: number | null | undefined) => {
if (!bytes) return "Sin límite";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(1)} ${units[unitIndex]}`;
};
if (isLoading) {
return <div className="flex justify-center p-8">
<span className="loading loading-spinner loading-lg"></span>
</div>;
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-semibold">Gestión de Tenants</h2>
<p className="text-sm text-base-content/60">
Administra tenants y sus configuraciones
</p>
</div>
{hasPermission("write_tenants") && (
<Button
color="primary"
startIcon={<Plus size={18} />}
onClick={() => setShowCreateDialog(true)}
>
Nuevo Tenant
</Button>
)}
</div>
{/* Tenants Table */}
<Card className="bg-base-100">
<Card.Body className="p-0">
<Table className="table-zebra">
<Table.Head>
<span>Tenant</span>
<span>Descripción</span>
<span>Estado</span>
<span>Límites</span>
<span>Cuota</span>
<span>Creado</span>
<span>Acciones</span>
</Table.Head>
<Table.Body>
{tenants?.map((tenant) => (
<Table.Row key={tenant.id}>
<td>
<div className="flex items-center space-x-3">
<div className="avatar placeholder">
<div className="bg-primary text-primary-content rounded-lg w-10 h-10">
<Building2 size={20} />
</div>
</div>
<div>
<div className="font-bold">{tenant.name}</div>
<div className="text-sm opacity-50">ID: {tenant.id.slice(0, 8)}...</div>
</div>
</div>
</td>
<td>
<div className="max-w-xs truncate">
{tenant.description || "Sin descripción"}
</div>
</td>
<td>
<Badge className={tenant.enabled ? "badge-success" : "badge-error"}>
{tenant.enabled ? "Activo" : "Inactivo"}
</Badge>
</td>
<td>
<div className="text-sm">
<div>Buckets: {tenant.max_buckets}</div>
<div>Keys: {tenant.max_keys}</div>
</div>
</td>
<td>
<span className="text-sm">
{formatBytes(tenant.quota_bytes)}
</span>
</td>
<td>
<span className="text-sm">
{dayjs(tenant.created_at).format("DD/MM/YYYY")}
</span>
</td>
<td>
<div className="flex space-x-1">
<Button
size="sm"
color="ghost"
shape="square"
onClick={() => setSelectedTenant(tenant)}
>
<Eye size={16} />
</Button>
{hasPermission("write_tenants") && (
<Button
size="sm"
color="ghost"
shape="square"
onClick={() => {
setSelectedTenant(tenant);
setShowEditDialog(true);
}}
>
<Edit size={16} />
</Button>
)}
{hasPermission("delete_tenants") && (
<Button
size="sm"
color="ghost"
shape="square"
onClick={() => {
setSelectedTenant(tenant);
setShowDeleteConfirm(true);
}}
>
<Trash2 size={16} />
</Button>
)}
</div>
</td>
</Table.Row>
))}
</Table.Body>
</Table>
{tenants?.length === 0 && (
<div className="text-center py-8 text-base-content/60">
No hay tenants registrados
</div>
)}
</Card.Body>
</Card>
{/* Dialogs */}
<CreateTenantDialog
open={showCreateDialog}
onClose={() => setShowCreateDialog(false)}
/>
<EditTenantDialog
open={showEditDialog}
tenant={selectedTenant}
onClose={() => {
setShowEditDialog(false);
setSelectedTenant(null);
}}
/>
{/* Delete Confirmation */}
<Modal open={showDeleteConfirm} onClickBackdrop={() => setShowDeleteConfirm(false)}>
<Modal.Header className="font-bold">
Confirmar Eliminación
</Modal.Header>
<Modal.Body>
<p>
¿Estás seguro de que deseas eliminar el tenant{" "}
<strong>{selectedTenant?.name}</strong>?
</p>
<p className="text-sm text-base-content/60 mt-2">
Esta acción no se puede deshacer y todos los usuarios asociados
perderán el acceso al tenant.
</p>
</Modal.Body>
<Modal.Actions>
<Button onClick={() => setShowDeleteConfirm(false)}>
Cancelar
</Button>
<Button
color="error"
loading={deleteTenant.isPending}
onClick={handleDelete}
>
Eliminar
</Button>
</Modal.Actions>
</Modal>
</div>
);
}

View File

@ -0,0 +1,257 @@
import { useState } from "react";
import { useUsers, useDeleteUser } from "@/hooks/useAdmin";
import { usePermissions } from "@/hooks/useAuth";
import { Card, Table, Button, Badge, Modal } from "react-daisyui";
import { Plus, Edit, Trash2, Eye, UserCheck, UserX } from "lucide-react";
import CreateUserDialog from "../components/create-user-dialog";
import EditUserDialog from "../components/edit-user-dialog";
import { User } from "@/types/admin";
import { toast } from "sonner";
import dayjs from "dayjs";
export default function UsersTab() {
const { hasPermission } = usePermissions();
const { data: users, isLoading } = useUsers();
const deleteUser = useDeleteUser();
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [showEditDialog, setShowEditDialog] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const handleDelete = async () => {
if (!selectedUser) return;
try {
await deleteUser.mutateAsync(selectedUser.id);
setShowDeleteConfirm(false);
setSelectedUser(null);
} catch (error) {
// Error is handled by the mutation
}
};
const getRoleBadgeColor = (role: string) => {
switch (role) {
case "admin":
return "badge-error";
case "tenant_admin":
return "badge-warning";
case "user":
return "badge-info";
case "readonly":
return "badge-neutral";
default:
return "badge-ghost";
}
};
const getRoleDisplayName = (role: string) => {
switch (role) {
case "admin":
return "Administrador";
case "tenant_admin":
return "Admin Tenant";
case "user":
return "Usuario";
case "readonly":
return "Solo Lectura";
default:
return role;
}
};
if (isLoading) {
return <div className="flex justify-center p-8">
<span className="loading loading-spinner loading-lg"></span>
</div>;
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-semibold">Gestión de Usuarios</h2>
<p className="text-sm text-base-content/60">
Administra usuarios del sistema y sus permisos
</p>
</div>
{hasPermission("write_users") && (
<Button
color="primary"
startIcon={<Plus size={18} />}
onClick={() => setShowCreateDialog(true)}
>
Nuevo Usuario
</Button>
)}
</div>
{/* Users Table */}
<Card className="bg-base-100">
<Card.Body className="p-0">
<Table className="table-zebra">
<Table.Head>
<span>Usuario</span>
<span>Email</span>
<span>Rol</span>
<span>Estado</span>
<span>Último Login</span>
<span>Acciones</span>
</Table.Head>
<Table.Body>
{users?.map((user) => (
<Table.Row key={user.id}>
<td>
<div className="flex items-center space-x-3">
<div className="avatar placeholder">
<div className="bg-neutral text-neutral-content rounded-full w-8 h-8">
<span className="text-xs">
{user.username.charAt(0).toUpperCase()}
</span>
</div>
</div>
<div>
<div className="font-bold">{user.username}</div>
<div className="text-sm opacity-50">ID: {user.id.slice(0, 8)}...</div>
</div>
</div>
</td>
<td>{user.email}</td>
<td>
<Badge className={getRoleBadgeColor(user.role)}>
{getRoleDisplayName(user.role)}
</Badge>
</td>
<td>
<Badge className={user.enabled ? "badge-success" : "badge-error"}>
{user.enabled ? (
<>
<UserCheck size={14} className="mr-1" />
Activo
</>
) : (
<>
<UserX size={14} className="mr-1" />
Inactivo
</>
)}
</Badge>
</td>
<td>
{user.last_login ? (
<span className="text-sm">
{dayjs(user.last_login).format("DD/MM/YYYY HH:mm")}
</span>
) : (
<span className="text-sm text-base-content/50">Nunca</span>
)}
</td>
<td>
<div className="flex space-x-1">
<Button
size="sm"
color="ghost"
shape="square"
onClick={() => setSelectedUser(user)}
>
<Eye size={16} />
</Button>
{hasPermission("write_users") && (
<Button
size="sm"
color="ghost"
shape="square"
onClick={() => {
setSelectedUser(user);
setShowEditDialog(true);
}}
>
<Edit size={16} />
</Button>
)}
{hasPermission("delete_users") && (
<Button
size="sm"
color="ghost"
shape="square"
onClick={() => {
setSelectedUser(user);
setShowDeleteConfirm(true);
}}
>
<Trash2 size={16} />
</Button>
)}
</div>
</td>
</Table.Row>
))}
</Table.Body>
</Table>
{users?.length === 0 && (
<div className="text-center py-8 text-base-content/60">
No hay usuarios registrados
</div>
)}
</Card.Body>
</Card>
{/* Dialogs */}
<CreateUserDialog
open={showCreateDialog}
onClose={() => setShowCreateDialog(false)}
/>
<EditUserDialog
open={showEditDialog}
user={selectedUser}
onClose={() => {
setShowEditDialog(false);
setSelectedUser(null);
}}
/>
{/* Delete Confirmation */}
<Modal open={showDeleteConfirm} onClickBackdrop={() => setShowDeleteConfirm(false)}>
<Modal.Header className="font-bold">
Confirmar Eliminación
</Modal.Header>
<Modal.Body>
<p>
¿Estás seguro de que deseas eliminar el usuario{" "}
<strong>{selectedUser?.username}</strong>?
</p>
<p className="text-sm text-base-content/60 mt-2">
Esta acción no se puede deshacer.
</p>
</Modal.Body>
<Modal.Actions>
<Button onClick={() => setShowDeleteConfirm(false)}>
Cancelar
</Button>
<Button
color="error"
loading={deleteUser.isPending}
onClick={handleDelete}
>
Eliminar
</Button>
</Modal.Actions>
</Modal>
</div>
);
}

View File

@ -1,4 +1,5 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
import { loginSchema } from "./schema";
import api from "@/lib/api";
@ -6,13 +7,21 @@ import { toast } from "sonner";
export const useLogin = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
return useMutation({
mutationFn: async (body: z.infer<typeof loginSchema>) => {
return api.post("/auth/login", { body });
},
onSuccess: () => {
onSuccess: (data) => {
console.log("Login successful!", data);
// Invalidate auth status without waiting
queryClient.invalidateQueries({ queryKey: ["auth"] });
console.log("Auth queries invalidated");
// Navigate immediately
console.log("Attempting navigation to /");
navigate("/", { replace: true });
console.log("Navigation call completed");
},
onError: (err) => {
toast.error(err?.message || "Unknown error");

View File

@ -10,7 +10,18 @@ import { CreateBucketSchema } from "./schema";
export const useBuckets = () => {
return useQuery({
queryKey: ["buckets"],
queryFn: () => api.get<GetBucketRes>("/buckets"),
queryFn: async () => {
try {
const response = await api.get<GetBucketRes>("/buckets");
// Handle the API response structure { data: [...], success: true }
return response?.data || [];
} catch (error) {
console.error("Failed to fetch buckets:", error);
// Return empty array on error to prevent UI crash
return [];
}
},
retry: 2,
});
};

View File

@ -16,8 +16,18 @@ export const useBrowseObjects = (
) => {
return useQuery({
queryKey: ["browse", bucket, options],
queryFn: () =>
api.get<GetObjectsResult>(`/browse/${bucket}`, { params: options }),
queryFn: async () => {
try {
const response = await api.get<GetObjectsResult>(`/browse/${bucket}`, { params: options });
// Handle the API response structure { data: {...}, success: true }
return response?.data || { objects: [], prefixes: [] };
} catch (error) {
console.error("Failed to browse objects:", error);
// Return empty structure on error to prevent UI crash
return { objects: [], prefixes: [] };
}
},
retry: 2,
});
};

View File

@ -65,7 +65,7 @@ const ObjectList = ({ prefix, onPrefixChange }: Props) => {
</tr>
) : null}
{data?.prefixes.map((prefix) => (
{data?.prefixes?.map((prefix) => (
<tr
key={prefix}
className="hover:bg-neutral/60 hover:text-neutral-content group"
@ -88,7 +88,7 @@ const ObjectList = ({ prefix, onPrefixChange }: Props) => {
</tr>
))}
{data?.objects.map((object, idx) => {
{data?.objects?.map((object, idx) => {
const extIdx = object.objectKey.lastIndexOf(".");
const filename =
extIdx >= 0

98
src/types/admin.ts Normal file
View File

@ -0,0 +1,98 @@
export type Role = "admin" | "user" | "readonly" | "tenant_admin";
export type Permission =
| "read_buckets"
| "write_buckets"
| "delete_buckets"
| "read_keys"
| "write_keys"
| "delete_keys"
| "read_cluster"
| "write_cluster"
| "read_users"
| "write_users"
| "delete_users"
| "read_tenants"
| "write_tenants"
| "delete_tenants"
| "system_admin";
export interface User {
id: string;
username: string;
email: string;
role: Role;
tenant_id?: string;
enabled: boolean;
last_login?: string;
created_at: string;
updated_at: string;
}
export interface Tenant {
id: string;
name: string;
description: string;
enabled: boolean;
max_buckets: number;
max_keys: number;
quota_bytes?: number;
created_at: string;
updated_at: string;
}
export interface CreateUserRequest {
username: string;
email: string;
password: string;
role: Role;
tenant_id?: string;
enabled: boolean;
}
export interface UpdateUserRequest {
username?: string;
email?: string;
password?: string;
role?: Role;
tenant_id?: string;
enabled?: boolean;
}
export interface CreateTenantRequest {
name: string;
description: string;
enabled: boolean;
max_buckets: number;
max_keys: number;
quota_bytes?: number;
}
export interface UpdateTenantRequest {
name?: string;
description?: string;
enabled?: boolean;
max_buckets?: number;
max_keys?: number;
quota_bytes?: number;
}
export interface LoginResponse {
user: User;
token: string;
expires_at: string;
}
export interface AuthStatusResponse {
enabled: boolean;
authenticated: boolean;
user?: User;
}
export interface TenantStats {
tenant: Tenant;
bucket_count: number;
key_count: number;
total_size: number;
user_count: number;
}

View File

@ -6,6 +6,8 @@ import path from "path";
export default defineConfig(({ mode }) => {
process.env = { ...process.env, ...loadEnv(mode, process.cwd()) };
const isDevelopment = mode === 'development';
return {
plugins: [react()],
resolve: {
@ -14,12 +16,55 @@ export default defineConfig(({ mode }) => {
},
},
server: {
host: true, // Listen on all addresses (needed for Docker)
port: 5173,
strictPort: true,
watch: {
usePolling: true, // Enable polling for file changes in Docker
interval: 100,
},
hmr: {
port: 5173,
host: 'localhost',
},
proxy: {
"/api": {
target: process.env.VITE_API_URL,
target: process.env.VITE_API_URL || "http://localhost:3909",
changeOrigin: true,
secure: false,
ws: true,
cookieDomainRewrite: "",
cookiePathRewrite: "/",
configure: (proxy, _options) => {
proxy.on('error', (err, _req, _res) => {
console.log('proxy error', err);
});
proxy.on('proxyReq', (proxyReq, req, _res) => {
console.log('Sending Request to the Target:', req.method, req.url);
});
proxy.on('proxyRes', (proxyRes, req, _res) => {
console.log('Received Response from the Target:', proxyRes.statusCode, req.url);
});
},
},
},
},
build: {
sourcemap: isDevelopment,
minify: !isDevelopment,
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
ui: ['react-daisyui', 'tailwindcss'],
forms: ['react-hook-form', '@hookform/resolvers', 'zod'],
query: ['@tanstack/react-query'],
},
},
},
},
optimizeDeps: {
include: ['react', 'react-dom', '@tanstack/react-query'],
},
};
});