mirror of
https://github.com/khairul169/garage-webui.git
synced 2025-10-14 14:59:32 +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