Feat : Lot of stuff
All checks were successful
Build and Push Docker Images / docker (push) Successful in 51s

This commit is contained in:
jeanotx32
2026-06-02 18:55:11 -04:00
parent daf68d98fa
commit f2e5a24b37
9 changed files with 655 additions and 151 deletions

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X } from 'lucide-react'
import { getAdminSettings, setAdminSetting, getLoginLogs } from '../api/client'
import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X, Database, Trash2, AlertTriangle } from 'lucide-react'
import { getAdminSettings, setAdminSetting, getLoginLogs, getDbInfo, purgeDb } from '../api/client'
const PAGE_SIZE = 50
@@ -27,6 +27,8 @@ function ToggleRow({ label, description, enabled, onChange, loading }) {
}
export default function AdminPage({ onBack }) {
const [activeTab, setActiveTab] = useState('settings') // 'settings' | 'logs' | 'database'
// ─── Settings ────────────────────────────────────────────────────────────
const [settings, setSettings] = useState(null)
const [settingsLoading, setSettingsLoading] = useState(true)
@@ -96,6 +98,82 @@ export default function AdminPage({ onBack }) {
const totalPages = Math.ceil(logsTotal / PAGE_SIZE)
// ─── Database management ─────────────────────────────────────────────────
const [dbInfo, setDbInfo] = useState(null)
const [dbInfoLoading, setDbInfoLoading] = useState(false)
const [dbInfoError, setDbInfoError] = useState(null)
const [purgeLoading, setPurgeLoading] = useState(false)
const [purgeResult, setPurgeResult] = useState(null)
const [purgeError, setPurgeError] = useState(null)
const [confirmState, setConfirmState] = useState(null) // { table, period, fromTs?, toTs? }
// Custom range
const [customFrom, setCustomFrom] = useState('')
const [customTo, setCustomTo] = useState('')
const loadDbInfo = useCallback(async () => {
setDbInfoLoading(true)
setDbInfoError(null)
try {
const data = await getDbInfo()
setDbInfo(data)
} catch (err) {
setDbInfoError(err.message)
} finally {
setDbInfoLoading(false)
}
}, [])
useEffect(() => {
if (activeTab === 'database') loadDbInfo()
}, [activeTab, loadDbInfo])
const requestPurge = (table, period, extraOpts = {}) => {
setPurgeResult(null)
setPurgeError(null)
setConfirmState({ table, period, ...extraOpts })
}
const confirmPurge = async () => {
if (!confirmState) return
setPurgeLoading(true)
setPurgeResult(null)
setPurgeError(null)
try {
const result = await purgeDb(confirmState)
const total = Object.values(result.deleted).reduce((a, b) => a + b, 0)
setPurgeResult(`${total} entrée${total !== 1 ? 's' : ''} supprimée${total !== 1 ? 's' : ''}.`)
loadDbInfo()
} catch (err) {
setPurgeError(err.message)
} finally {
setPurgeLoading(false)
setConfirmState(null)
}
}
const periodLabel = {
last_24h: '24 dernières heures',
last_7d: '7 derniers jours',
last_30d: '30 derniers jours',
all: 'toutes les entrées',
custom: 'la période personnalisée',
}
const tableLabel = {
vps_stats: 'Statistiques VPS',
login_logs: 'Logs de connexion',
all: 'toutes les tables',
}
function fmtTs(ts) {
if (!ts) return '—'
return new Date(ts * 1000).toLocaleString('fr-FR')
}
function fmtCount(n) {
return new Intl.NumberFormat('fr-FR').format(n)
}
return (
<div className="min-h-screen bg-gray-950 text-gray-100">
<div className="max-w-5xl mx-auto px-4 py-10">
@@ -107,152 +185,345 @@ export default function AdminPage({ onBack }) {
Retour au tableau de bord
</button>
<div className="flex items-center gap-3 mb-8">
<div className="flex items-center gap-3 mb-6">
<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>
{/* ── Tabs ── */}
<div className="flex gap-1 mb-8 border-b border-gray-800">
{[
{ key: 'settings', label: 'Paramètres' },
{ key: 'logs', label: 'Connexions' },
{ key: 'database', label: 'Base de données' },
].map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === tab.key
? 'border-indigo-500 text-indigo-400'
: 'border-transparent text-gray-500 hover:text-gray-300'
}`}
>
{tab.label}
</button>
))}
</div>
{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>
)}
{/* ── Tab: Paramètres ── */}
{activeTab === 'settings' && (
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
<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>
{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}
/>
{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>
)
}
</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>
{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>
)}
{/* ── Tab: Connexions ── */}
{activeTab === 'logs' && (
<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">
<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"
/>
<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>
<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>
{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>
)
}
{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>
)}
{/* ── Tab: Base de données ── */}
{activeTab === 'database' && (
<div className="space-y-6">
{/* En-tête */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-gray-300">Gestion de la base de données</h2>
<p className="text-xs text-gray-500 mt-0.5">Supprimez les données historiques par table et par période.</p>
</div>
<button
onClick={() => loadLogs(logsPage)}
disabled={logsLoading}
onClick={loadDbInfo}
disabled={dbInfoLoading}
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' : ''} />
<RefreshCw size={12} className={dbInfoLoading ? '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>
)}
{dbInfoError && (
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300">
{dbInfoError}
</div>
)}
{purgeResult && (
<div className="bg-emerald-950/40 border border-emerald-800/50 rounded-lg px-3 py-2 text-xs text-emerald-300">
{purgeResult}
</div>
)}
{purgeError && (
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300">
{purgeError}
</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>
{/* Cards par table */}
{[
{ key: 'vps_stats', label: 'Statistiques VPS', icon: <Database size={15} className="text-indigo-400" /> },
{ key: 'login_logs', label: 'Logs de connexion', icon: <Database size={15} className="text-violet-400" /> },
].map(({ key, label, icon }) => {
const info = dbInfo?.[key]
return (
<section key={key} className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-4">
{icon}
<h3 className="text-sm font-semibold text-gray-300">{label}</h3>
{info && (
<span className="ml-auto text-xs text-gray-500">
{fmtCount(info.count)} entrée{info.count !== 1 ? 's' : ''}
{info.oldest_ts && ` · du ${fmtTs(info.oldest_ts)} au ${fmtTs(info.newest_ts)}`}
</span>
)}
</div>
<div className="flex flex-wrap gap-2">
{[
{ period: 'last_24h', label: '24 dernières heures' },
{ period: 'last_7d', label: '7 derniers jours' },
{ period: 'last_30d', label: '30 derniers jours' },
{ period: 'all', label: 'Tout effacer', danger: true },
].map(({ period, label: btnLabel, danger }) => (
<button
key={period}
onClick={() => requestPurge(key, period)}
disabled={purgeLoading || dbInfoLoading}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs transition-colors disabled:opacity-50 ${
danger
? 'bg-red-950/50 hover:bg-red-900/60 text-red-400 border border-red-800/50'
: 'bg-gray-800 hover:bg-gray-700 text-gray-300'
}`}
>
<Trash2 size={11} />
{btnLabel}
</button>
))}
</div>
</section>
)
}
})}
{/* 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>
{/* Période personnalisée */}
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
<h3 className="text-sm font-semibold text-gray-300 mb-1">Période personnalisée</h3>
<p className="text-xs text-gray-500 mb-4">Supprimez les données comprises entre deux dates précises.</p>
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-gray-500 mb-1">Du</label>
<input
type="datetime-local"
value={customFrom}
onChange={e => setCustomFrom(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"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Au</label>
<input
type="datetime-local"
value={customTo}
onChange={e => setCustomTo(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"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Table</label>
<select
id="custom-table"
defaultValue="all"
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">Toutes</option>
<option value="vps_stats">Statistiques VPS</option>
<option value="login_logs">Logs de connexion</option>
</select>
</div>
<button
disabled={!customFrom || !customTo || purgeLoading}
onClick={() => {
const tbl = document.getElementById('custom-table').value
const fromTs = Math.floor(new Date(customFrom).getTime() / 1000)
const toTs = Math.floor(new Date(customTo).getTime() / 1000)
requestPurge(tbl, 'custom', { fromTs, toTs })
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs bg-red-950/50 hover:bg-red-900/60 text-red-400 border border-red-800/50 transition-colors disabled:opacity-50"
>
<Trash2 size={11} />
Supprimer
</button>
</div>
</section>
</div>
)}
{/* ── Modale de confirmation de purge ── */}
{confirmState && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-gray-900 border border-gray-700 rounded-2xl p-6 max-w-sm w-full shadow-2xl">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-xl bg-red-500/15">
<AlertTriangle size={18} className="text-red-400" />
</div>
<h3 className="text-sm font-semibold">Confirmer la suppression</h3>
</div>
<p className="text-xs text-gray-400 mb-6">
Vous êtes sur le point de supprimer{' '}
<span className="text-gray-200 font-medium">{periodLabel[confirmState.period]}</span>{' '}
dans{' '}
<span className="text-gray-200 font-medium">{tableLabel[confirmState.table]}</span>.
Cette action est irréversible.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setConfirmState(null)}
className="px-4 py-2 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 transition-colors"
>
Annuler
</button>
<button
onClick={confirmPurge}
disabled={purgeLoading}
className="px-4 py-2 rounded-lg text-xs bg-red-600 hover:bg-red-700 disabled:opacity-50 transition-colors text-white font-medium"
>
{purgeLoading ? 'Suppression' : 'Supprimer'}
</button>
</div>
</div>
)}
</section>
</div>
)}
</div>
</div>
)