import { useState, useEffect, useCallback } from 'react' import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X, Database, Trash2, Fingerprint, Key, Bell, Send, Eye, EyeOff, Activity } from 'lucide-react' import { getAdminSettings, setAdminSetting, getLoginLogs, getDbInfo, purgeDb, adminGetPasskeys, adminDeletePasskey, getContainerEvents, testPushoverNotification } from '../api/client' import ConfirmDialog from './ui/ConfirmDialog' import { inputClass } from './ui/controls' const PAGE_SIZE = 50 function ToggleRow({ label, description, enabled, onChange, loading }) { return (

{label}

{description}

) } const STATE_COLORS = { running: 'text-emerald-400 bg-emerald-950/40 border-emerald-800/50', 'running:healthy': 'text-emerald-400 bg-emerald-950/40 border-emerald-800/50', 'running:unhealthy': 'text-orange-400 bg-orange-950/40 border-orange-800/50', 'running:starting': 'text-yellow-400 bg-yellow-950/40 border-yellow-800/50', exited: 'text-gray-500 bg-gray-800/40 border-gray-700/50', stopped: 'text-gray-500 bg-gray-800/40 border-gray-700/50', removed: 'text-red-400 bg-red-950/40 border-red-800/50', } function StateChip({ state, highlight = false }) { if (!state) return const cls = STATE_COLORS[state] ?? 'text-gray-400 bg-gray-800/40 border-gray-700/50' return ( {state} ) } export default function AdminPage({ onBack }) { const [activeTab, setActiveTab] = useState('settings') // 'settings' | 'passkeys' | 'notifications' | 'logs' | 'database' // ─── 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) } } const togglePasskeys = async () => { if (!settings) return const newValue = settings.passkey_enabled === 'true' ? 'false' : 'true' setToggleLoading(true) try { await setAdminSetting('passkey_enabled', newValue) setSettings(prev => ({ ...prev, passkey_enabled: newValue })) } catch (err) { setSettingsError(err.message) } finally { setToggleLoading(false) } } // ─── Notifications (Pushover) ──────────────────────────────────────────── const [pushoverToken, setPushoverToken] = useState('') const [pushoverUserKey, setPushoverUserKey] = useState('') const [showToken, setShowToken] = useState(false) const [showUserKey, setShowUserKey] = useState(false) const [pushoverSaving, setPushoverSaving] = useState(false) const [pushoverMsg, setPushoverMsg] = useState(null) // { ok, text } const [testingNotif, setTestingNotif] = useState(false) const [testResult, setTestResult] = useState(null) // { ok, text } // Sync local fields from loaded settings useEffect(() => { if (!settings) return setPushoverToken(settings.pushover_app_token ?? '') setPushoverUserKey(settings.pushover_user_key ?? '') }, [settings]) const togglePushover = async () => { if (!settings) return const newValue = settings.pushover_enabled === 'true' ? 'false' : 'true' setToggleLoading(true) try { await setAdminSetting('pushover_enabled', newValue) setSettings(prev => ({ ...prev, pushover_enabled: newValue })) } catch (err) { setSettingsError(err.message) } finally { setToggleLoading(false) } } const savePushoverCredentials = async () => { setPushoverSaving(true) setPushoverMsg(null) try { await setAdminSetting('pushover_app_token', pushoverToken.trim()) await setAdminSetting('pushover_user_key', pushoverUserKey.trim()) setSettings(prev => ({ ...prev, pushover_app_token: pushoverToken.trim(), pushover_user_key: pushoverUserKey.trim(), })) setPushoverMsg({ ok: true, text: 'Identifiants sauvegardés.' }) } catch (err) { setPushoverMsg({ ok: false, text: err.message }) } finally { setPushoverSaving(false) } } const handleTestNotification = async () => { setTestingNotif(true) setTestResult(null) try { await testPushoverNotification() setTestResult({ ok: true, text: 'Notification envoyée avec succès.' }) } catch (err) { setTestResult({ ok: false, text: err.message }) } finally { setTestingNotif(false) } } // ─── Container events ──────────────────────────────────────────────────── const [events, setEvents] = useState([]) const [eventsTotal, setEventsTotal] = useState(0) const [eventsPage, setEventsPage] = useState(0) const [eventsLoading, setEventsLoading] = useState(false) const [eventsError, setEventsError] = useState(null) const loadEvents = useCallback(async (page = 0) => { setEventsLoading(true) setEventsError(null) try { const data = await getContainerEvents(PAGE_SIZE, page * PAGE_SIZE) setEvents(data.events) setEventsTotal(data.total) setEventsPage(page) } catch (err) { setEventsError(err.message) } finally { setEventsLoading(false) } }, []) useEffect(() => { if (activeTab === 'notifications') loadEvents(0) }, [activeTab, loadEvents]) // ─── Passkeys admin ────────────────────────────────────────────────────── const [adminPasskeys, setAdminPasskeys] = useState([]) const [adminPasskeysLoading, setAdminPasskeysLoading] = useState(false) const [adminPasskeysError, setAdminPasskeysError] = useState(null) const [revokeLoading, setRevokeLoading] = useState(null) const loadAdminPasskeys = useCallback(async () => { setAdminPasskeysLoading(true) setAdminPasskeysError(null) try { const data = await adminGetPasskeys() setAdminPasskeys(data) } catch (err) { setAdminPasskeysError(err.message) } finally { setAdminPasskeysLoading(false) } }, []) useEffect(() => { if (activeTab === 'passkeys') loadAdminPasskeys() }, [activeTab, loadAdminPasskeys]) const handleRevokePasskey = async (credentialId) => { setRevokeLoading(credentialId) try { await adminDeletePasskey(credentialId) setAdminPasskeys(prev => prev.filter(p => p.credential_id !== credentialId)) } catch (err) { setAdminPasskeysError(err.message) } finally { setRevokeLoading(null) } } // ─── 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) // ─── 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 [customTable, setCustomTable] = useState('all') 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 (

Administration

{/* ── Tabs ── */}
{[ { key: 'settings', label: 'Paramètres' }, { key: 'notifications', label: 'Notifications' }, { key: 'passkeys', label: 'Passkeys' }, { key: 'logs', label: 'Connexions' }, { key: 'database', label: 'Base de données' }, ].map(tab => ( ))}
{/* ── Tab: Paramètres ── */} {activeTab === 'settings' && (

Paramètres

Configuration globale de l'application.

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

Chargement…

: (
) }
)} {/* ── Tab: Notifications ── */} {activeTab === 'notifications' && (
{/* Pushover config */}

Pushover

Recevez une notification push sur vos appareils lors de tout changement d'état d'un conteneur.

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

Chargement…

: (
{/* Toggle */}
{/* Credentials form */}
setPushoverToken(e.target.value)} placeholder="aXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" className={`${inputClass} pr-9 font-mono`} />
setPushoverUserKey(e.target.value)} placeholder="uXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" className={`${inputClass} pr-9 font-mono`} />
{pushoverMsg && (
{pushoverMsg.text}
)}
{testResult && (
{testResult.text}
)}
) }
{/* Container events */}

Historique des événements

{eventsTotal} changement{eventsTotal !== 1 ? 's' : ''} enregistré{eventsTotal !== 1 ? 's' : ''}

{eventsError && (
{eventsError}
)} {eventsLoading && events.length === 0 ?

Chargement…

: events.length === 0 ? (

Aucun événement enregistré.

) : (
{events.map(ev => ( ))}
Date / Heure VPS Conteneur Avant Après
{new Date(ev.ts).toLocaleString('fr-FR')} {ev.vps_name} {ev.container}
) } {Math.ceil(eventsTotal / PAGE_SIZE) > 1 && (
Page {eventsPage + 1} / {Math.ceil(eventsTotal / PAGE_SIZE)}
)}
)} {/* ── Tab: Passkeys ── */} {activeTab === 'passkeys' && (

Passkeys enregistrées

Gérez les passkeys (TouchID / FaceID) de tous les utilisateurs.

{adminPasskeysError && (
{adminPasskeysError}
)} {adminPasskeysLoading && adminPasskeys.length === 0 ?

Chargement…

: adminPasskeys.length === 0 ? (

Aucune passkey enregistrée.

) : (
{adminPasskeys.map(pk => ( ))}
Utilisateur Appareil Enregistrée le
{pk.username} {pk.name} {new Date(pk.created_at).toLocaleString('fr-FR')}
) }
)} {/* ── Tab: Connexions ── */} {activeTab === 'logs' && (

Tentatives de connexion

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

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" />
{logsError && (
{logsError}
)} {logsLoading && logs.length === 0 ?

Chargement…

: filteredLogs.length === 0 ?

Aucune entrée.

: (
{filteredLogs.map(log => ( ))}
Date / Heure Utilisateur Adresse IP Résultat Détail
{new Date(log.ts).toLocaleString('fr-FR')} {log.username} {log.ip} {log.success ? ( Succès ) : ( Échec ) } {log.reason || '—'}
) } {totalPages > 1 && (
Page {logsPage + 1} / {totalPages}
)}
)} {/* ── Tab: Base de données ── */} {activeTab === 'database' && (
{/* En-tête */}

Gestion de la base de données

Supprimez les données historiques par table et par période.

{dbInfoError && (
{dbInfoError}
)} {purgeResult && (
{purgeResult}
)} {purgeError && (
{purgeError}
)} {/* Cards par table */} {[ { key: 'vps_stats', label: 'Statistiques VPS', icon: }, { key: 'login_logs', label: 'Logs de connexion', icon: }, ].map(({ key, label, icon }) => { const info = dbInfo?.[key] return (
{icon}

{label}

{info && ( {fmtCount(info.count)} entrée{info.count !== 1 ? 's' : ''} {info.oldest_ts && ` · du ${fmtTs(info.oldest_ts)} au ${fmtTs(info.newest_ts)}`} )}
{[ { 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 }) => ( ))}
) })} {/* Période personnalisée */}

Période personnalisée

Supprimez les données comprises entre deux dates précises.

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" />
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" />
)} {/* ── Modale de confirmation de purge ── */} {confirmState && ( Vous êtes sur le point de supprimer{' '} {periodLabel[confirmState.period]}{' '} dans {tableLabel[confirmState.table]}. Cette action est irréversible. } confirmLabel={purgeLoading ? 'Suppression…' : 'Supprimer'} loading={purgeLoading} onConfirm={confirmPurge} onCancel={() => setConfirmState(null)} /> )}
) }