Files

255 lines
48 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fr">
<head>
<link rel="icon" type="image/x-icon" href="favicon.png">
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<style>
:root{--bg:#0b1020;--panel:#131b31;--panel2:#1b2946;--text:#eef4ff;--muted:#9aa8c5;--blue:#5ca7ff;--green:#32d583;--red:#ff6b7a;--line:#283757}*{box-sizing:border-box}body{margin:0;background:linear-gradient(135deg,#0b1020,#111a32);color:var(--text);font:15px system-ui,sans-serif;min-height:100vh}.wrap{max-width:1100px;margin:auto;padding:28px 18px 48px}header,.topline{display:flex;justify-content:space-between;align-items:center;gap:16px}h1{font-size:27px;margin:0 0 5px}h2{font-size:17px;margin:0 0 18px}.sub,.muted,.label{color:var(--muted)}button{border:0;border-radius:10px;padding:11px 15px;background:var(--blue);color:#071326;font-weight:700;cursor:pointer}button.secondary{background:var(--panel2);color:var(--text)}nav{display:flex;gap:8px;margin:26px 0 18px}nav button:not(.active){background:var(--panel2);color:var(--text);box-shadow:none}nav button.active{background:var(--blue);color:#071326;box-shadow:0 0 0 2px #9bcaff66}.signal{font-weight:700}.signal-excellent{color:#32d583}.signal-bon{color:#8be28b}.signal-moyen{color:#ffad42}.signal-faible{color:#ff6b7a}.view{display:none}.view.active{display:block}.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px}.card{background:rgba(19,27,49,.94);border:1px solid var(--line);border-radius:16px;padding:19px;box-shadow:0 10px 30px #0002}.metric{font-size:29px;font-weight:800;margin-top:8px}.status{color:var(--green);display:flex;align-items:center;gap:8px}.dot{width:9px;height:9px;background:var(--green);border-radius:50%;box-shadow:0 0 12px var(--green)}.layout{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:18px;align-items:start}
.layout>section{grid-column:1}
.layout>aside{grid-column:2;position:sticky;top:18px;align-self:start}
.pill{padding:6px 10px;border-radius:20px;background:#173d35;color:var(--green);font-size:12px;font-weight:700}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:14px 10px;border-bottom:1px solid var(--line);white-space:nowrap}th{font-size:12px;color:var(--muted)}.client{display:flex;align-items:center;gap:10px}.avatar{width:34px;height:34px;border-radius:10px;background:#254a78;display:grid;place-items:center;font-weight:800}.badge{color:var(--green);font-size:12px}.info{display:grid;gap:13px}.info-row{display:flex;justify-content:space-between;border-bottom:1px solid var(--line);padding-bottom:12px}.info-row span{color:var(--muted)}input{display:block;width:100%;margin:7px 0 16px;padding:12px;border-radius:9px;border:1px solid var(--line);background:var(--panel2);color:var(--text);font-size:15px}.check{display:flex;align-items:center;gap:9px;margin:14px 0 20px}.check input{width:auto;margin:0}.notice{margin-top:16px;padding:13px;border-radius:10px;background:#2b2130;color:#ffb9c1;font-size:13px}.empty{text-align:center;color:var(--muted);padding:25px}@media(max-width:800px){.grid{grid-template-columns:repeat(2,1fr)}.layout{grid-template-columns:1fr}.layout>section,.layout>aside{grid-column:1}.layout>aside{position:static}
#settingsView>div[style*="grid-template-columns"]{grid-template-columns:1fr!important}}@media(max-width:480px){header{align-items:flex-start;flex-direction:column}header>div:last-child{width:100%;display:grid!important;grid-template-columns:1fr 1fr;gap:8px}header>div:last-child button:last-child{grid-column:1/-1}.grid{grid-template-columns:1fr 1fr}.metric{font-size:23px}.topline{align-items:flex-start;flex-direction:column}.card{padding:15px}#setupView .card{margin:25px auto!important}#setupView h1{font-size:24px}}
#loader{position:fixed;inset:0;z-index:1000;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;background:linear-gradient(135deg,#0b1020,#111a32);transition:opacity .35s}#loader.hide{opacity:0;pointer-events:none}.spinner{width:54px;height:54px;border-radius:50%;border:5px solid var(--panel2);border-top-color:var(--blue);animation:spin .9s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}#loaderText{color:var(--muted)}
html[data-theme="light"]{--bg:#f3f6fc;--panel:#ffffff;--panel2:#e8eef9;--text:#15203a;--muted:#5b6b8c;--green:#13a06a;--red:#e5484d;--line:#d5deee;color-scheme:light}
html[data-theme="light"] body,html[data-theme="light"] #loader{background:linear-gradient(135deg,#f3f6fc,#e3ebf8)}
html[data-theme="light"] .card{background:rgba(255,255,255,.96);box-shadow:0 10px 30px #1a2b4f14}
html[data-theme="light"] .pill{background:#d9f5e8}
html[data-theme="light"] .notice{background:#fde8ea;color:#a3202b}
html[data-theme="light"] .avatar{background:#cfe0fb;color:#15203a}
.recovery-card{background:var(--panel2);border:1px dashed var(--blue);border-radius:12px;padding:16px;margin:14px 0}
.recovery-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin:12px 0}
.code-chip{background:var(--bg);border:1px solid var(--line);padding:8px 10px;border-radius:8px;font-family:monospace;font-size:15px;font-weight:700;letter-spacing:1px;text-align:center;color:var(--blue);user-select:all}
.recovery-actions{display:flex;gap:10px;margin-top:10px;flex-wrap:wrap}
</style>
</head>
<body><div id="loader"><div class="spinner"></div><h2 class="app-title" style="margin:0">Administration de l’AP Wi‑Fi</h2><div id="loaderText">Chargement de l’API…</div></div><section id="authView" class="view active"><div class="card" style="max-width:520px;margin:80px auto"><h1 id="authTitle">Connexion</h1><p id="authIntro" class="muted">Connectez-vous pour accéder à <span class="app-title">l’administration de l’AP Wi‑Fi</span>.</p><label class="label" for="authUsername">Utilisateur</label><input id="authUsername" type="text" autocomplete="username" maxlength="32"><label class="label" for="authPassword">Mot de passe</label><div style="display:flex;gap:8px;margin:7px 0 16px"><input id="authPassword" type="password" autocomplete="current-password" maxlength="128" style="margin:0"><button id="toggleAuthPassword" class="secondary" type="button" aria-label="Afficher le mot de passe" title="Afficher le mot de passe"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.5-8 10-8 10 8 10 8-3.5 8-10 8S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg></button></div><div id="authOtpBox" style="display:none"><label class="label" for="authOtp">Code de double identification (TOTP ou secours)</label><input id="authOtp" type="text" autocomplete="one-time-code" maxlength="11" placeholder="Code à 6 chiffres ou code de secours (ex: ABCDE-12345)"></div><label id="rememberUserBox" class="check"><input id="rememberUser" type="checkbox"> Se rappeler de moi</label><button id="authSubmit" type="button">Se connecter</button><span id="authMessage" class="muted" style="margin-left:10px"></span></div></section><main id="app" class="wrap" style="display:none">
<header><div><h1><span class="app-title">Administration de l’AP Wi‑Fi</span></h1><div class="sub">AP Manageur V1.0</div></div><div style="display:flex;gap:8px"><button id="apControl" type="button">Démarrer</button><button id="apRestart" class="secondary" type="button">Redémarrer</button><button id="refresh">Actualiser</button><button id="logout" class="secondary" type="button">Déconnexion</button></div></header>
<nav id="nav"><button id="dashboardTab" class="active" aria-selected="true">Tableau de bord</button><button id="settingsTab" class="secondary" aria-selected="false">Réglages</button><button id="logsTab" class="secondary" aria-selected="false">Journaux</button><button id="blockedTab" class="secondary" aria-selected="false">Appareils bloqués</button></nav>
<section id="setupView" class="view"><div class="card" style="max-width:650px;margin:60px auto"><h1>Configuration initiale</h1><p class="muted">Aucun réseau Wi‑Fi n’est configuré. Créez votre point d’accès avec un SSID et un mot de passe.</p><div class="notice"><strong>Caractéristiques requises</strong><br>SSID : 1 à 32 caractères.<br>Mot de passe WPA : 8 à 63 caractères, recommandé avec des lettres, des chiffres et des symboles.<br>Le réseau sera démarré automatiquement au démarrage de la machine.</div><label class="label" for="setupSsid">Nom du SSID</label><input id="setupSsid" type="text" maxlength="32" placeholder="Mon réseau Wi‑Fi"><label class="label" for="setupPassword">Mot de passe Wi‑Fi</label><div style="display:flex;gap:8px;margin:7px 0 16px"><input id="setupPassword" type="password" minlength="8" maxlength="63" placeholder="8 caractères minimum" style="margin:0"><button id="toggleSetupPassword" class="secondary" type="button" aria-label="Afficher le mot de passe" title="Afficher le mot de passe"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.5-8 10-8 10 8 10 8-3.5 8-10 8S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg></button></div><label class="label" for="setupPasswordConfirm">Confirmation du mot de passe Wi‑Fi</label><div style="display:flex;gap:8px;margin:7px 0 16px"><input id="setupPasswordConfirm" type="password" minlength="8" maxlength="63" placeholder="Confirmation du mot de passe" style="margin:0"><button id="toggleSetupPasswordConfirm" class="secondary" type="button" aria-label="Afficher le mot de passe" title="Afficher le mot de passe"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.5-8 10-8 10 8 10 8-3.5 8-10 8S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg></button></div><label class="label" for="setupSecurity">Sécurité Wi‑Fi</label><select id="setupSecurity" style="display:block;width:100%;margin:7px 0 16px;padding:12px;border-radius:9px;border:1px solid var(--line);background:var(--panel2);color:var(--text);font-size:15px"><option value="wpa1">WPA1</option><option value="wpa2" selected>WPA2</option><option value="wpa3">WPA3</option></select><button id="setupAp" type="button">Créer le réseau</button><span id="setupMessage" class="muted" style="margin-left:10px"></span></div></section>
<section id="dashboardView" class="view active">
<div class="grid"><div class="card"><div class="label">État de l’AP</div><div id="state" class="metric status"><i class="dot"></i> Chargement</div></div><div class="card"><div class="label">Clients connectés</div><div id="clientCount" class="metric">—</div></div><div class="card"><div class="label">SSID</div><div id="ssid" class="metric" style="font-size:21px">—</div></div><div class="card"><div class="label">Uptime du SSID</div><div id="uptime" class="metric">—</div></div></div>
<div class="layout"><section class="card"><div class="topline"><h2>Clients connectés</h2><span id="updated" class="pill">—</span></div><div class="table-wrap"><table><thead><tr><th>APPAREIL</th><th>IP</th><th>MAC</th><th>SIGNAL</th><th>ÉTAT</th><th>ACTION</th></tr></thead><tbody id="clients"></tbody></table></div></section>
<aside class="card"><h2>Rejoindre le réseau</h2><div id="qrcode" style="background:#fff;padding:12px;width:max-content;border-radius:10px;margin:0 auto 14px"></div><div class="label">Mot de passe récupéré par l’API</div><input id="qrPassword" type="password" readonly><button id="showPassword" class="secondary" type="button" style="display:block;width:100%">Afficher le mot de passe</button><p class="muted" style="font-size:12px">Le QR code utilise le SSID et le mot de passe réels de l’AP.</p></aside></div></section>
<section id="settingsView" class="view"><div id="setupCard" style="display:none"></div><div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px;align-items:start"><div class="card"><h2>Réglages AP</h2><p class="muted">Les changements redémarrent la connexion Wi‑Fi active.</p><label class="label" for="settingsSsid">Nom du SSID</label><input id="settingsSsid" type="text" maxlength="32"><label class="label" for="settingsPassword">Mot de passe Wi‑Fi</label><div style="display:flex;gap:8px;margin:7px 0 16px"><input id="settingsPassword" type="password" minlength="8" maxlength="63" style="margin:0"><button id="toggleSettingsPassword" class="secondary" type="button" aria-label="Afficher le mot de passe" title="Afficher le mot de passe"><svg id="passwordEye" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.5-8 10-8 10 8 10 8-3.5 8-10 8S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg></button></div><label class="label" for="settingsSecurity">Sécurité Wi‑Fi</label><select id="settingsSecurity" style="display:block;width:100%;margin:7px 0 16px;padding:12px;border-radius:9px;border:1px solid var(--line);background:var(--panel2);color:var(--text);font-size:15px"><option value="wpa1">WPA1</option><option value="wpa2" selected>WPA2</option><option value="wpa3">WPA3</option></select><label class="check"><input id="settingsAutoconnect" type="checkbox"> Démarrer automatiquement au démarrage</label><button id="saveSettings" type="button">Enregistrer les réglages</button><span id="message" class="muted" style="margin-left:10px"></span><div style="margin-top:22px;padding-top:18px;border-top:1px solid var(--line)"><h2>Personnalisation</h2><label class="label" for="appNameInput">Nom complet de l’application</label><input id="appNameInput" type="text" maxlength="64" placeholder="Administration de l’AP Wi‑Fi"><label class="label" for="appShortNameInput">Nom court (onglet navigateur)</label><input id="appShortNameInput" type="text" maxlength="32" placeholder="AP Manageur"><label class="label" for="themeSelect">Thème</label><select id="themeSelect" style="display:block;width:100%;margin:7px 0 16px;padding:12px;border-radius:9px;border:1px solid var(--line);background:var(--panel2);color:var(--text);font-size:15px"><option value="dark">Sombre</option><option value="light">Clair</option><option value="auto">Automatique (selon l’appareil)</option></select><button id="saveCustomization" type="button">Enregistrer la personnalisation</button><p class="muted" style="font-size:12px;margin-top:8px">Enregistré dans la base de données : le nom et le thème se retrouvent sur tous vos appareils.</p><span id="themeMessage" class="muted"></span></div></div><div class="card"><h2>Réglages</h2><label class="label" for="authSettingsUsername">Utilisateur administrateur</label><input id="authSettingsUsername" type="text" maxlength="32"><label class="label" for="authSettingsPassword">Nouveau mot de passe</label><div style="display:flex;gap:8px;margin:7px 0 16px"><input id="authSettingsPassword" type="password" minlength="8" maxlength="128" placeholder="Nouveau mot de passe" style="margin:0"><button id="toggleAuthSettingsPassword" class="secondary" type="button" aria-label="Afficher le nouveau mot de passe" title="Afficher le nouveau mot de passe"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.5-8 10-8 10 8 10 8-3.5 8-10 8S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg></button></div><label class="label" for="authSettingsPasswordConfirm">Confirmation du mot de passe</label><div style="display:flex;gap:8px;margin:7px 0 16px"><input id="authSettingsPasswordConfirm" type="password" minlength="8" maxlength="128" placeholder="Confirmation du mot de passe" style="margin:0"><button id="toggleAuthSettingsPasswordConfirm" class="secondary" type="button" aria-label="Afficher la confirmation du mot de passe" title="Afficher la confirmation du mot de passe"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.5-8 10-8 10 8 10 8-3.5 8-10 8S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg></button></div><label class="label" for="authCurrentPassword">Mot de passe actuel</label><div style="display:flex;gap:8px;margin:7px 0 16px"><input id="authCurrentPassword" type="password" autocomplete="current-password" maxlength="128" style="margin:0"><button id="toggleCurrentPassword" class="secondary" type="button" aria-label="Afficher le mot de passe actuel" title="Afficher le mot de passe actuel"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.5-8 10-8 10 8 10 8-3.5 8-10 8S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg></button></div><button id="saveAuth" type="button">Modifier les identifiants</button><span id="authSettingsMessage" class="muted" style="margin-left:10px"></span><div id="twoFaBox" style="margin-top:22px;padding-top:18px;border-top:1px solid var(--line)"><h2>Double identification (2FA) &amp; Codes de secours</h2><p id="twoFaState" class="muted">Chargement…</p><div id="twoFaSetup" style="display:none"><p class="muted">Scannez ce QR code avec votre application d’authentification, ou saisissez la clé manuellement :</p><div id="twoFaQr" style="background:#fff;padding:12px;width:max-content;border-radius:10px;margin:0 auto 12px"></div><p class="muted">Clé : <strong id="twoFaSecret" style="color:var(--text);word-break:break-all"></strong></p></div><div id="twoFaRecoveryDisplay" class="recovery-card" style="display:none"><div style="font-weight:700;color:var(--blue);margin-bottom:6px">⚠️ Codes de récupération (secours)</div><p class="muted" style="font-size:13px;margin:0 0 10px">Conservez ces 8 codes en lieu sûr. En cas de perte de votre application 2FA, chaque code vous permettra de vous connecter une fois.</p><div id="twoFaRecoveryGrid" class="recovery-grid"></div><div class="recovery-actions"><button id="copyRecoveryCodes" class="secondary" type="button">📋 Copier les codes</button><button id="downloadRecoveryCodes" class="secondary" type="button">💾 Télécharger (.txt)</button></div></div><div id="twoFaInputGroup" style="margin-top:12px"><input id="twoFaCode" type="text" autocomplete="off" placeholder="Code TOTP à 6 chiffres ou code de secours" style="display:none"><p id="twoFaHelpText" class="muted" style="font-size:12px;display:none">Le mot de passe actuel saisi ci-dessus et un code (TOTP ou secours) sont requis.</p></div><div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px"><button id="twoFaEnable" type="button">Activer la 2FA</button><button id="twoFaConfirm" type="button" style="display:none">Valider le code 2FA</button><button id="twoFaDownloadMenuBtn" class="secondary" type="button" style="display:none">💾 Télécharger les codes de récupération</button><button id="twoFaRegenRecovery" class="secondary" type="button" style="display:none">Régénérer les codes</button><button id="twoFaDisable" class="secondary" type="button" style="display:none">Désactiver la 2FA</button></div><span id="twoFaMessage" class="muted" style="margin-top:10px;display:block"></span></div><div class="notice"><strong>Zone dangereuse</strong><p>Supprime l’historique des logs, les appareils bloqués et la configuration réseau enregistrée.</p><button id="clearAllData" type="button" style="background:var(--red);color:white">Supprimer toutes les données</button><span id="clearDataMessage" class="muted" style="margin-left:10px"></span></div></div></div></section>
<section id="logsView" class="view"><div class="card"><div class="topline"><h2>Journaux de connexion</h2><div style="display:flex;gap:8px"><button id="refreshLogs" class="secondary" type="button">Actualiser</button><button id="clearLogs" type="button" style="background:var(--red);color:white">Supprimer</button></div></div><div class="table-wrap"><table><thead><tr><th>DATE</th><th>ÉVÉNEMENT</th><th>APPAREIL</th><th>IP</th><th>MAC</th></tr></thead><tbody id="logs"></tbody></table></div></div></section>
<section id="blockedView" class="view"><div class="card"><div class="topline"><h2>Appareils bloqués</h2><button id="refreshBlocked" class="secondary" type="button">Actualiser</button></div><p class="muted">Ces appareils sont enregistrés dans la liste de blocage et peuvent être autorisés à nouveau.</p><div class="table-wrap"><table><thead><tr><th>APPAREIL</th><th>ADRESSE MAC</th><th>BLOQUÉ LE</th><th>ACTION</th></tr></thead><tbody id="blockedClients"></tbody></table></div></div></section>
</main>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script><script>
const API_URL=location.protocol==='file:'?'http://127.0.0.1:8080':('http://'+location.hostname+':8080');
/* Nom du projet configurable */
let PROJECT_NAME="Administration de l’AP Wi‑Fi";
let PROJECT_SHORT_NAME="AP Manageur";
function applyProjectTitle(name=PROJECT_NAME,shortName=PROJECT_SHORT_NAME){
PROJECT_NAME=name;PROJECT_SHORT_NAME=shortName;document.title=shortName;
document.querySelectorAll('.app-title').forEach(el=>el.textContent=name);
document.querySelectorAll('.app-short-title').forEach(el=>el.textContent=shortName);
}
function readCookie(name){const item=document.cookie.split('; ').find(row=>row.startsWith(name+'='));return item?decodeURIComponent(item.split('=').slice(1).join('=')):''}
function saveAuthCookie(token){document.cookie='apAuthToken='+encodeURIComponent(token)+'; Max-Age=3600; Path=/; SameSite=Lax'}
function clearAuthCookie(){document.cookie='apAuthToken=; Max-Age=0; Path=/; SameSite=Lax'}
let authToken=readCookie('apAuthToken')||sessionStorage.getItem('apAuthToken')||'';let authConfigured=false;let authSetupMode=false;let authTwoFactor=false;let clients=[];let status={};
async function apiFetch(path,options={}){options.headers={...(options.headers||{}),...(authToken?{'X-Session-Token':authToken}:{})};const response=await fetch(API_URL+path,options);if(response.status===401){authToken='';sessionStorage.removeItem('apAuthToken');clearAuthCookie();showAuth(false);throw Error('Session expirée');}return response}
function showAuth(setup){authSetupMode=setup;$('authView').classList.add('active');$('app').style.display='none';$('authTitle').textContent=setup?'Créer le compte administrateur':'Connexion';$('authIntro').innerHTML=setup?'Définissez le compte qui protégera le tableau de bord.':'Connectez-vous pour accéder à <span class="app-title">'+PROJECT_NAME+'</span>.';$('authSubmit').textContent=setup?'Créer le compte':'Se connecter';$('authPassword').value='';$('authOtp').value='';$('authOtpBox').style.display=(!setup&&authTwoFactor)?'block':'none';$('rememberUserBox').style.display=setup?'none':'block';$('authMessage').textContent='';if(!setup){$('authUsername').value=localStorage.getItem('apRememberedUser')||'';$('rememberUser').checked=!!localStorage.getItem('apRememberedUser')}else{$('authUsername').value='';$('rememberUser').checked=false;}}
function showApp(){loadPreferences();$('authView').classList.remove('active');$('app').style.display='block';$('authSettingsUsername').value=localStorage.getItem('apRememberedUser')||$('authUsername').value||''}
async function logout(){try{await apiFetch('/api/logout',{method:'POST'})}catch(_){ }authToken='';sessionStorage.removeItem('apAuthToken');clearAuthCookie();showAuth(false)}let previousClientSignature=null;let qrSignature=null;let currentTab='dashboard';let actionBusy=false;
const $=id=>document.getElementById(id);
let themePref=localStorage.getItem('apTheme')||'dark';const themeMedia=window.matchMedia('(prefers-color-scheme: light)');
function applyTheme(){document.documentElement.dataset.theme=themePref==='auto'?(themeMedia.matches?'light':'dark'):themePref;const s=document.getElementById('themeSelect');if(s)s.value=themePref}
themeMedia.addEventListener('change',()=>{if(themePref==='auto')applyTheme()});applyTheme();
async function loadPreferences(){try{const r=await apiFetch('/api/preferences',{cache:'no-store'});const d=await r.json();if(r.ok){if(['dark','light','auto'].includes(d.theme)){themePref=d.theme;localStorage.setItem('apTheme',themePref);applyTheme()}if(d.app_name)$('appNameInput').value=d.app_name;if(d.app_short_name)$('appShortNameInput').value=d.app_short_name;applyProjectTitle(d.app_name||PROJECT_NAME,d.app_short_name||PROJECT_SHORT_NAME)}}catch(_){}}
function setActionBusy(busy){actionBusy=busy;document.querySelectorAll('button').forEach(button=>{button.disabled=busy;button.style.opacity=busy?'0.55':'';button.style.cursor=busy?'wait':''})}
async function runAction(action){if(actionBusy)return;setActionBusy(true);try{return await action()}finally{setActionBusy(false)}}
function uptime(s){if(!s)return '—';let d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60),sec=s%60;return(d?d+'j ':'')+(h?String(h).padStart(2,'0')+'h ':'')+String(m).padStart(2,'0')+'m '+String(sec).padStart(2,'0')+'s'}
function esc(s){return String(s).replace(/([\\;,:])/g,'\\$1')}
function signalQuality(dbm){if(dbm===null||dbm===undefined)return '<span class="signal">—</span>';if(dbm>=-50)return '<span class="signal signal-excellent">Excellent</span>';if(dbm>=-60)return '<span class="signal signal-bon">Bon</span>';if(dbm>=-70)return '<span class="signal signal-moyen">Moyen</span>';return '<span class="signal signal-faible">Faible</span>'}
function qr(){if(!status.ssid||!status.password)return;let box=$('qrcode');let signature=status.ssid+'\u0000'+status.password;if(signature===qrSignature)return;$('qrPassword').value=status.password;box.innerHTML='';new QRCode(box,{text:`WIFI:T:WPA;S:${esc(status.ssid)};P:${esc(status.password)};;`,width:190,height:190});qrSignature=signature}
async function controlAp(action){return runAction(async()=>{try{const response=await apiFetch('/api/ap/control',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action})});const data=await response.json();if(!response.ok)throw Error(data.error||'Action refusée');await new Promise(resolve=>setTimeout(resolve,1000));await load()}catch(e){alert('Erreur : '+e.message)}})}
function updateApButtons(){const active=!!status.active,configured=!!status.configured;$('setupView').classList.toggle('active',!configured);$('dashboardView').classList.toggle('active',configured&&currentTab==='dashboard');$('settingsView').classList.toggle('active',configured&&currentTab==='settings');$('logsView').classList.toggle('active',configured&&currentTab==='logs');$('blockedView').classList.toggle('active',configured&&currentTab==='blocked');$('nav').style.display=configured?'flex':'none';$('dashboardTab').style.display=configured?'':'none';$('settingsTab').style.display=configured?'':'none';$('logsTab').style.display=configured?'':'none';$('blockedTab').style.display=configured?'':'none';$('apControl').style.display=configured?'':'none';$('apRestart').style.display=configured&&active?'':'none';$('refresh').style.display=configured?'':'none';if(configured){$('apControl').textContent=active?'Arrêter':'Démarrer';$('apControl').style.background=active?'var(--red)':'var(--blue)';$('apControl').style.color=active?'white':'#071326';}}
async function toggleBlock(mac,blocked){return runAction(async()=>{try{const response=await apiFetch('/api/clients/block',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mac,blocked})});const data=await response.json();if(!response.ok)throw Error(data.error||'Opération refusée');await load()}catch(e){alert('Erreur : '+e.message)}})}
function render(){ updateApButtons();$('clientCount').textContent=clients.length;$('ssid').textContent=status.ssid||'SSID inconnu';$('uptime').textContent=status.active?uptime(status.uptime_seconds):'—';$('settingsSsid').value=status.ssid||'';$('settingsPassword').value=status.password||'';$('settingsSecurity').value=status.security||'wpa2';$('settingsAutoconnect').checked=!!status.autoconnect;$('updated').textContent='Mis à jour '+new Date().toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'});$('state').innerHTML=status.active?'<i class="dot"></i> Actif':'<i class="dot" style="background:var(--red)"></i> Inactif';$('clients').innerHTML=clients.length?clients.map(c=>`<tr><td><div class="client"><div class="avatar">${(c.name||'?')[0].toUpperCase()}</div><strong>${c.name||'Nom non communiqué'}</strong></div></td><td>${c.ip||'Non attribuée'}</td><td>${c.mac||'—'}</td><td>${signalQuality(c.signal_dbm)}</td><td class="badge">${c.blocked?'● Bloqué':'● Connecté'}</td><td><button class="secondary" type="button" onclick="toggleBlock('${c.mac}',${!c.blocked})">${c.blocked?'Débloquer':'Bloquer'}</button></td></tr>`).join(''):'<tr><td colspan="6" class="empty">Aucun client connecté</td></tr>';qr()}
async function load(){try{let [a,b]=await Promise.all([apiFetch('/api/status',{cache:'no-store'}),apiFetch('/api/clients',{cache:'no-store'})]);if(!a.ok||!b.ok){const response=!a.ok?a:b;let detail='HTTP '+response.status;try{const body=await response.json();detail=body.error||detail}catch(_){ }throw Error(detail)}const nextStatus=await a.json();const nextClients=await b.json();const signature=nextClients.map(client=>client.mac).sort().join('|');const changed=previousClientSignature!==null&&signature!==previousClientSignature;status=nextStatus;clients=nextClients;previousClientSignature=signature;render();if(changed){loadLogs()}}catch(e){console.error('Chargement API :',e);if($('state'))$('state').textContent='API indisponible : '+e.message}}
async function loadLogs(){try{const response=await apiFetch('/api/logs',{cache:'no-store'});const data=await response.json();$('logs').innerHTML=data.length?data.map(log=>{const labels={connexion:'● Connexion Wi-Fi',déconnexion:'○ Déconnexion Wi-Fi',connexion_admin:'● Connexion Admin',connexion_code_recuperation:'🔑 Connexion Code Secours',echec_connexion:'❌ Échec Connexion',deconnexion_admin:'○ Déconnexion Admin',creation_compte_admin:'👤 Création Compte',modification_identifiants:'🔑 Modif. Identifiants',generation_secret_2fa:'⚙️ Secret 2FA généré',activation_2fa:'🛡️ Activation 2FA',desactivation_2fa:'🔓 Désactivation 2FA',regeneration_codes_secours:'🔄 Nouveaux Codes Secours',modification_preferences:'🎨 Modif. Préférences',suppression_journaux:'🗑️ Suppr. Journaux',reinitialisation_donnees:'⚠️ Réinitialisation Data',création:'+ Création AP',enregistrement_modifie:'✓ Config AP modifiée',blocage:'⛔ Blocage',déblocage:'✓ Déblocage',démarrage:'▶ Démarrage AP',arrêt:'■ Arrêt AP',redémarrage:'↻ Redémarrage AP'};const label=labels[log.event]||log.event;const badges={connexion_admin:'badge',connexion_code_recuperation:'badge',activation_2fa:'badge',creation_compte_admin:'badge',création:'badge',démarrage:'badge',connexion:'badge',déblocage:'signal-bon',regeneration_codes_secours:'signal-bon',modification_identifiants:'signal-bon',modification_preferences:'signal-bon',enregistrement_modifie:'signal-bon',desactivation_2fa:'signal-moyen',generation_secret_2fa:'signal-moyen',suppression_journaux:'signal-moyen',echec_connexion:'signal-faible',blocage:'signal-faible',arrêt:'signal-faible',reinitialisation_donnees:'signal-faible'};const style=badges[log.event]||'';const macText=log.mac==='AUTH'?'Authentification':log.mac==='CONFIG'?'Configuration':log.mac==='SYSTEM'?'Système':(log.mac||'—');return `<tr><td>${log.time}</td><td class="${style}">${label}</td><td>${log.name||'Nom non communiqué'}</td><td>${log.ip||'—'}</td><td>${macText}</td></tr>`}).join(''):'<tr><td colspan="5" class="empty">Aucun journal disponible</td></tr>'}catch(e){$('logs').innerHTML='<tr><td colspan="5" class="empty">API indisponible</td></tr>'}}
async function loadBlocked(){try{const response=await apiFetch('/api/clients/blocked',{cache:'no-store'});if(!response.ok)throw Error('API indisponible');const data=await response.json();$('blockedClients').innerHTML=data.length?data.map(c=>`<tr><td><div class="client"><div class="avatar">${String(c.name||'?')[0].toUpperCase()}</div><strong>${String(c.name||'Nom non communiqué').replace(/[&<>"']/g,ch=>'&#'+ch.charCodeAt(0)+';')}</strong></div></td><td>${c.mac}</td><td>${c.blocked_at}</td><td><button class="secondary" type="button" onclick="toggleBlock('${c.mac}',false);setTimeout(loadBlocked,300)">Débloquer</button></td></tr>`).join(''):'<tr><td colspan="4" class="empty">Aucun appareil bloqué</td></tr>'}catch(e){$('blockedClients').innerHTML='<tr><td colspan="4" class="empty">API indisponible</td></tr>'}}
function selectTab(tab){if(!status.configured)return;currentTab=tab;sessionStorage.setItem('apDashboardTab',tab);const dashboard=tab==='dashboard',settings=tab==='settings',logs=tab==='logs',blocked=tab==='blocked';$('dashboardView').classList.toggle('active',dashboard);$('settingsView').classList.toggle('active',settings);$('logsView').classList.toggle('active',logs);$('blockedView').classList.toggle('active',blocked);$('dashboardTab').classList.toggle('active',dashboard);$('settingsTab').classList.toggle('active',settings);$('logsTab').classList.toggle('active',logs);$('blockedTab').classList.toggle('active',blocked);$('dashboardTab').setAttribute('aria-selected',dashboard);$('settingsTab').setAttribute('aria-selected',settings);$('logsTab').setAttribute('aria-selected',logs);$('blockedTab').setAttribute('aria-selected',blocked);if(logs)loadLogs();if(blocked)loadBlocked();if(settings)load2fa()}
$('dashboardTab').onclick=()=>selectTab('dashboard');$('settingsTab').onclick=()=>selectTab('settings');$('logsTab').onclick=()=>selectTab('logs');$('blockedTab').onclick=()=>selectTab('blocked');$('refreshLogs').onclick=()=>runAction(loadLogs);$('refreshBlocked').onclick=()=>runAction(loadBlocked);
$('clearLogs').onclick=()=>runAction(async()=>{if(!confirm('Supprimer tous les journaux ?'))return;const response=await apiFetch('/api/logs/clear',{method:'POST'});if(response.ok)await loadLogs()});
const EYE_OPEN='<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.5-8 10-8 10 8 10 8-3.5 8-10 8S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg>';
const EYE_OFF='<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3l18 18M10.6 10.6a2 2 0 0 0 2.8 2.8M9.9 4.2A10.7 10.7 0 0 1 12 4c5 0 8.5 4 10 8a16.7 16.7 0 0 1-3.1 4.8M6.6 6.6C4.7 7.8 3.5 9.7 2 12c1.5 4 5 8 10 8 1 0 2-.2 2.9-.5"/></svg>';
function bindPasswordToggle(btnId,fieldId){const btn=$(btnId),field=$(fieldId);if(!btn||!field)return;btn.onclick=()=>{const isHidden=field.type==='password';field.type=isHidden?'text':'password';btn.innerHTML=isHidden?EYE_OFF:EYE_OPEN;const label=isHidden?'Masquer le mot de passe':'Afficher le mot de passe';btn.setAttribute('aria-label',label);btn.setAttribute('title',label)}}
bindPasswordToggle('toggleSetupPassword','setupPassword');
bindPasswordToggle('toggleSetupPasswordConfirm','setupPasswordConfirm');
bindPasswordToggle('toggleSettingsPassword','settingsPassword');
bindPasswordToggle('toggleAuthSettingsPassword','authSettingsPassword');
bindPasswordToggle('toggleAuthSettingsPasswordConfirm','authSettingsPasswordConfirm');
bindPasswordToggle('toggleAuthPassword','authPassword');
bindPasswordToggle('toggleCurrentPassword','authCurrentPassword');
$('apControl').onclick=()=>controlAp(status.active?'stop':'start');$('apRestart').onclick=()=>controlAp('restart');
$('clearAllData').onclick=()=>runAction(async()=>{if(!confirm('Supprimer définitivement les logs, les blocages et la configuration réseau ?'))return;const message=$('clearDataMessage');message.textContent='Suppression…';try{const response=await apiFetch('/api/data/clear',{method:'POST'});const data=await response.json();if(!response.ok)throw Error(data.error||'Suppression refusée');message.textContent=data.message;await new Promise(resolve=>setTimeout(resolve,500));await load()}catch(e){message.textContent='Erreur : '+e.message}});
$('setupAp').onclick=()=>runAction(async()=>{const message=$('setupMessage');const password=$('setupPassword').value;const confirmation=$('setupPasswordConfirm').value;if(password!==confirmation){message.textContent='Erreur : les mots de passe ne correspondent pas';return}message.textContent='Configuration…';try{const response=await apiFetch('/api/setup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ssid:$('setupSsid').value,password:password,security:$('setupSecurity').value})});const data=await response.json();if(!response.ok)throw Error(data.error||'Configuration refusée');message.textContent=data.message;currentTab='dashboard';sessionStorage.setItem('apDashboardTab','dashboard');await new Promise(resolve=>setTimeout(resolve,500));await load()}catch(e){message.textContent='Erreur : '+e.message}});
$('refresh').onclick=()=>runAction(load);
$('showPassword').onclick=()=>{const field=$('qrPassword');const button=$('showPassword');const hidden=field.type==='password';field.type=hidden?'text':'password';button.textContent=hidden?'Cacher le mot de passe':'Afficher le mot de passe';button.setAttribute('aria-label',button.textContent)};
$('saveSettings').onclick=()=>runAction(async()=>{let m=$('message');m.textContent='Enregistrement…';try{let r=await apiFetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ssid:$('settingsSsid').value,password:$('settingsPassword').value,security:$('settingsSecurity').value,autoconnect:$('settingsAutoconnect').checked})});let d=await r.json();if(!r.ok)throw Error(d.error);m.textContent=d.message;await new Promise(resolve=>setTimeout(resolve,1000));await load()}catch(e){m.textContent='Erreur : '+e.message}});
$('logout').onclick=logout;
$('saveCustomization').onclick=()=>{const appName=$('appNameInput').value.trim();const appShortName=$('appShortNameInput').value.trim();themePref=$('themeSelect').value;localStorage.setItem('apTheme',themePref);applyTheme();const m=$('themeMessage');m.textContent='Enregistrement…';apiFetch('/api/preferences',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({theme:themePref,app_name:appName,app_short_name:appShortName})}).then(async r=>{const d=await r.json();if(r.ok){applyProjectTitle(appName,appShortName);m.textContent='Personnalisation enregistrée sur le serveur'}else{m.textContent='Erreur : '+(d.error||'enregistrement refusé')}}).catch(e=>{m.textContent='Erreur : '+e.message})};
$('authSubmit').onclick=async()=>{const message=$('authMessage');message.textContent='Traitement…';try{const path=authSetupMode?'/api/auth/setup':'/api/login';const response=await fetch(API_URL+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:$('authUsername').value,password:$('authPassword').value,otp:$('authOtp').value.trim()})});const data=await response.json();if(!response.ok)throw Error(data.error||'Authentification refusée');if(authSetupMode){message.textContent='Compte créé. Connexion…';authSetupMode=false;return $('authSubmit').click()}authToken=data.token;sessionStorage.setItem('apAuthToken',authToken);currentTab='dashboard';sessionStorage.setItem('apDashboardTab','dashboard');if($('rememberUser').checked){localStorage.setItem('apRememberedUser',$('authUsername').value.trim());saveAuthCookie(authToken)}else{localStorage.removeItem('apRememberedUser');clearAuthCookie()}$('authOtp').value='';showApp();await load();selectTab('dashboard')}catch(e){message.textContent='Erreur : '+e.message}};
$('saveAuth').onclick=()=>runAction(async()=>{const message=$('authSettingsMessage');const password=$('authSettingsPassword').value;const confirmation=$('authSettingsPasswordConfirm').value;if(password!==confirmation){message.textContent='Erreur : les mots de passe ne correspondent pas';return}try{const response=await apiFetch('/api/auth/password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:$('authSettingsUsername').value,current_password:$('authCurrentPassword').value,password})});const data=await response.json();if(!response.ok)throw Error(data.error||'Modification refusée');message.textContent=data.message;$('authCurrentPassword').value='';$('authSettingsPassword').value='';$('authSettingsPasswordConfirm').value=''}catch(e){message.textContent='Erreur : '+e.message}});
/* Double identification (2FA) */
let twoFaPending=false;
let currentRecoveryCodes=[];
function displayRecoveryCodes(codes){
currentRecoveryCodes=codes||[];
const grid=$('twoFaRecoveryGrid');
grid.replaceChildren();
currentRecoveryCodes.forEach(code=>{
const div=document.createElement('div');
div.className='code-chip';
div.textContent=code;
grid.appendChild(div);
});
$('twoFaRecoveryDisplay').style.display='block';
}
$('copyRecoveryCodes').onclick=()=>{
if(!currentRecoveryCodes.length)return;
const text="Codes de récupération 2FA AP Wi-Fi:\n"+currentRecoveryCodes.join("\n");
navigator.clipboard.writeText(text).then(()=>alert("Codes copiés dans le presse-papier !")).catch(()=>{
const ta=document.createElement('textarea');ta.value=text;document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);alert("Codes copiés dans le presse-papier !");
});
};
function downloadRecoveryFile(codes){
if(!codes||!codes.length)return;
const text="Codes de récupération 2FA - AP Wi-Fi\nConservez ces codes en lieu sûr.\n\n"+codes.join("\n")+"\n";
const blob=new Blob([text],{type:'text/plain;charset=utf-8'});
const url=URL.createObjectURL(blob);
const a=document.createElement('a');a.href=url;a.download='codes-de-recuperation-2fa.txt';document.body.appendChild(a);a.click();document.body.removeChild(a);URL.revokeObjectURL(url);
}
$('downloadRecoveryCodes').onclick=()=>downloadRecoveryFile(currentRecoveryCodes);
function render2fa(enabled,recoveryRemaining){
authTwoFactor=enabled;
if(enabled){
$('twoFaState').textContent='Activée : un code TOTP ou un code de secours sera demandé à chaque connexion. (Codes de secours restants : '+(recoveryRemaining!==undefined?recoveryRemaining:'?')+' / 8)';
$('twoFaCode').style.display='block';
$('twoFaHelpText').style.display='block';
$('twoFaDownloadMenuBtn').style.display='';
$('twoFaRegenRecovery').style.display='';
$('twoFaDisable').style.display='';
$('twoFaEnable').style.display='none';
$('twoFaConfirm').style.display='none';
$('twoFaSetup').style.display='none';
}else if(twoFaPending){
$('twoFaState').textContent='Activation en cours : scannez le QR code et saisissez le code à 6 chiffres de votre application.';
$('twoFaSetup').style.display='block';
$('twoFaCode').style.display='block';
$('twoFaConfirm').style.display='';
$('twoFaHelpText').style.display='none';
$('twoFaEnable').style.display='none';
$('twoFaDisable').style.display='none';
$('twoFaDownloadMenuBtn').style.display='none';
$('twoFaRegenRecovery').style.display='none';
}else{
$('twoFaState').textContent='Désactivée.';
$('twoFaSetup').style.display='none';
$('twoFaCode').style.display='none';
$('twoFaHelpText').style.display='none';
$('twoFaEnable').style.display='';
$('twoFaConfirm').style.display='none';
$('twoFaDisable').style.display='none';
$('twoFaDownloadMenuBtn').style.display='none';
$('twoFaRegenRecovery').style.display='none';
$('twoFaRecoveryDisplay').style.display='none';
}
}
async function load2fa(){
try{
const response=await apiFetch('/api/auth/2fa/status',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
const data=await response.json();
if(!response.ok)throw Error(data.error||'Statut 2FA indisponible');
render2fa(!!data.enabled,data.recovery_remaining);
}catch(e){$('twoFaState').textContent='Erreur : '+e.message;}
}
$('twoFaEnable').onclick=()=>runAction(async()=>{
const message=$('twoFaMessage');message.textContent='';
try{
if(!$('authCurrentPassword').value)throw Error('Saisissez votre mot de passe actuel ci-dessus');
const response=await apiFetch('/api/auth/2fa/enable',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:$('authCurrentPassword').value})});
const data=await response.json();
if(!response.ok)throw Error(data.error||'Activation refusée');
const user=encodeURIComponent($('authSettingsUsername').value||'admin');
$('twoFaQr').replaceChildren();
new QRCode($('twoFaQr'),{text:'otpauth://totp/AP%20Wi-Fi:'+user+'?secret='+data.secret+'&issuer=AP%20Wi-Fi',width:160,height:160});
$('twoFaSecret').textContent=data.secret;
twoFaPending=true;
render2fa(false);
message.textContent=data.message;
}catch(e){message.textContent='Erreur : '+e.message;}
});
$('twoFaConfirm').onclick=()=>runAction(async()=>{
const message=$('twoFaMessage');
try{
const response=await apiFetch('/api/auth/2fa/confirm',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({code:$('twoFaCode').value.trim(),password:$('authCurrentPassword').value})});
const data=await response.json();
if(!response.ok)throw Error(data.error||'Code refusé');
twoFaPending=false;
$('twoFaCode').value='';
$('twoFaQr').replaceChildren();
$('twoFaSecret').textContent='';
if(data.recovery_codes)displayRecoveryCodes(data.recovery_codes);
await load2fa();
message.textContent=data.message||'Double identification activée avec succès !';
}catch(e){message.textContent='Erreur : '+e.message;}
});
$('twoFaDownloadMenuBtn').onclick=()=>runAction(async()=>{
const message=$('twoFaMessage');
if(currentRecoveryCodes.length){
downloadRecoveryFile(currentRecoveryCodes);
message.textContent='Fichier des codes de récupération téléchargé !';
return;
}
message.textContent='Génération et téléchargement des codes de secours…';
try{
if(!$('authCurrentPassword').value)throw Error('Saisissez votre mot de passe actuel ci-dessus');
const response=await apiFetch('/api/auth/2fa/recovery',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:$('authCurrentPassword').value,code:$('twoFaCode').value.trim()})});
const data=await response.json();
if(!response.ok)throw Error(data.error||'Génération refusée');
if(data.recovery_codes){
displayRecoveryCodes(data.recovery_codes);
downloadRecoveryFile(data.recovery_codes);
}
$('twoFaCode').value='';
await load2fa();
message.textContent=data.message||'Nouveaux codes générés et téléchargés avec succès !';
}catch(e){message.textContent='Erreur : '+e.message;}
});
$('twoFaRegenRecovery').onclick=()=>runAction(async()=>{
const message=$('twoFaMessage');
message.textContent='Génération de nouveaux codes…';
try{
if(!$('authCurrentPassword').value)throw Error('Saisissez votre mot de passe actuel ci-dessus');
const response=await apiFetch('/api/auth/2fa/recovery',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:$('authCurrentPassword').value,code:$('twoFaCode').value.trim()})});
const data=await response.json();
if(!response.ok)throw Error(data.error||'Régénération refusée');
if(data.recovery_codes)displayRecoveryCodes(data.recovery_codes);
$('twoFaCode').value='';
await load2fa();
message.textContent=data.message||'Nouveaux codes générés avec succès !';
}catch(e){message.textContent='Erreur : '+e.message;}
});
$('twoFaDisable').onclick=()=>runAction(async()=>{
const message=$('twoFaMessage');
try{
const response=await apiFetch('/api/auth/2fa/disable',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({code:$('twoFaCode').value.trim(),password:$('authCurrentPassword').value})});
const data=await response.json();
if(!response.ok)throw Error(data.error||'Désactivation refusée');
$('twoFaCode').value='';
twoFaPending=false;
$('twoFaRecoveryDisplay').style.display='none';
currentRecoveryCodes=[];
await load2fa();
message.textContent=data.message;
}catch(e){message.textContent='Erreur : '+e.message;}
});
(async()=>{applyProjectTitle();let data=null;for(let attempt=1;!data;attempt++){try{const response=await fetch(API_URL+'/api/auth/status',{cache:'no-store'});if(!response.ok)throw Error('HTTP '+response.status);data=await response.json()}catch(e){$('loaderText').textContent='API indisponible, nouvelle tentative ('+attempt+')…';await new Promise(resolve=>setTimeout(resolve,1500))}}authConfigured=data.configured;authTwoFactor=!!data.two_factor;if(data.app_name)applyProjectTitle(data.app_name,data.app_short_name);if(authToken){showApp();await load()}else showAuth(!authConfigured);$('loader').classList.add('hide');setTimeout(()=>$('loader').remove(),400)})();
setInterval(()=>{if(authToken&&currentTab!=='settings')load()},2000);
</script></body></html>