Files
cowork-local/ui/login_dialog.py
T
anhtnm1andClaude Opus 5 e29a0ccdbd refactor: vá 4 hồi quy, tách 4 file chạm trần LOC, docstring lên 100%
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>
2026-08-30 10:41:45 +09:00

334 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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