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:
Claude committed 2025-11-23 09:15:56 +00:00
1 parent 5e5f49a5f6
commit 1dd6d4dae1
11 files changed
+1758 -84

No files matched your search

+36 -5
View File
@@ -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():
+13
View File
@@ -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"
+306
View File
@@ -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"}
+150
View File
@@ -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
View File
@@ -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>
);
}
+44 -5
View File
@@ -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>
+101
View File
@@ -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;
}
+859
View File
@@ -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>
);
}
+101
View File
@@ -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>
);
}
+57 -63
View File
@@ -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>
+1
View File
@@ -25,6 +25,7 @@ dependencies = [
"slowapi",
"beautifulsoup4",
"lxml",
"pyjwt",
]
[build-system]