feat: add user profile and admin management features
Some checks failed
Build and Push Docker Images / docker (push) Failing after 9s

This commit is contained in:
jeanotx32
2026-05-19 01:25:21 -04:00
parent 43dd3c614d
commit daf68d98fa
8 changed files with 646 additions and 23 deletions

View File

@@ -1,2 +1,2 @@
7942 8423
8002 8473

View File

@@ -18,7 +18,7 @@ from typing import Annotated
import bcrypt as _bcrypt import bcrypt as _bcrypt
import aiohttp import aiohttp
from fastapi import Depends, FastAPI, HTTPException from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt from jose import JWTError, jwt
@@ -93,6 +93,15 @@ class LoginRequest(BaseModel):
password: str password: str
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
class SettingUpdateRequest(BaseModel):
value: str
# ─── SQLite ─────────────────────────────────────────────────────────────────── # ─── SQLite ───────────────────────────────────────────────────────────────────
@contextmanager @contextmanager
@@ -154,6 +163,29 @@ def init_db() -> None:
CREATE INDEX IF NOT EXISTS idx_vps_stats CREATE INDEX IF NOT EXISTS idx_vps_stats
ON vps_stats(vps_id, ts DESC) 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() init_db()
@@ -210,6 +242,14 @@ def update_vps(vps_id: str, data: dict) -> bool:
return cur.rowcount > 0 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 ───────────────────────────────────────────────────────────── # ─── Auth helpers ─────────────────────────────────────────────────────────────
def create_token(username: str, role: str) -> str: def create_token(username: str, role: str) -> str:
@@ -237,6 +277,27 @@ def get_current_user(
return 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 ──────────────────────────────────────────────────────────────────────
app = FastAPI(title="VPS Monitor Backend", version="1.0.0") 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) @app.post("/api/auth/register", status_code=201)
def register(body: RegisterRequest): def register(body: RegisterRequest):
"""Enregistre le premier utilisateur (admin). Fermé ensuite.""" """Enregistre un nouvel utilisateur. Ouvert uniquement si aucun utilisateur n'existe
if len(load_users()) > 0: ou si l'admin a activé les inscriptions."""
raise HTTPException( users = load_users()
status_code=403, if len(users) > 0:
detail="L'enregistrement public est désactivé. Seul l'admin peut créer des comptes." 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: 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.)") 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 = { user = {
"username": body.username.strip(), "username": body.username.strip(),
"password": _bcrypt.hashpw(body.password.encode(), _bcrypt.gensalt()).decode(), "password": _bcrypt.hashpw(body.password.encode(), _bcrypt.gensalt()).decode(),
"role": "admin", "role": role,
} }
add_user(user) add_user(user)
token = create_token(user["username"], user["role"]) token = create_token(user["username"], user["role"])
@@ -419,12 +486,15 @@ def register(body: RegisterRequest):
@app.post("/api/auth/login") @app.post("/api/auth/login")
def login(body: LoginRequest): def login(body: LoginRequest, request: Request):
"""Authentifie un utilisateur et retourne un JWT.""" """Authentifie un utilisateur et retourne un JWT."""
ip = _get_client_ip(request)
users = load_users() users = load_users()
user = next((u for u in users if u["username"] == body.username), None) 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()): 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") raise HTTPException(status_code=401, detail="Identifiants incorrects")
_log_login(user["username"], ip, True, "")
token = create_token(user["username"], user["role"]) token = create_token(user["username"], user["role"])
return {"access_token": token, "token_type": "bearer", "role": 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"]} 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 ─────────────────────────────────────────────────────────────── # ─── Routes VPS ───────────────────────────────────────────────────────────────
@app.get("/api/vps") @app.get("/api/vps")

View File

@@ -7,6 +7,8 @@ import AddVpsModal from './components/AddVpsModal'
import EditVpsModal from './components/EditVpsModal' import EditVpsModal from './components/EditVpsModal'
import StatsModal from './components/StatsModal' import StatsModal from './components/StatsModal'
import LoginPage from './components/LoginPage' import LoginPage from './components/LoginPage'
import ProfilePage from './components/ProfilePage'
import AdminPage from './components/AdminPage'
const INTERVAL_OPTIONS = [ const INTERVAL_OPTIONS = [
{ label: '10 s', value: 10_000 }, { label: '10 s', value: 10_000 },
@@ -20,6 +22,8 @@ const INTERVAL_OPTIONS = [
export default function App() { export default function App() {
const [token, setTokenState] = useState(() => getToken()) const [token, setTokenState] = useState(() => getToken())
const [username, setUsername] = useState(null) const [username, setUsername] = useState(null)
const [role, setRole] = useState(null)
const [page, setPage] = useState('main') // 'main' | 'profile' | 'admin'
const [isFirstUser, setIsFirstUser] = useState(false) const [isFirstUser, setIsFirstUser] = useState(false)
const [authChecked, setAuthChecked] = useState(false) const [authChecked, setAuthChecked] = useState(false)
@@ -63,6 +67,8 @@ export default function App() {
setToken(null) setToken(null)
setTokenState(null) setTokenState(null)
setUsername(null) setUsername(null)
setRole(null)
setPage('main')
} }
window.addEventListener('auth:expired', onExpired) window.addEventListener('auth:expired', onExpired)
return () => window.removeEventListener('auth:expired', onExpired) return () => window.removeEventListener('auth:expired', onExpired)
@@ -71,12 +77,13 @@ export default function App() {
const handleAuthenticated = (accessToken, role, user) => { const handleAuthenticated = (accessToken, role, user) => {
setToken(accessToken) setToken(accessToken)
setTokenState(accessToken) setTokenState(accessToken)
// Récupère le username depuis le payload JWT (base64)
try { try {
const payload = JSON.parse(atob(accessToken.split('.')[1])) const payload = JSON.parse(atob(accessToken.split('.')[1]))
setUsername(payload.sub) setUsername(payload.sub)
setRole(payload.role ?? role ?? 'user')
} catch { } catch {
setUsername(user ?? 'user') setUsername(user ?? 'user')
setRole(role ?? 'user')
} }
} }
@@ -84,6 +91,8 @@ export default function App() {
setToken(null) setToken(null)
setTokenState(null) setTokenState(null)
setUsername(null) setUsername(null)
setRole(null)
setPage('main')
setVpsList([]) setVpsList([])
setLoading(true) setLoading(true)
} }
@@ -111,12 +120,13 @@ export default function App() {
return () => clearInterval(id) return () => clearInterval(id)
}, [refresh, token, refreshInterval]) }, [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(() => { useEffect(() => {
if (token && !username) { if (token && !username) {
try { try {
const payload = JSON.parse(atob(token.split('.')[1])) const payload = JSON.parse(atob(token.split('.')[1]))
setUsername(payload.sub) setUsername(payload.sub)
setRole(payload.role ?? 'user')
} catch { /* ignore */ } } catch { /* ignore */ }
} }
}, [token, username]) }, [token, username])
@@ -191,6 +201,15 @@ export default function App() {
const totalContainers = vpsList.reduce((acc, v) => acc + v.containers.length, 0) 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) const totalRunning = vpsList.reduce((acc, v) => acc + v.containers.filter(c => c.status === 'running').length, 0)
// ─── Pages profil / admin ───────────────────────────────────────────────
if (page === 'profile') {
return <ProfilePage username={username} onBack={() => setPage('main')} />
}
if (page === 'admin') {
return <AdminPage onBack={() => setPage('main')} />
}
return ( return (
<div className="min-h-screen bg-gray-950 text-gray-100"> <div className="min-h-screen bg-gray-950 text-gray-100">
<Header <Header
@@ -199,7 +218,10 @@ export default function App() {
onAddVps={() => setShowAddVps(true)} onAddVps={() => setShowAddVps(true)}
refreshing={refreshing} refreshing={refreshing}
username={username} username={username}
role={role}
onLogout={handleLogout} onLogout={handleLogout}
onProfile={() => setPage('profile')}
onAdmin={() => setPage('admin')}
refreshInterval={refreshInterval} refreshInterval={refreshInterval}
onIntervalChange={handleIntervalChange} onIntervalChange={handleIntervalChange}
intervalOptions={INTERVAL_OPTIONS} intervalOptions={INTERVAL_OPTIONS}

View File

@@ -113,3 +113,42 @@ export async function fetchVpsStats(vpsId, duration = 600) {
const res = await fetch(`${BASE}/vps/${vpsId}/stats?duration=${duration}`, { headers: authHeaders() }) const res = await fetch(`${BASE}/vps/${vpsId}/stats?duration=${duration}`, { headers: authHeaders() })
return handleResponse(res) 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)
}

View File

@@ -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 (
<div className="flex items-center justify-between gap-4 py-3">
<div>
<p className="text-sm font-medium text-gray-200">{label}</p>
<p className="text-xs text-gray-500 mt-0.5">{description}</p>
</div>
<button
onClick={onChange}
disabled={loading}
title={enabled ? 'Désactiver' : 'Activer'}
className="flex-shrink-0 disabled:opacity-50 transition-opacity"
>
{enabled
? <ToggleRight size={32} className="text-indigo-400" />
: <ToggleLeft size={32} className="text-gray-600" />
}
</button>
</div>
)
}
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 (
<div className="min-h-screen bg-gray-950 text-gray-100">
<div className="max-w-5xl mx-auto px-4 py-10">
<button
onClick={onBack}
className="flex items-center gap-1.5 text-sm text-gray-400 hover:text-gray-200 mb-6 transition-colors"
>
<ArrowLeft size={15} />
Retour au tableau de bord
</button>
<div className="flex items-center gap-3 mb-8">
<div className="p-2 rounded-xl bg-violet-500/15">
<ShieldCheck size={20} className="text-violet-400" />
</div>
<h1 className="text-lg font-semibold">Administration</h1>
</div>
{/* ── Section Paramètres ── */}
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6 mb-8">
<h2 className="text-sm font-semibold text-gray-300 mb-1">Paramètres</h2>
<p className="text-xs text-gray-500 mb-4">Configuration globale de l'application.</p>
{settingsError && (
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 mb-3">
{settingsError}
</div>
)}
{settingsLoading
? <p className="text-xs text-gray-500">Chargement…</p>
: (
<div className="divide-y divide-gray-800">
<ToggleRow
label="Inscriptions ouvertes"
description="Permet à de nouveaux utilisateurs de créer un compte."
enabled={settings?.registration_open === 'true'}
onChange={toggleRegistration}
loading={toggleLoading}
/>
</div>
)
}
</section>
{/* ── Section Logs de connexion ── */}
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
<div className="flex items-center justify-between mb-4 gap-4 flex-wrap">
<div>
<h2 className="text-sm font-semibold text-gray-300">Tentatives de connexion</h2>
<p className="text-xs text-gray-500 mt-0.5">{logsTotal} entrée{logsTotal !== 1 ? 's' : ''} au total</p>
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* Filtre utilisateur */}
<input
type="text"
placeholder="Filtrer par utilisateur…"
value={filterUser}
onChange={(e) => 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 */}
<select
value={filterSuccess}
onChange={(e) => setFilterSuccess(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"
>
<option value="all">Tous</option>
<option value="true">Succès</option>
<option value="false">Échecs</option>
</select>
<button
onClick={() => loadLogs(logsPage)}
disabled={logsLoading}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-50 transition-colors"
>
<RefreshCw size={12} className={logsLoading ? 'animate-spin' : ''} />
Actualiser
</button>
</div>
</div>
{logsError && (
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 mb-3">
{logsError}
</div>
)}
{logsLoading && logs.length === 0
? <p className="text-xs text-gray-500 py-8 text-center">Chargement…</p>
: filteredLogs.length === 0
? <p className="text-xs text-gray-500 py-8 text-center">Aucune entrée.</p>
: (
<div className="overflow-x-auto -mx-2">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-gray-500 border-b border-gray-800">
<th className="pb-2 px-2 font-medium">Date / Heure</th>
<th className="pb-2 px-2 font-medium">Utilisateur</th>
<th className="pb-2 px-2 font-medium">Adresse IP</th>
<th className="pb-2 px-2 font-medium">Résultat</th>
<th className="pb-2 px-2 font-medium">Détail</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800/60">
{filteredLogs.map(log => (
<tr key={log.id} className="hover:bg-gray-800/30 transition-colors">
<td className="py-2 px-2 text-gray-400 whitespace-nowrap font-mono">
{new Date(log.ts).toLocaleString('fr-FR')}
</td>
<td className="py-2 px-2 text-gray-200 font-mono">{log.username}</td>
<td className="py-2 px-2 text-gray-400 font-mono">{log.ip}</td>
<td className="py-2 px-2">
{log.success
? (
<span className="inline-flex items-center gap-1 text-emerald-400">
<Check size={11} /> Succès
</span>
) : (
<span className="inline-flex items-center gap-1 text-red-400">
<X size={11} /> Échec
</span>
)
}
</td>
<td className="py-2 px-2 text-gray-500">{log.reason || ''}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-800">
<button
onClick={() => loadLogs(logsPage - 1)}
disabled={logsPage === 0 || logsLoading}
className="px-3 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-40 transition-colors"
>
Précédent
</button>
<span className="text-xs text-gray-500">
Page {logsPage + 1} / {totalPages}
</span>
<button
onClick={() => loadLogs(logsPage + 1)}
disabled={logsPage >= totalPages - 1 || logsLoading}
className="px-3 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-40 transition-colors"
>
Suivant
</button>
</div>
)}
</section>
</div>
</div>
)
}

View File

@@ -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 ( return (
<header className="sticky top-0 z-40 border-b border-gray-800 bg-gray-900/80 backdrop-blur-sm"> <header className="sticky top-0 z-40 border-b border-gray-800 bg-gray-900/80 backdrop-blur-sm">
<div className="max-w-7xl mx-auto px-4 h-14 flex items-center justify-between"> <div className="max-w-7xl mx-auto px-4 h-14 flex items-center justify-between">
@@ -58,14 +58,33 @@ export default function Header({ lastUpdate, onRefresh, onAddVps, refreshing, us
</button> </button>
{username && ( {username && (
<button <>
onClick={onLogout} {role === 'admin' && (
title={`Déconnexion (${username})`} <button
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-gray-800 hover:bg-gray-700 transition-colors text-gray-400 hover:text-gray-200" onClick={onAdmin}
> title="Administration"
<LogOut size={14} /> className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-gray-800 hover:bg-gray-700 transition-colors text-violet-400 hover:text-violet-300"
<span className="hidden sm:inline">{username}</span> >
</button> <ShieldCheck size={14} />
<span className="hidden sm:inline">Admin</span>
</button>
)}
<button
onClick={onProfile}
title={`Profil (${username})`}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-gray-800 hover:bg-gray-700 transition-colors text-gray-400 hover:text-gray-200"
>
<User size={14} />
<span className="hidden sm:inline">{username}</span>
</button>
<button
onClick={onLogout}
title="Déconnexion"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-gray-800 hover:bg-gray-700 transition-colors text-gray-400 hover:text-gray-200"
>
<LogOut size={14} />
</button>
</>
)} )}
</div> </div>
</div> </div>

View File

@@ -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 (
<div className="min-h-screen bg-gray-950 text-gray-100">
<div className="max-w-lg mx-auto px-4 py-10">
<button
onClick={onBack}
className="flex items-center gap-1.5 text-sm text-gray-400 hover:text-gray-200 mb-6 transition-colors"
>
<ArrowLeft size={15} />
Retour au tableau de bord
</button>
<div className="flex items-center gap-3 mb-6">
<div className="p-2 rounded-xl bg-indigo-500/15">
<KeyRound size={20} className="text-indigo-400" />
</div>
<div>
<h1 className="text-lg font-semibold">Mon profil</h1>
<p className="text-xs text-gray-500">{username}</p>
</div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
<h2 className="text-sm font-medium text-gray-300 mb-4">Changer le mot de passe</h2>
{success && (
<div className="flex items-center gap-2 bg-emerald-950/40 border border-emerald-800/50 rounded-lg px-3 py-2 text-xs text-emerald-300 mb-4">
<Check size={13} />
Mot de passe mis à jour avec succès.
</div>
)}
{error && (
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs text-gray-400 mb-1.5">Mot de passe actuel</label>
<input
type="password"
required
autoComplete="current-password"
value={oldPassword}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1.5">Nouveau mot de passe</label>
<input
type="password"
required
autoComplete="new-password"
value={newPassword}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1.5">Confirmer le nouveau mot de passe</label>
<input
type="password"
required
autoComplete="new-password"
value={newPassword2}
onChange={(e) => 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"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-sm font-medium transition-colors mt-2"
>
{loading ? 'Enregistrement…' : 'Changer le mot de passe'}
</button>
</form>
</div>
</div>
</div>
)
}