Files
cowork-local/ui/login_dialog.py
T
2026-08-09 20:12:05 +07:00

289 lines
12 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):
super().__init__()
self.setMaxLength(64)
self.textChanged.connect(self._normalize)
def _normalize(self, text: str) -> None:
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):
def __init__(self, ctx: AppContext, parent=None):
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:
if not shared_dir:
return False
try:
return Path(shared_dir).expanduser().exists()
except OSError:
return False
def _finish_login(self, account: Account) -> None:
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:
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:
chosen = QFileDialog.getExistingDirectory(self, tr("login.shared_dir"))
if chosen:
self.bs_dir_edit.setText(chosen)
def _bs_create_admin(self) -> None:
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:
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:
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:
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:
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