Files
cowork-local/ui/login_dialog.py
T
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## 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>
2026-08-31 05:15:13 +00: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