diff --git a/vps-monitor/.pids b/vps-monitor/.pids index 698ed21..4e28a64 100644 --- a/vps-monitor/.pids +++ b/vps-monitor/.pids @@ -1,2 +1,2 @@ -7942 -8002 +8423 +8473 diff --git a/vps-monitor/backend/__pycache__/main.cpython-313.pyc b/vps-monitor/backend/__pycache__/main.cpython-313.pyc index a1830c6..3273101 100644 Binary files a/vps-monitor/backend/__pycache__/main.cpython-313.pyc and b/vps-monitor/backend/__pycache__/main.cpython-313.pyc differ diff --git a/vps-monitor/backend/main.py b/vps-monitor/backend/main.py index 673f09d..486f91c 100644 --- a/vps-monitor/backend/main.py +++ b/vps-monitor/backend/main.py @@ -18,7 +18,7 @@ from typing import Annotated import bcrypt as _bcrypt import aiohttp -from fastapi import Depends, FastAPI, HTTPException +from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from jose import JWTError, jwt @@ -93,6 +93,15 @@ class LoginRequest(BaseModel): password: str +class ChangePasswordRequest(BaseModel): + old_password: str + new_password: str + + +class SettingUpdateRequest(BaseModel): + value: str + + # ─── SQLite ─────────────────────────────────────────────────────────────────── @contextmanager @@ -154,6 +163,29 @@ def init_db() -> None: CREATE INDEX IF NOT EXISTS idx_vps_stats ON vps_stats(vps_id, ts DESC) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS login_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + username TEXT NOT NULL, + ip TEXT NOT NULL, + success INTEGER NOT NULL, + reason TEXT NOT NULL DEFAULT '' + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_login_logs_ts + ON login_logs(ts DESC) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """) + conn.execute(""" + INSERT OR IGNORE INTO settings (key, value) VALUES ('registration_open', 'false') + """) init_db() @@ -210,6 +242,14 @@ def update_vps(vps_id: str, data: dict) -> bool: return cur.rowcount > 0 +# ─── Settings helpers ──────────────────────────────────────────────────────── + +def _get_setting(key: str) -> str: + with get_db() as conn: + row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() + return row["value"] if row else "" + + # ─── Auth helpers ───────────────────────────────────────────────────────────── def create_token(username: str, role: str) -> str: @@ -237,6 +277,27 @@ def get_current_user( return user +def require_admin(current_user: Annotated[dict, Depends(get_current_user)]) -> dict: + if current_user.get("role") != "admin": + raise HTTPException(status_code=403, detail="Accès réservé aux administrateurs") + return current_user + + +def _get_client_ip(request: Request) -> str: + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +def _log_login(username: str, ip: str, success: bool, reason: str) -> None: + with get_db() as conn: + conn.execute( + "INSERT INTO login_logs (ts, username, ip, success, reason) VALUES (?, ?, ?, ?, ?)", + (int(time.time()), username, ip, 1 if success else 0, reason), + ) + + # ─── App ────────────────────────────────────────────────────────────────────── app = FastAPI(title="VPS Monitor Backend", version="1.0.0") @@ -400,18 +461,24 @@ def auth_status(): @app.post("/api/auth/register", status_code=201) def register(body: RegisterRequest): - """Enregistre le premier utilisateur (admin). Fermé ensuite.""" - if len(load_users()) > 0: - raise HTTPException( - status_code=403, - detail="L'enregistrement public est désactivé. Seul l'admin peut créer des comptes." - ) + """Enregistre un nouvel utilisateur. Ouvert uniquement si aucun utilisateur n'existe + ou si l'admin a activé les inscriptions.""" + users = load_users() + if len(users) > 0: + if _get_setting("registration_open") != "true": + raise HTTPException( + status_code=403, + detail="L'enregistrement public est désactivé. Seul l'admin peut créer des comptes." + ) if not body.username.strip() or len(body.password) < 6: raise HTTPException(status_code=422, detail="Mot de passe trop court (6 caractères min.)") + if any(u["username"] == body.username.strip() for u in users): + raise HTTPException(status_code=409, detail="Ce nom d'utilisateur est déjà pris") + role = "admin" if len(users) == 0 else "user" user = { "username": body.username.strip(), "password": _bcrypt.hashpw(body.password.encode(), _bcrypt.gensalt()).decode(), - "role": "admin", + "role": role, } add_user(user) token = create_token(user["username"], user["role"]) @@ -419,12 +486,15 @@ def register(body: RegisterRequest): @app.post("/api/auth/login") -def login(body: LoginRequest): +def login(body: LoginRequest, request: Request): """Authentifie un utilisateur et retourne un JWT.""" + ip = _get_client_ip(request) users = load_users() user = next((u for u in users if u["username"] == body.username), None) if not user or not _bcrypt.checkpw(body.password.encode(), user["password"].encode()): + _log_login(body.username, ip, False, "Identifiants incorrects") raise HTTPException(status_code=401, detail="Identifiants incorrects") + _log_login(user["username"], ip, True, "") token = create_token(user["username"], user["role"]) return {"access_token": token, "token_type": "bearer", "role": user["role"]} @@ -434,6 +504,93 @@ def me(current_user: Annotated[dict, Depends(get_current_user)]): return {"username": current_user["username"], "role": current_user["role"]} +@app.post("/api/auth/change-password") +def change_password( + body: ChangePasswordRequest, + current_user: Annotated[dict, Depends(get_current_user)], +): + """Permet à l'utilisateur connecté de changer son mot de passe.""" + if not _bcrypt.checkpw(body.old_password.encode(), current_user["password"].encode()): + raise HTTPException(status_code=400, detail="Ancien mot de passe incorrect") + if len(body.new_password) < 6: + raise HTTPException(status_code=422, detail="Nouveau mot de passe trop court (6 caractères min.)") + new_hash = _bcrypt.hashpw(body.new_password.encode(), _bcrypt.gensalt()).decode() + with get_db() as conn: + conn.execute( + "UPDATE users SET password = ? WHERE username = ?", + (new_hash, current_user["username"]), + ) + return {"status": "ok"} + + +# ─── Routes Admin ───────────────────────────────────────────────────────────── + +@app.get("/api/admin/settings") +def admin_get_settings(_: Annotated[dict, Depends(require_admin)]): + """Retourne les paramètres d'administration.""" + with get_db() as conn: + rows = conn.execute("SELECT key, value FROM settings").fetchall() + return {row["key"]: row["value"] for row in rows} + + +@app.put("/api/admin/settings/{key}") +def admin_update_setting( + key: str, + body: SettingUpdateRequest, + _: Annotated[dict, Depends(require_admin)], +): + """Met à jour un paramètre d'administration.""" + allowed_keys = {"registration_open"} + if key not in allowed_keys: + raise HTTPException(status_code=400, detail="Clé de paramètre inconnue") + with get_db() as conn: + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + (key, body.value), + ) + return {"status": "ok"} + + +@app.get("/api/admin/login-logs") +def admin_login_logs( + limit: int = 100, + offset: int = 0, + _: Annotated[dict, Depends(require_admin)] = None, +): + """Retourne les tentatives de connexion enregistrées.""" + limit = max(1, min(limit, 500)) + offset = max(0, offset) + with get_db() as conn: + rows = conn.execute( + "SELECT * FROM login_logs ORDER BY ts DESC LIMIT ? OFFSET ?", + (limit, offset), + ).fetchall() + total = conn.execute("SELECT COUNT(*) FROM login_logs").fetchone()[0] + return { + "total": total, + "logs": [ + { + "id": row["id"], + "ts": datetime.fromtimestamp(row["ts"], tz=timezone.utc).isoformat(), + "username": row["username"], + "ip": row["ip"], + "success": bool(row["success"]), + "reason": row["reason"], + } + for row in rows + ], + } + + +@app.get("/api/admin/users") +def admin_list_users(_: Annotated[dict, Depends(require_admin)]): + """Liste les utilisateurs (sans mots de passe).""" + return [ + {"username": u["username"], "role": u["role"]} + for u in load_users() + ] + + # ─── Routes VPS ─────────────────────────────────────────────────────────────── @app.get("/api/vps") diff --git a/vps-monitor/frontend/src/App.jsx b/vps-monitor/frontend/src/App.jsx index e22ee06..9b49d8a 100644 --- a/vps-monitor/frontend/src/App.jsx +++ b/vps-monitor/frontend/src/App.jsx @@ -7,6 +7,8 @@ import AddVpsModal from './components/AddVpsModal' import EditVpsModal from './components/EditVpsModal' import StatsModal from './components/StatsModal' import LoginPage from './components/LoginPage' +import ProfilePage from './components/ProfilePage' +import AdminPage from './components/AdminPage' const INTERVAL_OPTIONS = [ { label: '10 s', value: 10_000 }, @@ -20,6 +22,8 @@ const INTERVAL_OPTIONS = [ export default function App() { const [token, setTokenState] = useState(() => getToken()) const [username, setUsername] = useState(null) + const [role, setRole] = useState(null) + const [page, setPage] = useState('main') // 'main' | 'profile' | 'admin' const [isFirstUser, setIsFirstUser] = useState(false) const [authChecked, setAuthChecked] = useState(false) @@ -63,6 +67,8 @@ export default function App() { setToken(null) setTokenState(null) setUsername(null) + setRole(null) + setPage('main') } window.addEventListener('auth:expired', onExpired) return () => window.removeEventListener('auth:expired', onExpired) @@ -71,12 +77,13 @@ export default function App() { const handleAuthenticated = (accessToken, role, user) => { setToken(accessToken) setTokenState(accessToken) - // Récupère le username depuis le payload JWT (base64) try { const payload = JSON.parse(atob(accessToken.split('.')[1])) setUsername(payload.sub) + setRole(payload.role ?? role ?? 'user') } catch { setUsername(user ?? 'user') + setRole(role ?? 'user') } } @@ -84,6 +91,8 @@ export default function App() { setToken(null) setTokenState(null) setUsername(null) + setRole(null) + setPage('main') setVpsList([]) setLoading(true) } @@ -111,12 +120,13 @@ export default function App() { return () => clearInterval(id) }, [refresh, token, refreshInterval]) - // Extrait le username du token stocké au rechargement de page + // Extrait le username et le rôle du token stocké au rechargement de page useEffect(() => { if (token && !username) { try { const payload = JSON.parse(atob(token.split('.')[1])) setUsername(payload.sub) + setRole(payload.role ?? 'user') } catch { /* ignore */ } } }, [token, username]) @@ -191,6 +201,15 @@ export default function App() { const totalContainers = vpsList.reduce((acc, v) => acc + v.containers.length, 0) const totalRunning = vpsList.reduce((acc, v) => acc + v.containers.filter(c => c.status === 'running').length, 0) + // ─── Pages profil / admin ─────────────────────────────────────────────── + if (page === 'profile') { + return setPage('main')} /> + } + + if (page === 'admin') { + return setPage('main')} /> + } + return (
setShowAddVps(true)} refreshing={refreshing} username={username} + role={role} onLogout={handleLogout} + onProfile={() => setPage('profile')} + onAdmin={() => setPage('admin')} refreshInterval={refreshInterval} onIntervalChange={handleIntervalChange} intervalOptions={INTERVAL_OPTIONS} diff --git a/vps-monitor/frontend/src/api/client.js b/vps-monitor/frontend/src/api/client.js index 6916bce..0dacf45 100644 --- a/vps-monitor/frontend/src/api/client.js +++ b/vps-monitor/frontend/src/api/client.js @@ -113,3 +113,42 @@ export async function fetchVpsStats(vpsId, duration = 600) { const res = await fetch(`${BASE}/vps/${vpsId}/stats?duration=${duration}`, { headers: authHeaders() }) return handleResponse(res) } + +// ─── Profile ────────────────────────────────────────────────────────────────── + +export async function changePassword(oldPassword, newPassword) { + const res = await fetch(`${BASE}/auth/change-password`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }), + }) + return handleResponse(res) +} + +// ─── Admin ──────────────────────────────────────────────────────────────────── + +export async function getAdminSettings() { + const res = await fetch(`${BASE}/admin/settings`, { headers: authHeaders() }) + return handleResponse(res) +} + +export async function setAdminSetting(key, value) { + const res = await fetch(`${BASE}/admin/settings/${key}`, { + method: 'PUT', + headers: authHeaders(), + body: JSON.stringify({ value }), + }) + return handleResponse(res) +} + +export async function getLoginLogs(limit = 100, offset = 0) { + const res = await fetch(`${BASE}/admin/login-logs?limit=${limit}&offset=${offset}`, { + headers: authHeaders(), + }) + return handleResponse(res) +} + +export async function getAdminUsers() { + const res = await fetch(`${BASE}/admin/users`, { headers: authHeaders() }) + return handleResponse(res) +} diff --git a/vps-monitor/frontend/src/components/AdminPage.jsx b/vps-monitor/frontend/src/components/AdminPage.jsx new file mode 100644 index 0000000..c2c0cc0 --- /dev/null +++ b/vps-monitor/frontend/src/components/AdminPage.jsx @@ -0,0 +1,259 @@ +import { useState, useEffect, useCallback } from 'react' +import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X } from 'lucide-react' +import { getAdminSettings, setAdminSetting, getLoginLogs } from '../api/client' + +const PAGE_SIZE = 50 + +function ToggleRow({ label, description, enabled, onChange, loading }) { + return ( +
+
+

{label}

+

{description}

+
+ +
+ ) +} + +export default function AdminPage({ onBack }) { + // ─── Settings ──────────────────────────────────────────────────────────── + const [settings, setSettings] = useState(null) + const [settingsLoading, setSettingsLoading] = useState(true) + const [settingsError, setSettingsError] = useState(null) + const [toggleLoading, setToggleLoading] = useState(false) + + const loadSettings = useCallback(async () => { + setSettingsLoading(true) + setSettingsError(null) + try { + const data = await getAdminSettings() + setSettings(data) + } catch (err) { + setSettingsError(err.message) + } finally { + setSettingsLoading(false) + } + }, []) + + useEffect(() => { loadSettings() }, [loadSettings]) + + const toggleRegistration = async () => { + if (!settings) return + const newValue = settings.registration_open === 'true' ? 'false' : 'true' + setToggleLoading(true) + try { + await setAdminSetting('registration_open', newValue) + setSettings(prev => ({ ...prev, registration_open: newValue })) + } catch (err) { + setSettingsError(err.message) + } finally { + setToggleLoading(false) + } + } + + // ─── Login logs ────────────────────────────────────────────────────────── + const [logs, setLogs] = useState([]) + const [logsTotal, setLogsTotal] = useState(0) + const [logsPage, setLogsPage] = useState(0) + const [logsLoading, setLogsLoading] = useState(true) + const [logsError, setLogsError] = useState(null) + const [filterUser, setFilterUser] = useState('') + const [filterSuccess, setFilterSuccess] = useState('all') // 'all' | 'true' | 'false' + + const loadLogs = useCallback(async (page = 0) => { + setLogsLoading(true) + setLogsError(null) + try { + const data = await getLoginLogs(PAGE_SIZE, page * PAGE_SIZE) + setLogs(data.logs) + setLogsTotal(data.total) + setLogsPage(page) + } catch (err) { + setLogsError(err.message) + } finally { + setLogsLoading(false) + } + }, []) + + useEffect(() => { loadLogs(0) }, [loadLogs]) + + const filteredLogs = logs.filter(log => { + const matchUser = filterUser === '' || log.username.toLowerCase().includes(filterUser.toLowerCase()) + const matchSuccess = filterSuccess === 'all' || String(log.success) === filterSuccess + return matchUser && matchSuccess + }) + + const totalPages = Math.ceil(logsTotal / PAGE_SIZE) + + return ( +
+
+ + +
+
+ +
+

Administration

+
+ + {/* ── Section Paramètres ── */} +
+

Paramètres

+

Configuration globale de l'application.

+ + {settingsError && ( +
+ {settingsError} +
+ )} + + {settingsLoading + ?

Chargement…

+ : ( +
+ +
+ ) + } +
+ + {/* ── Section Logs de connexion ── */} +
+
+
+

Tentatives de connexion

+

{logsTotal} entrée{logsTotal !== 1 ? 's' : ''} au total

+
+
+ {/* Filtre utilisateur */} + setFilterUser(e.target.value)} + className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-indigo-500 transition-colors w-44" + /> + {/* Filtre succès */} + + +
+
+ + {logsError && ( +
+ {logsError} +
+ )} + + {logsLoading && logs.length === 0 + ?

Chargement…

+ : filteredLogs.length === 0 + ?

Aucune entrée.

+ : ( +
+ + + + + + + + + + + + {filteredLogs.map(log => ( + + + + + + + + ))} + +
Date / HeureUtilisateurAdresse IPRésultatDétail
+ {new Date(log.ts).toLocaleString('fr-FR')} + {log.username}{log.ip} + {log.success + ? ( + + Succès + + ) : ( + + Échec + + ) + } + {log.reason || '—'}
+
+ ) + } + + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {logsPage + 1} / {totalPages} + + +
+ )} +
+
+
+ ) +} diff --git a/vps-monitor/frontend/src/components/Header.jsx b/vps-monitor/frontend/src/components/Header.jsx index ce6114e..d1fa9bc 100644 --- a/vps-monitor/frontend/src/components/Header.jsx +++ b/vps-monitor/frontend/src/components/Header.jsx @@ -1,6 +1,6 @@ -import { Monitor, LogOut, Timer } from 'lucide-react' +import { Monitor, LogOut, Timer, User, ShieldCheck } from 'lucide-react' -export default function Header({ lastUpdate, onRefresh, onAddVps, refreshing, username, onLogout, refreshInterval, onIntervalChange, intervalOptions }) { +export default function Header({ lastUpdate, onRefresh, onAddVps, refreshing, username, role, onLogout, onProfile, onAdmin, refreshInterval, onIntervalChange, intervalOptions }) { return (
@@ -58,14 +58,33 @@ export default function Header({ lastUpdate, onRefresh, onAddVps, refreshing, us {username && ( - + <> + {role === 'admin' && ( + + )} + + + )}
diff --git a/vps-monitor/frontend/src/components/ProfilePage.jsx b/vps-monitor/frontend/src/components/ProfilePage.jsx new file mode 100644 index 0000000..0c2d745 --- /dev/null +++ b/vps-monitor/frontend/src/components/ProfilePage.jsx @@ -0,0 +1,127 @@ +import { useState } from 'react' +import { KeyRound, ArrowLeft, Check } from 'lucide-react' +import { changePassword } from '../api/client' + +export default function ProfilePage({ username, onBack }) { + const [oldPassword, setOldPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [newPassword2, setNewPassword2] = useState('') + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e) => { + e.preventDefault() + setError(null) + setSuccess(false) + + if (newPassword !== newPassword2) { + setError('Les nouveaux mots de passe ne correspondent pas.') + return + } + if (newPassword.length < 6) { + setError('Le nouveau mot de passe doit contenir au moins 6 caractères.') + return + } + + setLoading(true) + try { + await changePassword(oldPassword, newPassword) + setSuccess(true) + setOldPassword('') + setNewPassword('') + setNewPassword2('') + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + return ( +
+
+ + +
+
+ +
+
+

Mon profil

+

{username}

+
+
+ +
+

Changer le mot de passe

+ + {success && ( +
+ + Mot de passe mis à jour avec succès. +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+
+ + setOldPassword(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors" + /> +
+ +
+ + setNewPassword(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors" + /> +
+ +
+ + setNewPassword2(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors" + /> +
+ + +
+
+
+
+ ) +}