CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
334 lines
15 KiB
Python
334 lines
15 KiB
Python
"""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 |