Files
WGSecure/app/ui/access_code_dialog.py
T
tuxgyverandClaude Opus 5 9083525634 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>
2026-09-06 19:32:27 +02:00

233 lines
9.2 KiB
Python

"""Saisie du code à 6 chiffres, validé par le serveur.
Remplace l'ancienne fenêtre MFA, qui vérifiait le code localement contre un
secret stocké sur le poste : elle ne verrouillait alors que sa propre
interface. Ici le code part au serveur, seul détenteur du secret et seul
point où l'accès est réellement ouvert ou refusé.
La requête part dans un `TaskWorker` : elle peut attendre jusqu'à dix
secondes si le serveur ne répond pas, et une fenêtre modale figée pendant
ce temps donnerait l'impression d'une application plantée.
"""
from __future__ import annotations
import time
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QProgressBar, QFrame,
)
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QFont
from app.core import vpn_session
from app.core.config import Config
from app.ui import theme
from app.ui.worker import TaskWorker
_TOTP_PERIOD = 30
class AccessCodeDialog(QDialog):
"""Demande le code d'accès et le fait valider par le serveur.
`message()` porte la réponse du serveur — succès comme refus — pour que
l'appelant la journalise telle quelle plutôt qu'un message générique.
"""
def __init__(self, cfg: Config, parent=None):
super().__init__(parent)
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)
self.setStyleSheet(f"""
QDialog {{ background: {theme.BG_SUNKEN}; }}
QLabel {{ color: {theme.TEXT}; background: transparent; }}
""")
self._build_ui()
self._timer = QTimer(self)
self._timer.timeout.connect(self._tick)
self._timer.start(500)
self._tick()
# ------------------------------------------------------------------ #
# Construction
# ------------------------------------------------------------------ #
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setSpacing(12)
layout.setContentsMargins(20, 20, 20, 20)
title = QLabel("Ouverture de l'accès distant")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
f = QFont()
f.setPointSize(13)
f.setBold(True)
title.setFont(f)
layout.addWidget(title)
sub = QLabel("Le tunnel est monté, mais les ressources distantes "
"restent fermées tant que le serveur n'a pas validé "
"votre code à 6 chiffres.")
sub.setWordWrap(True)
sub.setAlignment(Qt.AlignmentFlag.AlignCenter)
sub.setStyleSheet(f"color: {theme.TEXT_MUTED};")
layout.addWidget(sub)
sep = QFrame()
sep.setFrameShape(QFrame.Shape.HLine)
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)
self._code_input.setAlignment(Qt.AlignmentFlag.AlignCenter)
f2 = QFont("Courier", 20)
f2.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 4)
self._code_input.setFont(f2)
self._code_input.setStyleSheet(self._input_css(theme.ACCENT))
self._code_input.returnPressed.connect(self._submit)
layout.addWidget(self._code_input)
self._error_label = QLabel("")
self._error_label.setWordWrap(True)
self._error_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._error_label.setStyleSheet(
f"color: {theme.FAIL_TEXT}; font-weight: bold;")
layout.addWidget(self._error_label)
self._progress = QProgressBar()
self._progress.setRange(0, _TOTP_PERIOD)
self._progress.setTextVisible(False)
self._progress.setFixedHeight(6)
self._progress.setStyleSheet(
f"QProgressBar {{ border-radius: 3px; background: {theme.BG}; }}"
f"QProgressBar::chunk {{ background: {theme.ACCENT_LIGHT};"
f" border-radius: 3px; }}"
)
layout.addWidget(self._progress)
self._timer_label = QLabel("")
self._timer_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._timer_label.setStyleSheet(
f"color: {theme.TEXT_FAINT}; font-size: 11px;")
layout.addWidget(self._timer_label)
btn_row = QHBoxLayout()
self._btn_cancel = QPushButton("Plus tard")
self._btn_cancel.setStyleSheet(
f"QPushButton {{ padding: 8px 16px; border-radius: 5px; border: none;"
f" background: {theme.BG_RAISED}; color: {theme.TEXT}; }}"
f"QPushButton:hover {{ background: {theme.ACCENT_HOVER}; }}"
)
self._btn_cancel.clicked.connect(self.reject)
self._btn_ok = QPushButton("Valider")
self._btn_ok.setDefault(True)
self._btn_ok.setStyleSheet(
f"QPushButton {{ padding: 8px 20px; border-radius: 5px; border: none;"
f" background: {theme.ACCENT}; color: {theme.TEXT}; font-weight: bold; }}"
f"QPushButton:hover {{ background: {theme.ACCENT_HOVER}; }}"
f"QPushButton:disabled {{ background: {theme.BG_RAISED};"
f" color: {theme.TEXT_FAINT}; }}"
)
self._btn_ok.clicked.connect(self._submit)
btn_row.addWidget(self._btn_cancel)
btn_row.addStretch()
btn_row.addWidget(self._btn_ok)
layout.addLayout(btn_row)
@staticmethod
def _input_css(border: str) -> str:
return (f"QLineEdit {{ padding: 8px; border: 2px solid {border};"
f" border-radius: 6px; background: {theme.BG};"
f" color: {theme.TEXT}; }}")
# ------------------------------------------------------------------ #
# Compte à rebours
# ------------------------------------------------------------------ #
def _tick(self):
# La période TOTP est fixe et alignée sur l'epoch Unix : le temps
# restant se calcule sans connaître le secret, que cette application
# ne détient justement plus.
remaining = _TOTP_PERIOD - int(time.time()) % _TOTP_PERIOD
self._progress.setValue(remaining)
self._timer_label.setText(f"Code valide encore {remaining}s")
# ------------------------------------------------------------------ #
# Validation
# ------------------------------------------------------------------ #
def _submit(self):
if self._worker is not None:
return
raw = self._code_input.text().replace(" ", "").replace("-", "")
if len(raw) != 6 or not raw.isdigit():
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,
self._username, tag="auth", parent=self)
self._worker.done.connect(self._on_reply)
self._worker.finished.connect(self._worker.deleteLater)
self._worker.start()
def _on_reply(self, ok: bool, message: str, _tag: str):
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)
self._fail(message)
self._code_input.clear()
def _fail(self, message: str):
self._error_label.setText(message)
self._code_input.setStyleSheet(self._input_css(theme.FAIL_TEXT))
def _set_busy(self, busy: bool):
self._btn_ok.setEnabled(not busy)
self._btn_ok.setText("Vérification…" if busy else "Valider")
self._code_input.setEnabled(not busy)
def message(self) -> str:
return self._message
def closeEvent(self, event):
self._timer.stop()
super().closeEvent(event)