"""Login screen shown once at startup, before ``MainWindow`` is ever built (see ``app.py::run()``). Three paths, chosen automatically: 1. **Bootstrap** — no shared folder configured yet, or it's configured but still empty: a short setup form creates the shared folder path + the first Admin account, shows its generated 12-character code once, then logs straight in as that Admin. 2. **Normal login** — Account (auto-lowercased as typed) + 12-character code. An optional Department field (e.g. "FA.PDS") is offered on this page for filling it auto-creates/joins a Group of that exact name (see ``core/groups.py::find_or_create_by_name``), a convenience the user may skip entirely. 3. **Offline fallback** — the shared folder is configured but unreachable (VPN off, share down): if a previous login on this machine succeeded, its (username, role) — never the code — was cached locally and can be reused so the app stays usable off-network; a freshly revoked/edited account only takes effect once the shared folder is reachable again. """ from __future__ import annotations import re from pathlib import Path from typing import Optional from PySide6.QtCore import Qt from PySide6.QtGui import QIcon from PySide6.QtWidgets import ( QDialog, QFileDialog, QFormLayout, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, QStackedWidget, QVBoxLayout, QWidget, ) from ..core import accounts from ..core.accounts import Account from ..i18n import tr from ..state import AppContext class _AccountEdit(QLineEdit): """Account field: alnum/./- only, auto-lowercased as the user types.""" def __init__(self): """Ô nhập tên tài khoản, tự chuẩn hoá theo từng ký tự gõ vào.""" super().__init__() self.setMaxLength(64) self.textChanged.connect(self._normalize) def _normalize(self, text: str) -> None: """Ép về chữ thường và chỉ giữ chữ–số–dấu chấm–gạch. Tên tài khoản thành tên file trên thư mục dùng chung, nên khoảng trắng và ký tự lạ phải chặn ngay tại ô nhập chứ không đợi tới lúc lưu. Vị trí con trỏ được đặt lại sau khi sửa chữ, nếu không nó nhảy về cuối sau mỗi ký tự. """ cleaned = re.sub(r"[^\w.\-]", "", text.lower()) if cleaned == text: return cur = self.cursorPosition() self.blockSignals(True) self.setText(cleaned) self.setCursorPosition(min(cur, len(cleaned))) self.blockSignals(False) class LoginDialog(QDialog): """Hộp thoại đăng nhập, ba trang tuỳ tình trạng thư mục dùng chung. * Chưa cấu hình thư mục, hoặc có mà chưa có tài khoản nào — trang khởi tạo, nơi người đầu tiên nhận suất Admin. * Thư mục tới được — trang đăng nhập bằng tên tài khoản và mã. * Thư mục không tới được — trang ngoại tuyến, cho vào bằng lần đăng nhập gần nhất đã nhớ, để mất mạng không đồng nghĩa với mất luôn ứng dụng. Bản đang chạy không có lớp đăng nhập nên hộp thoại này không được mở ra. """ def __init__(self, ctx: AppContext, parent=None): """Chọn và dựng đúng một trong ba trang theo tình trạng thư mục dùng chung.""" super().__init__(parent) self.ctx = ctx self.account: Optional[Account] = None self.setWindowTitle(tr("login.title")) self.setMinimumWidth(380) self.setWindowFlag(Qt.WindowContextHelpButtonHint, False) root = QVBoxLayout(self) header = QLabel(tr("login.header")) header.setStyleSheet("font-weight:700; font-size:16px;") root.addWidget(header) self._stack = QStackedWidget() root.addWidget(self._stack) shared_dir = self.ctx.config.shared_dir reachable = self._is_reachable(shared_dir) needs_bootstrap = (not shared_dir) or ( reachable and not accounts.list_accounts(accounts.accounts_dir(shared_dir))) if needs_bootstrap: self._stack.addWidget(self._build_bootstrap_page()) elif not reachable: self._stack.addWidget(self._build_offline_page(shared_dir)) else: self._stack.addWidget(self._build_login_page(shared_dir)) self._stack.setCurrentIndex(0) exit_btn = QPushButton(tr("login.exit_btn")) exit_btn.clicked.connect(self.reject) root.addWidget(exit_btn, alignment=Qt.AlignRight) # ---- helpers ----------------------------------------------------- @staticmethod def _is_reachable(shared_dir: str) -> bool: """Thư mục dùng chung có tới được không (ổ mạng có thể rớt bất cứ lúc nào). Mọi ``OSError`` đều tính là không tới được: đường dẫn sai, hết quyền hay ổ mạng rớt đều dẫn tới cùng một lối đi ngoại tuyến. """ if not shared_dir: return False try: return Path(shared_dir).expanduser().exists() except OSError: return False def _finish_login(self, account: Account) -> None: """Ghi nhận đăng nhập thành công rồi đóng hộp thoại. Mã được cất vào keyring để lần sau khỏi gõ lại — nhưng chỉ khi có mã thật: lối vào ngoại tuyến không có mã, cất chuỗi rỗng vào sẽ hỏng lần đăng nhập tự động sau đó. """ self.account = account accounts.save_last_login(account.username, account.role) self.ctx.config.auth["last_account"] = account.username self.ctx.save() if account.code: # offline fallback has no real code — never cache an empty one try: import keyring keyring.set_password("cowork_local_login", account.username, account.code) except Exception: # noqa: BLE001 — no OS credential store available pass self.accept() # ---- bootstrap (no shared folder / no accounts yet) --------------- def _build_bootstrap_page(self) -> QWidget: """Trang khởi tạo: chọn thư mục dùng chung và tạo tài khoản Admin đầu tiên.""" page = QWidget() lay = QVBoxLayout(page) lay.addWidget(QLabel(tr("login.bootstrap_hint"))) form = QFormLayout() self.bs_dir_edit = QLineEdit(self.ctx.config.shared_dir) browse_btn = QPushButton(tr("login.browse")) from .icons import icon browse_btn.setIcon(icon("folder")) browse_btn.clicked.connect(self._bs_browse) dir_row = QHBoxLayout() dir_row.addWidget(self.bs_dir_edit, 1) dir_row.addWidget(browse_btn) form.addRow(tr("login.shared_dir"), dir_row) self.bs_user_edit = _AccountEdit() form.addRow(tr("login.account"), self.bs_user_edit) lay.addLayout(form) self.bs_error = QLabel("") self.bs_error.setObjectName("warning") self.bs_error.setWordWrap(True) lay.addWidget(self.bs_error) create_btn = QPushButton(tr("login.create_admin")) create_btn.setObjectName("primary") create_btn.clicked.connect(self._bs_create_admin) lay.addWidget(create_btn) return page def _bs_browse(self) -> None: """Mở hộp thoại chọn thư mục dùng chung.""" chosen = QFileDialog.getExistingDirectory(self, tr("login.shared_dir")) if chosen: self.bs_dir_edit.setText(chosen) def _bs_create_admin(self) -> None: """Tạo tài khoản Admin đầu tiên. Suất Admin được giành bằng ``claim_admin_slot`` chứ không chỉ kiểm tra rồi ghi: hai máy cùng khởi tạo trên một thư mục dùng chung sẽ chỉ có một máy thắng, thay vì cả hai cùng thành Admin. """ shared_dir = self.bs_dir_edit.text().strip() username = self.bs_user_edit.text().strip() if not shared_dir or not username: self.bs_error.setText(tr("login.err_missing_fields")) return directory = accounts.accounts_dir(shared_dir) try: if accounts.admin_exists(directory) or not accounts.claim_admin_slot(directory): self.bs_error.setText(tr("login.err_admin_exists")) self.ctx.config.auth["shared_dir"] = shared_dir self.ctx.save() self._retry() return existing = {a.code for a in accounts.list_accounts(directory)} account = accounts.new_account( username, "admin", created_by="bootstrap", existing_codes=existing) accounts.save_account(account, directory) except OSError as exc: self.bs_error.setText(tr("login.err_shared_dir", error=str(exc))) return self.ctx.config.auth["shared_dir"] = shared_dir self.ctx.save() QMessageBox.information( self, tr("login.code_shown_title"), tr("login.code_shown_body", username=account.username, code=account.code)) self._finish_login(account) # ---- normal login --------------------------------------------------- def _build_login_page(self, shared_dir: str) -> QWidget: """Trang đăng nhập thường: tên tài khoản + mã, điền sẵn tài khoản lần trước.""" page = QWidget() lay = QVBoxLayout(page) form = QFormLayout() self.user_edit = _AccountEdit() last_account = self.ctx.config.auth.get("last_account", "") if last_account: self.user_edit.setText(last_account) form.addRow(tr("login.account"), self.user_edit) self.code_edit = QLineEdit() self.code_edit.setEchoMode(QLineEdit.Password) self.code_edit.setMaxLength(accounts.CODE_LENGTH) if last_account: try: import keyring cached_code = keyring.get_password("cowork_local_login", last_account) if cached_code: self.code_edit.setText(cached_code) except Exception: # noqa: BLE001 pass form.addRow(tr("login.code"), self.code_edit) self.department_edit = QLineEdit(self.ctx.config.auth.get("last_department", "")) self.department_edit.setPlaceholderText(tr("login.department_placeholder")) form.addRow(tr("login.department"), self.department_edit) lay.addLayout(form) self.login_error = QLabel("") self.login_error.setObjectName("warning") self.login_error.setWordWrap(True) lay.addWidget(self.login_error) login_btn = QPushButton(tr("login.login_btn")) login_btn.setObjectName("primary") login_btn.clicked.connect(lambda: self._do_login(shared_dir)) lay.addWidget(login_btn) return page def _do_login(self, shared_dir: str) -> None: """Kiểm tra tên tài khoản và mã; sai thì báo ngay trên trang, không đóng hộp thoại. """ username = self.user_edit.text().strip() code = self.code_edit.text().strip() directory = accounts.accounts_dir(shared_dir) account = accounts.verify_login(username, code, directory) if account is None: self.login_error.setText(tr("login.err_invalid")) return self._apply_department(account, shared_dir, self.department_edit.text()) self._finish_login(account) def _apply_department(self, account: Account, shared_dir: str, department: str) -> None: """Optional, login-time-only convenience: record the typed Department on the account and auto-create/join a same-named Group — skipped entirely when left blank. Never touches role/admin state.""" department = (department or "").strip() self.ctx.config.auth["last_department"] = department if not department: return from ..core import groups acc_dir = accounts.accounts_dir(shared_dir) if account.department != department: account.department = department accounts.save_account(account, acc_dir) group = groups.find_or_create_by_name(department, groups.groups_dir(shared_dir)) if account.group_id != group.group_id: account.group_id = group.group_id accounts.save_account(account, acc_dir) groups.ensure_member(group, account.username, groups.groups_dir(shared_dir)) # ---- offline fallback (shared folder configured but unreachable) ---- def _build_offline_page(self, shared_dir: str) -> QWidget: """Trang ngoại tuyến: cho vào bằng vai trò của lần đăng nhập gần nhất, kèm nút thử lại. """ page = QWidget() lay = QVBoxLayout(page) lay.addWidget(QLabel(tr("login.unreachable", path=shared_dir))) cached = accounts.load_last_login() if cached: username, role = cached lay.addWidget(QLabel(tr("login.offline_hint", username=username, role=role))) offline_btn = QPushButton(tr("login.offline_btn", role=role)) offline_btn.setObjectName("primary") offline_btn.clicked.connect(lambda: self._finish_login( Account(username=username, role=role, code=""))) lay.addWidget(offline_btn) else: lay.addWidget(QLabel(tr("login.no_offline_cache"))) retry_btn = QPushButton(tr("login.retry_btn")) retry_btn.clicked.connect(self._retry) lay.addWidget(retry_btn) return page def _retry(self) -> None: """Kiểm tra lại thư mục dùng chung và dựng lại trang cho đúng tình trạng mới.""" self._stack.removeWidget(self._stack.currentWidget()) shared_dir = self.ctx.config.shared_dir reachable = self._is_reachable(shared_dir) needs_bootstrap = (not shared_dir) or ( reachable and not accounts.list_accounts(accounts.accounts_dir(shared_dir))) if needs_bootstrap: self._stack.addWidget(self._build_bootstrap_page()) elif not reachable: self._stack.addWidget(self._build_offline_page(shared_dir)) else: self._stack.addWidget(self._build_login_page(shared_dir)) self._stack.setCurrentIndex(0) def show_login(ctx: AppContext) -> Optional[Account]: """Run the login flow; ``None`` means the user cancelled (caller must not proceed to build ``MainWindow``).""" dlg = LoginDialog(ctx) if dlg.exec(): return dlg.account return None