fix(ui): la saisie du code ne dépend plus du champ Identifiant VPN (v0.8.4)

Le bouton et l'entrée de systray étaient conditionnés à un champ vide par
défaut depuis son introduction en v0.8.0 : un poste tenu en quarantaine par
le serveur n'affichait donc aucun moyen d'en sortir. La saisie ne dépend
plus que de l'état du tunnel ; la fenêtre demande l'identifiant quand il
manque et ne l'enregistre qu'une fois le serveur l'ayant accepté.

L'état « Non géré par le serveur » disparaît au passage : le client
l'affirmait à partir de sa seule configuration locale, sans rien vérifier.
« Non vérifié » est la vérité tant qu'aucun code n'a été validé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 19:32:27 +02:00
co-authored by Claude Opus 5
parent 1e015b5a1f
commit 9083525634
8 changed files with 103 additions and 34 deletions
+11
View File
@@ -6,6 +6,17 @@ Ce projet suit le [Versionnage Sémantique](https://semver.org/lang/fr/).
---
## [0.8.4] — 2026-09-06
### Corrigé
- **Aucun moyen de saisir le code tant que le champ *Identifiant VPN* n'était pas renseigné** — ni bouton, ni entrée de systray, sur les deux plateformes. Ce champ, introduit en v0.8.0 et vide par défaut, conditionnait l'affichage des deux commandes : un poste que le serveur tenait bel et bien en quarantaine n'affichait donc rien pour en sortir, précisément dans la situation où il en avait besoin. La saisie ne dépend plus que du tunnel : monté et accès non ouvert, elle est atteignable. L'identifiant est demandé dans la fenêtre elle-même quand il manque, et enregistré une fois que le serveur l'a reconnu — jamais avant.
- **L'état « Non géré par le serveur » était une affirmation sans vérification.** Le client la tirait de sa seule configuration locale, alors qu'il n'en sait rien : le serveur peut parfaitement tenir ce pair en quarantaine pendant que le poste ne mentionne aucun serveur. La ligne indique désormais « Non vérifié », qui est la vérité tant qu'aucun code n'a été validé.
### Modifié
- L'invite spontanée après connexion reste, elle, conditionnée à un identifiant connu : une installation sans serveur d'authentification n'a aucune raison de voir surgir cette fenêtre à chaque connexion. Le bouton reflète la nuance — mis en avant quand un code est réellement attendu, discret sinon.
---
## [0.8.3] — 2026-09-06
### Corrigé
+1 -1
View File
@@ -4,7 +4,7 @@
## ──────────────────────────────────────────────
APP := wgsecure
VERSION := 0.8.3
VERSION := 0.8.4
VENV := .venv
PYTHON := $(VENV)/bin/python3
PIP := $(VENV)/bin/pip
+1 -1
View File
@@ -1,6 +1,6 @@
# 🛡️ WGSecure (WGS)
🚀 v0.8.3 · 🐍 · 🪟🐧 · 🔐 Accès validé par le serveur · 🛡️ WireGuard
🚀 v0.8.4 · 🐍 · 🪟🐧 · 🔐 Accès validé par le serveur · 🛡️ WireGuard
**WGSecure** est une interface graphique multiplateforme (Windows & Linux) pour gérer une connexion WireGuard dont l'accès aux ressources distantes est ouvert par un code à 6 chiffres validé **par le serveur**.
+1 -1
View File
@@ -1,3 +1,3 @@
__version__ = "0.8.3"
__version__ = "0.8.4"
APP_NAME = "WGSecure"
APP_SHORT = "WGS"
+9 -4
View File
@@ -52,23 +52,28 @@ def api_base_url(cfg: Config) -> str:
return f"http://{gateway}:8000"
def authenticate(cfg: Config, code: str) -> tuple[bool, str]:
def authenticate(cfg: Config, code: str, username: str = "") -> tuple[bool, str]:
"""Valide un code TOTP auprès du serveur et ouvre l'accès réseau.
Retourne (succès, message). Un échec n'est jamais silencieux : sans
cette validation le tunnel reste monté mais ne mène nulle part, et
l'utilisateur doit comprendre pourquoi plutôt que de constater un
réseau inexplicablement muet.
`username` permet de fournir l'identifiant à la volée, quand il n'est pas
encore en configuration : la fenêtre de saisie le demande alors avec le
code, plutôt que de renvoyer l'utilisateur vers le panneau Administrateur
au moment précis il cherche à ouvrir son accès. Il est enregistré
une fois le serveur l'ayant accepté.
"""
base = api_base_url(cfg)
if not base:
return False, ("Adresse du serveur d'authentification inconnue. "
"Renseignez-la dans le panneau Administrateur.")
username = (cfg.wg.get("vpn_username") or "").strip()
username = (username or cfg.wg.get("vpn_username") or "").strip()
if not username:
return False, ("Aucun identifiant VPN configuré. Renseignez-le dans "
"le panneau Administrateur.")
return False, "Indiquez votre identifiant VPN."
payload = json.dumps({"username": username, "code": code}).encode("utf-8")
request = urllib.request.Request(
+28 -1
View File
@@ -41,6 +41,7 @@ class AccessCodeDialog(QDialog):
self._cfg = cfg
self._worker: TaskWorker | None = None
self._message = ""
self._username = ""
self.setWindowTitle("WGSecure — Code d'accès")
self.setFixedWidth(360)
self.setModal(True)
@@ -83,6 +84,19 @@ class AccessCodeDialog(QDialog):
sep.setStyleSheet(f"color: {theme.BORDER_SOFT};")
layout.addWidget(sep)
# L'identifiant n'est demandé que s'il manque encore. Le renvoyer au
# panneau Administrateur à cet instant précis — tunnel monté, accès
# fermé, code en main — était un cul-de-sac : il est saisi ici, puis
# enregistré dès que le serveur l'a reconnu.
self._user_input: QLineEdit | None = None
if not (self._cfg.wg.get("vpn_username") or "").strip():
self._user_input = QLineEdit()
self._user_input.setPlaceholderText("Identifiant VPN (ex. TestB)")
self._user_input.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._user_input.setStyleSheet(self._input_css(theme.BORDER_SOFT))
self._user_input.returnPressed.connect(self._submit)
layout.addWidget(self._user_input)
self._code_input = QLineEdit()
self._code_input.setPlaceholderText("000 000")
self._code_input.setMaxLength(7)
@@ -170,10 +184,18 @@ class AccessCodeDialog(QDialog):
self._fail("Entrez exactement 6 chiffres.")
return
self._username = ""
if self._user_input is not None:
self._username = self._user_input.text().strip()
if not self._username:
self._error_label.setText("Indiquez votre identifiant VPN.")
self._user_input.setStyleSheet(self._input_css(theme.FAIL_TEXT))
return
self._error_label.setText("")
self._set_busy(True)
self._worker = TaskWorker(vpn_session.authenticate, self._cfg, raw,
tag="auth", parent=self)
self._username, tag="auth", parent=self)
self._worker.done.connect(self._on_reply)
self._worker.finished.connect(self._worker.deleteLater)
self._worker.start()
@@ -182,6 +204,11 @@ class AccessCodeDialog(QDialog):
self._worker = None
self._message = message
if ok:
# Enregistré seulement maintenant : un identifiant que le serveur
# a refusé n'a rien à faire dans la configuration.
if self._username:
self._cfg.set("wg", "vpn_username", self._username)
self._cfg.save()
self.accept()
return
self._set_busy(False)
+51 -25
View File
@@ -247,14 +247,10 @@ class MainWindow(QMainWindow):
# rien ajouter aux installations sans authentification serveur.
self._btn_access = QPushButton("🔐 Saisir le code d'accès")
self._btn_access.setFixedHeight(32)
self._btn_access.setStyleSheet(
f"QPushButton {{ background: {theme.WARN_SOLID}; color: {theme.TEXT};"
" font-size: 12px; font-weight: bold; border-radius: 6px; border: none; }"
"QPushButton:hover { background: #d68910; }"
)
self._btn_access.clicked.connect(self.request_access_code)
self._btn_access.setVisible(False)
s.addWidget(self._btn_access)
self._style_access_button(False)
# Repli/dépli du détail (transfert, config, bande passante, journal) :
# rattaché à l'action qu'il détaille, pas à l'en-tête.
@@ -370,6 +366,18 @@ class MainWindow(QMainWindow):
)
self._sync_window_height()
def _style_access_button(self, expected: bool):
"""Met le bouton en avant seulement quand un code est réellement dû."""
bg, hover = ((theme.WARN_SOLID, "#d68910") if expected
else (theme.BG_RAISED, theme.ACCENT_HOVER))
weight = "bold" if expected else "normal"
self._btn_access.setStyleSheet(
f"QPushButton {{ background: {bg}; color: {theme.TEXT};"
f" font-size: 12px; font-weight: {weight}; border-radius: 6px;"
" border: none; }"
f"QPushButton:hover {{ background: {hover}; }}"
)
def _sync_window_height(self):
"""Réajuste la hauteur fixe de la fenêtre à son contenu visible.
@@ -591,12 +599,19 @@ class MainWindow(QMainWindow):
tant que le serveur n'a pas levé la quarantaine. Sans cette ligne,
un utilisateur ayant refusé la saisie du code verrait « Connecté »
et un réseau inexplicablement muet.
Sans identifiant VPN, l'état est « non vérifié » et non « non géré » :
le client n'en sait rien. Il l'affirmait, et se trompait le serveur
peut parfaitement tenir ce pair en quarantaine pendant que la
configuration locale, elle, ne mentionne aucun serveur.
"""
if not (self._cfg.wg.get("vpn_username") or "").strip():
return "Non géré par le serveur"
if not self._is_connected:
return ""
return "Ouvert ✓" if self._access_open else "Quarantaine (code requis)"
if self._access_open:
return "Ouvert ✓"
if (self._cfg.wg.get("vpn_username") or "").strip():
return "Quarantaine (code requis)"
return "Non vérifié"
def _refresh_status(self):
wg = self._cfg.wg
@@ -638,6 +653,13 @@ class MainWindow(QMainWindow):
if needed == self._btn_access.isHidden():
self._btn_access.setVisible(needed)
self._sync_window_height()
if needed:
# Un code est attendu de façon certaine quand l'identifiant VPN
# est connu ; sinon le bouton reste disponible mais discret, pour
# ne pas réclamer une action à une installation qui n'a pas de
# serveur d'authentification.
self._style_access_button(
bool((self._cfg.wg.get("vpn_username") or "").strip()))
if connected:
if self._connected_since is None:
@@ -734,12 +756,17 @@ class MainWindow(QMainWindow):
def access_code_needed(self) -> bool:
"""Vrai quand une saisie de code aurait un sens ici et maintenant.
Trois conditions : un identifiant VPN configuré (sans quoi le serveur
ne gère pas ce poste), un tunnel monté (l'API n'est joignable que par
l'intérieur) et aucune autorisation en cours.
Deux conditions seulement : un tunnel mon l'API du serveur n'est
joignable que par l'intérieur — et aucune autorisation en cours.
L'identifiant VPN n'en fait volontairement pas partie. Il en faisait
partie, et c'était un piège : un poste bel et bien tenu en quarantaine
par le serveur, mais dont ce champ n'avait jamais été renseigné,
n'affichait aucun moyen de saisir son code — précisément dans la
situation il en avait besoin. La fenêtre de saisie demande
l'identifiant quand il manque.
"""
return (bool((self._cfg.wg.get("vpn_username") or "").strip())
and self._is_connected and not self._access_open)
return self._is_connected and not self._access_open
def request_access_code(self):
"""Ouvre la saisie du code, à la demande de l'utilisateur.
@@ -755,21 +782,17 @@ class MainWindow(QMainWindow):
"Le serveur d'authentification n'est joignable que depuis "
"l'intérieur du tunnel.\nConnectez-vous d'abord.")
return
if not (self._cfg.wg.get("vpn_username") or "").strip():
QMessageBox.information(
self, "Aucun identifiant VPN",
"Renseignez l'identifiant VPN connu du serveur dans le "
"panneau Administrateur.")
return
self._authenticate_with_server(silent=False)
self._open_access_dialog()
def _authenticate_with_server(self, silent: bool):
"""Demande le code à 6 chiffres et le fait valider par le serveur.
"""Invite spontanée à saisir le code, juste après une connexion.
Sans identifiant VPN configuré, l'étape est ignorée : le serveur
peut très bien tourner en mode permissif, un compte non enrôlé
conserve son accès. Imposer la saisie dans ce cas bloquerait des
installations qui fonctionnent parfaitement.
Sans identifiant VPN configuré, l'invite est tue : le serveur peut
très bien tourner en mode permissif, un compte non enrôlé conserve
son accès, et une installation sans serveur d'authentification n'a
aucune raison de voir surgir cette fenêtre à chaque connexion. Cela
ne vaut que pour l'invite : la saisie reste atteignable à la demande,
par le bouton et par le systray, quelle que soit la configuration.
"""
if not (self._cfg.wg.get("vpn_username") or "").strip():
return
@@ -780,7 +803,10 @@ class MainWindow(QMainWindow):
self._add_log("Code d'accès requis — bouton « Saisir le code "
"d'accès »", "warning")
return
self._open_access_dialog()
def _open_access_dialog(self):
"""Ouvre la fenêtre de saisie et journalise la réponse du serveur."""
dlg = AccessCodeDialog(self._cfg, self)
accepted = dlg.exec() == AccessCodeDialog.DialogCode.Accepted
self._access_open = accepted
+1 -1
View File
@@ -5,7 +5,7 @@
; ──────────────────────────────────────────────
#define MyAppName "WGSecure"
#define MyAppVersion "0.8.3"
#define MyAppVersion "0.8.4"
#define MyAppPublisher "WGSecure"
#define MyAppExeName "wgsecure.exe"