mirror of
				https://github.com/khairul169/garage-webui.git
				synced 2025-10-30 22:59:31 +07:00 
			
		
		
		
	Add auth and tenant function
This commit is contained in:
		
							parent
							
								
									ee420fbf29
								
							
						
					
					
						commit
						15a350370c
					
				
							
								
								
									
										10
									
								
								.env.development
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								.env.development
									
									
									
									
									
										Normal 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
									
									
								
							
							
						
						
									
										48
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -23,10 +23,56 @@ dist-ssr | |||||||
| *.sln | *.sln | ||||||
| *.sw? | *.sw? | ||||||
| 
 | 
 | ||||||
|  | # Environment files | ||||||
| .env* | .env* | ||||||
| !.env.example | !.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/ | data/ | ||||||
| meta/ | meta/ | ||||||
|  | dev-data/ | ||||||
| garage.toml | 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
									
								
							
							
						
						
									
										48
									
								
								Dockerfile.dev
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										234
									
								
								Makefile
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										459
									
								
								README.dev.md
									
									
									
									
									
										Normal 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 | ||||||
| @ -7,7 +7,7 @@ tmp_dir = "tmp" | |||||||
|   bin = "./tmp/main" |   bin = "./tmp/main" | ||||||
|   cmd = "go build -o ./tmp/main ." |   cmd = "go build -o ./tmp/main ." | ||||||
|   delay = 1000 |   delay = 1000 | ||||||
|   exclude_dir = ["assets", "tmp", "vendor", "testdata"] |   exclude_dir = ["assets", "tmp", "vendor", "testdata", "ui"] | ||||||
|   exclude_file = [] |   exclude_file = [] | ||||||
|   exclude_regex = ["_test.go"] |   exclude_regex = ["_test.go"] | ||||||
|   exclude_unchanged = false |   exclude_unchanged = false | ||||||
| @ -20,12 +20,10 @@ tmp_dir = "tmp" | |||||||
|   log = "build-errors.log" |   log = "build-errors.log" | ||||||
|   poll = false |   poll = false | ||||||
|   poll_interval = 0 |   poll_interval = 0 | ||||||
|   post_cmd = [] |  | ||||||
|   pre_cmd = [] |  | ||||||
|   rerun = false |   rerun = false | ||||||
|   rerun_delay = 500 |   rerun_delay = 500 | ||||||
|   send_interrupt = false |   send_interrupt = false | ||||||
|   stop_on_error = false |   stop_on_root = false | ||||||
| 
 | 
 | ||||||
| [color] | [color] | ||||||
|   app = "" |   app = "" | ||||||
| @ -41,11 +39,6 @@ tmp_dir = "tmp" | |||||||
| [misc] | [misc] | ||||||
|   clean_on_exit = false |   clean_on_exit = false | ||||||
| 
 | 
 | ||||||
| [proxy] |  | ||||||
|   app_port = 0 |  | ||||||
|   enabled = false |  | ||||||
|   proxy_port = 0 |  | ||||||
| 
 |  | ||||||
| [screen] | [screen] | ||||||
|   clear_on_rebuild = false |   clear_on_rebuild = false | ||||||
|   keep_scroll = true |   keep_scroll = true | ||||||
| @ -2,6 +2,7 @@ package main | |||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"fmt" | 	"fmt" | ||||||
|  | 	"khairul169/garage-webui/middleware" | ||||||
| 	"khairul169/garage-webui/router" | 	"khairul169/garage-webui/router" | ||||||
| 	"khairul169/garage-webui/ui" | 	"khairul169/garage-webui/ui" | ||||||
| 	"khairul169/garage-webui/utils" | 	"khairul169/garage-webui/utils" | ||||||
| @ -18,6 +19,11 @@ func main() { | |||||||
| 	utils.InitCacheManager() | 	utils.InitCacheManager() | ||||||
| 	sessionMgr := utils.InitSessionManager() | 	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 { | 	if err := utils.Garage.LoadConfig(); err != nil { | ||||||
| 		log.Println("Cannot load garage config!", err) | 		log.Println("Cannot load garage config!", err) | ||||||
| 	} | 	} | ||||||
| @ -27,7 +33,8 @@ func main() { | |||||||
| 
 | 
 | ||||||
| 	// Serve API | 	// Serve API | ||||||
| 	apiPrefix := basePath + "/api" | 	apiPrefix := basePath + "/api" | ||||||
| 	mux.Handle(apiPrefix+"/", http.StripPrefix(apiPrefix, router.HandleApiRouter())) | 	apiHandler := http.StripPrefix(apiPrefix, router.HandleApiRouter()) | ||||||
|  | 	mux.Handle(apiPrefix+"/", apiHandler) | ||||||
| 
 | 
 | ||||||
| 	// Static files | 	// Static files | ||||||
| 	ui.ServeUI(mux) | 	ui.ServeUI(mux) | ||||||
| @ -37,13 +44,23 @@ func main() { | |||||||
| 		mux.Handle("/", http.RedirectHandler(basePath, http.StatusMovedPermanently)) | 		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") | 	host := utils.GetEnv("HOST", "0.0.0.0") | ||||||
| 	port := utils.GetEnv("PORT", "3909") | 	port := utils.GetEnv("PORT", "3909") | ||||||
| 
 | 
 | ||||||
| 	addr := fmt.Sprintf("%s:%s", host, port) | 	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) | 		log.Fatal(err) | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -7,17 +7,21 @@ import ( | |||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func AuthMiddleware(next http.Handler) http.Handler { | func AuthMiddleware(next http.Handler) http.Handler { | ||||||
| 	authData := utils.GetEnv("AUTH_USER_PASS", "") |  | ||||||
| 
 |  | ||||||
| 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||||||
| 		auth := utils.Session.Get(r, "authenticated") | 		auth := utils.Session.Get(r, "authenticated") | ||||||
|  | 		userID := utils.Session.Get(r, "user_id") | ||||||
| 
 | 
 | ||||||
| 		if authData == "" { | 		// Check if user is authenticated | ||||||
| 			next.ServeHTTP(w, r) | 		if auth == nil || !auth.(bool) || userID == nil { | ||||||
|  | 			utils.ResponseErrorStatus(w, errors.New("unauthorized"), http.StatusUnauthorized) | ||||||
| 			return | 			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) | 			utils.ResponseErrorStatus(w, errors.New("unauthorized"), http.StatusUnauthorized) | ||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
|  | |||||||
							
								
								
									
										200
									
								
								backend/middleware/security.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										200
									
								
								backend/middleware/security.go
									
									
									
									
									
										Normal 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 | ||||||
|  | } | ||||||
| @ -2,63 +2,100 @@ package router | |||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
| 	"errors" | 	"fmt" | ||||||
|  | 	"khairul169/garage-webui/schema" | ||||||
| 	"khairul169/garage-webui/utils" | 	"khairul169/garage-webui/utils" | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"strings" |  | ||||||
| 
 |  | ||||||
| 	"golang.org/x/crypto/bcrypt" |  | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| type Auth struct{} | type Auth struct{} | ||||||
| 
 | 
 | ||||||
| func (c *Auth) Login(w http.ResponseWriter, r *http.Request) { | func (c *Auth) Login(w http.ResponseWriter, r *http.Request) { | ||||||
| 	var body struct { | 	fmt.Println("Login attempt started") | ||||||
| 		Username string `json:"username"` | 	var body schema.LoginRequest | ||||||
| 		Password string `json:"password"` |  | ||||||
| 	} |  | ||||||
| 	if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | 	if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | ||||||
|  | 		fmt.Printf("Failed to decode request body: %v\n", err) | ||||||
| 		utils.ResponseError(w, err) | 		utils.ResponseError(w, err) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  | 	fmt.Printf("Login request for user: %s\n", body.Username) | ||||||
| 
 | 
 | ||||||
| 	userPass := strings.Split(utils.GetEnv("AUTH_USER_PASS", ""), ":") | 	// Authenticate user | ||||||
| 	if len(userPass) < 2 { | 	user, err := utils.DB.AuthenticateUser(body.Username, body.Password) | ||||||
| 		utils.ResponseErrorStatus(w, errors.New("AUTH_USER_PASS not set"), 500) | 	if err != nil { | ||||||
|  | 		fmt.Printf("Authentication failed: %v\n", err) | ||||||
|  | 		utils.ResponseErrorStatus(w, err, 401) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  | 	fmt.Println("User authenticated successfully") | ||||||
| 
 | 
 | ||||||
| 	if strings.TrimSpace(body.Username) != userPass[0] || bcrypt.CompareHashAndPassword([]byte(userPass[1]), []byte(body.Password)) != nil { | 	// Create session | ||||||
| 		utils.ResponseErrorStatus(w, errors.New("invalid username or password"), 401) | 	session, err := utils.DB.CreateSession(user.ID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		fmt.Printf("Failed to create session: %v\n", err) | ||||||
|  | 		utils.ResponseError(w, err) | ||||||
| 		return | 		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.Session.Set(r, "authenticated", true) | ||||||
| 	utils.ResponseSuccess(w, map[string]bool{ | 	fmt.Println("Session data set") | ||||||
| 		"authenticated": true, | 
 | ||||||
| 	}) | 	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) { | 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.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) { | 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") | 	authSession := utils.Session.Get(r, "authenticated") | ||||||
| 	enabled := false | 	userID := utils.Session.Get(r, "user_id") | ||||||
| 
 | 
 | ||||||
| 	if utils.GetEnv("AUTH_USER_PASS", "") != "" { | 	fmt.Printf("GetStatus: authSession=%v, userID=%v\n", authSession, userID) | ||||||
| 		enabled = true | 
 | ||||||
|  | 	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) { | 	response := schema.AuthStatusResponse{ | ||||||
| 		isAuthenticated = true | 		Enabled:       enabled, | ||||||
|  | 		Authenticated: authenticated, | ||||||
|  | 		User:          user, | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	utils.ResponseSuccess(w, map[string]bool{ | 	utils.ResponseSuccess(w, response) | ||||||
| 		"enabled":       enabled, |  | ||||||
| 		"authenticated": isAuthenticated, |  | ||||||
| 	}) |  | ||||||
| } | } | ||||||
| @ -27,6 +27,30 @@ func HandleApiRouter() *http.ServeMux { | |||||||
| 	router.HandleFunc("PUT /browse/{bucket}/{key...}", browse.PutObject) | 	router.HandleFunc("PUT /browse/{bucket}/{key...}", browse.PutObject) | ||||||
| 	router.HandleFunc("DELETE /browse/{bucket}/{key...}", browse.DeleteObject) | 	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 | 	// Proxy request to garage api endpoint | ||||||
| 	router.HandleFunc("/", ProxyHandler) | 	router.HandleFunc("/", ProxyHandler) | ||||||
| 
 | 
 | ||||||
|  | |||||||
							
								
								
									
										164
									
								
								backend/router/s3config.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								backend/router/s3config.go
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										190
									
								
								backend/router/tenants.go
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										147
									
								
								backend/router/users.go
									
									
									
									
									
										Normal 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) | ||||||
|  | } | ||||||
| @ -26,3 +26,33 @@ type S3Web struct { | |||||||
| 	Index      string `json:"index" toml:"index"` | 	Index      string `json:"index" toml:"index"` | ||||||
| 	RootDomain string `json:"root_domain" toml:"root_domain"` | 	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
									
								
							
							
						
						
									
										172
									
								
								backend/schema/user.go
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										500
									
								
								backend/utils/database.go
									
									
									
									
									
										Normal 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 | ||||||
|  | } | ||||||
| @ -85,6 +85,27 @@ func (g *garage) GetS3Region() string { | |||||||
| 	return g.Config.S3API.S3Region | 	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 { | func (g *garage) GetAdminKey() string { | ||||||
| 	key := os.Getenv("API_ADMIN_KEY") | 	key := os.Getenv("API_ADMIN_KEY") | ||||||
| 	if len(key) > 0 { | 	if len(key) > 0 { | ||||||
|  | |||||||
| @ -2,10 +2,15 @@ package utils | |||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
|  | 	"log" | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"os" | 	"os" | ||||||
|  | 	"strings" | ||||||
|  | 	"sync" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | var envMutex sync.RWMutex | ||||||
|  | 
 | ||||||
| func GetEnv(key, defaultValue string) string { | func GetEnv(key, defaultValue string) string { | ||||||
| 	value := os.Getenv(key) | 	value := os.Getenv(key) | ||||||
| 	if len(value) == 0 { | 	if len(value) == 0 { | ||||||
| @ -14,22 +19,72 @@ func GetEnv(key, defaultValue string) string { | |||||||
| 	return value | 	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 { | func LastString(str []string) string { | ||||||
| 	return str[len(str)-1] | 	return str[len(str)-1] | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func ResponseError(w http.ResponseWriter, err error) { | func ResponseError(w http.ResponseWriter, err error) { | ||||||
| 	w.WriteHeader(http.StatusInternalServerError) | 	ResponseErrorStatus(w, err, http.StatusInternalServerError) | ||||||
| 	w.Write([]byte(err.Error())) |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func ResponseErrorStatus(w http.ResponseWriter, err error, status int) { | 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.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{}) { | func ResponseSuccess(w http.ResponseWriter, data interface{}) { | ||||||
| 	w.Header().Set("Content-Type", "application/json") | 	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) | 	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
									
								
							
							
						
						
									
										41
									
								
								docker-compose.dev.yml
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										58
									
								
								garage.toml.example
									
									
									
									
									
										Normal 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" | ||||||
							
								
								
									
										12
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								package.json
									
									
									
									
									
								
							| @ -5,11 +5,21 @@ | |||||||
|   "type": "module", |   "type": "module", | ||||||
|   "scripts": { |   "scripts": { | ||||||
|     "dev:client": "vite", |     "dev:client": "vite", | ||||||
|  |     "dev:client:host": "vite --host", | ||||||
|     "build": "tsc -b && vite build", |     "build": "tsc -b && vite build", | ||||||
|  |     "build:dev": "tsc -b && vite build --mode development", | ||||||
|     "lint": "eslint .", |     "lint": "eslint .", | ||||||
|  |     "lint:fix": "eslint . --fix", | ||||||
|     "preview": "vite preview", |     "preview": "vite preview", | ||||||
|     "dev:server": "cd backend && air", |     "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": { |   "dependencies": { | ||||||
|     "@hookform/resolvers": "^3.9.0", |     "@hookform/resolvers": "^3.9.0", | ||||||
|  | |||||||
| @ -10,6 +10,7 @@ const HomePage = lazy(() => import("@/pages/home/page")); | |||||||
| const BucketsPage = lazy(() => import("@/pages/buckets/page")); | const BucketsPage = lazy(() => import("@/pages/buckets/page")); | ||||||
| const ManageBucketPage = lazy(() => import("@/pages/buckets/manage/page")); | const ManageBucketPage = lazy(() => import("@/pages/buckets/manage/page")); | ||||||
| const KeysPage = lazy(() => import("@/pages/keys/page")); | const KeysPage = lazy(() => import("@/pages/keys/page")); | ||||||
|  | const AdminPage = lazy(() => import("@/pages/admin/page")); | ||||||
| 
 | 
 | ||||||
| const router = createBrowserRouter( | const router = createBrowserRouter( | ||||||
|   [ |   [ | ||||||
| @ -46,6 +47,10 @@ const router = createBrowserRouter( | |||||||
|           path: "keys", |           path: "keys", | ||||||
|           Component: KeysPage, |           Component: KeysPage, | ||||||
|         }, |         }, | ||||||
|  |         { | ||||||
|  |           path: "admin", | ||||||
|  |           Component: AdminPage, | ||||||
|  |         }, | ||||||
|       ], |       ], | ||||||
|     }, |     }, | ||||||
|   ], |   ], | ||||||
|  | |||||||
| @ -6,18 +6,19 @@ import { | |||||||
|   LayoutDashboard, |   LayoutDashboard, | ||||||
|   LogOut, |   LogOut, | ||||||
|   Palette, |   Palette, | ||||||
|  |   Settings, | ||||||
| } from "lucide-react"; | } from "lucide-react"; | ||||||
| import { Dropdown, Menu } from "react-daisyui"; | 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 Button from "../ui/button"; | ||||||
| import { themes } from "@/app/themes"; | import { themes } from "@/app/themes"; | ||||||
| import appStore from "@/stores/app-store"; | import appStore from "@/stores/app-store"; | ||||||
| import garageLogo from "@/assets/garage-logo.svg"; | 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 api from "@/lib/api"; | ||||||
| import * as utils from "@/lib/utils"; | import * as utils from "@/lib/utils"; | ||||||
| import { toast } from "sonner"; | import { toast } from "sonner"; | ||||||
| import { useAuth } from "@/hooks/useAuth"; | import { useAuth, usePermissions } from "@/hooks/useAuth"; | ||||||
| 
 | 
 | ||||||
| const pages = [ | const pages = [ | ||||||
|   { icon: LayoutDashboard, title: "Dashboard", path: "/", exact: true }, |   { icon: LayoutDashboard, title: "Dashboard", path: "/", exact: true }, | ||||||
| @ -29,6 +30,9 @@ const pages = [ | |||||||
| const Sidebar = () => { | const Sidebar = () => { | ||||||
|   const { pathname } = useLocation(); |   const { pathname } = useLocation(); | ||||||
|   const auth = useAuth(); |   const auth = useAuth(); | ||||||
|  |   const { hasAnyPermission } = usePermissions(); | ||||||
|  | 
 | ||||||
|  |   const showAdminLink = hasAnyPermission(["system_admin", "read_users", "read_tenants"]); | ||||||
| 
 | 
 | ||||||
|   return ( |   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"> |     <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" |           className="w-full max-w-[100px] mx-auto" | ||||||
|         /> |         /> | ||||||
|         <p className="text-sm font-medium text-center">WebUI</p> |         <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> |       </div> | ||||||
| 
 | 
 | ||||||
|       <Menu className="gap-y-1 flex-1 overflow-y-auto"> |       <Menu className="gap-y-1 flex-1 overflow-y-auto"> | ||||||
| @ -62,6 +78,23 @@ const Sidebar = () => { | |||||||
|             </Menu.Item> |             </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> |       </Menu> | ||||||
| 
 | 
 | ||||||
|       <div className="py-2 px-4 flex items-center gap-2"> |       <div className="py-2 px-4 flex items-center gap-2"> | ||||||
| @ -91,10 +124,16 @@ const Sidebar = () => { | |||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const LogoutButton = () => { | const LogoutButton = () => { | ||||||
|  |   const navigate = useNavigate(); | ||||||
|  |   const queryClient = useQueryClient(); | ||||||
|  | 
 | ||||||
|   const logout = useMutation({ |   const logout = useMutation({ | ||||||
|     mutationFn: () => api.post("/auth/logout"), |     mutationFn: () => api.post("/auth/logout"), | ||||||
|     onSuccess: () => { |     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) => { |     onError: (err) => { | ||||||
|       toast.error(err?.message || "Unknown error"); |       toast.error(err?.message || "Unknown error"); | ||||||
|  | |||||||
| @ -4,14 +4,23 @@ import { Navigate, Outlet } from "react-router-dom"; | |||||||
| const AuthLayout = () => { | const AuthLayout = () => { | ||||||
|   const auth = useAuth(); |   const auth = useAuth(); | ||||||
| 
 | 
 | ||||||
|  |   console.log("AuthLayout render:", { | ||||||
|  |     isLoading: auth.isLoading, | ||||||
|  |     isAuthenticated: auth.isAuthenticated, | ||||||
|  |     user: auth.user | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|   if (auth.isLoading) { |   if (auth.isLoading) { | ||||||
|  |     console.log("AuthLayout: Loading..."); | ||||||
|     return null; |     return null; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   if (auth.isAuthenticated) { |   if (auth.isAuthenticated) { | ||||||
|  |     console.log("AuthLayout: User authenticated, redirecting to /"); | ||||||
|     return <Navigate to="/" replace />; |     return <Navigate to="/" replace />; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   console.log("AuthLayout: User not authenticated, showing login"); | ||||||
|   return ( |   return ( | ||||||
|     <div className="min-h-svh flex items-center justify-center"> |     <div className="min-h-svh flex items-center justify-center"> | ||||||
|       <Outlet /> |       <Outlet /> | ||||||
|  | |||||||
| @ -56,3 +56,4 @@ export const ToggleField = <T extends FieldValues>({ | |||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default Toggle; | export default Toggle; | ||||||
|  | export { Toggle }; | ||||||
|  | |||||||
							
								
								
									
										171
									
								
								src/hooks/useAdmin.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								src/hooks/useAdmin.ts
									
									
									
									
									
										Normal 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"); | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
| @ -1,20 +1,69 @@ | |||||||
| import api from "@/lib/api"; | import api from "@/lib/api"; | ||||||
| import { useQuery } from "@tanstack/react-query"; | import { useQuery } from "@tanstack/react-query"; | ||||||
| 
 | import { AuthStatusResponse, Permission, User } from "@/types/admin"; | ||||||
| type AuthResponse = { |  | ||||||
|   enabled: boolean; |  | ||||||
|   authenticated: boolean; |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| export const useAuth = () => { | export const useAuth = () => { | ||||||
|   const { data, isLoading } = useQuery({ |   const { data, isLoading } = useQuery({ | ||||||
|     queryKey: ["auth"], |     queryKey: ["auth"], | ||||||
|     queryFn: () => api.get<AuthResponse>("/auth/status"), |     queryFn: () => api.get<AuthStatusResponse>("/auth/status"), | ||||||
|     retry: false, |     retry: false, | ||||||
|   }); |   }); | ||||||
|  | 
 | ||||||
|  |   console.log("useAuth data:", data); | ||||||
|  | 
 | ||||||
|   return { |   return { | ||||||
|     isLoading, |     isLoading, | ||||||
|     isEnabled: data?.enabled, |     isEnabled: data?.data?.enabled, | ||||||
|     isAuthenticated: data?.authenticated, |     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", | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  | |||||||
							
								
								
									
										160
									
								
								src/pages/admin/components/create-tenant-dialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										160
									
								
								src/pages/admin/components/create-tenant-dialog.tsx
									
									
									
									
									
										Normal 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> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										169
									
								
								src/pages/admin/components/create-user-dialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										169
									
								
								src/pages/admin/components/create-user-dialog.tsx
									
									
									
									
									
										Normal 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> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										187
									
								
								src/pages/admin/components/edit-tenant-dialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										187
									
								
								src/pages/admin/components/edit-tenant-dialog.tsx
									
									
									
									
									
										Normal 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> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										188
									
								
								src/pages/admin/components/edit-user-dialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										188
									
								
								src/pages/admin/components/edit-user-dialog.tsx
									
									
									
									
									
										Normal 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> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										110
									
								
								src/pages/admin/dashboard.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								src/pages/admin/dashboard.tsx
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										14
									
								
								src/pages/admin/page.tsx
									
									
									
									
									
										Normal 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 />; | ||||||
|  | } | ||||||
							
								
								
									
										201
									
								
								src/pages/admin/tabs/system-tab.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								src/pages/admin/tabs/system-tab.tsx
									
									
									
									
									
										Normal 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> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										238
									
								
								src/pages/admin/tabs/tenants-tab.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										238
									
								
								src/pages/admin/tabs/tenants-tab.tsx
									
									
									
									
									
										Normal 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> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										257
									
								
								src/pages/admin/tabs/users-tab.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										257
									
								
								src/pages/admin/tabs/users-tab.tsx
									
									
									
									
									
										Normal 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> | ||||||
|  |   ); | ||||||
|  | } | ||||||
| @ -1,4 +1,5 @@ | |||||||
| import { useMutation, useQueryClient } from "@tanstack/react-query"; | import { useMutation, useQueryClient } from "@tanstack/react-query"; | ||||||
|  | import { useNavigate } from "react-router-dom"; | ||||||
| import { z } from "zod"; | import { z } from "zod"; | ||||||
| import { loginSchema } from "./schema"; | import { loginSchema } from "./schema"; | ||||||
| import api from "@/lib/api"; | import api from "@/lib/api"; | ||||||
| @ -6,13 +7,21 @@ import { toast } from "sonner"; | |||||||
| 
 | 
 | ||||||
| export const useLogin = () => { | export const useLogin = () => { | ||||||
|   const queryClient = useQueryClient(); |   const queryClient = useQueryClient(); | ||||||
|  |   const navigate = useNavigate(); | ||||||
| 
 | 
 | ||||||
|   return useMutation({ |   return useMutation({ | ||||||
|     mutationFn: async (body: z.infer<typeof loginSchema>) => { |     mutationFn: async (body: z.infer<typeof loginSchema>) => { | ||||||
|       return api.post("/auth/login", { body }); |       return api.post("/auth/login", { body }); | ||||||
|     }, |     }, | ||||||
|     onSuccess: () => { |     onSuccess: (data) => { | ||||||
|  |       console.log("Login successful!", data); | ||||||
|  |       // Invalidate auth status without waiting
 | ||||||
|       queryClient.invalidateQueries({ queryKey: ["auth"] }); |       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) => { |     onError: (err) => { | ||||||
|       toast.error(err?.message || "Unknown error"); |       toast.error(err?.message || "Unknown error"); | ||||||
|  | |||||||
| @ -10,7 +10,18 @@ import { CreateBucketSchema } from "./schema"; | |||||||
| export const useBuckets = () => { | export const useBuckets = () => { | ||||||
|   return useQuery({ |   return useQuery({ | ||||||
|     queryKey: ["buckets"], |     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, | ||||||
|   }); |   }); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -16,8 +16,18 @@ export const useBrowseObjects = ( | |||||||
| ) => { | ) => { | ||||||
|   return useQuery({ |   return useQuery({ | ||||||
|     queryKey: ["browse", bucket, options], |     queryKey: ["browse", bucket, options], | ||||||
|     queryFn: () => |     queryFn: async () => { | ||||||
|       api.get<GetObjectsResult>(`/browse/${bucket}`, { params: options }), |       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, | ||||||
|   }); |   }); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -65,7 +65,7 @@ const ObjectList = ({ prefix, onPrefixChange }: Props) => { | |||||||
|             </tr> |             </tr> | ||||||
|           ) : null} |           ) : null} | ||||||
| 
 | 
 | ||||||
|           {data?.prefixes.map((prefix) => ( |           {data?.prefixes?.map((prefix) => ( | ||||||
|             <tr |             <tr | ||||||
|               key={prefix} |               key={prefix} | ||||||
|               className="hover:bg-neutral/60 hover:text-neutral-content group" |               className="hover:bg-neutral/60 hover:text-neutral-content group" | ||||||
| @ -88,7 +88,7 @@ const ObjectList = ({ prefix, onPrefixChange }: Props) => { | |||||||
|             </tr> |             </tr> | ||||||
|           ))} |           ))} | ||||||
| 
 | 
 | ||||||
|           {data?.objects.map((object, idx) => { |           {data?.objects?.map((object, idx) => { | ||||||
|             const extIdx = object.objectKey.lastIndexOf("."); |             const extIdx = object.objectKey.lastIndexOf("."); | ||||||
|             const filename = |             const filename = | ||||||
|               extIdx >= 0 |               extIdx >= 0 | ||||||
|  | |||||||
							
								
								
									
										98
									
								
								src/types/admin.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								src/types/admin.ts
									
									
									
									
									
										Normal 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; | ||||||
|  | } | ||||||
| @ -6,6 +6,8 @@ import path from "path"; | |||||||
| export default defineConfig(({ mode }) => { | export default defineConfig(({ mode }) => { | ||||||
|   process.env = { ...process.env, ...loadEnv(mode, process.cwd()) }; |   process.env = { ...process.env, ...loadEnv(mode, process.cwd()) }; | ||||||
| 
 | 
 | ||||||
|  |   const isDevelopment = mode === 'development'; | ||||||
|  | 
 | ||||||
|   return { |   return { | ||||||
|     plugins: [react()], |     plugins: [react()], | ||||||
|     resolve: { |     resolve: { | ||||||
| @ -14,12 +16,55 @@ export default defineConfig(({ mode }) => { | |||||||
|       }, |       }, | ||||||
|     }, |     }, | ||||||
|     server: { |     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: { |       proxy: { | ||||||
|         "/api": { |         "/api": { | ||||||
|           target: process.env.VITE_API_URL, |           target: process.env.VITE_API_URL || "http://localhost:3909", | ||||||
|           changeOrigin: true, |           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'], | ||||||
|  |     }, | ||||||
|   }; |   }; | ||||||
| }); | }); | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 Aluisco Ricardo
						Aluisco Ricardo