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
|
||||
*.sw?
|
||||
|
||||
# Environment files
|
||||
.env*
|
||||
!.env.example
|
||||
docker-compose.*.yml
|
||||
!.env.development
|
||||
|
||||
# Docker compose development files (keep only production)
|
||||
# docker-compose.*.yml # Commented out to allow dev files
|
||||
|
||||
# Development data
|
||||
data/
|
||||
meta/
|
||||
dev-data/
|
||||
garage.toml
|
||||
|
||||
# Backend specific
|
||||
backend/main
|
||||
backend/tmp/
|
||||
backend/data/
|
||||
backend/*.log
|
||||
backend/.air_tmp
|
||||
backend/build-errors.log
|
||||
|
||||
# Go build artifacts
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
vendor/
|
||||
tmp/
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.backup
|
||||
|
||||
# Development certificates
|
||||
*.pem
|
||||
*.crt
|
||||
*.key
|
||||
|
||||
# Cache and runtime
|
||||
.cache/
|
||||
.vite/
|
||||
runtime/
|
||||
logs/
|
||||
.claude/
|
||||
|
48
Dockerfile.dev
Normal file
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"
|
||||
cmd = "go build -o ./tmp/main ."
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "ui"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
@ -20,12 +20,10 @@ tmp_dir = "tmp"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
stop_on_root = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
@ -41,11 +39,6 @@ tmp_dir = "tmp"
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[proxy]
|
||||
app_port = 0
|
||||
enabled = false
|
||||
proxy_port = 0
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"khairul169/garage-webui/middleware"
|
||||
"khairul169/garage-webui/router"
|
||||
"khairul169/garage-webui/ui"
|
||||
"khairul169/garage-webui/utils"
|
||||
@ -18,6 +19,11 @@ func main() {
|
||||
utils.InitCacheManager()
|
||||
sessionMgr := utils.InitSessionManager()
|
||||
|
||||
// Initialize database
|
||||
if err := utils.InitDatabase(); err != nil {
|
||||
log.Fatal("Failed to initialize database:", err)
|
||||
}
|
||||
|
||||
if err := utils.Garage.LoadConfig(); err != nil {
|
||||
log.Println("Cannot load garage config!", err)
|
||||
}
|
||||
@ -27,7 +33,8 @@ func main() {
|
||||
|
||||
// Serve API
|
||||
apiPrefix := basePath + "/api"
|
||||
mux.Handle(apiPrefix+"/", http.StripPrefix(apiPrefix, router.HandleApiRouter()))
|
||||
apiHandler := http.StripPrefix(apiPrefix, router.HandleApiRouter())
|
||||
mux.Handle(apiPrefix+"/", apiHandler)
|
||||
|
||||
// Static files
|
||||
ui.ServeUI(mux)
|
||||
@ -37,13 +44,23 @@ func main() {
|
||||
mux.Handle("/", http.RedirectHandler(basePath, http.StatusMovedPermanently))
|
||||
}
|
||||
|
||||
// Apply security middleware
|
||||
handler := sessionMgr.LoadAndSave(mux)
|
||||
handler = middleware.CORSMiddleware(handler)
|
||||
handler = middleware.SecurityHeadersMiddleware(handler)
|
||||
handler = middleware.RateLimitMiddleware(handler)
|
||||
|
||||
host := utils.GetEnv("HOST", "0.0.0.0")
|
||||
port := utils.GetEnv("PORT", "3909")
|
||||
|
||||
addr := fmt.Sprintf("%s:%s", host, port)
|
||||
log.Printf("Starting server on http://%s", addr)
|
||||
log.Printf("Starting secure server on http://%s", addr)
|
||||
log.Printf("Authentication: enabled")
|
||||
log.Printf("Rate limiting: %s requests per %s",
|
||||
utils.GetEnv("RATE_LIMIT_REQUESTS", "100"),
|
||||
utils.GetEnv("RATE_LIMIT_WINDOW", "1m"))
|
||||
|
||||
if err := http.ListenAndServe(addr, sessionMgr.LoadAndSave(mux)); err != nil {
|
||||
if err := http.ListenAndServe(addr, handler); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
@ -7,17 +7,21 @@ import (
|
||||
)
|
||||
|
||||
func AuthMiddleware(next http.Handler) http.Handler {
|
||||
authData := utils.GetEnv("AUTH_USER_PASS", "")
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := utils.Session.Get(r, "authenticated")
|
||||
userID := utils.Session.Get(r, "user_id")
|
||||
|
||||
if authData == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
// Check if user is authenticated
|
||||
if auth == nil || !auth.(bool) || userID == nil {
|
||||
utils.ResponseErrorStatus(w, errors.New("unauthorized"), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if auth == nil || !auth.(bool) {
|
||||
// Verify user still exists and is enabled
|
||||
user, err := utils.DB.GetUser(userID.(string))
|
||||
if err != nil || !user.Enabled {
|
||||
// Clear invalid session
|
||||
utils.Session.Clear(r)
|
||||
utils.ResponseErrorStatus(w, errors.New("unauthorized"), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
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 (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"khairul169/garage-webui/schema"
|
||||
"khairul169/garage-webui/utils"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type Auth struct{}
|
||||
|
||||
func (c *Auth) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
fmt.Println("Login attempt started")
|
||||
var body schema.LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
fmt.Printf("Failed to decode request body: %v\n", err)
|
||||
utils.ResponseError(w, err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("Login request for user: %s\n", body.Username)
|
||||
|
||||
userPass := strings.Split(utils.GetEnv("AUTH_USER_PASS", ""), ":")
|
||||
if len(userPass) < 2 {
|
||||
utils.ResponseErrorStatus(w, errors.New("AUTH_USER_PASS not set"), 500)
|
||||
// Authenticate user
|
||||
user, err := utils.DB.AuthenticateUser(body.Username, body.Password)
|
||||
if err != nil {
|
||||
fmt.Printf("Authentication failed: %v\n", err)
|
||||
utils.ResponseErrorStatus(w, err, 401)
|
||||
return
|
||||
}
|
||||
fmt.Println("User authenticated successfully")
|
||||
|
||||
if strings.TrimSpace(body.Username) != userPass[0] || bcrypt.CompareHashAndPassword([]byte(userPass[1]), []byte(body.Password)) != nil {
|
||||
utils.ResponseErrorStatus(w, errors.New("invalid username or password"), 401)
|
||||
// Create session
|
||||
session, err := utils.DB.CreateSession(user.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to create session: %v\n", err)
|
||||
utils.ResponseError(w, err)
|
||||
return
|
||||
}
|
||||
fmt.Println("Session created successfully")
|
||||
|
||||
// Set session in cookie/session store
|
||||
utils.Session.Set(r, "user_id", user.ID)
|
||||
utils.Session.Set(r, "session_id", session.ID)
|
||||
utils.Session.Set(r, "authenticated", true)
|
||||
utils.ResponseSuccess(w, map[string]bool{
|
||||
"authenticated": true,
|
||||
})
|
||||
fmt.Println("Session data set")
|
||||
|
||||
response := schema.LoginResponse{
|
||||
User: *user,
|
||||
Token: session.Token,
|
||||
ExpiresAt: session.ExpiresAt,
|
||||
}
|
||||
|
||||
fmt.Println("Sending login response")
|
||||
utils.ResponseSuccess(w, response)
|
||||
}
|
||||
|
||||
func (c *Auth) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
// Get session ID from session store
|
||||
sessionID := utils.Session.Get(r, "session_id")
|
||||
if sessionID != nil {
|
||||
// Delete session from database
|
||||
utils.DB.DeleteSession(sessionID.(string))
|
||||
}
|
||||
|
||||
utils.Session.Clear(r)
|
||||
utils.ResponseSuccess(w, true)
|
||||
utils.ResponseSuccess(w, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (c *Auth) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
isAuthenticated := true
|
||||
fmt.Println("GetStatus: Checking authentication status")
|
||||
enabled := true // Authentication is always enabled now
|
||||
authenticated := false
|
||||
var user *schema.User
|
||||
|
||||
authSession := utils.Session.Get(r, "authenticated")
|
||||
enabled := false
|
||||
userID := utils.Session.Get(r, "user_id")
|
||||
|
||||
if utils.GetEnv("AUTH_USER_PASS", "") != "" {
|
||||
enabled = true
|
||||
fmt.Printf("GetStatus: authSession=%v, userID=%v\n", authSession, userID)
|
||||
|
||||
if authSession != nil && authSession.(bool) && userID != nil {
|
||||
authenticated = true
|
||||
fmt.Println("GetStatus: User is authenticated")
|
||||
// Get user details
|
||||
if u, err := utils.DB.GetUser(userID.(string)); err == nil {
|
||||
user = u
|
||||
fmt.Printf("GetStatus: User found: %s\n", user.Username)
|
||||
} else {
|
||||
fmt.Printf("GetStatus: Failed to get user: %v\n", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("GetStatus: User is not authenticated")
|
||||
}
|
||||
|
||||
if authSession != nil && authSession.(bool) {
|
||||
isAuthenticated = true
|
||||
response := schema.AuthStatusResponse{
|
||||
Enabled: enabled,
|
||||
Authenticated: authenticated,
|
||||
User: user,
|
||||
}
|
||||
|
||||
utils.ResponseSuccess(w, map[string]bool{
|
||||
"enabled": enabled,
|
||||
"authenticated": isAuthenticated,
|
||||
})
|
||||
utils.ResponseSuccess(w, response)
|
||||
}
|
@ -27,6 +27,30 @@ func HandleApiRouter() *http.ServeMux {
|
||||
router.HandleFunc("PUT /browse/{bucket}/{key...}", browse.PutObject)
|
||||
router.HandleFunc("DELETE /browse/{bucket}/{key...}", browse.DeleteObject)
|
||||
|
||||
// User management routes
|
||||
users := &Users{}
|
||||
router.HandleFunc("GET /users", users.GetAll)
|
||||
router.HandleFunc("GET /users/{id}", users.GetOne)
|
||||
router.HandleFunc("POST /users", users.Create)
|
||||
router.HandleFunc("PUT /users/{id}", users.Update)
|
||||
router.HandleFunc("DELETE /users/{id}", users.Delete)
|
||||
|
||||
// Tenant management routes
|
||||
tenants := &Tenants{}
|
||||
router.HandleFunc("GET /tenants", tenants.GetAll)
|
||||
router.HandleFunc("GET /tenants/{id}", tenants.GetOne)
|
||||
router.HandleFunc("POST /tenants", tenants.Create)
|
||||
router.HandleFunc("PUT /tenants/{id}", tenants.Update)
|
||||
router.HandleFunc("DELETE /tenants/{id}", tenants.Delete)
|
||||
router.HandleFunc("GET /tenants/{id}/stats", tenants.GetStats)
|
||||
|
||||
// S3 Configuration routes
|
||||
s3config := &S3Config{}
|
||||
router.HandleFunc("GET /s3/config", s3config.GetConfig)
|
||||
router.HandleFunc("PUT /s3/config", s3config.UpdateConfig)
|
||||
router.HandleFunc("POST /s3/test", s3config.TestConnection)
|
||||
router.HandleFunc("GET /s3/status", s3config.GetStatus)
|
||||
|
||||
// Proxy request to garage api endpoint
|
||||
router.HandleFunc("/", ProxyHandler)
|
||||
|
||||
|
164
backend/router/s3config.go
Normal file
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"`
|
||||
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
|
||||
}
|
||||
|
||||
func (g *garage) GetWebEndpoint() string {
|
||||
endpoint := os.Getenv("S3_WEB_ENDPOINT_URL")
|
||||
if len(endpoint) > 0 {
|
||||
return endpoint
|
||||
}
|
||||
|
||||
if len(g.Config.S3Web.BindAddr) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
host := strings.Split(g.Config.RPCPublicAddr, ":")[0]
|
||||
port := LastString(strings.Split(g.Config.S3Web.BindAddr, ":"))
|
||||
|
||||
endpoint = fmt.Sprintf("%s:%s", host, port)
|
||||
if !strings.HasPrefix(endpoint, "http") {
|
||||
endpoint = fmt.Sprintf("http://%s", endpoint)
|
||||
}
|
||||
|
||||
return endpoint
|
||||
}
|
||||
|
||||
func (g *garage) GetAdminKey() string {
|
||||
key := os.Getenv("API_ADMIN_KEY")
|
||||
if len(key) > 0 {
|
||||
|
@ -2,10 +2,15 @@ package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var envMutex sync.RWMutex
|
||||
|
||||
func GetEnv(key, defaultValue string) string {
|
||||
value := os.Getenv(key)
|
||||
if len(value) == 0 {
|
||||
@ -14,22 +19,72 @@ func GetEnv(key, defaultValue string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// SetEnv sets an environment variable (thread-safe)
|
||||
func SetEnv(key, value string) error {
|
||||
envMutex.Lock()
|
||||
defer envMutex.Unlock()
|
||||
return os.Setenv(key, value)
|
||||
}
|
||||
|
||||
// GetAllEnv returns all environment variables as a map
|
||||
func GetAllEnv() map[string]string {
|
||||
envMutex.RLock()
|
||||
defer envMutex.RUnlock()
|
||||
|
||||
result := make(map[string]string)
|
||||
for _, env := range os.Environ() {
|
||||
parts := strings.SplitN(env, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
result[parts[0]] = parts[1]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func LastString(str []string) string {
|
||||
return str[len(str)-1]
|
||||
}
|
||||
|
||||
func ResponseError(w http.ResponseWriter, err error) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
ResponseErrorStatus(w, err, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func ResponseErrorStatus(w http.ResponseWriter, err error, status int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
|
||||
message := "Internal server error"
|
||||
if err != nil {
|
||||
message = err.Error()
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"success": false,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
// Add error details for development (remove in production)
|
||||
if status >= 500 && err != nil {
|
||||
log.Printf("Server error: %v", err)
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func ResponseSuccess(w http.ResponseWriter, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"data": data,
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
41
docker-compose.dev.yml
Normal file
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",
|
||||
"scripts": {
|
||||
"dev:client": "vite",
|
||||
"dev:client:host": "vite --host",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:dev": "tsc -b && vite build --mode development",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"preview": "vite preview",
|
||||
"dev:server": "cd backend && air",
|
||||
"dev": "concurrently \"npm run dev:client\" \"npm run dev:server\""
|
||||
"dev": "concurrently \"npm run dev:client\" \"npm run dev:server\"",
|
||||
"dev:docker": "docker-compose -f docker-compose.dev.yml up --build",
|
||||
"dev:docker:clean": "docker-compose -f docker-compose.dev.yml down -v && docker system prune -f",
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist node_modules/.vite",
|
||||
"install:backend": "cd backend && go mod download",
|
||||
"build:backend": "cd backend && go build -o main .",
|
||||
"test:backend": "cd backend && go test ./..."
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
|
@ -10,6 +10,7 @@ const HomePage = lazy(() => import("@/pages/home/page"));
|
||||
const BucketsPage = lazy(() => import("@/pages/buckets/page"));
|
||||
const ManageBucketPage = lazy(() => import("@/pages/buckets/manage/page"));
|
||||
const KeysPage = lazy(() => import("@/pages/keys/page"));
|
||||
const AdminPage = lazy(() => import("@/pages/admin/page"));
|
||||
|
||||
const router = createBrowserRouter(
|
||||
[
|
||||
@ -46,6 +47,10 @@ const router = createBrowserRouter(
|
||||
path: "keys",
|
||||
Component: KeysPage,
|
||||
},
|
||||
{
|
||||
path: "admin",
|
||||
Component: AdminPage,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
@ -6,18 +6,19 @@ import {
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Palette,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { Dropdown, Menu } from "react-daisyui";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import Button from "../ui/button";
|
||||
import { themes } from "@/app/themes";
|
||||
import appStore from "@/stores/app-store";
|
||||
import garageLogo from "@/assets/garage-logo.svg";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import * as utils from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useAuth, usePermissions } from "@/hooks/useAuth";
|
||||
|
||||
const pages = [
|
||||
{ icon: LayoutDashboard, title: "Dashboard", path: "/", exact: true },
|
||||
@ -29,6 +30,9 @@ const pages = [
|
||||
const Sidebar = () => {
|
||||
const { pathname } = useLocation();
|
||||
const auth = useAuth();
|
||||
const { hasAnyPermission } = usePermissions();
|
||||
|
||||
const showAdminLink = hasAnyPermission(["system_admin", "read_users", "read_tenants"]);
|
||||
|
||||
return (
|
||||
<aside className="bg-base-100 border-r border-base-300/30 w-[80%] md:w-[250px] flex flex-col items-stretch overflow-hidden h-full">
|
||||
@ -39,6 +43,18 @@ const Sidebar = () => {
|
||||
className="w-full max-w-[100px] mx-auto"
|
||||
/>
|
||||
<p className="text-sm font-medium text-center">WebUI</p>
|
||||
|
||||
{/* User info */}
|
||||
{auth.user && (
|
||||
<div className="mt-2 p-2 bg-base-200 rounded-lg">
|
||||
<p className="text-xs text-center text-base-content/60">
|
||||
{auth.user.username}
|
||||
</p>
|
||||
<p className="text-xs text-center text-base-content/40">
|
||||
{auth.user.role}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Menu className="gap-y-1 flex-1 overflow-y-auto">
|
||||
@ -62,6 +78,23 @@ const Sidebar = () => {
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Admin link */}
|
||||
{showAdminLink && (
|
||||
<Menu.Item>
|
||||
<Link
|
||||
to="/admin"
|
||||
className={cn(
|
||||
"h-12 flex items-center px-6",
|
||||
pathname.startsWith("/admin") &&
|
||||
"bg-primary text-primary-content hover:bg-primary/60 focus:bg-primary focus:text-primary-content"
|
||||
)}
|
||||
>
|
||||
<Settings size={18} />
|
||||
<p>Administración</p>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu>
|
||||
|
||||
<div className="py-2 px-4 flex items-center gap-2">
|
||||
@ -91,10 +124,16 @@ const Sidebar = () => {
|
||||
};
|
||||
|
||||
const LogoutButton = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const logout = useMutation({
|
||||
mutationFn: () => api.post("/auth/logout"),
|
||||
onSuccess: () => {
|
||||
window.location.href = utils.url("/auth/login");
|
||||
// Clear auth queries
|
||||
queryClient.removeQueries({ queryKey: ["auth"] });
|
||||
// Navigate to login page
|
||||
navigate("/auth/login", { replace: true });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err?.message || "Unknown error");
|
||||
|
@ -4,14 +4,23 @@ import { Navigate, Outlet } from "react-router-dom";
|
||||
const AuthLayout = () => {
|
||||
const auth = useAuth();
|
||||
|
||||
console.log("AuthLayout render:", {
|
||||
isLoading: auth.isLoading,
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
user: auth.user
|
||||
});
|
||||
|
||||
if (auth.isLoading) {
|
||||
console.log("AuthLayout: Loading...");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
console.log("AuthLayout: User authenticated, redirecting to /");
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
console.log("AuthLayout: User not authenticated, showing login");
|
||||
return (
|
||||
<div className="min-h-svh flex items-center justify-center">
|
||||
<Outlet />
|
||||
|
@ -56,3 +56,4 @@ export const ToggleField = <T extends FieldValues>({
|
||||
};
|
||||
|
||||
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 { useQuery } from "@tanstack/react-query";
|
||||
|
||||
type AuthResponse = {
|
||||
enabled: boolean;
|
||||
authenticated: boolean;
|
||||
};
|
||||
import { AuthStatusResponse, Permission, User } from "@/types/admin";
|
||||
|
||||
export const useAuth = () => {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["auth"],
|
||||
queryFn: () => api.get<AuthResponse>("/auth/status"),
|
||||
queryFn: () => api.get<AuthStatusResponse>("/auth/status"),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
console.log("useAuth data:", data);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isEnabled: data?.enabled,
|
||||
isAuthenticated: data?.authenticated,
|
||||
isEnabled: data?.data?.enabled,
|
||||
isAuthenticated: data?.data?.authenticated,
|
||||
user: data?.data?.user,
|
||||
};
|
||||
};
|
||||
|
||||
// Role permissions mapping
|
||||
const rolePermissions: Record<string, Permission[]> = {
|
||||
admin: [
|
||||
"system_admin",
|
||||
"read_buckets", "write_buckets", "delete_buckets",
|
||||
"read_keys", "write_keys", "delete_keys",
|
||||
"read_cluster", "write_cluster",
|
||||
"read_users", "write_users", "delete_users",
|
||||
"read_tenants", "write_tenants", "delete_tenants",
|
||||
],
|
||||
tenant_admin: [
|
||||
"read_buckets", "write_buckets", "delete_buckets",
|
||||
"read_keys", "write_keys", "delete_keys",
|
||||
"read_users", "write_users", "delete_users",
|
||||
],
|
||||
user: [
|
||||
"read_buckets", "write_buckets",
|
||||
"read_keys", "write_keys",
|
||||
],
|
||||
readonly: [
|
||||
"read_buckets",
|
||||
"read_keys",
|
||||
"read_cluster",
|
||||
],
|
||||
};
|
||||
|
||||
export const usePermissions = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
const hasPermission = (permission: Permission): boolean => {
|
||||
if (!user) return false;
|
||||
const permissions = rolePermissions[user.role] || [];
|
||||
return permissions.includes(permission);
|
||||
};
|
||||
|
||||
const hasAnyPermission = (permissions: Permission[]): boolean => {
|
||||
return permissions.some(permission => hasPermission(permission));
|
||||
};
|
||||
|
||||
return {
|
||||
hasPermission,
|
||||
hasAnyPermission,
|
||||
isAdmin: hasPermission("system_admin"),
|
||||
isTenantAdmin: user?.role === "tenant_admin",
|
||||
isUser: user?.role === "user",
|
||||
isReadOnly: user?.role === "readonly",
|
||||
};
|
||||
};
|
||||
|
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 { useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
import { loginSchema } from "./schema";
|
||||
import api from "@/lib/api";
|
||||
@ -6,13 +7,21 @@ import { toast } from "sonner";
|
||||
|
||||
export const useLogin = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (body: z.infer<typeof loginSchema>) => {
|
||||
return api.post("/auth/login", { body });
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
console.log("Login successful!", data);
|
||||
// Invalidate auth status without waiting
|
||||
queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
console.log("Auth queries invalidated");
|
||||
// Navigate immediately
|
||||
console.log("Attempting navigation to /");
|
||||
navigate("/", { replace: true });
|
||||
console.log("Navigation call completed");
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err?.message || "Unknown error");
|
||||
|
@ -10,7 +10,18 @@ import { CreateBucketSchema } from "./schema";
|
||||
export const useBuckets = () => {
|
||||
return useQuery({
|
||||
queryKey: ["buckets"],
|
||||
queryFn: () => api.get<GetBucketRes>("/buckets"),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await api.get<GetBucketRes>("/buckets");
|
||||
// Handle the API response structure { data: [...], success: true }
|
||||
return response?.data || [];
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch buckets:", error);
|
||||
// Return empty array on error to prevent UI crash
|
||||
return [];
|
||||
}
|
||||
},
|
||||
retry: 2,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -16,8 +16,18 @@ export const useBrowseObjects = (
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: ["browse", bucket, options],
|
||||
queryFn: () =>
|
||||
api.get<GetObjectsResult>(`/browse/${bucket}`, { params: options }),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await api.get<GetObjectsResult>(`/browse/${bucket}`, { params: options });
|
||||
// Handle the API response structure { data: {...}, success: true }
|
||||
return response?.data || { objects: [], prefixes: [] };
|
||||
} catch (error) {
|
||||
console.error("Failed to browse objects:", error);
|
||||
// Return empty structure on error to prevent UI crash
|
||||
return { objects: [], prefixes: [] };
|
||||
}
|
||||
},
|
||||
retry: 2,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -65,7 +65,7 @@ const ObjectList = ({ prefix, onPrefixChange }: Props) => {
|
||||
</tr>
|
||||
) : null}
|
||||
|
||||
{data?.prefixes.map((prefix) => (
|
||||
{data?.prefixes?.map((prefix) => (
|
||||
<tr
|
||||
key={prefix}
|
||||
className="hover:bg-neutral/60 hover:text-neutral-content group"
|
||||
@ -88,7 +88,7 @@ const ObjectList = ({ prefix, onPrefixChange }: Props) => {
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{data?.objects.map((object, idx) => {
|
||||
{data?.objects?.map((object, idx) => {
|
||||
const extIdx = object.objectKey.lastIndexOf(".");
|
||||
const filename =
|
||||
extIdx >= 0
|
||||
|
98
src/types/admin.ts
Normal file
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 }) => {
|
||||
process.env = { ...process.env, ...loadEnv(mode, process.cwd()) };
|
||||
|
||||
const isDevelopment = mode === 'development';
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
@ -14,12 +16,55 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true, // Listen on all addresses (needed for Docker)
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
watch: {
|
||||
usePolling: true, // Enable polling for file changes in Docker
|
||||
interval: 100,
|
||||
},
|
||||
hmr: {
|
||||
port: 5173,
|
||||
host: 'localhost',
|
||||
},
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: process.env.VITE_API_URL,
|
||||
target: process.env.VITE_API_URL || "http://localhost:3909",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true,
|
||||
cookieDomainRewrite: "",
|
||||
cookiePathRewrite: "/",
|
||||
configure: (proxy, _options) => {
|
||||
proxy.on('error', (err, _req, _res) => {
|
||||
console.log('proxy error', err);
|
||||
});
|
||||
proxy.on('proxyReq', (proxyReq, req, _res) => {
|
||||
console.log('Sending Request to the Target:', req.method, req.url);
|
||||
});
|
||||
proxy.on('proxyRes', (proxyRes, req, _res) => {
|
||||
console.log('Received Response from the Target:', proxyRes.statusCode, req.url);
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
sourcemap: isDevelopment,
|
||||
minify: !isDevelopment,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['react', 'react-dom'],
|
||||
ui: ['react-daisyui', 'tailwindcss'],
|
||||
forms: ['react-hook-form', '@hookform/resolvers', 'zod'],
|
||||
query: ['@tanstack/react-query'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['react', 'react-dom', '@tanstack/react-query'],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user