diff --git a/app/main.py b/app/main.py index 40e95e0..824aada 100644 --- a/app/main.py +++ b/app/main.py @@ -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(): diff --git a/app/models.py b/app/models.py index 9d0bb1f..5fee7ac 100644 --- a/app/models.py +++ b/app/models.py @@ -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" diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..5eb08fd --- /dev/null +++ b/app/routers/auth.py @@ -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 " 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"} diff --git a/app/services/auth_service.py b/app/services/auth_service.py new file mode 100644 index 0000000..95df927 --- /dev/null +++ b/app/services/auth_service.py @@ -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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7838f78..3c0fc52 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ( +
+
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + if (adminOnly && !isAdmin) { + return ; + } + + return children; +} + +// Public route that redirects to home if already logged in +function PublicRoute({ children }) { + const { isAuthenticated, loading } = useAuth(); + + if (loading) { + return ( +
+
+
+ ); + } + + if (isAuthenticated) { + return ; + } + + return children; +} + +function AppRoutes() { const { theme, toggleTheme } = useTheme(); + const { isAuthenticated } = useAuth(); return ( - + <> - - - } /> - } /> - } /> - - + + + + + } + /> + + + + } /> + } /> + + + + } + /> + + + + } + /> + + + ); +} + +export default function App() { + return ( + + + + ); } diff --git a/frontend/src/components/layout/Layout.jsx b/frontend/src/components/layout/Layout.jsx index 88a1fad..955d15d 100644 --- a/frontend/src/components/layout/Layout.jsx +++ b/frontend/src/components/layout/Layout.jsx @@ -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 (
{/* Sidebar */} @@ -47,7 +59,13 @@ const Layout = ({ children, theme, toggleTheme }) => { ); })} -
+
+ {/* User info */} +
+ {user?.username} + {isAdmin && Admin} +
+ {/* Theme toggle */} + {/* Logout button */} +
@@ -103,7 +130,11 @@ const Layout = ({ children, theme, toggleTheme }) => { ); })} -
+
+
+ {user?.username} + {isAdmin && Admin} +
+
diff --git a/frontend/src/hooks/use-auth.js b/frontend/src/hooks/use-auth.js new file mode 100644 index 0000000..944aa5e --- /dev/null +++ b/frontend/src/hooks/use-auth.js @@ -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 ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx new file mode 100644 index 0000000..1918a8e --- /dev/null +++ b/frontend/src/pages/Admin.jsx @@ -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 ( +
+
+

Administration

+

Gérez les utilisateurs et la configuration de l'application.

+
+ + {/* Tabs */} +
+ {tabs.map(tab => { + const Icon = tab.icon; + return ( + + ); + })} +
+ + {/* Users Tab */} + {activeTab === 'users' && ( +
+ {/* Change My Password */} + + + Changer mon mot de passe + + +
+
+
+ + setPasswordChange({ ...passwordChange, currentPassword: e.target.value })} + /> +
+
+ + setPasswordChange({ ...passwordChange, newPassword: e.target.value })} + /> +
+
+ + setPasswordChange({ ...passwordChange, confirmPassword: e.target.value })} + /> +
+
+ +
+
+
+ + {/* Create User */} + + + Créer un utilisateur + + +
+
+
+ + setNewUser({ ...newUser, username: e.target.value })} + /> +
+
+ + setNewUser({ ...newUser, password: e.target.value })} + /> +
+
+ +
+ setNewUser({ ...newUser, is_admin: checked })} + /> + {newUser.is_admin ? 'Administrateur' : 'Utilisateur'} +
+
+
+ +
+
+
+ + {/* User List */} + + + Liste des utilisateurs + + +
+ {users.map(u => ( +
+
+
+ {u.is_admin ? : } +
+
+

{u.username}

+

+ {u.is_admin ? 'Administrateur' : 'Utilisateur'} • + {u.is_active ? ' Actif' : ' Inactif'} + {u.last_login && ` • Dernière connexion: ${new Date(u.last_login).toLocaleDateString()}`} +

+
+
+
+ {adminPasswordChange.userId === u.id ? ( +
+ setAdminPasswordChange({ ...adminPasswordChange, newPassword: e.target.value })} + className="w-40" + /> + + +
+ ) : ( + <> + + {u.id !== user?.id && ( + <> + + + + )} + + )} +
+
+ ))} +
+
+
+
+ )} + + {/* AI Configuration Tab */} + {activeTab === 'ai' && ( + + + Configuration IA + Configure le modèle IA utilisé pour l'analyse des pages produits. + + +
+
+ + +
+ {config.ai_provider !== 'openrouter' ? ( +
+ + updateSetting('ai_model', e.target.value)} /> +
+ ) : ( +
+ + +
+ )} +
+ +
+ + updateSetting('ai_api_key', e.target.value)} + placeholder={config.ai_provider === 'openrouter' ? 'sk-or-...' : 'Optionnel pour Ollama'} + /> +
+ + {config.ai_provider === 'openrouter' && ( +
+
+ + +
+
+ {openrouterCategories.map(cat => { + const Icon = categoryIcons[cat.id] || Filter; + return ( + + ); + })} +
+
+ + {openrouterLoading ? ( +
Chargement...
+ ) : ( + + )} +
+
+ )} + +
+ +
+ + {showAdvancedAI && ( +
+
+ + updateSetting('ai_api_base', e.target.value)} /> +
+
+
+
+
{config.ai_temperature}
+ updateSetting('ai_temperature', parseFloat(e.target.value))} /> +
+
+
{config.confidence_threshold_price}
+ updateSetting('confidence_threshold_price', parseFloat(e.target.value))} /> +
+
+
+
+ + updateSetting('ai_max_tokens', parseInt(e.target.value))} /> +
+
+ + updateSetting('enable_json_repair', checked)} /> +
+
+
+
+ )} +
+
+ )} + + {/* Scraper Tab */} + {activeTab === 'scraper' && ( + + + Configuration Scraper + + +
+

Scroll pour charger le contenu lazy.

+ updateSetting('smart_scroll_enabled', checked)} /> +
+ {config.smart_scroll_enabled && ( +
+ + updateSetting('smart_scroll_pixels', parseInt(e.target.value))} /> +
+ )} +
+

Envoyer le texte de la page à l'IA.

+ updateSetting('text_context_enabled', checked)} /> +
+ {config.text_context_enabled && ( +
+ + updateSetting('text_context_length', parseInt(e.target.value))} /> +
+ )} +
+ + updateSetting('scraper_timeout', parseInt(e.target.value))} /> +
+
+
+ )} + + {/* Sites Tab */} + {activeTab === 'sites' && ( + + +
+
+ Sites de recherche + Sites e-commerce français pour la recherche. +
+ +
+
+ +
+ {searchSites.filter(s => s.is_active).length} site(s) actif(s) sur {searchSites.length} +
+
+ {searchSites.map(site => ( +
+
+
+

{site.name}

+

{site.domain}

+
+ toggleSiteActive(site)} className="ml-2" /> +
+ {site.category && ( + + {site.category} + + )} +
+ ))} +
+
+
+ )} + + {/* Jobs Tab */} + {activeTab === 'jobs' && ( + + + Job de rafraîchissement + + +
+ + setJobConfig({ ...jobConfig, refresh_interval_minutes: e.target.value })} /> +
+
+

Prochain lancement: {jobConfig.next_run ? new Date(jobConfig.next_run).toLocaleString() : 'Non planifié'}

+

Statut: {jobConfig.running ? 'En cours' : 'Inactif'}

+
+ +
+
+ )} + + {/* Notifications Tab */} + {activeTab === 'notifications' && ( + + + Profils de notification + + +
+
+

{editingProfileId ? 'Modifier le profil' : 'Nouveau profil'}

+ {editingProfileId && } +
+
+
+ + setNewProfile({ ...newProfile, name: e.target.value })} /> +
+
+ + setNewProfile({ ...newProfile, apprise_url: e.target.value })} /> +
+
+
+
+ + setNewProfile({ ...newProfile, check_interval_minutes: parseInt(e.target.value) })} /> +
+
+ + setNewProfile({ ...newProfile, price_drop_threshold_percent: parseFloat(e.target.value) })} /> +
+
+
+
+ setNewProfile({ ...newProfile, notify_on_price_drop: checked })} /> + +
+
+ setNewProfile({ ...newProfile, notify_on_target_price: checked })} /> + +
+
+ setNewProfile({ ...newProfile, notify_on_stock_change: checked })} /> + +
+
+ +
+ +
+

Profils existants

+ {profiles.length === 0 ? ( +

Aucun profil créé.

+ ) : ( +
+ {profiles.map(profile => ( +
+
+
+

{profile.name}

+

{profile.apprise_url}

+
+
+ + +
+
+
+
{profile.check_interval_minutes}m
+
{profile.price_drop_threshold_percent}%
+
+
+ {profile.notify_on_price_drop && Baisse} + {profile.notify_on_target_price && Cible} + {profile.notify_on_stock_change && Stock} +
+
+ ))} +
+ )} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..17cb237 --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -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 ( +
+ + +
+ PriceFlow Logo +
+ + PriceFlow + + + Connectez-vous pour accéder à l'application + +
+ +
+
+ + setUsername(e.target.value)} + disabled={isLoading} + autoComplete="username" + /> +
+
+ + setPassword(e.target.value)} + disabled={isLoading} + autoComplete="current-password" + /> +
+ +
+

+ Identifiants par défaut: admin / admin +

+
+
+
+ ); +} diff --git a/frontend/src/pages/Search.jsx b/frontend/src/pages/Search.jsx index bc02475..6bcca66 100644 --- a/frontend/src/pages/Search.jsx +++ b/frontend/src/pages/Search.jsx @@ -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 (
{/* Header */} @@ -133,21 +125,47 @@ export default function Search() {

Recherche de produits

- Recherchez des produits sur plusieurs sites et ajoutez-les au monitoring + Recherchez des produits sur un site et ajoutez-les au monitoring

- - -
{/* Search Form */}
+ {/* Site Selection */} +
+

Site de recherche :

+ {sites.length === 0 ? ( +

+ Aucun site configuré. Contactez l'administrateur pour activer des sites. +

+ ) : ( + + )} +
+ {/* Search Input */}
@@ -161,7 +179,7 @@ export default function Search() { disabled={isSearching} />
-
- {/* Site Selection */} -
-

Sites de recherche :

-
- {sites.filter(s => s.is_active).map(site => ( - - ))} - {sites.filter(s => s.is_active).length === 0 && ( -

- Aucun site configuré.{' '} - - Ajouter des sites - -

- )} -
-
+ {/* Selected site info */} + {selectedSite && ( +

+ Recherche sur {selectedSite.name} ({selectedSite.domain}) +

+ )}
@@ -217,7 +211,7 @@ export default function Search() {
{progress.message || 'Recherche en cours...'} - {progress.completed}/{progress.total} + {progressPercent}%
@@ -252,7 +246,7 @@ export default function Search() {

Aucun produit trouvé

- 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

diff --git a/pyproject.toml b/pyproject.toml index 8173dbb..1c9156d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "slowapi", "beautifulsoup4", "lxml", + "pyjwt", ] [build-system]