Compare commits
3 Commits
02d17a046e
...
4670366ffc
| Author | SHA1 | Date | |
|---|---|---|---|
| 4670366ffc | |||
| 3a905a4d02 | |||
| e2783015e3 |
@@ -116,7 +116,7 @@ Lista todos los usuarios del sistema.
|
|||||||
**Query Params:**
|
**Query Params:**
|
||||||
- `skip` (int, default: 0) - Paginación
|
- `skip` (int, default: 0) - Paginación
|
||||||
- `limit` (int, default: 100) - Límite de resultados
|
- `limit` (int, default: 100) - Límite de resultados
|
||||||
- `active_only` (bool, default: true) - Solo usuarios activos
|
- `active_only` (bool, default: false) - Solo usuarios activos
|
||||||
|
|
||||||
**Headers:**
|
**Headers:**
|
||||||
```
|
```
|
||||||
@@ -234,6 +234,7 @@ Authorization: Bearer {token}
|
|||||||
**Body:**
|
**Body:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"username": "nuevo_username",
|
||||||
"email": "nuevo_email@syntria.com",
|
"email": "nuevo_email@syntria.com",
|
||||||
"full_name": "Nombre Actualizado",
|
"full_name": "Nombre Actualizado",
|
||||||
"role": "admin"
|
"role": "admin"
|
||||||
@@ -244,7 +245,7 @@ Authorization: Bearer {token}
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"username": "mecanico",
|
"username": "nuevo_username",
|
||||||
"email": "nuevo_email@syntria.com",
|
"email": "nuevo_email@syntria.com",
|
||||||
"full_name": "Nombre Actualizado",
|
"full_name": "Nombre Actualizado",
|
||||||
"role": "admin",
|
"role": "admin",
|
||||||
@@ -253,8 +254,12 @@ Authorization: Bearer {token}
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Notas:**
|
||||||
|
- El campo `is_active` NO puede ser modificado por este endpoint
|
||||||
|
- Para cambiar el estado usar `/deactivate` o `/activate`
|
||||||
|
|
||||||
**Errores:**
|
**Errores:**
|
||||||
- `400 Bad Request` - Email ya en uso
|
- `400 Bad Request` - Email o username ya en uso
|
||||||
- `403 Forbidden` - No tienes permisos
|
- `403 Forbidden` - No tienes permisos
|
||||||
- `404 Not Found` - Usuario no encontrado
|
- `404 Not Found` - Usuario no encontrado
|
||||||
|
|
||||||
@@ -405,6 +410,156 @@ Authorization: Bearer {token}
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🔑 API TOKENS - Tokens de Acceso API
|
||||||
|
|
||||||
|
### Autenticación con API Tokens
|
||||||
|
|
||||||
|
Además de JWT tokens, puedes usar API tokens persistentes para autenticación. Los API tokens:
|
||||||
|
- Tienen el prefijo `syntria_`
|
||||||
|
- No expiran (hasta que sean revocados)
|
||||||
|
- Se rastrean por último uso
|
||||||
|
- Pueden ser gestionados desde el panel de usuario
|
||||||
|
|
||||||
|
**Uso:**
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer syntria_xxxxxxxxxxxxx" http://api/inspections
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Listar Mis Tokens
|
||||||
|
**GET** `/api/users/me/tokens`
|
||||||
|
|
||||||
|
Lista todos tus API tokens.
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"description": "Token para integración con sistema externo",
|
||||||
|
"is_active": true,
|
||||||
|
"last_used_at": "2025-11-19T14:30:00Z",
|
||||||
|
"created_at": "2025-11-15T10:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nota:** El valor del token no se muestra por seguridad.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Generar Nuevo Token
|
||||||
|
**POST** `/api/users/me/tokens`
|
||||||
|
|
||||||
|
Genera un nuevo API token.
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"description": "Token para integración con sistema externo"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"token": "syntria_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||||
|
"description": "Token para integración con sistema externo",
|
||||||
|
"is_active": true,
|
||||||
|
"last_used_at": null,
|
||||||
|
"created_at": "2025-11-19T15:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ IMPORTANTE:** El token completo solo se muestra una vez. Guárdalo de forma segura.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Revocar Mi Token
|
||||||
|
**DELETE** `/api/users/me/tokens/{token_id}`
|
||||||
|
|
||||||
|
Revoca (desactiva) uno de tus tokens.
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Token revocado correctamente",
|
||||||
|
"token_id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errores:**
|
||||||
|
- `404 Not Found` - Token no encontrado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Listar Tokens de Usuario (Admin)
|
||||||
|
**GET** `/api/users/{user_id}/tokens`
|
||||||
|
|
||||||
|
Lista todos los tokens de un usuario específico.
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"description": "Token de producción",
|
||||||
|
"is_active": true,
|
||||||
|
"last_used_at": "2025-11-19T14:30:00Z",
|
||||||
|
"created_at": "2025-11-15T10:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errores:**
|
||||||
|
- `403 Forbidden` - No tienes permisos (solo admin)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Revocar Token de Usuario (Admin)
|
||||||
|
**DELETE** `/api/users/{user_id}/tokens/{token_id}`
|
||||||
|
|
||||||
|
Revoca un token de cualquier usuario.
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Token revocado correctamente",
|
||||||
|
"token_id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errores:**
|
||||||
|
- `403 Forbidden` - No tienes permisos (solo admin)
|
||||||
|
- `404 Not Found` - Token no encontrado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 📋 CHECKLISTS - Plantillas de Inspección
|
## 📋 CHECKLISTS - Plantillas de Inspección
|
||||||
|
|
||||||
### 1. Listar Checklists
|
### 1. Listar Checklists
|
||||||
@@ -771,7 +926,9 @@ Content-Type: application/json
|
|||||||
## 🔐 Roles y Permisos
|
## 🔐 Roles y Permisos
|
||||||
|
|
||||||
### Admin
|
### Admin
|
||||||
- ✅ Gestionar usuarios (crear, editar, inactivar)
|
- ✅ Gestionar usuarios (crear, editar, inactivar, activar)
|
||||||
|
- ✅ Cambiar contraseñas de cualquier usuario
|
||||||
|
- ✅ Ver y revocar tokens API de cualquier usuario
|
||||||
- ✅ Gestionar checklists y preguntas
|
- ✅ Gestionar checklists y preguntas
|
||||||
- ✅ Ver todas las inspecciones
|
- ✅ Ver todas las inspecciones
|
||||||
- ✅ Crear inspecciones
|
- ✅ Crear inspecciones
|
||||||
@@ -784,8 +941,11 @@ Content-Type: application/json
|
|||||||
- ✅ Responder preguntas de inspección
|
- ✅ Responder preguntas de inspección
|
||||||
- ✅ Usar análisis con IA
|
- ✅ Usar análisis con IA
|
||||||
- ✅ Actualizar su propio perfil
|
- ✅ Actualizar su propio perfil
|
||||||
|
- ✅ Cambiar su propia contraseña
|
||||||
|
- ✅ Gestionar sus propios tokens API
|
||||||
- ❌ No puede gestionar usuarios
|
- ❌ No puede gestionar usuarios
|
||||||
- ❌ No puede gestionar checklists
|
- ❌ No puede gestionar checklists
|
||||||
|
- ❌ No puede ver tokens de otros usuarios
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from typing import Optional
|
|||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
import secrets
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
@@ -33,3 +34,7 @@ def decode_access_token(token: str):
|
|||||||
except JWTError as e:
|
except JWTError as e:
|
||||||
print(f"JWT decode error: {e}") # Debug
|
print(f"JWT decode error: {e}") # Debug
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def generate_api_token() -> str:
|
||||||
|
"""Genera un token API aleatorio seguro"""
|
||||||
|
return f"syntria_{secrets.token_urlsafe(32)}"
|
||||||
|
|||||||
@@ -33,6 +33,35 @@ def get_current_user(
|
|||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
|
|
||||||
|
# Verificar si es un API token (comienza con "syntria_")
|
||||||
|
if token.startswith("syntria_"):
|
||||||
|
api_token = db.query(models.APIToken).filter(
|
||||||
|
models.APIToken.token == token,
|
||||||
|
models.APIToken.is_active == True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not api_token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="API Token inválido o inactivo"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Actualizar último uso
|
||||||
|
api_token.last_used_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Obtener usuario
|
||||||
|
user = db.query(models.User).filter(models.User.id == api_token.user_id).first()
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Usuario inválido o inactivo"
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
# Si no es API token, es JWT token
|
||||||
payload = decode_access_token(token)
|
payload = decode_access_token(token)
|
||||||
print(f"Token payload: {payload}") # Debug
|
print(f"Token payload: {payload}") # Debug
|
||||||
if payload is None:
|
if payload is None:
|
||||||
@@ -101,7 +130,7 @@ def get_me(current_user: models.User = Depends(get_current_user)):
|
|||||||
def get_users(
|
def get_users(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
active_only: bool = True,
|
active_only: bool = False,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user)
|
current_user: models.User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
@@ -186,6 +215,16 @@ def update_user(
|
|||||||
raise HTTPException(status_code=404, detail="Usuario no encontrado")
|
raise HTTPException(status_code=404, detail="Usuario no encontrado")
|
||||||
|
|
||||||
# Actualizar campos
|
# Actualizar campos
|
||||||
|
if user_update.username is not None:
|
||||||
|
# Verificar si username está en uso
|
||||||
|
existing = db.query(models.User).filter(
|
||||||
|
models.User.username == user_update.username,
|
||||||
|
models.User.id != user_id
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Nombre de usuario ya está en uso")
|
||||||
|
db_user.username = user_update.username
|
||||||
|
|
||||||
if user_update.email is not None:
|
if user_update.email is not None:
|
||||||
# Verificar si email está en uso
|
# Verificar si email está en uso
|
||||||
existing = db.query(models.User).filter(
|
existing = db.query(models.User).filter(
|
||||||
@@ -320,6 +359,116 @@ def update_my_profile(
|
|||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
# ============= API TOKENS ENDPOINTS =============
|
||||||
|
@app.get("/api/users/me/tokens", response_model=List[schemas.APIToken])
|
||||||
|
def get_my_tokens(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Obtener todos mis API tokens"""
|
||||||
|
tokens = db.query(models.APIToken).filter(
|
||||||
|
models.APIToken.user_id == current_user.id
|
||||||
|
).all()
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/users/me/tokens", response_model=schemas.APITokenWithValue)
|
||||||
|
def create_my_token(
|
||||||
|
token_create: schemas.APITokenCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Generar un nuevo API token"""
|
||||||
|
from app.core.security import generate_api_token
|
||||||
|
|
||||||
|
# Generar token único
|
||||||
|
token_value = generate_api_token()
|
||||||
|
|
||||||
|
# Crear registro
|
||||||
|
api_token = models.APIToken(
|
||||||
|
user_id=current_user.id,
|
||||||
|
token=token_value,
|
||||||
|
description=token_create.description,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(api_token)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(api_token)
|
||||||
|
|
||||||
|
# Retornar con el token completo (solo esta vez)
|
||||||
|
return schemas.APITokenWithValue(
|
||||||
|
id=api_token.id,
|
||||||
|
token=api_token.token,
|
||||||
|
description=api_token.description,
|
||||||
|
is_active=api_token.is_active,
|
||||||
|
last_used_at=api_token.last_used_at,
|
||||||
|
created_at=api_token.created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/users/me/tokens/{token_id}")
|
||||||
|
def delete_my_token(
|
||||||
|
token_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Revocar uno de mis API tokens"""
|
||||||
|
api_token = db.query(models.APIToken).filter(
|
||||||
|
models.APIToken.id == token_id,
|
||||||
|
models.APIToken.user_id == current_user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not api_token:
|
||||||
|
raise HTTPException(status_code=404, detail="Token no encontrado")
|
||||||
|
|
||||||
|
api_token.is_active = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Token revocado correctamente", "token_id": token_id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/users/{user_id}/tokens", response_model=List[schemas.APIToken])
|
||||||
|
def get_user_tokens(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Obtener tokens de un usuario (solo admin)"""
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="No tienes permisos")
|
||||||
|
|
||||||
|
tokens = db.query(models.APIToken).filter(
|
||||||
|
models.APIToken.user_id == user_id
|
||||||
|
).all()
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/users/{user_id}/tokens/{token_id}")
|
||||||
|
def delete_user_token(
|
||||||
|
user_id: int,
|
||||||
|
token_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Revocar token de un usuario (solo admin)"""
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="No tienes permisos")
|
||||||
|
|
||||||
|
api_token = db.query(models.APIToken).filter(
|
||||||
|
models.APIToken.id == token_id,
|
||||||
|
models.APIToken.user_id == user_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not api_token:
|
||||||
|
raise HTTPException(status_code=404, detail="Token no encontrado")
|
||||||
|
|
||||||
|
api_token.is_active = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Token revocado correctamente", "token_id": token_id}
|
||||||
|
|
||||||
|
|
||||||
# ============= CHECKLIST ENDPOINTS =============
|
# ============= CHECKLIST ENDPOINTS =============
|
||||||
@app.get("/api/checklists", response_model=List[schemas.Checklist])
|
@app.get("/api/checklists", response_model=List[schemas.Checklist])
|
||||||
def get_checklists(
|
def get_checklists(
|
||||||
|
|||||||
@@ -18,6 +18,22 @@ class User(Base):
|
|||||||
# Relationships
|
# Relationships
|
||||||
checklists_created = relationship("Checklist", back_populates="creator")
|
checklists_created = relationship("Checklist", back_populates="creator")
|
||||||
inspections = relationship("Inspection", back_populates="mechanic")
|
inspections = relationship("Inspection", back_populates="mechanic")
|
||||||
|
api_tokens = relationship("APIToken", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class APIToken(Base):
|
||||||
|
__tablename__ = "api_tokens"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
token = Column(String(100), unique=True, index=True, nullable=False)
|
||||||
|
description = Column(String(200))
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
last_used_at = Column(DateTime(timezone=True))
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
# Relationship
|
||||||
|
user = relationship("User", back_populates="api_tokens")
|
||||||
|
|
||||||
|
|
||||||
class Checklist(Base):
|
class Checklist(Base):
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class UserCreate(UserBase):
|
|||||||
password: str
|
password: str
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
|
username: Optional[str] = None
|
||||||
email: Optional[EmailStr] = None
|
email: Optional[EmailStr] = None
|
||||||
full_name: Optional[str] = None
|
full_name: Optional[str] = None
|
||||||
role: Optional[str] = None
|
role: Optional[str] = None
|
||||||
@@ -42,6 +43,24 @@ class Token(BaseModel):
|
|||||||
user: User
|
user: User
|
||||||
|
|
||||||
|
|
||||||
|
# API Token Schemas
|
||||||
|
class APITokenCreate(BaseModel):
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
class APIToken(BaseModel):
|
||||||
|
id: int
|
||||||
|
description: Optional[str] = None
|
||||||
|
is_active: bool
|
||||||
|
last_used_at: Optional[datetime] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class APITokenWithValue(APIToken):
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
# Checklist Schemas
|
# Checklist Schemas
|
||||||
class ChecklistBase(BaseModel):
|
class ChecklistBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
|||||||
@@ -334,11 +334,10 @@ function DashboardPage({ user, setUser }) {
|
|||||||
<InspectionsTab inspections={inspections} user={user} onUpdate={loadData} />
|
<InspectionsTab inspections={inspections} user={user} onUpdate={loadData} />
|
||||||
) : activeTab === 'settings' ? (
|
) : activeTab === 'settings' ? (
|
||||||
<SettingsTab user={user} />
|
<SettingsTab user={user} />
|
||||||
|
) : activeTab === 'api-tokens' ? (
|
||||||
|
<APITokensTab user={user} />
|
||||||
) : activeTab === 'users' ? (
|
) : activeTab === 'users' ? (
|
||||||
<div className="text-center py-12">
|
<UsersTab user={user} />
|
||||||
<div className="text-xl text-gray-400">👥</div>
|
|
||||||
<div className="text-gray-500 mt-2">Módulo de Usuarios en desarrollo...</div>
|
|
||||||
</div>
|
|
||||||
) : activeTab === 'reports' ? (
|
) : activeTab === 'reports' ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="text-xl text-gray-400">📊</div>
|
<div className="text-xl text-gray-400">📊</div>
|
||||||
@@ -597,6 +596,310 @@ function SettingsTab({ user }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function APITokensTab({ user }) {
|
||||||
|
const [tokens, setTokens] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||||
|
const [newTokenDescription, setNewTokenDescription] = useState('')
|
||||||
|
const [createdToken, setCreatedToken] = useState(null)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTokens()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadTokens = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||||
|
|
||||||
|
const response = await fetch(`${API_URL}/api/users/me/tokens`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setTokens(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading tokens:', error)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateToken = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setCreating(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||||
|
|
||||||
|
const response = await fetch(`${API_URL}/api/users/me/tokens`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ description: newTokenDescription || null }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setCreatedToken(data.token)
|
||||||
|
setShowCreateForm(false)
|
||||||
|
setNewTokenDescription('')
|
||||||
|
loadTokens()
|
||||||
|
} else {
|
||||||
|
alert('Error al crear token')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error)
|
||||||
|
alert('Error al crear token')
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRevokeToken = async (tokenId) => {
|
||||||
|
if (!confirm('¿Estás seguro de revocar este token? Esta acción no se puede deshacer.')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||||
|
|
||||||
|
const response = await fetch(`${API_URL}/api/users/me/tokens/${tokenId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Token revocado correctamente')
|
||||||
|
loadTokens()
|
||||||
|
} else {
|
||||||
|
alert('Error al revocar token')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error)
|
||||||
|
alert('Error al revocar token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = (text) => {
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
alert('Token copiado al portapapeles')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<div className="mb-6 flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Mis API Tokens</h2>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
Genera tokens para acceder a la API sin necesidad de login
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="px-4 py-2 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all transform hover:scale-105 shadow-lg"
|
||||||
|
>
|
||||||
|
+ Generar Nuevo Token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal de Token Creado */}
|
||||||
|
{createdToken && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg max-w-2xl w-full p-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900">Token Creado Exitosamente</h3>
|
||||||
|
<p className="text-sm text-yellow-600 mt-2">
|
||||||
|
⚠️ Guarda este token ahora. No podrás verlo de nuevo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 border border-gray-300 rounded-lg p-4 mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 text-sm font-mono break-all">{createdToken}</code>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(createdToken)}
|
||||||
|
className="px-3 py-1 bg-indigo-600 text-white rounded hover:bg-indigo-700 transition flex-shrink-0"
|
||||||
|
>
|
||||||
|
📋 Copiar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
||||||
|
<p className="text-sm text-blue-900 font-semibold mb-2">Ejemplo de uso:</p>
|
||||||
|
<code className="text-xs text-blue-800 block">
|
||||||
|
curl -H "Authorization: Bearer {createdToken}" http://tu-api.com/api/inspections
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setCreatedToken(null)}
|
||||||
|
className="w-full px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
|
||||||
|
>
|
||||||
|
Cerrar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Formulario de Crear Token */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg max-w-md w-full p-6">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 mb-4">Generar Nuevo Token</h3>
|
||||||
|
|
||||||
|
<form onSubmit={handleCreateToken}>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Descripción (opcional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newTokenDescription}
|
||||||
|
onChange={(e) => setNewTokenDescription(e.target.value)}
|
||||||
|
placeholder="ej: Integración con sistema X"
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Te ayuda a identificar para qué usas este token
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateForm(false)
|
||||||
|
setNewTokenDescription('')
|
||||||
|
}}
|
||||||
|
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={creating}
|
||||||
|
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{creating ? 'Generando...' : 'Generar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lista de Tokens */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-gray-500">Cargando tokens...</div>
|
||||||
|
</div>
|
||||||
|
) : tokens.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
|
||||||
|
<div className="text-4xl mb-3">🔑</div>
|
||||||
|
<p className="text-gray-600 mb-2">No tienes tokens API creados</p>
|
||||||
|
<p className="text-sm text-gray-500">Genera uno para acceder a la API sin login</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{tokens.map((token) => (
|
||||||
|
<div
|
||||||
|
key={token.id}
|
||||||
|
className="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md transition"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-lg">🔑</span>
|
||||||
|
<h4 className="font-semibold text-gray-900">
|
||||||
|
{token.description || 'Token sin descripción'}
|
||||||
|
</h4>
|
||||||
|
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${
|
||||||
|
token.is_active
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-red-100 text-red-800'
|
||||||
|
}`}>
|
||||||
|
{token.is_active ? 'Activo' : 'Revocado'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-600 space-y-1">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Creado:</span>{' '}
|
||||||
|
{new Date(token.created_at).toLocaleDateString('es-ES', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{token.last_used_at && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Último uso:</span>{' '}
|
||||||
|
{new Date(token.last_used_at).toLocaleDateString('es-ES', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!token.last_used_at && (
|
||||||
|
<div className="text-yellow-600">
|
||||||
|
⚠️ Nunca usado
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{token.is_active && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRevokeToken(token.id)}
|
||||||
|
className="ml-4 px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200 transition"
|
||||||
|
>
|
||||||
|
Revocar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Información de Ayuda */}
|
||||||
|
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="text-blue-600 text-xl">ℹ️</span>
|
||||||
|
<div className="flex-1 text-sm text-blue-900">
|
||||||
|
<p className="font-semibold mb-2">¿Cómo usar los tokens API?</p>
|
||||||
|
<p className="mb-2">
|
||||||
|
Los tokens API te permiten acceder a todos los endpoints sin necesidad de hacer login.
|
||||||
|
Son perfectos para integraciones, scripts automatizados o aplicaciones externas.
|
||||||
|
</p>
|
||||||
|
<p className="mb-2">
|
||||||
|
Incluye el token en el header <code className="bg-blue-100 px-1 py-0.5 rounded">Authorization</code> de tus requests:
|
||||||
|
</p>
|
||||||
|
<code className="block bg-blue-100 p-2 rounded text-xs mt-2">
|
||||||
|
Authorization: Bearer syntria_tu_token_aqui
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function QuestionsManagerModal({ checklist, onClose }) {
|
function QuestionsManagerModal({ checklist, onClose }) {
|
||||||
const [questions, setQuestions] = useState([])
|
const [questions, setQuestions] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -2409,6 +2712,435 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Componente de Gestión de Usuarios (Admin)
|
||||||
|
function UsersTab({ user }) {
|
||||||
|
const [users, setUsers] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||||
|
const [editingUser, setEditingUser] = useState(null)
|
||||||
|
const [showInactive, setShowInactive] = useState(false)
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: 'mechanic'
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const response = await fetch('/api/users', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setUsers(data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading users:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateUser = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const response = await fetch('/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setShowCreateForm(false)
|
||||||
|
setFormData({ username: '', email: '', password: '', role: 'mechanic' })
|
||||||
|
loadUsers()
|
||||||
|
} else {
|
||||||
|
const error = await response.json()
|
||||||
|
alert(error.detail || 'Error al crear usuario')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating user:', error)
|
||||||
|
alert('Error al crear usuario')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateUser = async (userId, updates) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const response = await fetch(`/api/users/${userId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updates)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setEditingUser(null)
|
||||||
|
loadUsers()
|
||||||
|
} else {
|
||||||
|
const error = await response.json()
|
||||||
|
alert(error.detail || 'Error al actualizar usuario')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating user:', error)
|
||||||
|
alert('Error al actualizar usuario')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeactivateUser = async (userId) => {
|
||||||
|
if (!confirm('¿Está seguro que desea desactivar este usuario?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const response = await fetch(`/api/users/${userId}/deactivate`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
loadUsers()
|
||||||
|
} else {
|
||||||
|
const error = await response.json()
|
||||||
|
alert(error.detail || 'Error al desactivar usuario')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deactivating user:', error)
|
||||||
|
alert('Error al desactivar usuario')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleActivateUser = async (userId) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const response = await fetch(`/api/users/${userId}/activate`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
loadUsers()
|
||||||
|
} else {
|
||||||
|
const error = await response.json()
|
||||||
|
alert(error.detail || 'Error al activar usuario')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error activating user:', error)
|
||||||
|
alert('Error al activar usuario')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-center py-12">Cargando usuarios...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredUsers = showInactive ? users : users.filter(u => u.is_active)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800">Gestión de Usuarios</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowInactive(!showInactive)}
|
||||||
|
className={`px-4 py-2 rounded-lg transition ${
|
||||||
|
showInactive
|
||||||
|
? 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{showInactive ? '👁️ Ocultar Inactivos' : '👁️🗨️ Mostrar Inactivos'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="px-4 py-2 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition"
|
||||||
|
>
|
||||||
|
+ Nuevo Usuario
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lista de usuarios */}
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{filteredUsers.map(u => (
|
||||||
|
<div key={u.id} className="bg-white rounded-lg shadow-md p-4">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-full flex items-center justify-center text-white font-bold">
|
||||||
|
{u.username.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-800">{u.username}</h3>
|
||||||
|
<p className="text-sm text-gray-500">{u.email}</p>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<span className={`text-xs px-2 py-1 rounded ${
|
||||||
|
u.role === 'admin'
|
||||||
|
? 'bg-purple-100 text-purple-700'
|
||||||
|
: 'bg-blue-100 text-blue-700'
|
||||||
|
}`}>
|
||||||
|
{u.role === 'admin' ? '👑 Admin' : '🔧 Mecánico'}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs px-2 py-1 rounded ${
|
||||||
|
u.is_active
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{u.is_active ? 'Activo' : 'Inactivo'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user.role === 'admin' && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingUser(u)}
|
||||||
|
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition"
|
||||||
|
>
|
||||||
|
✏️ Editar
|
||||||
|
</button>
|
||||||
|
{u.id !== user.id && (
|
||||||
|
<>
|
||||||
|
{u.is_active ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeactivateUser(u.id)}
|
||||||
|
className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200 transition"
|
||||||
|
>
|
||||||
|
Desactivar
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleActivateUser(u.id)}
|
||||||
|
className="px-3 py-1 text-sm bg-green-100 text-green-700 rounded hover:bg-green-200 transition"
|
||||||
|
>
|
||||||
|
Activar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal de creación */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
||||||
|
<h3 className="text-xl font-bold mb-4">Nuevo Usuario</h3>
|
||||||
|
<form onSubmit={handleCreateUser} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Nombre de usuario
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.username}
|
||||||
|
onChange={(e) => setFormData({...formData, username: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({...formData, email: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Contraseña
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({...formData, password: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Rol
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.role}
|
||||||
|
onChange={(e) => setFormData({...formData, role: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="mechanic">Mecánico</option>
|
||||||
|
<option value="admin">Administrador</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateForm(false)
|
||||||
|
setFormData({ username: '', email: '', password: '', role: 'mechanic' })
|
||||||
|
}}
|
||||||
|
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 px-4 py-2 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition"
|
||||||
|
>
|
||||||
|
Crear Usuario
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal de edición */}
|
||||||
|
{editingUser && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
||||||
|
<h3 className="text-xl font-bold mb-4">Editar Usuario</h3>
|
||||||
|
<form onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const updates = {
|
||||||
|
username: editingUser.username,
|
||||||
|
email: editingUser.email,
|
||||||
|
role: editingUser.role
|
||||||
|
}
|
||||||
|
handleUpdateUser(editingUser.id, updates)
|
||||||
|
}} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Nombre de usuario
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={editingUser.username}
|
||||||
|
onChange={(e) => setEditingUser({...editingUser, username: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={editingUser.email}
|
||||||
|
onChange={(e) => setEditingUser({...editingUser, email: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Rol
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={editingUser.role}
|
||||||
|
onChange={(e) => setEditingUser({...editingUser, role: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="mechanic">Mecánico</option>
|
||||||
|
<option value="admin">Administrador</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
const newPassword = prompt('Ingrese la nueva contraseña:')
|
||||||
|
if (newPassword && newPassword.length >= 6) {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const response = await fetch(`/api/users/${editingUser.id}/password`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ new_password: newPassword })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Contraseña actualizada correctamente')
|
||||||
|
} else {
|
||||||
|
const error = await response.json()
|
||||||
|
alert(error.detail || 'Error al actualizar contraseña')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error)
|
||||||
|
alert('Error al actualizar contraseña')
|
||||||
|
}
|
||||||
|
} else if (newPassword !== null) {
|
||||||
|
alert('La contraseña debe tener al menos 6 caracteres')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2 bg-yellow-100 text-yellow-700 rounded-lg hover:bg-yellow-200 transition"
|
||||||
|
>
|
||||||
|
🔑 Resetear Contraseña
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingUser(null)}
|
||||||
|
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 px-4 py-2 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition"
|
||||||
|
>
|
||||||
|
Guardar Cambios
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,20 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, se
|
|||||||
{sidebarOpen && <span>Reportes</span>}
|
{sidebarOpen && <span>Reportes</span>}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('api-tokens')}
|
||||||
|
className={`w-full flex items-center ${sidebarOpen ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition ${
|
||||||
|
activeTab === 'api-tokens'
|
||||||
|
? 'bg-gradient-to-r from-indigo-600 to-purple-600 text-white shadow-lg'
|
||||||
|
: 'text-indigo-200 hover:bg-indigo-900/50'
|
||||||
|
}`}
|
||||||
|
title={!sidebarOpen ? 'API Tokens' : ''}
|
||||||
|
>
|
||||||
|
<span className="text-xl">🔑</span>
|
||||||
|
{sidebarOpen && <span>API Tokens</span>}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('settings')}
|
onClick={() => setActiveTab('settings')}
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-E
|
|||||||
EOSQL
|
EOSQL
|
||||||
|
|
||||||
echo "Database initialization completed successfully!"
|
echo "Database initialization completed successfully!"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user