mirror of
https://github.com/R0m1k3/Priceflow.git
synced 2026-10-24 15:48:11 +02:00
feat: Add authentication system and admin panel
- Add User model with authentication - Create JWT-based auth service - Add login page with admin/admin default credentials - Create admin panel with user management, settings, and site configuration - Move settings to admin section (admin only) - Change search page from multi-site checkboxes to single site selector - Add logout functionality to layout - Protect routes with authentication
This commit is contained in:
11 files changed
+1758
-84
No files matched your search
+36
-5
@@ -13,9 +13,9 @@ from sqlalchemy import text
|
|||||||
|
|
||||||
from app.database import SessionLocal, engine
|
from app.database import SessionLocal, engine
|
||||||
from app.limiter import limiter
|
from app.limiter import limiter
|
||||||
from app.routers import items, jobs, notifications, openrouter, search, search_sites, settings
|
from app.routers import auth, items, jobs, notifications, openrouter, search, search_sites, settings
|
||||||
from app.services.scheduler_service import scheduled_refresh, scheduler
|
from app.services.scheduler_service import scheduled_refresh, scheduler
|
||||||
from app.services import search_service
|
from app.services import auth_service, search_service
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -28,6 +28,27 @@ logger = logging.getLogger(__name__)
|
|||||||
def run_migrations():
|
def run_migrations():
|
||||||
"""Run database migrations for missing tables and columns"""
|
"""Run database migrations for missing tables and columns"""
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
|
# 0. Create users table if it doesn't exist
|
||||||
|
result = conn.execute(text(
|
||||||
|
"SELECT 1 FROM information_schema.tables WHERE table_name = 'users'"
|
||||||
|
))
|
||||||
|
if not result.fetchone():
|
||||||
|
logger.info("Creating users table...")
|
||||||
|
conn.execute(text("""
|
||||||
|
CREATE TABLE users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(512) NOT NULL,
|
||||||
|
is_admin BOOLEAN DEFAULT FALSE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_login TIMESTAMP
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)"))
|
||||||
|
conn.commit()
|
||||||
|
logger.info("users table created")
|
||||||
|
|
||||||
# 1. Create search_sites table if it doesn't exist
|
# 1. Create search_sites table if it doesn't exist
|
||||||
result = conn.execute(text(
|
result = conn.execute(text(
|
||||||
"SELECT 1 FROM information_schema.tables WHERE table_name = 'search_sites'"
|
"SELECT 1 FROM information_schema.tables WHERE table_name = 'search_sites'"
|
||||||
@@ -81,20 +102,27 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Migration error: {e}")
|
logger.error(f"Migration error: {e}")
|
||||||
|
|
||||||
# Seed default sites
|
# Seed default sites and admin user
|
||||||
logger.info("Seeding default search sites...")
|
logger.info("Seeding default data...")
|
||||||
try:
|
try:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
# Seed search sites
|
||||||
created = search_service.seed_default_sites(db)
|
created = search_service.seed_default_sites(db)
|
||||||
if created > 0:
|
if created > 0:
|
||||||
logger.info(f"{created} site(s) de recherche créé(s)")
|
logger.info(f"{created} site(s) de recherche créé(s)")
|
||||||
else:
|
else:
|
||||||
logger.info("Sites de recherche déjà initialisés")
|
logger.info("Sites de recherche déjà initialisés")
|
||||||
|
|
||||||
|
# Seed default admin user
|
||||||
|
if auth_service.seed_default_admin(db):
|
||||||
|
logger.info("Utilisateur admin par défaut créé (admin/admin)")
|
||||||
|
else:
|
||||||
|
logger.info("Utilisateurs déjà initialisés")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error seeding sites: {e}")
|
logger.error(f"Error seeding data: {e}")
|
||||||
|
|
||||||
logger.info("Starting smart scheduler (Heartbeat: 1 minute)")
|
logger.info("Starting smart scheduler (Heartbeat: 1 minute)")
|
||||||
scheduler.add_job(scheduled_refresh, IntervalTrigger(minutes=1), id="refresh_job", replace_existing=True)
|
scheduler.add_job(scheduled_refresh, IntervalTrigger(minutes=1), id="refresh_job", replace_existing=True)
|
||||||
@@ -142,6 +170,9 @@ for router in [notifications.router, items.router, settings.router, jobs.router,
|
|||||||
app.include_router(search.router)
|
app.include_router(search.router)
|
||||||
app.include_router(search_sites.router)
|
app.include_router(search_sites.router)
|
||||||
|
|
||||||
|
# Auth router (already has /api prefix)
|
||||||
|
app.include_router(auth.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/")
|
@app.get("/api/")
|
||||||
def read_root():
|
def read_root():
|
||||||
|
|||||||
@@ -6,6 +6,19 @@ from sqlalchemy.orm import relationship
|
|||||||
from .database import Base
|
from .database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""User model for authentication"""
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: int = Column(Integer, primary_key=True, index=True) # type: ignore
|
||||||
|
username: str = Column(String, unique=True, nullable=False, index=True) # type: ignore
|
||||||
|
password_hash: str = Column(String, nullable=False) # type: ignore
|
||||||
|
is_admin: bool = Column(Boolean, default=False) # type: ignore
|
||||||
|
is_active: bool = Column(Boolean, default=True) # type: ignore
|
||||||
|
created_at: datetime = Column(DateTime, default=lambda: datetime.now(UTC)) # type: ignore
|
||||||
|
last_login: datetime | None = Column(DateTime, nullable=True) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
class NotificationProfile(Base):
|
class NotificationProfile(Base):
|
||||||
__tablename__ = "notification_profiles"
|
__tablename__ = "notification_profiles"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""
|
||||||
|
Authentication router for login, user management, and admin functions.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import models
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.services import auth_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# Pydantic models
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
token: str
|
||||||
|
username: str
|
||||||
|
is_admin: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
is_admin: bool
|
||||||
|
is_active: bool
|
||||||
|
created_at: str
|
||||||
|
last_login: str | None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUserRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
is_admin: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class AdminChangePasswordRequest(BaseModel):
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
authorization: Annotated[str | None, Header()] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> models.User:
|
||||||
|
"""Dependency to get current authenticated user from JWT token."""
|
||||||
|
if not authorization:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Non authentifié",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract token from "Bearer <token>" format
|
||||||
|
parts = authorization.split()
|
||||||
|
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Format de token invalide",
|
||||||
|
)
|
||||||
|
|
||||||
|
token = parts[1]
|
||||||
|
payload = auth_service.decode_token(token)
|
||||||
|
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Token invalide ou expiré",
|
||||||
|
)
|
||||||
|
|
||||||
|
user = auth_service.get_user_by_id(db, payload["user_id"])
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Utilisateur non trouvé ou désactivé",
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_user(
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
) -> models.User:
|
||||||
|
"""Dependency to require admin privileges."""
|
||||||
|
if not current_user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Accès administrateur requis",
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=LoginResponse)
|
||||||
|
def login(request: LoginRequest, db: Session = Depends(get_db)):
|
||||||
|
"""Authenticate user and return JWT token."""
|
||||||
|
user = auth_service.authenticate_user(db, request.username, request.password)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Nom d'utilisateur ou mot de passe incorrect",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update last login
|
||||||
|
auth_service.update_last_login(db, user)
|
||||||
|
|
||||||
|
# Create token
|
||||||
|
token = auth_service.create_token(user.id, user.username, user.is_admin)
|
||||||
|
|
||||||
|
logger.info(f"User '{user.username}' logged in successfully")
|
||||||
|
|
||||||
|
return LoginResponse(
|
||||||
|
token=token,
|
||||||
|
username=user.username,
|
||||||
|
is_admin=user.is_admin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
def get_current_user_info(current_user: models.User = Depends(get_current_user)):
|
||||||
|
"""Get current user information."""
|
||||||
|
return UserResponse(
|
||||||
|
id=current_user.id,
|
||||||
|
username=current_user.username,
|
||||||
|
is_admin=current_user.is_admin,
|
||||||
|
is_active=current_user.is_active,
|
||||||
|
created_at=current_user.created_at.isoformat() if current_user.created_at else "",
|
||||||
|
last_login=current_user.last_login.isoformat() if current_user.last_login else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password")
|
||||||
|
def change_password(
|
||||||
|
request: ChangePasswordRequest,
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Change current user's password."""
|
||||||
|
# Verify current password
|
||||||
|
if not auth_service.verify_password(request.current_password, current_user.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Mot de passe actuel incorrect",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
auth_service.update_password(db, current_user, request.new_password)
|
||||||
|
|
||||||
|
logger.info(f"User '{current_user.username}' changed password")
|
||||||
|
|
||||||
|
return {"message": "Mot de passe modifié avec succès"}
|
||||||
|
|
||||||
|
|
||||||
|
# Admin endpoints
|
||||||
|
@router.get("/users", response_model=list[UserResponse])
|
||||||
|
def list_users(
|
||||||
|
admin_user: models.User = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List all users (admin only)."""
|
||||||
|
users = auth_service.get_all_users(db)
|
||||||
|
return [
|
||||||
|
UserResponse(
|
||||||
|
id=u.id,
|
||||||
|
username=u.username,
|
||||||
|
is_admin=u.is_admin,
|
||||||
|
is_active=u.is_active,
|
||||||
|
created_at=u.created_at.isoformat() if u.created_at else "",
|
||||||
|
last_login=u.last_login.isoformat() if u.last_login else None,
|
||||||
|
)
|
||||||
|
for u in users
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users", response_model=UserResponse)
|
||||||
|
def create_user(
|
||||||
|
request: CreateUserRequest,
|
||||||
|
admin_user: models.User = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a new user (admin only)."""
|
||||||
|
# Check if username already exists
|
||||||
|
existing = auth_service.get_user_by_username(db, request.username)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Ce nom d'utilisateur existe déjà",
|
||||||
|
)
|
||||||
|
|
||||||
|
user = auth_service.create_user(db, request.username, request.password, request.is_admin)
|
||||||
|
|
||||||
|
logger.info(f"Admin '{admin_user.username}' created user '{user.username}'")
|
||||||
|
|
||||||
|
return UserResponse(
|
||||||
|
id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
is_admin=user.is_admin,
|
||||||
|
is_active=user.is_active,
|
||||||
|
created_at=user.created_at.isoformat() if user.created_at else "",
|
||||||
|
last_login=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}")
|
||||||
|
def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
admin_user: models.User = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete a user (admin only)."""
|
||||||
|
# Prevent self-deletion
|
||||||
|
if user_id == admin_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Vous ne pouvez pas supprimer votre propre compte",
|
||||||
|
)
|
||||||
|
|
||||||
|
success = auth_service.delete_user(db, user_id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Utilisateur non trouvé",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Admin '{admin_user.username}' deleted user ID {user_id}")
|
||||||
|
|
||||||
|
return {"message": "Utilisateur supprimé"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/users/{user_id}/toggle-active", response_model=UserResponse)
|
||||||
|
def toggle_user_active(
|
||||||
|
user_id: int,
|
||||||
|
admin_user: models.User = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Toggle user active status (admin only)."""
|
||||||
|
# Prevent self-deactivation
|
||||||
|
if user_id == admin_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Vous ne pouvez pas désactiver votre propre compte",
|
||||||
|
)
|
||||||
|
|
||||||
|
user = auth_service.toggle_user_active(db, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Utilisateur non trouvé",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Admin '{admin_user.username}' toggled active status for user '{user.username}'")
|
||||||
|
|
||||||
|
return UserResponse(
|
||||||
|
id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
is_admin=user.is_admin,
|
||||||
|
is_active=user.is_active,
|
||||||
|
created_at=user.created_at.isoformat() if user.created_at else "",
|
||||||
|
last_login=user.last_login.isoformat() if user.last_login else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/users/{user_id}/password")
|
||||||
|
def admin_change_user_password(
|
||||||
|
user_id: int,
|
||||||
|
request: AdminChangePasswordRequest,
|
||||||
|
admin_user: models.User = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Change a user's password (admin only)."""
|
||||||
|
user = auth_service.get_user_by_id(db, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Utilisateur non trouvé",
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_service.update_password(db, user, request.new_password)
|
||||||
|
|
||||||
|
logger.info(f"Admin '{admin_user.username}' changed password for user '{user.username}'")
|
||||||
|
|
||||||
|
return {"message": "Mot de passe modifié avec succès"}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
Authentication service for user management and JWT tokens.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import models
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET = os.getenv("JWT_SECRET", secrets.token_hex(32))
|
||||||
|
JWT_ALGORITHM = "HS256"
|
||||||
|
JWT_EXPIRATION_HOURS = 24
|
||||||
|
|
||||||
|
# Default admin credentials
|
||||||
|
DEFAULT_ADMIN_USERNAME = "admin"
|
||||||
|
DEFAULT_ADMIN_PASSWORD = "admin"
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Hash password using SHA-256 with salt."""
|
||||||
|
salt = secrets.token_hex(16)
|
||||||
|
password_hash = hashlib.sha256(f"{salt}{password}".encode()).hexdigest()
|
||||||
|
return f"{salt}:{password_hash}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, password_hash: str) -> bool:
|
||||||
|
"""Verify password against hash."""
|
||||||
|
try:
|
||||||
|
salt, stored_hash = password_hash.split(":")
|
||||||
|
computed_hash = hashlib.sha256(f"{salt}{password}".encode()).hexdigest()
|
||||||
|
return computed_hash == stored_hash
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def create_token(user_id: int, username: str, is_admin: bool) -> str:
|
||||||
|
"""Create JWT token for authenticated user."""
|
||||||
|
payload = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"username": username,
|
||||||
|
"is_admin": is_admin,
|
||||||
|
"exp": datetime.now(UTC) + timedelta(hours=JWT_EXPIRATION_HOURS),
|
||||||
|
"iat": datetime.now(UTC),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict | None:
|
||||||
|
"""Decode and validate JWT token."""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||||
|
return payload
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
return None
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_username(db: Session, username: str) -> models.User | None:
|
||||||
|
"""Get user by username."""
|
||||||
|
return db.query(models.User).filter(models.User.username == username).first()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_id(db: Session, user_id: int) -> models.User | None:
|
||||||
|
"""Get user by ID."""
|
||||||
|
return db.query(models.User).filter(models.User.id == user_id).first()
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_user(db: Session, username: str, password: str) -> models.User | None:
|
||||||
|
"""Authenticate user with username and password."""
|
||||||
|
user = get_user_by_username(db, username)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
if not verify_password(password, user.password_hash):
|
||||||
|
return None
|
||||||
|
if not user.is_active:
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(
|
||||||
|
db: Session,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
is_admin: bool = False,
|
||||||
|
) -> models.User:
|
||||||
|
"""Create a new user."""
|
||||||
|
password_hash = hash_password(password)
|
||||||
|
user = models.User(
|
||||||
|
username=username,
|
||||||
|
password_hash=password_hash,
|
||||||
|
is_admin=is_admin,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def update_password(db: Session, user: models.User, new_password: str) -> None:
|
||||||
|
"""Update user password."""
|
||||||
|
user.password_hash = hash_password(new_password)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def update_last_login(db: Session, user: models.User) -> None:
|
||||||
|
"""Update user's last login timestamp."""
|
||||||
|
user.last_login = datetime.now(UTC)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_users(db: Session) -> list[models.User]:
|
||||||
|
"""Get all users."""
|
||||||
|
return db.query(models.User).all()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user(db: Session, user_id: int) -> bool:
|
||||||
|
"""Delete a user by ID."""
|
||||||
|
user = get_user_by_id(db, user_id)
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
db.delete(user)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_user_active(db: Session, user_id: int) -> models.User | None:
|
||||||
|
"""Toggle user active status."""
|
||||||
|
user = get_user_by_id(db, user_id)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
user.is_active = not user.is_active
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def seed_default_admin(db: Session) -> bool:
|
||||||
|
"""Create default admin user if no users exist."""
|
||||||
|
user_count = db.query(models.User).count()
|
||||||
|
if user_count == 0:
|
||||||
|
create_user(db, DEFAULT_ADMIN_USERNAME, DEFAULT_ADMIN_PASSWORD, is_admin=True)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
+90
-11
@@ -1,25 +1,104 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
import Layout from '@/components/layout/Layout'
|
import Layout from '@/components/layout/Layout'
|
||||||
import Dashboard from '@/pages/Dashboard'
|
import Dashboard from '@/pages/Dashboard'
|
||||||
import Search from '@/pages/Search'
|
import Search from '@/pages/Search'
|
||||||
import Settings from '@/pages/Settings'
|
import Login from '@/pages/Login'
|
||||||
|
import Admin from '@/pages/Admin'
|
||||||
import { useTheme } from '@/hooks/use-theme'
|
import { useTheme } from '@/hooks/use-theme'
|
||||||
|
import { AuthProvider, useAuth } from '@/hooks/use-auth'
|
||||||
|
|
||||||
export default function App() {
|
// Protected route wrapper
|
||||||
|
function ProtectedRoute({ children, adminOnly = false }) {
|
||||||
|
const { isAuthenticated, isAdmin, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminOnly && !isAdmin) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public route that redirects to home if already logged in
|
||||||
|
function PublicRoute({ children }) {
|
||||||
|
const { isAuthenticated, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router>
|
<>
|
||||||
<Toaster position="bottom-right" theme={theme} />
|
<Toaster position="bottom-right" theme={theme} />
|
||||||
<Layout theme={theme} toggleTheme={toggleTheme}>
|
<Routes>
|
||||||
<Routes>
|
<Route
|
||||||
<Route path="/" element={<Dashboard />} />
|
path="/login"
|
||||||
<Route path="/search" element={<Search />} />
|
element={
|
||||||
<Route path="/settings" element={<Settings />} />
|
<PublicRoute>
|
||||||
</Routes>
|
<Login />
|
||||||
</Layout>
|
</PublicRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/*"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout theme={theme} toggleTheme={toggleTheme}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/search" element={<Search />} />
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute adminOnly>
|
||||||
|
<Admin />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</AuthProvider>
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,34 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { LayoutDashboard, Search, Settings, Moon, Sun, Menu } from 'lucide-react';
|
import { LayoutDashboard, Search, Moon, Sun, Menu, LogOut, Shield } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
|
||||||
const Layout = ({ children, theme, toggleTheme }) => {
|
const Layout = ({ children, theme, toggleTheme }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, logout, isAdmin } = useAuth();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ icon: LayoutDashboard, label: t('nav.dashboard'), path: '/' },
|
{ icon: LayoutDashboard, label: t('nav.dashboard'), path: '/' },
|
||||||
{ icon: Search, label: t('nav.search') || 'Recherche', path: '/search' },
|
{ icon: Search, label: t('nav.search') || 'Recherche', path: '/search' },
|
||||||
{ icon: Settings, label: t('nav.settings'), path: '/settings' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Add admin link for admin users
|
||||||
|
if (isAdmin) {
|
||||||
|
navItems.push({ icon: Shield, label: 'Administration', path: '/admin' });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background font-sans antialiased">
|
<div className="min-h-screen bg-background font-sans antialiased">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
@@ -47,7 +59,13 @@ const Layout = ({ children, theme, toggleTheme }) => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="border-t p-4">
|
<div className="border-t p-4 space-y-2">
|
||||||
|
{/* User info */}
|
||||||
|
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">{user?.username}</span>
|
||||||
|
{isAdmin && <span className="ml-2 text-xs bg-primary/20 text-primary px-2 py-0.5 rounded">Admin</span>}
|
||||||
|
</div>
|
||||||
|
{/* Theme toggle */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -66,6 +84,15 @@ const Layout = ({ children, theme, toggleTheme }) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Logout button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full justify-start gap-2 px-4 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
<span>Déconnexion</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -103,7 +130,11 @@ const Layout = ({ children, theme, toggleTheme }) => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="border-t p-4">
|
<div className="border-t p-4 space-y-2">
|
||||||
|
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">{user?.username}</span>
|
||||||
|
{isAdmin && <span className="ml-2 text-xs bg-primary/20 text-primary px-2 py-0.5 rounded">Admin</span>}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
@@ -121,6 +152,14 @@ const Layout = ({ children, theme, toggleTheme }) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => { handleLogout(); setMobileMenuOpen(false); }}
|
||||||
|
className="w-full justify-start gap-2 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
<span>Déconnexion</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
const API_URL = '/api/auth';
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Check for existing token on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
// Verify token is still valid
|
||||||
|
axios.get(`${API_URL}/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
setUser({
|
||||||
|
...response.data,
|
||||||
|
token
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Token invalid, clear it
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (username, password) => {
|
||||||
|
const response = await axios.post(`${API_URL}/login`, {
|
||||||
|
username,
|
||||||
|
password
|
||||||
|
});
|
||||||
|
|
||||||
|
const { token, username: userName, is_admin } = response.data;
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
|
||||||
|
setUser({
|
||||||
|
username: userName,
|
||||||
|
is_admin,
|
||||||
|
token
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePassword = async (currentPassword, newPassword) => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
await axios.post(`${API_URL}/change-password`, {
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword
|
||||||
|
}, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get auth headers for API calls
|
||||||
|
const getAuthHeaders = () => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
changePassword,
|
||||||
|
getAuthHeaders,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
isAdmin: user?.is_admin || false
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,859 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Users, UserPlus, Trash2, Key, Shield, ShieldOff,
|
||||||
|
Settings as SettingsIcon, Cpu, Clock, Bell, Globe,
|
||||||
|
ChevronDown, ChevronUp, RefreshCw, RotateCcw, Edit2,
|
||||||
|
Sparkles, Eye, Code, Brain, DollarSign, Filter,
|
||||||
|
TrendingDown, CheckCircle2, Package
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
|
||||||
|
const API_URL = '/api';
|
||||||
|
|
||||||
|
const categoryIcons = {
|
||||||
|
chat: Sparkles,
|
||||||
|
vision: Eye,
|
||||||
|
code: Code,
|
||||||
|
reasoning: Brain,
|
||||||
|
free: DollarSign
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Admin() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, getAuthHeaders, isAdmin, changePassword } = useAuth();
|
||||||
|
const [activeTab, setActiveTab] = useState('users');
|
||||||
|
|
||||||
|
// User management state
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [newUser, setNewUser] = useState({ username: '', password: '', is_admin: false });
|
||||||
|
const [passwordChange, setPasswordChange] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||||
|
const [adminPasswordChange, setAdminPasswordChange] = useState({ userId: null, newPassword: '' });
|
||||||
|
|
||||||
|
// Settings state (from Settings.jsx)
|
||||||
|
const [profiles, setProfiles] = useState([]);
|
||||||
|
const [jobConfig, setJobConfig] = useState({ refresh_interval_minutes: 60, next_run: null, running: false });
|
||||||
|
const [config, setConfig] = useState({
|
||||||
|
ai_provider: 'ollama',
|
||||||
|
ai_model: 'moondream',
|
||||||
|
ai_api_key: '',
|
||||||
|
ai_api_base: 'http://ollama:11434',
|
||||||
|
ai_temperature: 0.1,
|
||||||
|
ai_max_tokens: 300,
|
||||||
|
confidence_threshold_price: 0.5,
|
||||||
|
confidence_threshold_stock: 0.5,
|
||||||
|
enable_json_repair: true,
|
||||||
|
smart_scroll_enabled: false,
|
||||||
|
smart_scroll_pixels: 350,
|
||||||
|
text_context_enabled: false,
|
||||||
|
text_context_length: 5000,
|
||||||
|
scraper_timeout: 90000
|
||||||
|
});
|
||||||
|
const [newProfile, setNewProfile] = useState({
|
||||||
|
name: '',
|
||||||
|
apprise_url: '',
|
||||||
|
check_interval_minutes: 60,
|
||||||
|
notify_on_price_drop: true,
|
||||||
|
notify_on_target_price: true,
|
||||||
|
price_drop_threshold_percent: 10,
|
||||||
|
notify_on_stock_change: true
|
||||||
|
});
|
||||||
|
const [editingProfileId, setEditingProfileId] = useState(null);
|
||||||
|
const [showAdvancedAI, setShowAdvancedAI] = useState(false);
|
||||||
|
const [searchSites, setSearchSites] = useState([]);
|
||||||
|
const [resettingSites, setResettingSites] = useState(false);
|
||||||
|
const [openrouterModels, setOpenrouterModels] = useState([]);
|
||||||
|
const [openrouterLoading, setOpenrouterLoading] = useState(false);
|
||||||
|
const [openrouterCategory, setOpenrouterCategory] = useState('all');
|
||||||
|
const [openrouterCategories] = useState([
|
||||||
|
{ id: 'all', name: 'Tous', description: 'Tous les modèles' },
|
||||||
|
{ id: 'chat', name: 'Chat', description: 'Conversation générale' },
|
||||||
|
{ id: 'vision', name: 'Vision', description: 'Analyse d\'images' },
|
||||||
|
{ id: 'code', name: 'Code', description: 'Programmation' },
|
||||||
|
{ id: 'reasoning', name: 'Raisonnement', description: 'Raisonnement avancé' },
|
||||||
|
{ id: 'free', name: 'Gratuit', description: 'Modèles gratuits' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin) {
|
||||||
|
navigate('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchAll();
|
||||||
|
}, [isAdmin, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config.ai_provider === 'openrouter') {
|
||||||
|
fetchOpenRouterModels();
|
||||||
|
}
|
||||||
|
}, [config.ai_provider, openrouterCategory]);
|
||||||
|
|
||||||
|
const fetchAll = async () => {
|
||||||
|
try {
|
||||||
|
const headers = getAuthHeaders();
|
||||||
|
const [usersRes, profilesRes, settingsRes, jobRes, sitesRes] = await Promise.all([
|
||||||
|
axios.get(`${API_URL}/auth/users`, { headers }),
|
||||||
|
axios.get(`${API_URL}/notification-profiles`),
|
||||||
|
axios.get(`${API_URL}/settings`),
|
||||||
|
axios.get(`${API_URL}/jobs/config`),
|
||||||
|
axios.get(`${API_URL}/search-sites`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
setUsers(usersRes.data);
|
||||||
|
setProfiles(profilesRes.data);
|
||||||
|
setJobConfig(jobRes.data);
|
||||||
|
setSearchSites(sitesRes.data);
|
||||||
|
|
||||||
|
const settingsMap = {};
|
||||||
|
settingsRes.data.forEach(s => settingsMap[s.key] = s.value);
|
||||||
|
setConfig({
|
||||||
|
ai_provider: settingsMap['ai_provider'] || 'ollama',
|
||||||
|
ai_model: settingsMap['ai_model'] || 'moondream',
|
||||||
|
ai_api_key: settingsMap['ai_api_key'] || '',
|
||||||
|
ai_api_base: settingsMap['ai_api_base'] || 'http://ollama:11434',
|
||||||
|
ai_temperature: parseFloat(settingsMap['ai_temperature'] || '0.1'),
|
||||||
|
ai_max_tokens: parseInt(settingsMap['ai_max_tokens'] || '300'),
|
||||||
|
confidence_threshold_price: parseFloat(settingsMap['confidence_threshold_price'] || '0.5'),
|
||||||
|
confidence_threshold_stock: parseFloat(settingsMap['confidence_threshold_stock'] || '0.5'),
|
||||||
|
enable_json_repair: settingsMap['enable_json_repair'] !== 'false',
|
||||||
|
smart_scroll_enabled: settingsMap['smart_scroll_enabled'] === 'true',
|
||||||
|
smart_scroll_pixels: parseInt(settingsMap['smart_scroll_pixels'] || '350'),
|
||||||
|
text_context_enabled: settingsMap['text_context_enabled'] === 'true',
|
||||||
|
text_context_length: parseInt(settingsMap['text_context_length'] || '5000'),
|
||||||
|
scraper_timeout: parseInt(settingsMap['scraper_timeout'] || '90000')
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
toast.error('Erreur lors du chargement des données');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// User management functions
|
||||||
|
const handleCreateUser = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newUser.username.trim() || !newUser.password) {
|
||||||
|
toast.error('Veuillez remplir tous les champs');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_URL}/auth/users`, newUser, { headers: getAuthHeaders() });
|
||||||
|
toast.success('Utilisateur créé');
|
||||||
|
setNewUser({ username: '', password: '', is_admin: false });
|
||||||
|
fetchAll();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Erreur lors de la création');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async (userId) => {
|
||||||
|
if (!confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ?')) return;
|
||||||
|
try {
|
||||||
|
await axios.delete(`${API_URL}/auth/users/${userId}`, { headers: getAuthHeaders() });
|
||||||
|
toast.success('Utilisateur supprimé');
|
||||||
|
fetchAll();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Erreur lors de la suppression');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleUserActive = async (userId) => {
|
||||||
|
try {
|
||||||
|
await axios.put(`${API_URL}/auth/users/${userId}/toggle-active`, {}, { headers: getAuthHeaders() });
|
||||||
|
toast.success('Statut modifié');
|
||||||
|
fetchAll();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Erreur lors de la modification');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeMyPassword = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (passwordChange.newPassword !== passwordChange.confirmPassword) {
|
||||||
|
toast.error('Les mots de passe ne correspondent pas');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await changePassword(passwordChange.currentPassword, passwordChange.newPassword);
|
||||||
|
toast.success('Mot de passe modifié');
|
||||||
|
setPasswordChange({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Erreur lors du changement de mot de passe');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdminChangePassword = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!adminPasswordChange.userId || !adminPasswordChange.newPassword) return;
|
||||||
|
try {
|
||||||
|
await axios.put(
|
||||||
|
`${API_URL}/auth/users/${adminPasswordChange.userId}/password`,
|
||||||
|
{ new_password: adminPasswordChange.newPassword },
|
||||||
|
{ headers: getAuthHeaders() }
|
||||||
|
);
|
||||||
|
toast.success('Mot de passe modifié');
|
||||||
|
setAdminPasswordChange({ userId: null, newPassword: '' });
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Erreur lors du changement de mot de passe');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Settings functions
|
||||||
|
const updateSetting = async (key, value) => {
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_URL}/settings`, { key, value: value.toString() });
|
||||||
|
setConfig(prev => ({ ...prev, [key]: value }));
|
||||||
|
toast.success('Paramètre mis à jour');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la mise à jour');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchOpenRouterModels = async () => {
|
||||||
|
setOpenrouterLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (config.ai_api_key) params.append('api_key', config.ai_api_key);
|
||||||
|
if (openrouterCategory && openrouterCategory !== 'all') params.append('category', openrouterCategory);
|
||||||
|
const response = await axios.get(`${API_URL}/openrouter/models?${params.toString()}`);
|
||||||
|
setOpenrouterModels(response.data.models || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur chargement modèles OpenRouter:', error);
|
||||||
|
} finally {
|
||||||
|
setOpenrouterLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPrice = (pricePerToken) => {
|
||||||
|
if (!pricePerToken || pricePerToken === 0) return 'Gratuit';
|
||||||
|
const pricePerMillion = pricePerToken * 1000000;
|
||||||
|
if (pricePerMillion < 0.01) return `$${pricePerMillion.toFixed(4)}/1M`;
|
||||||
|
if (pricePerMillion < 1) return `$${pricePerMillion.toFixed(3)}/1M`;
|
||||||
|
return `$${pricePerMillion.toFixed(2)}/1M`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateJobConfig = async () => {
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_URL}/jobs/config`, {
|
||||||
|
key: 'refresh_interval_minutes',
|
||||||
|
value: jobConfig.refresh_interval_minutes.toString()
|
||||||
|
});
|
||||||
|
toast.success('Configuration du job mise à jour');
|
||||||
|
fetchAll();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la mise à jour');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Profile functions
|
||||||
|
const handleProfileSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
if (editingProfileId) {
|
||||||
|
await axios.put(`${API_URL}/notification-profiles/${editingProfileId}`, newProfile);
|
||||||
|
toast.success('Profil mis à jour');
|
||||||
|
} else {
|
||||||
|
await axios.post(`${API_URL}/notification-profiles`, newProfile);
|
||||||
|
toast.success('Profil créé');
|
||||||
|
}
|
||||||
|
setNewProfile({
|
||||||
|
name: '', apprise_url: '', check_interval_minutes: 60,
|
||||||
|
notify_on_price_drop: true, notify_on_target_price: true,
|
||||||
|
price_drop_threshold_percent: 10, notify_on_stock_change: true
|
||||||
|
});
|
||||||
|
setEditingProfileId(null);
|
||||||
|
fetchAll();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de l\'enregistrement');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const editProfile = (profile) => {
|
||||||
|
setNewProfile({
|
||||||
|
name: profile.name, apprise_url: profile.apprise_url,
|
||||||
|
check_interval_minutes: profile.check_interval_minutes,
|
||||||
|
notify_on_price_drop: profile.notify_on_price_drop,
|
||||||
|
notify_on_target_price: profile.notify_on_target_price,
|
||||||
|
price_drop_threshold_percent: profile.price_drop_threshold_percent,
|
||||||
|
notify_on_stock_change: profile.notify_on_stock_change
|
||||||
|
});
|
||||||
|
setEditingProfileId(profile.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setNewProfile({
|
||||||
|
name: '', apprise_url: '', check_interval_minutes: 60,
|
||||||
|
notify_on_price_drop: true, notify_on_target_price: true,
|
||||||
|
price_drop_threshold_percent: 10, notify_on_stock_change: true
|
||||||
|
});
|
||||||
|
setEditingProfileId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteProfile = async (id) => {
|
||||||
|
if (confirm('Êtes-vous sûr de vouloir supprimer ce profil ?')) {
|
||||||
|
try {
|
||||||
|
await axios.delete(`${API_URL}/notification-profiles/${id}`);
|
||||||
|
toast.success('Profil supprimé');
|
||||||
|
if (editingProfileId === id) cancelEdit();
|
||||||
|
fetchAll();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la suppression');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Site functions
|
||||||
|
const toggleSiteActive = async (site) => {
|
||||||
|
try {
|
||||||
|
await axios.put(`${API_URL}/search-sites/${site.id}`, { is_active: !site.is_active });
|
||||||
|
fetchAll();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la mise à jour');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetSitesToDefaults = async () => {
|
||||||
|
if (!confirm('Êtes-vous sûr de vouloir réinitialiser tous les sites ?')) return;
|
||||||
|
setResettingSites(true);
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${API_URL}/search-sites/reset`);
|
||||||
|
toast.success(response.data.message);
|
||||||
|
fetchAll();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Erreur lors de la réinitialisation');
|
||||||
|
} finally {
|
||||||
|
setResettingSites(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'users', label: 'Utilisateurs', icon: Users },
|
||||||
|
{ id: 'ai', label: 'Configuration IA', icon: Cpu },
|
||||||
|
{ id: 'scraper', label: 'Scraper', icon: SettingsIcon },
|
||||||
|
{ id: 'sites', label: 'Sites', icon: Globe },
|
||||||
|
{ id: 'jobs', label: 'Jobs', icon: Clock },
|
||||||
|
{ id: 'notifications', label: 'Notifications', icon: Bell },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in duration-500 max-w-5xl mx-auto pb-10">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Administration</h2>
|
||||||
|
<p className="text-muted-foreground">Gérez les utilisateurs et la configuration de l'application.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex flex-wrap gap-2 border-b pb-4">
|
||||||
|
{tabs.map(tab => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={tab.id}
|
||||||
|
variant={activeTab === tab.id ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 mr-2" />
|
||||||
|
{tab.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Users Tab */}
|
||||||
|
{activeTab === 'users' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Change My Password */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><Key className="h-5 w-5" />Changer mon mot de passe</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleChangeMyPassword} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Mot de passe actuel</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={passwordChange.currentPassword}
|
||||||
|
onChange={(e) => setPasswordChange({ ...passwordChange, currentPassword: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Nouveau mot de passe</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={passwordChange.newPassword}
|
||||||
|
onChange={(e) => setPasswordChange({ ...passwordChange, newPassword: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Confirmer</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={passwordChange.confirmPassword}
|
||||||
|
onChange={(e) => setPasswordChange({ ...passwordChange, confirmPassword: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="submit">Changer le mot de passe</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Create User */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><UserPlus className="h-5 w-5" />Créer un utilisateur</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleCreateUser} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Nom d'utilisateur</Label>
|
||||||
|
<Input
|
||||||
|
value={newUser.username}
|
||||||
|
onChange={(e) => setNewUser({ ...newUser, username: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Mot de passe</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={newUser.password}
|
||||||
|
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Rôle</Label>
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
<Switch
|
||||||
|
checked={newUser.is_admin}
|
||||||
|
onCheckedChange={(checked) => setNewUser({ ...newUser, is_admin: checked })}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{newUser.is_admin ? 'Administrateur' : 'Utilisateur'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="submit"><UserPlus className="h-4 w-4 mr-2" />Créer</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* User List */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><Users className="h-5 w-5" />Liste des utilisateurs</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{users.map(u => (
|
||||||
|
<div key={u.id} className={cn(
|
||||||
|
"flex items-center justify-between p-4 rounded-lg border",
|
||||||
|
!u.is_active && "opacity-50"
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={cn(
|
||||||
|
"w-10 h-10 rounded-full flex items-center justify-center",
|
||||||
|
u.is_admin ? "bg-primary/20" : "bg-secondary"
|
||||||
|
)}>
|
||||||
|
{u.is_admin ? <Shield className="h-5 w-5 text-primary" /> : <Users className="h-5 w-5" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{u.username}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{u.is_admin ? 'Administrateur' : 'Utilisateur'} •
|
||||||
|
{u.is_active ? ' Actif' : ' Inactif'}
|
||||||
|
{u.last_login && ` • Dernière connexion: ${new Date(u.last_login).toLocaleDateString()}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{adminPasswordChange.userId === u.id ? (
|
||||||
|
<form onSubmit={handleAdminChangePassword} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Nouveau mot de passe"
|
||||||
|
value={adminPasswordChange.newPassword}
|
||||||
|
onChange={(e) => setAdminPasswordChange({ ...adminPasswordChange, newPassword: e.target.value })}
|
||||||
|
className="w-40"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="sm">OK</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => setAdminPasswordChange({ userId: null, newPassword: '' })}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setAdminPasswordChange({ userId: u.id, newPassword: '' })}
|
||||||
|
title="Changer le mot de passe"
|
||||||
|
>
|
||||||
|
<Key className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{u.id !== user?.id && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleToggleUserActive(u.id)}
|
||||||
|
title={u.is_active ? 'Désactiver' : 'Activer'}
|
||||||
|
>
|
||||||
|
{u.is_active ? <ShieldOff className="h-4 w-4" /> : <Shield className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => handleDeleteUser(u.id)}
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Configuration Tab */}
|
||||||
|
{activeTab === 'ai' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><Cpu className="h-5 w-5" />Configuration IA</CardTitle>
|
||||||
|
<CardDescription>Configure le modèle IA utilisé pour l'analyse des pages produits.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Fournisseur</Label>
|
||||||
|
<Select value={config.ai_provider} onValueChange={(val) => updateSetting('ai_provider', val)}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ollama">Ollama</SelectItem>
|
||||||
|
<SelectItem value="openai">OpenAI</SelectItem>
|
||||||
|
<SelectItem value="anthropic">Anthropic</SelectItem>
|
||||||
|
<SelectItem value="openrouter">OpenRouter</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{config.ai_provider !== 'openrouter' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Modèle</Label>
|
||||||
|
<Input value={config.ai_model} onChange={(e) => updateSetting('ai_model', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Catégorie</Label>
|
||||||
|
<Select value={openrouterCategory} onValueChange={setOpenrouterCategory}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Filtrer par catégorie" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{openrouterCategories.map(cat => (
|
||||||
|
<SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Clé API</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={config.ai_api_key}
|
||||||
|
onChange={(e) => updateSetting('ai_api_key', e.target.value)}
|
||||||
|
placeholder={config.ai_provider === 'openrouter' ? 'sk-or-...' : 'Optionnel pour Ollama'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.ai_provider === 'openrouter' && (
|
||||||
|
<div className="space-y-4 p-4 border rounded-lg bg-muted/30">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-base font-medium">Modèles OpenRouter</Label>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchOpenRouterModels} disabled={openrouterLoading}>
|
||||||
|
<RefreshCw className={cn("h-4 w-4 mr-2", openrouterLoading && "animate-spin")} />
|
||||||
|
Actualiser
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{openrouterCategories.map(cat => {
|
||||||
|
const Icon = categoryIcons[cat.id] || Filter;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={cat.id}
|
||||||
|
variant={openrouterCategory === cat.id ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOpenrouterCategory(cat.id)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Icon className="h-3 w-3 mr-1" />{cat.name}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Modèle ({openrouterModels.length} disponibles)</Label>
|
||||||
|
{openrouterLoading ? (
|
||||||
|
<div className="text-sm text-muted-foreground py-4 text-center">Chargement...</div>
|
||||||
|
) : (
|
||||||
|
<Select value={config.ai_model} onValueChange={(val) => updateSetting('ai_model', val)}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Sélectionner" /></SelectTrigger>
|
||||||
|
<SelectContent className="max-h-[300px]">
|
||||||
|
{openrouterModels.map(model => (
|
||||||
|
<SelectItem key={model.id} value={model.id}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{model.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatPrice(model.pricing?.prompt)} / {formatPrice(model.pricing?.completion)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button variant="outline" size="sm" className="w-full" onClick={() => setShowAdvancedAI(!showAdvancedAI)}>
|
||||||
|
Paramètres avancés {showAdvancedAI ? <ChevronUp className="h-4 w-4 ml-2" /> : <ChevronDown className="h-4 w-4 ml-2" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAdvancedAI && (
|
||||||
|
<div className="space-y-6 pt-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>API Base URL</Label>
|
||||||
|
<Input value={config.ai_api_base} onChange={(e) => updateSetting('ai_api_base', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between"><Label>Temperature</Label><span className="text-xs">{config.ai_temperature}</span></div>
|
||||||
|
<Slider min={0} max={1} step={0.1} value={config.ai_temperature} onChange={(e) => updateSetting('ai_temperature', parseFloat(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between"><Label>Seuil confiance prix</Label><span className="text-xs">{config.confidence_threshold_price}</span></div>
|
||||||
|
<Slider min={0} max={1} step={0.1} value={config.confidence_threshold_price} onChange={(e) => updateSetting('confidence_threshold_price', parseFloat(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Max Tokens</Label>
|
||||||
|
<Input type="number" value={config.ai_max_tokens} onChange={(e) => updateSetting('ai_max_tokens', parseInt(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<Label>JSON Repair</Label>
|
||||||
|
<Switch checked={config.enable_json_repair} onCheckedChange={(checked) => updateSetting('enable_json_repair', checked)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scraper Tab */}
|
||||||
|
{activeTab === 'scraper' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><SettingsIcon className="h-5 w-5" />Configuration Scraper</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div><Label>Smart Scroll</Label><p className="text-sm text-muted-foreground">Scroll pour charger le contenu lazy.</p></div>
|
||||||
|
<Switch checked={config.smart_scroll_enabled} onCheckedChange={(checked) => updateSetting('smart_scroll_enabled', checked)} />
|
||||||
|
</div>
|
||||||
|
{config.smart_scroll_enabled && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Pixels de scroll</Label>
|
||||||
|
<Input type="number" value={config.smart_scroll_pixels} onChange={(e) => updateSetting('smart_scroll_pixels', parseInt(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div><Label>Contexte texte</Label><p className="text-sm text-muted-foreground">Envoyer le texte de la page à l'IA.</p></div>
|
||||||
|
<Switch checked={config.text_context_enabled} onCheckedChange={(checked) => updateSetting('text_context_enabled', checked)} />
|
||||||
|
</div>
|
||||||
|
{config.text_context_enabled && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Longueur max du texte</Label>
|
||||||
|
<Input type="number" value={config.text_context_length} onChange={(e) => updateSetting('text_context_length', parseInt(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Timeout (ms)</Label>
|
||||||
|
<Input type="number" value={config.scraper_timeout} onChange={(e) => updateSetting('scraper_timeout', parseInt(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sites Tab */}
|
||||||
|
{activeTab === 'sites' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2"><Globe className="h-5 w-5" />Sites de recherche</CardTitle>
|
||||||
|
<CardDescription>Sites e-commerce français pour la recherche.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={resetSitesToDefaults} disabled={resettingSites}>
|
||||||
|
<RotateCcw className={cn("h-4 w-4 mr-2", resettingSites && "animate-spin")} />Réinitialiser
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{searchSites.filter(s => s.is_active).length} site(s) actif(s) sur {searchSites.length}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{searchSites.map(site => (
|
||||||
|
<div key={site.id} className={cn("rounded-lg border bg-card p-3 space-y-2", !site.is_active && "opacity-50")}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-medium text-sm truncate">{site.name}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{site.domain}</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={site.is_active} onCheckedChange={() => toggleSiteActive(site)} className="ml-2" />
|
||||||
|
</div>
|
||||||
|
{site.category && (
|
||||||
|
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-xs bg-secondary">
|
||||||
|
{site.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Jobs Tab */}
|
||||||
|
{activeTab === 'jobs' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><Clock className="h-5 w-5" />Job de rafraîchissement</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Intervalle (minutes)</Label>
|
||||||
|
<Input type="number" min="1" value={jobConfig.refresh_interval_minutes} onChange={(e) => setJobConfig({ ...jobConfig, refresh_interval_minutes: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<p>Prochain lancement: {jobConfig.next_run ? new Date(jobConfig.next_run).toLocaleString() : 'Non planifié'}</p>
|
||||||
|
<p>Statut: {jobConfig.running ? 'En cours' : 'Inactif'}</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={updateJobConfig}>Enregistrer</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notifications Tab */}
|
||||||
|
{activeTab === 'notifications' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><Bell className="h-5 w-5" />Profils de notification</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-8">
|
||||||
|
<form onSubmit={handleProfileSubmit} className="space-y-4 border-b pb-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-medium">{editingProfileId ? 'Modifier le profil' : 'Nouveau profil'}</h4>
|
||||||
|
{editingProfileId && <Button type="button" variant="ghost" size="sm" onClick={cancelEdit}>Annuler</Button>}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Nom</Label>
|
||||||
|
<Input required value={newProfile.name} onChange={(e) => setNewProfile({ ...newProfile, name: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>URL Apprise</Label>
|
||||||
|
<Input required value={newProfile.apprise_url} onChange={(e) => setNewProfile({ ...newProfile, apprise_url: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Intervalle (min)</Label>
|
||||||
|
<Input type="number" min="1" value={newProfile.check_interval_minutes} onChange={(e) => setNewProfile({ ...newProfile, check_interval_minutes: parseInt(e.target.value) })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Seuil de baisse (%)</Label>
|
||||||
|
<Input type="number" min="1" max="100" value={newProfile.price_drop_threshold_percent} onChange={(e) => setNewProfile({ ...newProfile, price_drop_threshold_percent: parseFloat(e.target.value) })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch checked={newProfile.notify_on_price_drop} onCheckedChange={(checked) => setNewProfile({ ...newProfile, notify_on_price_drop: checked })} />
|
||||||
|
<Label>Baisse de prix</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch checked={newProfile.notify_on_target_price} onCheckedChange={(checked) => setNewProfile({ ...newProfile, notify_on_target_price: checked })} />
|
||||||
|
<Label>Prix cible</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch checked={newProfile.notify_on_stock_change} onCheckedChange={(checked) => setNewProfile({ ...newProfile, notify_on_stock_change: checked })} />
|
||||||
|
<Label>Changement stock</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="submit">{editingProfileId ? 'Mettre à jour' : 'Créer'}</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium">Profils existants</h4>
|
||||||
|
{profiles.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Aucun profil créé.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{profiles.map(profile => (
|
||||||
|
<div key={profile.id} className={cn("relative group rounded-xl border bg-card p-6 space-y-4", editingProfileId === profile.id && "ring-2 ring-primary")}>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{profile.name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground truncate max-w-[200px]">{profile.apprise_url}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => editProfile(profile)}><Edit2 className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => deleteProfile(profile.id)}><Trash2 className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-2"><Clock className="h-4 w-4 text-muted-foreground" /><span>{profile.check_interval_minutes}m</span></div>
|
||||||
|
<div className="flex items-center gap-2"><TrendingDown className="h-4 w-4 text-muted-foreground" /><span>{profile.price_drop_threshold_percent}%</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
|
{profile.notify_on_price_drop && <span className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs bg-secondary"><DollarSign className="mr-1 h-3 w-3" />Baisse</span>}
|
||||||
|
{profile.notify_on_target_price && <span className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs bg-secondary"><CheckCircle2 className="mr-1 h-3 w-3" />Cible</span>}
|
||||||
|
{profile.notify_on_stock_change && <span className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs bg-secondary"><Package className="mr-1 h-3 w-3" />Stock</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { LogIn, Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!username.trim() || !password) {
|
||||||
|
toast.error('Veuillez remplir tous les champs');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await login(username.trim(), password);
|
||||||
|
toast.success('Connexion réussie');
|
||||||
|
navigate('/');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
const message = error.response?.data?.detail || 'Erreur de connexion';
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<img src="/logo.svg" alt="PriceFlow Logo" className="h-16 w-16 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
|
||||||
|
PriceFlow
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Connectez-vous pour accéder à l'application
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Nom d'utilisateur</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="admin"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Mot de passe</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Connexion...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LogIn className="h-4 w-4 mr-2" />
|
||||||
|
Se connecter
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||||
|
Identifiants par défaut: admin / admin
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,14 +2,13 @@ import React, { useState, useEffect, useRef } from 'react';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Search as SearchIcon, Loader2, Plus, ExternalLink, Settings } from 'lucide-react';
|
import { Search as SearchIcon, Loader2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { SearchResultCard } from '@/components/search/SearchResultCard';
|
import { SearchResultCard } from '@/components/search/SearchResultCard';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
|
|
||||||
@@ -17,7 +16,7 @@ export default function Search() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [sites, setSites] = useState([]);
|
const [sites, setSites] = useState([]);
|
||||||
const [selectedSites, setSelectedSites] = useState([]);
|
const [selectedSiteId, setSelectedSiteId] = useState(null);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [progress, setProgress] = useState(null);
|
const [progress, setProgress] = useState(null);
|
||||||
const [results, setResults] = useState([]);
|
const [results, setResults] = useState([]);
|
||||||
@@ -31,31 +30,22 @@ export default function Search() {
|
|||||||
const loadSites = async () => {
|
const loadSites = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${API_URL}/search-sites`);
|
const response = await axios.get(`${API_URL}/search-sites`);
|
||||||
setSites(response.data);
|
const activeSites = response.data.filter(site => site.is_active);
|
||||||
// Sélectionner tous les sites actifs par défaut
|
setSites(activeSites);
|
||||||
setSelectedSites(
|
// Sélectionner le premier site actif par défaut
|
||||||
response.data
|
if (activeSites.length > 0 && !selectedSiteId) {
|
||||||
.filter(site => site.is_active)
|
setSelectedSiteId(activeSites[0].id.toString());
|
||||||
.map(site => site.id)
|
}
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading sites:', error);
|
console.error('Error loading sites:', error);
|
||||||
toast.error('Erreur lors du chargement des sites');
|
toast.error('Erreur lors du chargement des sites');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSite = (siteId) => {
|
|
||||||
setSelectedSites(prev =>
|
|
||||||
prev.includes(siteId)
|
|
||||||
? prev.filter(id => id !== siteId)
|
|
||||||
: [...prev, siteId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearch = async (e) => {
|
const handleSearch = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!query.trim() || selectedSites.length === 0) {
|
if (!query.trim() || !selectedSiteId) {
|
||||||
toast.error('Veuillez entrer une recherche et sélectionner au moins un site');
|
toast.error('Veuillez entrer une recherche et sélectionner un site');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,12 +56,12 @@ export default function Search() {
|
|||||||
|
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setProgress({ status: 'searching', total: 0, completed: 0 });
|
setProgress({ status: 'searching', total: 1, completed: 0 });
|
||||||
|
|
||||||
// Construire l'URL avec les paramètres
|
// Construire l'URL avec les paramètres
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
q: query.trim(),
|
q: query.trim(),
|
||||||
sites: selectedSites.join(','),
|
sites: selectedSiteId,
|
||||||
max_results: 20,
|
max_results: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,6 +116,8 @@ export default function Search() {
|
|||||||
? Math.round((progress.completed / Math.max(progress.total, 1)) * 100)
|
? Math.round((progress.completed / Math.max(progress.total, 1)) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
const selectedSite = sites.find(s => s.id.toString() === selectedSiteId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-in fade-in duration-500">
|
<div className="space-y-6 animate-in fade-in duration-500">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -133,21 +125,47 @@ export default function Search() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Recherche de produits</h2>
|
<h2 className="text-2xl font-bold tracking-tight">Recherche de produits</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Recherchez des produits sur plusieurs sites et ajoutez-les au monitoring
|
Recherchez des produits sur un site et ajoutez-les au monitoring
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link to="/settings">
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
|
||||||
Gérer les sites
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Form */}
|
{/* Search Form */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<form onSubmit={handleSearch} className="space-y-4">
|
<form onSubmit={handleSearch} className="space-y-4">
|
||||||
|
{/* Site Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">Site de recherche :</p>
|
||||||
|
{sites.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Aucun site configuré. Contactez l'administrateur pour activer des sites.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={selectedSiteId || ''}
|
||||||
|
onValueChange={setSelectedSiteId}
|
||||||
|
disabled={isSearching}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full md:w-[300px]">
|
||||||
|
<SelectValue placeholder="Sélectionner un site" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sites.map(site => (
|
||||||
|
<SelectItem key={site.id} value={site.id.toString()}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{site.name}</span>
|
||||||
|
{site.category && (
|
||||||
|
<span className="text-xs text-muted-foreground">({site.category})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
@@ -161,7 +179,7 @@ export default function Search() {
|
|||||||
disabled={isSearching}
|
disabled={isSearching}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" disabled={isSearching || !query.trim()}>
|
<Button type="submit" disabled={isSearching || !query.trim() || !selectedSiteId}>
|
||||||
{isSearching ? (
|
{isSearching ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
@@ -176,36 +194,12 @@ export default function Search() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Site Selection */}
|
{/* Selected site info */}
|
||||||
<div className="space-y-2">
|
{selectedSite && (
|
||||||
<p className="text-sm font-medium">Sites de recherche :</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
<div className="flex flex-wrap gap-3">
|
Recherche sur <span className="font-medium">{selectedSite.name}</span> ({selectedSite.domain})
|
||||||
{sites.filter(s => s.is_active).map(site => (
|
</p>
|
||||||
<label
|
)}
|
||||||
key={site.id}
|
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-colors ${
|
|
||||||
selectedSites.includes(site.id)
|
|
||||||
? 'bg-primary/10 border-primary'
|
|
||||||
: 'bg-background border-border hover:bg-muted'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedSites.includes(site.id)}
|
|
||||||
onCheckedChange={() => toggleSite(site.id)}
|
|
||||||
/>
|
|
||||||
<span className="text-sm">{site.name}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
{sites.filter(s => s.is_active).length === 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Aucun site configuré.{' '}
|
|
||||||
<Link to="/settings" className="text-primary underline">
|
|
||||||
Ajouter des sites
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -217,7 +211,7 @@ export default function Search() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span>{progress.message || 'Recherche en cours...'}</span>
|
<span>{progress.message || 'Recherche en cours...'}</span>
|
||||||
<span>{progress.completed}/{progress.total}</span>
|
<span>{progressPercent}%</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={progressPercent} />
|
<Progress value={progressPercent} />
|
||||||
</div>
|
</div>
|
||||||
@@ -252,7 +246,7 @@ export default function Search() {
|
|||||||
<SearchIcon className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
<SearchIcon className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||||
<h3 className="text-lg font-medium mb-2">Aucun produit trouvé</h3>
|
<h3 className="text-lg font-medium mb-2">Aucun produit trouvé</h3>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Essayez avec d'autres termes de recherche ou sélectionnez plus de sites
|
Essayez avec d'autres termes de recherche ou sélectionnez un autre site
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ dependencies = [
|
|||||||
"slowapi",
|
"slowapi",
|
||||||
"beautifulsoup4",
|
"beautifulsoup4",
|
||||||
"lxml",
|
"lxml",
|
||||||
|
"pyjwt",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
Reference in new issue
Block a user