Hồi quy đã vá
-------------
F-12 Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
`chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
`composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
gắn 1 tệp, khớp bản trước refactor.
F-01 Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
`folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
`ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
tiên hỏng thì đổi provider chính là lúc phải thử lại.
F-07 Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
`check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.
F-11 Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
và không bao giờ chạy.
Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:
graph_renderer.py -> graph_scene_builder.py + graph_export.py
co4e_workflow_service.py -> co4e_run_history.py
json_config_repository.py -> config_sections.py
agents_admin_tab.py -> shared/agent_kind_visuals.py
File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.
Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.
Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.
859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.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 |