APmanageur Wi-Fi : conteneur Debian + Apache

This commit is contained in:
yoann committed 2026-10-04 19:56:51 +02:00
commit dbebafd34a
8 files changed
+906

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
FROM debian:13-slim
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONUNBUFFERED=1
# Apache2 + NetworkManager embarqué (pour la carte Netgear) + outils Wi-Fi + Python
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
apache2 python3 \
network-manager wpasupplicant dbus \
iw iproute2 rfkill wireless-regdb \
dnsmasq-base iptables nftables procps \
&& a2enmod proxy proxy_http headers \
&& rm -rf /var/lib/apt/lists/*
# Vos fichiers
WORKDIR /app
COPY ap.py /app/ap.py
# Nom de votre fichier HTML dans le dossier de build (par défaut index.html)
ARG HTML_FILE=index.html
COPY ${HTML_FILE} /app/index.html
COPY ${HTML_FILE} /var/www/html/index.html
COPY apache-ap.conf /etc/apache2/sites-available/000-default.conf
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh && rm -f /var/www/html/index.html.orig \
&& sed -i 's/^Listen 80$/Listen 1001/; s/^\s*Listen 443$/ Listen 1443/' /etc/apache2/ports.conf
ENV AP_DB_FILE=/data/ap-dashboard.sqlite3 \
AP_CONNECTION_FILE=/data/connection \
AP_UPTIME_FILE=/data/uptime
VOLUME ["/data", "/etc/NetworkManager/system-connections", "/var/lib/NetworkManager"]
EXPOSE 1001 8080
ENTRYPOINT ["/entrypoint.sh"]
+2
View File
@@ -0,0 +1,2 @@
# apmanageur
+505
View File
@@ -0,0 +1,505 @@
#!/usr/bin/env python3
import base64, hashlib, hmac, json, os, re, secrets, socket, sqlite3, subprocess, threading, time, urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
INTERFACE = os.environ.get('AP_INTERFACE', 'wls16b1')
HOST, PORT = '0.0.0.0', 8080
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_FILE = os.environ.get('AP_DB_FILE', os.path.join(BASE_DIR, 'ap-dashboard.sqlite3'))
CONNECTION_FILE = os.environ.get('AP_CONNECTION_FILE', os.path.join(BASE_DIR, 'ap-dashboard-connection'))
UPTIME_FILE = os.environ.get('AP_UPTIME_FILE', os.path.join(BASE_DIR, 'ap-dashboard-uptime'))
# Auto-migration de l'ancienne DB si présente
OLD_DB_FILE = '/var/lib/ap-dashboard.sqlite3'
if not os.path.exists(DB_FILE) and os.path.exists(OLD_DB_FILE):
try:
import shutil
shutil.copy2(OLD_DB_FILE, DB_FILE)
except Exception: pass
SESSION_TTL = 3600
sessions = {}
last_macs = set()
def uptime_start():
try: return float(open(UPTIME_FILE, encoding='utf8').read().strip())
except (OSError, ValueError): return None
def mark_ap_started():
try:
folder = os.path.dirname(UPTIME_FILE)
if folder: os.makedirs(folder, exist_ok=True)
open(UPTIME_FILE, 'w', encoding='utf8').write(str(time.time()))
except OSError: pass
def ap_uptime():
started = uptime_start()
return max(0, int(time.time() - started)) if started else 0
def device_name(ip, mac):
mac = (mac or '').upper()
# Les baux dnsmasq peuvent être dans plusieurs fichiers selon NetworkManager.
lease_files = ['/var/lib/NetworkManager/dnsmasq.leases', '/var/lib/misc/dnsmasq.leases']
try:
import glob
lease_files += glob.glob('/var/lib/NetworkManager/dnsmasq-*.leases')
for lease_file in lease_files:
with open(lease_file, encoding='utf8') as f:
for line in f:
fields = line.strip().split()
if len(fields) >= 4 and (fields[1].upper() == mac or fields[2] == ip):
hostname = fields[3].strip()
if hostname and hostname != '*': return hostname
except (OSError, UnicodeError): pass
# NetworkManager/dnsmasq écrit parfois uniquement le DHCPACK dans le journal.
try:
journal = command('journalctl','-u','NetworkManager','--no-pager','-n','500')
pattern = r'DHCPACK\([^)]*\)\s+(\S+)\s+([0-9a-f:]{17})\s+(\S+)'
for line in reversed(journal.splitlines()):
match = re.search(pattern, line, re.I)
if match and (match.group(1) == ip or match.group(2).upper() == mac):
hostname = match.group(3).strip()
if hostname and hostname != '*': return hostname
except Exception: pass
if ip and ip not in ('Non attribuée', '—'):
try:
name = socket.gethostbyaddr(ip)[0]
if name and name != ip: return name.split('.')[0]
except (socket.herror, socket.gaierror, OSError): pass
host_line = command('ip','neigh','show',ip)
match = re.search(r'\(([^)]+)\)', host_line)
if match and match.group(1) != ip: return match.group(1)
try:
c = db(); row = c.execute('SELECT name FROM client_names WHERE mac=?', (mac,)).fetchone(); c.close()
if row and row[0]: return row[0]
except sqlite3.Error: pass
return 'Client ' + mac.replace(':', '')[-4:] if mac else 'Nom non communiqué'
# __AUTHENTICATION_IMPLEMENTATION__
def db():
folder = os.path.dirname(DB_FILE)
if folder: os.makedirs(folder, exist_ok=True)
c = sqlite3.connect(DB_FILE)
c.execute('CREATE TABLE IF NOT EXISTS auth_users (username TEXT PRIMARY KEY, password_hash TEXT NOT NULL)')
columns = {row[1] for row in c.execute('PRAGMA table_info(auth_users)').fetchall()}
if 'totp_secret' not in columns:
c.execute('ALTER TABLE auth_users ADD COLUMN totp_secret TEXT DEFAULT NULL')
c.commit()
recovery_table(c)
c.execute('CREATE TABLE IF NOT EXISTS connection_logs(id INTEGER PRIMARY KEY,event_time TEXT,event TEXT,name TEXT,ip TEXT,mac TEXT)')
c.execute('CREATE TABLE IF NOT EXISTS blocked_clients(mac TEXT PRIMARY KEY,blocked_at TEXT)')
c.execute('CREATE TABLE IF NOT EXISTS client_names(mac TEXT PRIMARY KEY,name TEXT NOT NULL,last_ip TEXT,last_seen TEXT)')
c.execute('CREATE TABLE IF NOT EXISTS preferences(key TEXT PRIMARY KEY, value TEXT NOT NULL)')
c.commit(); return c
def password_hash(value, salt=None):
salt = salt or secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac('sha256', value.encode(), salt, 240000)
return salt.hex() + ':' + digest.hex()
def password_ok(value, stored):
try:
salt, digest = stored.split(':', 1)
actual = hashlib.pbkdf2_hmac('sha256', value.encode(), bytes.fromhex(salt), 240000).hex()
return hmac.compare_digest(actual, digest)
except (ValueError, TypeError): return False
def totp_code(secret, counter=None):
counter = int(time.time() // 30) if counter is None else counter
key = base64.b32decode(secret + '=' * (-len(secret) % 8), casefold=True)
raw = hmac.new(key, counter.to_bytes(8, 'big'), hashlib.sha1).digest()
offset = raw[-1] & 15
number = int.from_bytes(raw[offset:offset + 4], 'big') & 0x7fffffff
return f'{number % 1000000:06d}'
def totp_ok(secret, code):
code = str(code or '').strip()
if not re.fullmatch(r'\d{6}', code): return False
counter = int(time.time() // 30)
return any(hmac.compare_digest(totp_code(secret, counter + drift), code) for drift in (-1, 0, 1))
def auth_state():
c = db(); row = c.execute('SELECT username,totp_secret FROM auth_users LIMIT 1').fetchone()
rows = dict(c.execute("SELECT key,value FROM preferences").fetchall()); c.close()
return {
'configured': bool(row),
'two_factor': bool(row and row[1]),
'app_name': rows.get('app_name', 'Administration de l’AP Wi‑Fi'),
'app_short_name': rows.get('app_short_name', 'AP Manageur')
}
RECOVERY_COUNT = 8
def recovery_table(c):
c.execute('CREATE TABLE IF NOT EXISTS recovery_codes(code_hash TEXT PRIMARY KEY, used INTEGER NOT NULL DEFAULT 0)')
def recovery_hash(code):
return hashlib.sha256(re.sub(r'[^A-Z0-9]', '', str(code or '').upper()).encode()).hexdigest()
def recovery_generate():
alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
codes = []
for _ in range(RECOVERY_COUNT):
raw = ''.join(secrets.choice(alphabet) for _ in range(10))
codes.append(raw[:5] + '-' + raw[5:])
c = db(); recovery_table(c); c.execute('DELETE FROM recovery_codes')
c.executemany('INSERT INTO recovery_codes(code_hash) VALUES(?)', [(recovery_hash(x),) for x in codes]); c.commit(); c.close()
return codes
def recovery_use(code):
cleaned = re.sub(r'[^A-Za-z0-9]', '', str(code or ''))
if len(cleaned) != 10: return False
c = db(); recovery_table(c)
cur = c.execute('UPDATE recovery_codes SET used=1 WHERE code_hash=? AND used=0', (recovery_hash(cleaned),))
c.commit(); ok = cur.rowcount == 1; c.close(); return ok
def recovery_remaining():
c = db(); recovery_table(c); n = c.execute('SELECT COUNT(*) FROM recovery_codes WHERE used=0').fetchone()[0]; c.close(); return n
def recovery_clear():
c = db(); recovery_table(c); c.execute('DELETE FROM recovery_codes'); c.commit(); c.close()
def second_factor_ok(secret, code):
return totp_ok(secret, code) or recovery_use(code)
def create_user(username, password, secret=None):
if not re.fullmatch(r'[A-Za-z0-9_.-]{3,32}', username or ''): raise ValueError('Nom utilisateur invalide')
if not 8 <= len(password) <= 128: raise ValueError('Le mot de passe doit contenir entre 8 et 128 caractères')
c = db(); c.execute('DELETE FROM auth_users'); c.execute('INSERT INTO auth_users VALUES(?,?,?)', (username, password_hash(password), secret)); c.commit(); c.close()
def verify_user(username, password):
c = db(); row = c.execute('SELECT username,password_hash,totp_secret FROM auth_users WHERE username=?', (username.strip(),)).fetchone(); c.close()
return row if row and password_ok(password, row[1]) else None
def new_session(username):
token = secrets.token_urlsafe(32); sessions[token] = {'username': username, 'expires': time.time() + SESSION_TTL}; return token
def valid_session(h):
token = h.headers.get('X-Session-Token', ''); s = sessions.get(token)
if isinstance(s, dict) and s['expires'] > time.time(): return True
sessions.pop(token, None); return False
def username(h):
s = sessions.get(h.headers.get('X-Session-Token', ''), {}); return s.get('username', '') if isinstance(s, dict) else ''
def command(*args):
try: return subprocess.check_output(args, text=True, stderr=subprocess.DEVNULL).strip()
except (subprocess.CalledProcessError, FileNotFoundError): return ''
def read_json(h):
return json.loads(h.rfile.read(int(h.headers.get('Content-Length', '0'))) or b'{}')
def request_ip(h): return h.headers.get('X-Real-IP', '').strip() or h.client_address[0]
def log(event, name='Nom non communiqué', ip='Non attribuée', mac='—'):
try:
c = db(); c.execute('INSERT INTO connection_logs(event_time,event,name,ip,mac) VALUES(?,?,?,?,?)', (time.strftime('%Y-%m-%d %H:%M:%S'), event, name, ip, mac)); c.commit(); c.close()
except sqlite3.Error: pass
def logs():
c = db(); rows = c.execute('SELECT event_time,event,name,ip,mac FROM connection_logs ORDER BY id DESC LIMIT 200').fetchall(); c.close()
return [{'time':r[0], 'event':r[1], 'name':r[2], 'ip':r[3], 'mac':r[4]} for r in rows]
def connection_name():
active = command('nmcli','-t','-g','GENERAL.CONNECTION','device','show',INTERFACE)
if active and active != '--': return active
try: return open(CONNECTION_FILE, encoding='utf8').read().strip()
except OSError: return os.environ.get('AP_CONNECTION', '')
def save_connection(name):
try: open(CONNECTION_FILE, 'w', encoding='utf8').write(name)
except OSError: pass
def security(value):
values = {'wpa1':('wpa-psk','wpa','optional'), 'wpa2':('wpa-psk','rsn','optional'), 'wpa3':('sae',None,'required')}
value = str(value or 'wpa2').lower()
if value not in values: raise ValueError('Sécurité Wi-Fi invalide')
key, proto, pmf = values[value]; return value, key, proto, pmf
def status():
name = connection_name(); ssid = command('nmcli','-t','-g','802-11-wireless.ssid','connection','show',name) if name else ''
password = command('nmcli','--show-secrets','-t','-g','802-11-wireless-security.psk','connection','show',name) if name else ''
key = command('nmcli','-t','-g','802-11-wireless-security.key-mgmt','connection','show',name).lower() if name else ''
proto = command('nmcli','-t','-g','802-11-wireless-security.proto','connection','show',name).lower() if name else ''
sec = 'wpa3' if key == 'sae' else 'wpa1' if proto == 'wpa' else 'wpa2'
state = command('nmcli','-t','-g','GENERAL.STATE','device','show',INTERFACE)
# CORRECTION : "30 (disconnected)" contient "connected" ; seul le code 100 signifie connecté.
active = state.startswith('100')
auto = command('nmcli','-t','-g','connection.autoconnect','connection','show',name).lower() == 'yes' if name else False
if active and uptime_start() is None:
mark_ap_started()
return {'ssid':ssid or 'SSID inconnu','password':password,'security':sec,'autoconnect':auto,'interface':INTERFACE,'connection':name,'active':active,'configured':bool(name),'uptime_seconds':ap_uptime() if active else 0}
def blocked_set():
c = db(); rows = c.execute('SELECT mac FROM blocked_clients').fetchall(); c.close(); return {r[0] for r in rows}
def lease_ip(mac):
mac = (mac or '').upper()
try:
import glob
files = ['/var/lib/NetworkManager/dnsmasq.leases', '/var/lib/misc/dnsmasq.leases']
files += glob.glob('/var/lib/NetworkManager/dnsmasq-*.leases')
for path in files:
with open(path, encoding='utf8') as f:
for line in f:
fields = line.strip().split()
if len(fields) >= 3 and fields[1].upper() == mac:
ip = fields[2].strip()
if re.fullmatch(r'\d{1,3}(?:\.\d{1,3}){3}', ip): return ip
except (OSError, UnicodeError): pass
return ''
def clients():
neigh = command('ip','neigh','show','dev',INTERFACE); ipmac = {}
for line in neigh.splitlines():
m = re.search(r'^(\S+).*?lladdr ([0-9a-f:]{17})', line, re.I)
if m: ipmac[m.group(2).upper()] = m.group(1)
output = []; dump = command('iw','dev',INTERFACE,'station','dump'); blocked_macs = blocked_set()
for m in re.finditer(r'^Station ([0-9a-f:]{17})(.*?)(?=^Station |\Z)', dump, re.I|re.M|re.S):
mac = m.group(1).upper(); detail = m.group(2); sm = re.search(r'^\s*signal:\s*(-?\d+)', detail, re.I|re.M); ip = ipmac.get(mac, '') or lease_ip(mac); blocked = mac in blocked_macs; name = device_name(ip or 'Non attribuée', mac)
if not ip:
try:
c = db(); row = c.execute('SELECT last_ip FROM client_names WHERE mac=?', (mac,)).fetchone(); c.close(); ip = row[0] if row and row[0] else 'Non attribuée'
except sqlite3.Error: ip = 'Non attribuée'
if not name.startswith('Client ') and name != 'Nom non communiqué':
try:
c = db(); c.execute('INSERT INTO client_names(mac,name,last_ip,last_seen) VALUES(?,?,?,?) ON CONFLICT(mac) DO UPDATE SET name=excluded.name,last_ip=CASE WHEN excluded.last_ip != \'Non attribuée\' THEN excluded.last_ip ELSE client_names.last_ip END,last_seen=excluded.last_seen', (mac,name,ip,time.strftime('%Y-%m-%d %H:%M:%S'))); c.commit(); c.close()
except sqlite3.Error: pass
output.append({'name':name,'ip':ip,'mac':mac,'signal_dbm':int(sm.group(1)) if sm else None,'blocked':blocked,'connected':not blocked})
return output
def known_device(mac):
name, ip = '', 'Non attribuée'
try:
c = db(); row = c.execute('SELECT name,last_ip FROM client_names WHERE mac=?', (mac,)).fetchone(); c.close()
if row: name, ip = row[0] or '', row[1] or ip
except sqlite3.Error: pass
return name or device_name(ip, mac), ip
def set_blocked(mac, blocked):
mac = mac.upper()
if not re.fullmatch(r'[0-9A-F]{2}(:[0-9A-F]{2}){5}', mac): raise ValueError('Adresse MAC invalide')
name, ip = known_device(mac)
c = db()
if blocked: c.execute('INSERT OR IGNORE INTO blocked_clients VALUES(?,?)',(mac,time.strftime('%Y-%m-%d %H:%M:%S'))); command('iw','dev',INTERFACE,'station','del',mac)
else: c.execute('DELETE FROM blocked_clients WHERE mac=?',(mac,))
c.commit(); c.close(); log('blocage' if blocked else 'déblocage', name, ip, mac)
def blocked_list():
c = db(); rows = c.execute('SELECT b.mac,b.blocked_at,n.name FROM blocked_clients b LEFT JOIN client_names n ON n.mac=b.mac ORDER BY b.blocked_at DESC').fetchall(); c.close()
return [{'mac':r[0],'blocked_at':r[1],'name':r[2] or 'Client ' + r[0].replace(':', '')[-4:]} for r in rows]
def update_logs(items):
global last_macs
current = {x['mac']:x for x in items}
for mac,x in current.items():
if mac not in last_macs: log('connexion', x['name'], x['ip'], mac)
for mac in last_macs - set(current):
name, ip = 'Nom non communiqué', 'Non attribuée'
try:
c = db(); row = c.execute('SELECT name,last_ip FROM client_names WHERE mac=?', (mac,)).fetchone(); c.close()
if row:
name = row[0] or name
ip = row[1] or ip
except sqlite3.Error: pass
log('déconnexion', name, ip, mac)
last_macs = set(current)
def configure_ap(data, initial=False, caller_ip=None):
ssid, password = str(data.get('ssid','')).strip(), str(data.get('password',''))
sec, key, proto, pmf = security(data.get('security','wpa2'))
if not ssid or len(ssid)>32 or not 8<=len(password)<=63: raise ValueError('SSID ou mot de passe Wi-Fi invalide')
name = ('AP-' + ssid) if initial else connection_name()
if not name: raise ValueError('Aucune connexion AP configurée')
if initial:
r=subprocess.run(['nmcli','connection','add','type','wifi','ifname',INTERFACE,'con-name',name,'ssid',ssid],text=True,capture_output=True)
if r.returncode and 'already exists' not in r.stderr.lower(): raise RuntimeError(r.stderr.strip())
args=['nmcli','connection','modify',name,'802-11-wireless.mode','ap','802-11-wireless.ssid',ssid,'802-11-wireless-security.key-mgmt',key,'802-11-wireless-security.psk',password,'802-11-wireless-security.pmf',pmf,'ipv4.method','shared','ipv6.method','ignore','connection.autoconnect','yes' if data.get('autoconnect',True) else 'no']
if proto: args += ['802-11-wireless-security.proto',proto]
r=subprocess.run(args,text=True,capture_output=True)
if r.returncode: raise RuntimeError(r.stderr.strip() or 'NetworkManager a refusé la configuration')
save_connection(name); r=subprocess.run(['nmcli','connection','up',name],text=True,capture_output=True)
if r.returncode: raise RuntimeError(r.stderr.strip() or 'Impossible de démarrer le point d’accès')
mark_ap_started(); log('création' if initial else 'enregistrement_modifie', 'Réseau Wi-Fi '+ssid, ip=caller_ip or 'Non attribuée', mac='AP'); return name
class Handler(BaseHTTPRequestHandler):
def send_json(self, data, status=200):
body=json.dumps(data,ensure_ascii=False).encode(); self.send_response(status); self.send_header('Content-Type','application/json; charset=utf-8'); self.send_header('Access-Control-Allow-Origin','*'); self.send_header('Access-Control-Allow-Headers','Content-Type, X-Session-Token'); self.send_header('Access-Control-Allow-Methods','GET, POST, OPTIONS'); self.send_header('Content-Length',str(len(body))); self.end_headers(); self.wfile.write(body)
def do_OPTIONS(self): self.send_response(204); self.send_header('Access-Control-Allow-Origin','*'); self.send_header('Access-Control-Allow-Headers','Content-Type, X-Session-Token'); self.send_header('Access-Control-Allow-Methods','GET, POST, OPTIONS'); self.end_headers()
def do_GET(self):
if self.path in ('/','/index.html','/ap_dashboard.html'):
try: body=open(os.path.join(os.path.dirname(__file__),'index.html'),'rb').read(); self.send_response(200); self.send_header('Content-Type','text/html; charset=utf-8'); self.send_header('Content-Length',str(len(body))); self.end_headers(); self.wfile.write(body)
except OSError: self.send_error(404,'index.html introuvable')
return
if self.path == '/api/auth/status': self.send_json(auth_state()); return
if not valid_session(self): self.send_json({'error':'Authentification requise'},401); return
if self.path == '/api/status': self.send_json(status())
elif self.path == '/api/clients': self.send_json(clients())
elif self.path == '/api/clients/blocked': self.send_json(blocked_list())
elif self.path == '/api/logs': self.send_json(logs())
elif self.path == '/api/preferences':
c=db(); rows=dict(c.execute("SELECT key,value FROM preferences").fetchall()); c.close()
self.send_json({
'theme': rows.get('theme', 'dark'),
'app_name': rows.get('app_name', 'Administration de l’AP Wi‑Fi'),
'app_short_name': rows.get('app_short_name', 'AP Manageur')
})
else: self.send_json({'error':'Route inconnue'},404)
def do_POST(self):
try: data=read_json(self)
except Exception: self.send_json({'error':'JSON invalide'},400); return
if not isinstance(data, dict): self.send_json({'error':'JSON invalide'},400); return
try:
if self.path == '/api/auth/setup':
if auth_state()['configured']: raise ValueError('Un compte existe déjà')
u=str(data.get('username',''))
create_user(u,str(data.get('password','')),None)
log('creation_compte_admin', u, request_ip(self), 'AUTH')
self.send_json({'ok':True,'message':'Compte créé','two_factor_enabled':False}); return
if self.path == '/api/login':
u=str(data.get('username',''))
row=verify_user(u,str(data.get('password','')))
if not row:
log('echec_connexion', u or 'Inconnu', request_ip(self), 'AUTH')
raise ValueError('Utilisateur ou mot de passe incorrect')
used_recovery = False
if row[2]:
otp=data.get('otp')
if not totp_ok(row[2],otp):
if not recovery_use(otp):
log('echec_connexion', row[0], request_ip(self), 'AUTH')
raise ValueError('Code de double identification incorrect')
used_recovery = True
log('connexion_code_recuperation', row[0], request_ip(self), 'AUTH')
else:
log('connexion_admin', row[0], request_ip(self), 'AUTH')
else:
log('connexion_admin', row[0], request_ip(self), 'AUTH')
self.send_json({'ok':True,'token':new_session(row[0]),'used_recovery':used_recovery}); return
if self.path == '/api/logout':
log('deconnexion_admin', username(self) or 'Administrateur', request_ip(self), 'AUTH')
sessions.pop(self.headers.get('X-Session-Token',''),None); self.send_json({'ok':True}); return
if not valid_session(self): self.send_json({'error':'Authentification requise'},401); return
if self.path == '/api/clients/block': set_blocked(str(data.get('mac','')),bool(data.get('blocked',True))); self.send_json({'ok':True}); return
if self.path == '/api/ap/control':
action=str(data.get('action','')); name=connection_name()
if not name: raise ValueError('Aucune connexion AP configurée')
cmd=['nmcli','connection','up' if action=='start' else 'down',name] if action in ('start','stop') else ['nmcli','connection','down',name]
r=subprocess.run(cmd,capture_output=True,text=True)
if action=='restart': r=subprocess.run(['nmcli','connection','up',name],capture_output=True,text=True)
if r.returncode: raise RuntimeError(r.stderr.strip())
if action in ('start','restart'): mark_ap_started()
elif action == 'stop':
try: os.remove(UPTIME_FILE)
except OSError: pass
log({'start':'démarrage','stop':'arrêt','restart':'redémarrage'}.get(action,'action'), 'Point d’accès Wi-Fi', ip=request_ip(self), mac='AP'); self.send_json({'ok':True}); return
if self.path == '/api/setup': self.send_json({'ok':True,'message':'Configuration enregistrée','connection':configure_ap(data,True,caller_ip=request_ip(self))}); return
if self.path == '/api/settings': self.send_json({'ok':True,'message':'Réglages enregistrés','connection':configure_ap(data,caller_ip=request_ip(self))}); return
if self.path == '/api/logs/clear':
c=db(); c.execute('DELETE FROM connection_logs'); c.commit(); c.close()
log('suppression_journaux', username(self) or 'Administrateur', request_ip(self), 'SYSTEM')
self.send_json({'ok':True,'message':'Journaux supprimés'}); return
if self.path == '/api/data/clear':
name = connection_name()
names_to_delete = set()
if name: names_to_delete.add(name)
out = subprocess.run(['nmcli', '-t', '-g', 'NAME', 'connection', 'show'], capture_output=True, text=True).stdout
for line in out.splitlines():
line = line.strip()
if line.startswith('AP-') or line == name:
names_to_delete.add(line)
for n in names_to_delete:
if n:
subprocess.run(['nmcli', 'connection', 'down', n], capture_output=True, text=True)
subprocess.run(['nmcli', 'connection', 'delete', n], capture_output=True, text=True)
try: os.remove(CONNECTION_FILE)
except OSError: pass
try: os.remove(UPTIME_FILE)
except OSError: pass
c=db()
c.execute('DELETE FROM connection_logs')
c.execute('DELETE FROM blocked_clients')
c.execute('DELETE FROM client_names')
c.execute('DELETE FROM auth_users')
c.execute('DROP TABLE IF EXISTS recovery_codes')
c.execute('DELETE FROM preferences')
c.commit(); c.close()
sessions.clear()
log('reinitialisation_donnees', 'Système', request_ip(self), 'SYSTEM')
self.send_json({'ok':True,'message':'Toutes les données et le point d’accès ont été réinitialisés et supprimés'}); return
if self.path == '/api/auth/2fa/status':
self.send_json({'enabled':auth_state()['two_factor'],'recovery_remaining':recovery_remaining()}); return
if self.path == '/api/auth/2fa/enable':
row=verify_user(username(self),str(data.get('password','')))
if not row: raise ValueError('Mot de passe incorrect')
secret=base64.b32encode(secrets.token_bytes(10)).decode('ascii').rstrip('=')
sessions[self.headers.get('X-Session-Token','')]['pending_2fa']=secret
log('generation_secret_2fa', username(self), request_ip(self), 'AUTH')
self.send_json({'ok':True,'message':'Secret généré. Ajoutez-le dans votre application TOTP puis confirmez avec le code affiché.','secret':secret}); return
if self.path == '/api/auth/2fa/confirm':
session=sessions.get(self.headers.get('X-Session-Token',''),{})
secret=session.get('pending_2fa')
if not secret or not totp_ok(secret,data.get('code')): raise ValueError('Code 2FA invalide')
row=verify_user(username(self),str(data.get('password','')))
if not row: raise ValueError('Mot de passe incorrect')
create_user(row[0],str(data.get('password','')),secret); session.pop('pending_2fa',None)
log('activation_2fa', username(self), request_ip(self), 'AUTH')
self.send_json({'ok':True,'message':'Double identification activée. Conservez vos codes de récupération.','recovery_codes':recovery_generate()}); return
if self.path == '/api/auth/2fa/disable':
row=verify_user(username(self),str(data.get('password','')))
if not row: raise ValueError('Mot de passe incorrect')
if row[2] and not second_factor_ok(row[2],data.get('code')): raise ValueError('Code 2FA invalide')
create_user(row[0],str(data.get('password','')),None); recovery_clear()
log('desactivation_2fa', username(self), request_ip(self), 'AUTH')
self.send_json({'ok':True,'message':'Double identification désactivée'}); return
if self.path == '/api/auth/2fa/recovery':
row=verify_user(username(self),str(data.get('password','')))
if not row: raise ValueError('Mot de passe incorrect')
if not row[2]: raise ValueError('La 2FA n’est pas activée')
if not second_factor_ok(row[2],data.get('code')): raise ValueError('Code 2FA invalide')
log('regeneration_codes_secours', username(self), request_ip(self), 'AUTH')
self.send_json({'ok':True,'message':'Nouveaux codes générés','recovery_codes':recovery_generate()}); return
if self.path == '/api/auth/password':
current=verify_user(username(self),str(data.get('current_password','')))
if not current: raise ValueError('Mot de passe actuel incorrect')
new_u = str(data.get('username',username(self)))
create_user(new_u,str(data.get('password','')),current[2])
log('modification_identifiants', new_u, request_ip(self), 'AUTH')
self.send_json({'ok':True,'message':'Identifiants modifiés'}); return
if self.path == '/api/preferences':
c=db()
if 'theme' in data:
theme=str(data.get('theme',''))
if theme not in ('dark','light','auto'): raise ValueError('Thème invalide')
c.execute("INSERT OR REPLACE INTO preferences(key,value) VALUES('theme',?)",(theme,))
if 'app_name' in data:
app_name=str(data.get('app_name','')).strip()
if not app_name or len(app_name)>64: raise ValueError('Nom de l’application invalide')
c.execute("INSERT OR REPLACE INTO preferences(key,value) VALUES('app_name',?)",(app_name,))
if 'app_short_name' in data:
app_short_name=str(data.get('app_short_name','')).strip()
if not app_short_name or len(app_short_name)>32: raise ValueError('Nom court invalide')
c.execute("INSERT OR REPLACE INTO preferences(key,value) VALUES('app_short_name',?)",(app_short_name,))
c.commit(); c.close()
log('modification_preferences', username(self) or 'Administrateur', request_ip(self), 'CONFIG')
self.send_json({'ok':True,'message':'Préférences enregistrées'}); return
self.send_json({'error':'Route inconnue'},404)
except (ValueError, RuntimeError, sqlite3.Error) as e: self.send_json({'error':str(e)},400)
except Exception: self.send_json({'error':'Erreur interne'},500)
def log_message(self,*args): pass
def monitor():
while True:
try:
items = clients()
# CORRECTION : éjecter en continu les appareils bloqués qui se reconnectent.
for x in items:
if x['blocked']: command('iw','dev',INTERFACE,'station','del',x['mac'])
update_logs([x for x in items if not x['blocked']])
except Exception: pass
time.sleep(2)
if __name__ == '__main__':
db(); threading.Thread(target=monitor,daemon=True).start(); print(f'API AP sur http://0.0.0.0:{PORT}'); ThreadingHTTPServer((HOST,PORT),Handler).serve_forever()
+20
View File
@@ -0,0 +1,20 @@
ServerName localhost
<VirtualHost *:1001>
DocumentRoot /var/www/html
DirectoryIndex index.html
<Directory /var/www/html>
Options -Indexes
Require all granted
</Directory>
# L'API Python lit l'IP réelle du client dans X-Real-IP
RequestHeader set X-Real-IP "expr=%{REMOTE_ADDR}"
ProxyPreserveHost On
ProxyPass /api http://127.0.0.1:8080/api
ProxyPassReverse /api http://127.0.0.1:8080/api
ErrorLog /dev/stderr
CustomLog /dev/stdout combined
</VirtualHost>
+27
View File
@@ -0,0 +1,27 @@
# 1. Trouver l'interface de la carte Netgear : iw dev
# 2. Lancer : AP_INTERFACE=wlan0 docker compose up -d --build
# 3. Ouvrir : http://IP-DE-LA-MACHINE:1001/
#
# Si votre fichier HTML ne s'appelle pas index.html, ajoutez sous "build:"
# args:
# HTML_FILE: ap_dashboard.html
services:
ap-wifi:
build: .
container_name: ap-wifi
restart: unless-stopped
network_mode: host # accès direct à la carte Wi-Fi de l'hôte
privileged: true # NetworkManager interne : rfkill, NAT, sysctl
environment:
AP_INTERFACE: ${AP_INTERFACE:-}
volumes:
- ap-data:/data
- nm-connections:/etc/NetworkManager/system-connections
- nm-state:/var/lib/NetworkManager
- /run/udev:/run/udev:ro # NetworkManager reconnaît ainsi la carte USB
volumes:
ap-data:
nm-connections:
nm-state:
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
log() { echo "[ap-wifi] $*"; }
# 1. Interface Wi-Fi (Netgear) --------------------------------------------------
IFACE="${AP_INTERFACE:-}"
mapfile -t FOUND < <(iw dev 2>/dev/null | awk '$1=="Interface"{print $2}')
if [[ -z "$IFACE" ]]; then
if [[ ${#FOUND[@]} -eq 1 ]]; then
IFACE="${FOUND[0]}"
else
log "ERREUR : précisez AP_INTERFACE. Interfaces Wi-Fi détectées : ${FOUND[*]:-aucune}"
exit 1
fi
fi
iw dev "$IFACE" info >/dev/null 2>&1 || { log "ERREUR : interface '$IFACE' introuvable (détectées : ${FOUND[*]:-aucune})"; exit 1; }
export AP_INTERFACE="$IFACE"
log "Interface utilisée : $IFACE"
# 2. NetworkManager : ne gère que l'interface choisie ---------------------------
mkdir -p /etc/NetworkManager/conf.d /run/dbus /run/NetworkManager /var/run/apache2 /var/lock/apache2 /data
cat > /etc/NetworkManager/conf.d/10-ap.conf <<EOF
[main]
plugins=keyfile
no-auto-default=*
[keyfile]
unmanaged-devices=*,except:interface-name:${IFACE}
[device]
wifi.scan-rand-mac-address=no
EOF
rm -f /run/dbus/pid /run/NetworkManager/*.pid
dbus-daemon --system --fork
NetworkManager --no-daemon &
NM_PID=$!
for _ in $(seq 1 30); do
nmcli -t general status >/dev/null 2>&1 && break
sleep 1
done
nmcli -t general status >/dev/null 2>&1 || { log "ERREUR : NetworkManager ne répond pas"; exit 1; }
rfkill unblock wifi 2>/dev/null || true
nmcli radio wifi on 2>/dev/null || true
nmcli device set "$IFACE" managed yes 2>/dev/null || true
log "NetworkManager prêt"
# 3. API Python (port 8080) + Apache (port 80) ------------------------------------
python3 /app/ap_dashboard.py &
API_PID=$!
apache2ctl -D FOREGROUND &
WEB_PID=$!
trap 'log "Arrêt"; kill "$WEB_PID" "$API_PID" "$NM_PID" 2>/dev/null || true; exit 0' TERM INT
log "Interface web : http://<ip-de-la-machine>:1001/ (API directe : port 8080)"
wait -n
log "Un service s'est arrêté, fermeture du conteneur"
kill "$WEB_PID" "$API_PID" "$NM_PID" 2>/dev/null || true
exit 1
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+254
View File
@@ -0,0 +1,254 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<link rel="icon" type="image/x-icon" href="/AP-Manageur/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" aLine truncated
<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>