mirror of
https://github.com/R0m1k3/Priceflow.git
synced 2026-10-11 17:29:14 +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.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 import search_service
|
||||
from app.services import auth_service, search_service
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@@ -28,6 +28,27 @@ logger = logging.getLogger(__name__)
|
||||
def run_migrations():
|
||||
"""Run database migrations for missing tables and columns"""
|
||||
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
|
||||
result = conn.execute(text(
|
||||
"SELECT 1 FROM information_schema.tables WHERE table_name = 'search_sites'"
|
||||
@@ -81,20 +102,27 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as e:
|
||||
logger.error(f"Migration error: {e}")
|
||||
|
||||
# Seed default sites
|
||||
logger.info("Seeding default search sites...")
|
||||
# Seed default sites and admin user
|
||||
logger.info("Seeding default data...")
|
||||
try:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Seed search sites
|
||||
created = search_service.seed_default_sites(db)
|
||||
if created > 0:
|
||||
logger.info(f"{created} site(s) de recherche créé(s)")
|
||||
else:
|
||||
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:
|
||||
db.close()
|
||||
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)")
|
||||
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_sites.router)
|
||||
|
||||
# Auth router (already has /api prefix)
|
||||
app.include_router(auth.router)
|
||||
|
||||
|
||||
@app.get("/api/")
|
||||
def read_root():
|
||||
|
||||
@@ -6,6 +6,19 @@ from sqlalchemy.orm import relationship
|
||||
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):
|
||||
__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 { 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 Layout from '@/components/layout/Layout'
|
||||
import Dashboard from '@/pages/Dashboard'
|
||||
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 { 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 { isAuthenticated } = useAuth();
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<>
|
||||
<Toaster position="bottom-right" theme={theme} />
|
||||
<Layout theme={theme} toggleTheme={toggleTheme}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/search" element={<Search />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<Login />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,34 @@
|
||||
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 { 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 { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
|
||||
const Layout = ({ children, theme, toggleTheme }) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, logout, isAdmin } = useAuth();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ icon: LayoutDashboard, label: t('nav.dashboard'), path: '/' },
|
||||
{ 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 (
|
||||
<div className="min-h-screen bg-background font-sans antialiased">
|
||||
{/* Sidebar */}
|
||||
@@ -47,7 +59,13 @@ const Layout = ({ children, theme, toggleTheme }) => {
|
||||
);
|
||||
})}
|
||||
</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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -66,6 +84,15 @@ const Layout = ({ children, theme, toggleTheme }) => {
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
</aside>
|
||||
@@ -103,7 +130,11 @@ const Layout = ({ children, theme, toggleTheme }) => {
|
||||
);
|
||||
})}
|
||||
</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
|
||||
variant="ghost"
|
||||
onClick={toggleTheme}
|
||||
@@ -121,6 +152,14 @@ const Layout = ({ children, theme, toggleTheme }) => {
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
</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 { toast } from 'sonner';
|
||||
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 { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
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 { Link } from 'react-router-dom';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
@@ -17,7 +16,7 @@ export default function Search() {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState('');
|
||||
const [sites, setSites] = useState([]);
|
||||
const [selectedSites, setSelectedSites] = useState([]);
|
||||
const [selectedSiteId, setSelectedSiteId] = useState(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [progress, setProgress] = useState(null);
|
||||
const [results, setResults] = useState([]);
|
||||
@@ -31,31 +30,22 @@ export default function Search() {
|
||||
const loadSites = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/search-sites`);
|
||||
setSites(response.data);
|
||||
// Sélectionner tous les sites actifs par défaut
|
||||
setSelectedSites(
|
||||
response.data
|
||||
.filter(site => site.is_active)
|
||||
.map(site => site.id)
|
||||
);
|
||||
const activeSites = response.data.filter(site => site.is_active);
|
||||
setSites(activeSites);
|
||||
// Sélectionner le premier site actif par défaut
|
||||
if (activeSites.length > 0 && !selectedSiteId) {
|
||||
setSelectedSiteId(activeSites[0].id.toString());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading sites:', error);
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (!query.trim() || selectedSites.length === 0) {
|
||||
toast.error('Veuillez entrer une recherche et sélectionner au moins un site');
|
||||
if (!query.trim() || !selectedSiteId) {
|
||||
toast.error('Veuillez entrer une recherche et sélectionner un site');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -66,12 +56,12 @@ export default function Search() {
|
||||
|
||||
setIsSearching(true);
|
||||
setResults([]);
|
||||
setProgress({ status: 'searching', total: 0, completed: 0 });
|
||||
setProgress({ status: 'searching', total: 1, completed: 0 });
|
||||
|
||||
// Construire l'URL avec les paramètres
|
||||
const params = new URLSearchParams({
|
||||
q: query.trim(),
|
||||
sites: selectedSites.join(','),
|
||||
sites: selectedSiteId,
|
||||
max_results: 20,
|
||||
});
|
||||
|
||||
@@ -126,6 +116,8 @@ export default function Search() {
|
||||
? Math.round((progress.completed / Math.max(progress.total, 1)) * 100)
|
||||
: 0;
|
||||
|
||||
const selectedSite = sites.find(s => s.id.toString() === selectedSiteId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in duration-500">
|
||||
{/* Header */}
|
||||
@@ -133,21 +125,47 @@ export default function Search() {
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Recherche de produits</h2>
|
||||
<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>
|
||||
</div>
|
||||
<Link to="/settings">
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Gérer les sites
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Search Form */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<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 */}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
@@ -161,7 +179,7 @@ export default function Search() {
|
||||
disabled={isSearching}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={isSearching || !query.trim()}>
|
||||
<Button type="submit" disabled={isSearching || !query.trim() || !selectedSiteId}>
|
||||
{isSearching ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
@@ -176,36 +194,12 @@ export default function Search() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Site Selection */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Sites de recherche :</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{sites.filter(s => s.is_active).map(site => (
|
||||
<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>
|
||||
{/* Selected site info */}
|
||||
{selectedSite && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Recherche sur <span className="font-medium">{selectedSite.name}</span> ({selectedSite.domain})
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -217,7 +211,7 @@ export default function Search() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{progress.message || 'Recherche en cours...'}</span>
|
||||
<span>{progress.completed}/{progress.total}</span>
|
||||
<span>{progressPercent}%</span>
|
||||
</div>
|
||||
<Progress value={progressPercent} />
|
||||
</div>
|
||||
@@ -252,7 +246,7 @@ export default function Search() {
|
||||
<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>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -25,6 +25,7 @@ dependencies = [
|
||||
"slowapi",
|
||||
"beautifulsoup4",
|
||||
"lxml",
|
||||
"pyjwt",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
Reference in new issue
Block a user