From f0a1adf52f2cacd082c65ea2eddf9d368b6aee21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Sat, 29 Aug 2026 11:24:39 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=BD=E5=B7=A5(HEL-226):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E7=99=BB=E5=BD=95=E9=97=A8=E6=88=B7=E4=B8=8E=E6=9C=AC?= =?UTF-8?q?=E6=9C=BA=E5=85=8D=E5=AF=86=E5=88=87=E6=8D=A2=E8=B4=A6=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用设备 Cookie 和授权表记住本机已验证账号,登录页按确认样图做成门户,不再把密码写进浏览器。 Co-authored-by: Cursor Co-authored-by: multica-agent --- backend/bootstrap/config.py | 2 + backend/database/migrations/__init__.py | 2 + .../migrations/m0005_account_switch_grants.py | 42 ++ backend/features/accounts/application.py | 8 +- backend/features/accounts/http.py | 116 ++++- backend/features/accounts/repository.py | 93 ++++ backend/features/accounts/routes.py | 3 + backend/features/accounts/service.py | 97 +++- backend/http/dispatch.py | 2 + backend/http/handler.py | 36 +- config/api.config.json | 21 + config/architecture-inventory.json | 46 +- frontend/app.js | 6 +- frontend/index.html | 10 +- frontend/login/index.html | 59 +++ frontend/login/page.js | 273 +++++++++++ frontend/m/css/shell.css | 23 + frontend/m/index.html | 4 +- frontend/m/js/boot.js | 7 +- frontend/m/js/router.js | 30 ++ frontend/m/js/session.js | 27 +- frontend/shared/auth.css | 442 ++++++++++++++++++ frontend/shared/session.js | 20 +- frontend/shared/tokens.css | 2 + tests/e2e/login-portal.spec.js | 151 ++++++ tests/test_account_switch_grants.py | 132 ++++++ tests/test_database_migrations.py | 7 +- tests/test_governance_registries.py | 3 + tools/build_api_registry.py | 8 +- 29 files changed, 1583 insertions(+), 89 deletions(-) create mode 100644 backend/database/migrations/m0005_account_switch_grants.py create mode 100644 frontend/login/index.html create mode 100644 frontend/login/page.js create mode 100644 tests/e2e/login-portal.spec.js create mode 100644 tests/test_account_switch_grants.py diff --git a/backend/bootstrap/config.py b/backend/bootstrap/config.py index 58813bd..3ed5980 100644 --- a/backend/bootstrap/config.py +++ b/backend/bootstrap/config.py @@ -18,6 +18,8 @@ TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,128}$") USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_\-\u4e00-\u9fff]{3,30}$") SESSION_COOKIE = "xiaobai_session" SESSION_MAX_AGE = 30 * 24 * 60 * 60 +DEVICE_COOKIE = "xiaobai_device" +DEVICE_MAX_AGE = 180 * 24 * 60 * 60 def load_local_env() -> None: diff --git a/backend/database/migrations/__init__.py b/backend/database/migrations/__init__.py index 694558f..71755cf 100644 --- a/backend/database/migrations/__init__.py +++ b/backend/database/migrations/__init__.py @@ -2,6 +2,7 @@ from .m0001_adopt_legacy import MIGRATION as M0001_ADOPT_LEGACY from .m0002_job_runs import MIGRATION as M0002_JOB_RUNS from .m0003_llm_audit import MIGRATION as M0003_LLM_AUDIT from .m0004_mentor_notes import MIGRATION as M0004_MENTOR_NOTES +from .m0005_account_switch_grants import MIGRATION as M0005_ACCOUNT_SWITCH_GRANTS from .runner import Migration, MigrationError, MigrationRunner MIGRATIONS = ( @@ -9,6 +10,7 @@ MIGRATIONS = ( M0002_JOB_RUNS, M0003_LLM_AUDIT, M0004_MENTOR_NOTES, + M0005_ACCOUNT_SWITCH_GRANTS, ) __all__ = ["MIGRATIONS", "Migration", "MigrationError", "MigrationRunner"] diff --git a/backend/database/migrations/m0005_account_switch_grants.py b/backend/database/migrations/m0005_account_switch_grants.py new file mode 100644 index 0000000..e63f245 --- /dev/null +++ b/backend/database/migrations/m0005_account_switch_grants.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import sqlite3 + +from backend.database.migrations.runner import Migration + + +def create_account_switch_grants(connection: sqlite3.Connection) -> None: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS account_switch_grants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_hash TEXT NOT NULL, + user_id INTEGER NOT NULL, + granted_at TEXT NOT NULL, + last_used_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + UNIQUE (device_hash, user_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + connection.execute( + """ + CREATE INDEX IF NOT EXISTS idx_account_switch_grants_device + ON account_switch_grants(device_hash, last_used_at DESC) + """ + ) + connection.execute( + """ + CREATE INDEX IF NOT EXISTS idx_account_switch_grants_expiry + ON account_switch_grants(expires_at) + """ + ) + + +MIGRATION = Migration( + version="0005", + name="create_account_switch_grants", + action=create_account_switch_grants, + signature="account-switch-grants:v1:device,user,granted,used,expires,unique", +) diff --git a/backend/features/accounts/application.py b/backend/features/accounts/application.py index 6608e6b..1d227e9 100644 --- a/backend/features/accounts/application.py +++ b/backend/features/accounts/application.py @@ -28,11 +28,11 @@ class AccountApplicationMixin: def update_membership(self, payload: dict[str, Any]) -> None: self.accounts.update_membership(payload) - def register_account(self, username: str, password: str) -> dict[str, Any]: - return self.accounts.register(username, password) + def register_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]: + return self.accounts.register(username, password, device_hash) - def login_account(self, username: str, password: str) -> dict[str, Any]: - return self.accounts.login(username, password) + def login_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]: + return self.accounts.login(username, password, device_hash) def change_password(self, current_password: str, new_password: str) -> None: self.accounts.change_password(current_password, new_password) diff --git a/backend/features/accounts/http.py b/backend/features/accounts/http.py index 4bc282e..22aafeb 100644 --- a/backend/features/accounts/http.py +++ b/backend/features/accounts/http.py @@ -1,49 +1,108 @@ from __future__ import annotations import json +import secrets from http import HTTPStatus +from backend.features.accounts.security import token_hash + class AccountHttpMixin: + def _device_hash(self) -> str: + raw = self.device_token() + return token_hash(raw) if raw else "" + + def _ensure_device_token(self) -> str: + return self.device_token() or secrets.token_urlsafe(32) + + def _auth_success_headers(self, session_token: str, device_raw: str) -> list[tuple[str, str]]: + return [ + ("Set-Cookie", self.session_cookie(session_token)), + ("Set-Cookie", self.device_cookie(device_raw)), + ] + + def _send_authenticated_session(self, result: dict, status: HTTPStatus, device_raw: str) -> None: + self.send_json( + { + "ok": True, + "authenticated": True, + "user": result["user"], + "csrf_token": result["csrf_token"], + }, + status, + self._auth_success_headers(result["session_token"], device_raw), + ) + def auth_register(self) -> None: try: body = self.read_json_body() + device_raw = self._ensure_device_token() result = self.application_service.register_account( str(body.get("username") or ""), str(body.get("password") or ""), + token_hash(device_raw), ) - self.send_json( - { - "ok": True, - "authenticated": True, - "user": result["user"], - "csrf_token": result["csrf_token"], - }, - HTTPStatus.CREATED, - {"Set-Cookie": self.session_cookie(result["session_token"])}, - ) + self._send_authenticated_session(result, HTTPStatus.CREATED, device_raw) except (ValueError, json.JSONDecodeError) as exc: self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) def auth_login(self) -> None: try: body = self.read_json_body() + device_raw = self._ensure_device_token() result = self.application_service.login_account( str(body.get("username") or ""), str(body.get("password") or ""), + token_hash(device_raw), ) - self.send_json( - { - "ok": True, - "authenticated": True, - "user": result["user"], - "csrf_token": result["csrf_token"], - }, - headers={"Set-Cookie": self.session_cookie(result["session_token"])}, - ) + self._send_authenticated_session(result, HTTPStatus.OK, device_raw) except (ValueError, json.JSONDecodeError) as exc: self.send_json({"error": str(exc)}, HTTPStatus.UNAUTHORIZED) + def auth_accounts(self) -> None: + current_user_id = None + if self.require_auth(send_error=False): + current_user_id = int(self.auth_user["id"]) + payload = self.application_service.accounts.list_device_accounts( + self._device_hash(), + current_user_id, + ) + self.send_json({"ok": True, **payload}) + + def auth_switch(self) -> None: + try: + body = self.read_json_body() + try: + user_id = int(body.get("user_id")) + except (TypeError, ValueError): + user_id = 0 + device_raw = self.device_token() + result = self.application_service.accounts.switch_account( + token_hash(device_raw) if device_raw else "", + user_id, + ) + self._send_authenticated_session( + result, + HTTPStatus.OK, + device_raw or self._ensure_device_token(), + ) + except PermissionError as exc: + self.send_json({"error": str(exc)}, HTTPStatus.UNAUTHORIZED) + except (ValueError, json.JSONDecodeError) as exc: + self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + + def auth_forget(self) -> None: + try: + body = self.read_json_body() + try: + user_id = int(body.get("user_id")) + except (TypeError, ValueError): + user_id = 0 + self.application_service.accounts.forget_account(self._device_hash(), user_id) + except (ValueError, json.JSONDecodeError): + pass + self.send_json({"ok": True}) + def auth_me(self) -> None: service = self.application_service if not self.require_auth(send_error=False): @@ -55,6 +114,15 @@ class AccountHttpMixin: } ) return + headers = None + if not self.device_token(): + device_raw = secrets.token_urlsafe(32) + service.accounts.remember_account( + token_hash(device_raw), + int(self.auth_user["id"]), + fresh=True, + ) + headers = [("Set-Cookie", self.device_cookie(device_raw))] self.send_json( { "ok": True, @@ -66,15 +134,19 @@ class AccountHttpMixin: "membership": service.membership(), }, "csrf_token": str(self.auth_user["csrf_token"]), - } + }, + headers=headers, ) def auth_logout(self) -> None: raw_token = self.session_token() + user_id = int(getattr(self, "auth_user", {}).get("id") or 0) if raw_token: - from backend.features.accounts.security import token_hash - self.application_service.database.delete_session(token_hash(raw_token)) + self.application_service.accounts.revoke_current_device_grant( + self._device_hash(), + user_id, + ) self.send_json( {"ok": True}, headers={"Set-Cookie": self.session_cookie("", clear=True)}, diff --git a/backend/features/accounts/repository.py b/backend/features/accounts/repository.py index 98cc534..04bb60c 100644 --- a/backend/features/accounts/repository.py +++ b/backend/features/accounts/repository.py @@ -234,3 +234,96 @@ class AccountRepositoryMixin: (user_id,), ) return cursor.rowcount > 0 + + def cleanup_expired_switch_grants(self, now: str) -> int: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM account_switch_grants WHERE expires_at <= ?", + (now,), + ) + return int(cursor.rowcount) + + def list_switch_grants(self, device_hash: str, now: str) -> list[dict[str, Any]]: + with self.connect() as connection: + rows = connection.execute( + """ + SELECT u.id, u.username, u.role, u.llm_mode, u.membership_status, + u.membership_plan, u.membership_starts_at, u.membership_expires_at, + u.created_at, g.last_used_at, g.granted_at, g.expires_at + FROM account_switch_grants AS g + JOIN users AS u ON u.id = g.user_id + WHERE g.device_hash = ? AND g.expires_at > ? + ORDER BY g.last_used_at DESC, g.id DESC + """, + (device_hash, now), + ).fetchall() + return [dict(row) for row in rows] + + def get_switch_grant(self, device_hash: str, user_id: int) -> dict[str, Any] | None: + with self.connect() as connection: + row = connection.execute( + """ + SELECT device_hash, user_id, granted_at, last_used_at, expires_at + FROM account_switch_grants + WHERE device_hash = ? AND user_id = ? + """, + (device_hash, user_id), + ).fetchone() + return dict(row) if row else None + + def upsert_switch_grant( + self, + device_hash: str, + user_id: int, + granted_at: str, + last_used_at: str, + expires_at: str, + ) -> None: + with self.connect() as connection: + connection.execute( + """ + INSERT INTO account_switch_grants + (device_hash, user_id, granted_at, last_used_at, expires_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(device_hash, user_id) DO UPDATE SET + granted_at = excluded.granted_at, + last_used_at = excluded.last_used_at, + expires_at = excluded.expires_at + """, + (device_hash, user_id, granted_at, last_used_at, expires_at), + ) + + def prune_switch_grants(self, device_hash: str, keep: int) -> int: + with self.connect() as connection: + rows = connection.execute( + """ + SELECT id FROM account_switch_grants + WHERE device_hash = ? + ORDER BY last_used_at DESC, id DESC + """, + (device_hash,), + ).fetchall() + extra = [int(row["id"]) for row in rows[keep:]] + if not extra: + return 0 + connection.execute( + f"DELETE FROM account_switch_grants WHERE id IN ({','.join('?' * len(extra))})", + extra, + ) + return len(extra) + + def delete_switch_grant(self, device_hash: str, user_id: int) -> bool: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM account_switch_grants WHERE device_hash = ? AND user_id = ?", + (device_hash, user_id), + ) + return cursor.rowcount > 0 + + def delete_switch_grants_for_user(self, user_id: int) -> int: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM account_switch_grants WHERE user_id = ?", + (user_id,), + ) + return int(cursor.rowcount) diff --git a/backend/features/accounts/routes.py b/backend/features/accounts/routes.py index 89af383..a5921b9 100644 --- a/backend/features/accounts/routes.py +++ b/backend/features/accounts/routes.py @@ -6,6 +6,9 @@ class AccountRoutesMixin: if parsed.path == "/api/auth/me": self.auth_me() return True + if parsed.path == "/api/auth/accounts": + self.auth_accounts() + return True return False def _handle_accounts_get(self, parsed) -> bool: diff --git a/backend/features/accounts/service.py b/backend/features/accounts/service.py index aa6958c..47d5a75 100644 --- a/backend/features/accounts/service.py +++ b/backend/features/accounts/service.py @@ -77,15 +77,22 @@ class AccountService: access = self.access_supplier() or self.database.user_access(self.current_user_id) or {} return self.membership_for_access(access) - def register(self, username: str, password: str) -> dict[str, Any]: + GRANT_SLIDE_DAYS = 30 + GRANT_HARD_DAYS = 180 + MAX_GRANTS_PER_DEVICE = 5 + SWITCH_REAUTH_MESSAGE = "该账号需重新验证" + + def register(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]: username = username.strip() self.validate_input(username, password) with self.auth_lock: salt, password_digest = hash_password(password) user = self.database.create_user(username, salt, password_digest) - return self.create_session(user) + result = self.create_session(user) + self.remember_account(device_hash, int(user["id"]), fresh=True) + return result - def login(self, username: str, password: str) -> dict[str, Any]: + def login(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]: username = username.strip() if not username or not password: raise ValueError("账号名和密码不能为空。") @@ -96,7 +103,9 @@ class AccountService: str(user.get("password_hash") or ""), ): raise ValueError("账号名或密码不正确。") - return self.create_session(user) + result = self.create_session(user) + self.remember_account(device_hash, int(user["id"]), fresh=True) + return result def change_password(self, current_password: str, new_password: str) -> None: current_password = str(current_password or "") @@ -112,6 +121,86 @@ class AccountService: salt, digest = hash_password(new_password) if not self.database.update_user_password(self.current_user_id, salt, digest): raise ValueError("账号不存在。") + self.database.delete_switch_grants_for_user(self.current_user_id) + + @staticmethod + def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + @classmethod + def _iso(cls, value: datetime) -> str: + return value.isoformat(timespec="seconds") + + def remember_account(self, device_hash: str, user_id: int, *, fresh: bool = False) -> None: + if not device_hash or user_id <= 0: + return + now = self._utc_now() + now_text = self._iso(now) + self.database.cleanup_expired_switch_grants(now_text) + existing = None if fresh else self.database.get_switch_grant(device_hash, user_id) + granted_at = parse_iso_datetime(existing["granted_at"]) if existing else now + if granted_at is None: + granted_at = now + expires = min( + now + timedelta(days=self.GRANT_SLIDE_DAYS), + granted_at + timedelta(days=self.GRANT_HARD_DAYS), + ) + if not existing: + self.database.prune_switch_grants(device_hash, self.MAX_GRANTS_PER_DEVICE - 1) + self.database.upsert_switch_grant( + device_hash, + user_id, + self._iso(granted_at), + now_text, + self._iso(expires), + ) + + def list_device_accounts( + self, device_hash: str, current_user_id: int | None = None + ) -> dict[str, Any]: + if not device_hash: + return {"accounts": [], "current_user_id": current_user_id} + now_text = self._iso(self._utc_now()) + self.database.cleanup_expired_switch_grants(now_text) + accounts = [] + for row in self.database.list_switch_grants(device_hash, now_text): + accounts.append( + { + "user_id": int(row["id"]), + "username": str(row["username"]), + "role": str(row.get("role") or "user"), + "membership": self.membership_for_access(row), + "last_used_at": str(row.get("last_used_at") or ""), + } + ) + return {"accounts": accounts, "current_user_id": current_user_id} + + def switch_account(self, device_hash: str, user_id: int) -> dict[str, Any]: + if not device_hash or user_id <= 0: + raise PermissionError(self.SWITCH_REAUTH_MESSAGE) + now = self._utc_now() + now_text = self._iso(now) + self.database.cleanup_expired_switch_grants(now_text) + grant = self.database.get_switch_grant(device_hash, user_id) + expires = parse_iso_datetime(grant.get("expires_at")) if grant else None + if not grant or not expires or expires <= now: + if grant: + self.database.delete_switch_grant(device_hash, user_id) + raise PermissionError(self.SWITCH_REAUTH_MESSAGE) + user = self.database.user_access(user_id) + if not user: + raise PermissionError(self.SWITCH_REAUTH_MESSAGE) + result = self.create_session(user) + self.remember_account(device_hash, user_id) + return result + + def forget_account(self, device_hash: str, user_id: int) -> None: + if device_hash and user_id > 0: + self.database.delete_switch_grant(device_hash, user_id) + + def revoke_current_device_grant(self, device_hash: str, user_id: int) -> None: + if device_hash and user_id > 0: + self.database.delete_switch_grant(device_hash, user_id) def create_session(self, user: dict[str, Any]) -> dict[str, Any]: session_token = secrets.token_urlsafe(32) diff --git a/backend/http/dispatch.py b/backend/http/dispatch.py index 3b58526..3c31e5c 100644 --- a/backend/http/dispatch.py +++ b/backend/http/dispatch.py @@ -7,6 +7,8 @@ from urllib.parse import urlparse PUBLIC_POST_HANDLERS = { "/api/auth/register": "auth_register", "/api/auth/login": "auth_login", + "/api/auth/switch": "auth_switch", + "/api/auth/forget": "auth_forget", } AUTHENTICATED_POST_HANDLERS = { diff --git a/backend/http/handler.py b/backend/http/handler.py index c95e484..2a906fc 100644 --- a/backend/http/handler.py +++ b/backend/http/handler.py @@ -9,7 +9,13 @@ from http.cookies import SimpleCookie from typing import Any from urllib.parse import unquote -from backend.bootstrap.config import SESSION_COOKIE, SESSION_MAX_AGE, STATIC_DIR +from backend.bootstrap.config import ( + DEVICE_COOKIE, + DEVICE_MAX_AGE, + SESSION_COOKIE, + SESSION_MAX_AGE, + STATIC_DIR, +) from backend.features.accounts.security import token_hash from backend.http.context import correlation_id from backend.http.errors import normalize_error_payload @@ -21,15 +27,21 @@ class HttpTransportMixin: application_service: Any route_registry: Any - def session_token(self) -> str: + def cookie_value(self, name: str) -> str: cookie = SimpleCookie() try: cookie.load(self.headers.get("Cookie", "")) except Exception: return "" - morsel = cookie.get(SESSION_COOKIE) + morsel = cookie.get(name) return morsel.value if morsel else "" + def session_token(self) -> str: + return self.cookie_value(SESSION_COOKIE) + + def device_token(self) -> str: + return self.cookie_value(DEVICE_COOKIE) + def require_auth(self, send_error: bool = True) -> bool: raw_token = self.session_token() service = self.application_service @@ -79,15 +91,18 @@ class HttpTransportMixin: return self.require_member() return True - def session_cookie(self, value: str, clear: bool = False) -> str: - max_age = 0 if clear else SESSION_MAX_AGE - cookie = ( - f"{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}" - ) + def _cookie_header(self, name: str, value: str, max_age: int) -> str: + cookie = f"{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}" if self.headers.get("X-Forwarded-Proto", "").lower() == "https": cookie += "; Secure" return cookie + def session_cookie(self, value: str, clear: bool = False) -> str: + return self._cookie_header(SESSION_COOKIE, value, 0 if clear else SESSION_MAX_AGE) + + def device_cookie(self, value: str, clear: bool = False) -> str: + return self._cookie_header(DEVICE_COOKIE, value, 0 if clear else DEVICE_MAX_AGE) + def read_json_body(self, allow_empty: bool = False) -> dict[str, Any]: length = int(self.headers.get("Content-Length", "0")) if length == 0 and allow_empty: @@ -132,7 +147,7 @@ class HttpTransportMixin: self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK, - headers: dict[str, str] | None = None, + headers: dict[str, str] | list[tuple[str, str]] | tuple[tuple[str, str], ...] | None = None, ) -> None: request_id = getattr(self, "_correlation_id", "") if not request_id: @@ -145,7 +160,8 @@ class HttpTransportMixin: self.send_header("Content-Length", str(len(content))) self.send_header("Cache-Control", "no-store") self.send_header("X-Request-ID", request_id) - for name, value in (headers or {}).items(): + header_items = headers.items() if isinstance(headers, dict) else (headers or ()) + for name, value in header_items: self.send_header(name, value) self.end_headers() self.wfile.write(content) diff --git a/config/api.config.json b/config/api.config.json index 02fd77f..dc88d77 100644 --- a/config/api.config.json +++ b/config/api.config.json @@ -128,6 +128,20 @@ "feature": "auction", "access": "authenticated" }, + { + "method": "GET", + "path": "/api/auth/accounts", + "match": "exact", + "feature": "auth", + "access": "public" + }, + { + "method": "POST", + "path": "/api/auth/forget", + "match": "exact", + "feature": "auth", + "access": "public" + }, { "method": "POST", "path": "/api/auth/login", @@ -156,6 +170,13 @@ "feature": "auth", "access": "public" }, + { + "method": "POST", + "path": "/api/auth/switch", + "match": "exact", + "feature": "auth", + "access": "public" + }, { "method": "POST", "path": "/api/backfill", diff --git a/config/architecture-inventory.json b/config/architecture-inventory.json index e725ad6..4d51c0b 100644 --- a/config/architecture-inventory.json +++ b/config/architecture-inventory.json @@ -10,10 +10,10 @@ }, "counts": { "primary_pages": 16, - "api_exact_paths": 53, + "api_exact_paths": 56, "api_prefixes": 0, "api_patterns": 11, - "database_tables": 36, + "database_tables": 37, "frontend_page_fragments": 12 }, "pages": [ @@ -96,10 +96,13 @@ "/api/assistant/chat", "/api/assistant/messages", "/api/auction", + "/api/auth/accounts", + "/api/auth/forget", "/api/auth/login", "/api/auth/logout", "/api/auth/me", "/api/auth/register", + "/api/auth/switch", "/api/backfill", "/api/chart/intraday", "/api/dashboard", @@ -189,6 +192,7 @@ "assistant_messages", "heaven_readings", "job_runs", + "account_switch_grants", "schema_migrations" ], "background_job_methods": [ @@ -370,10 +374,10 @@ } ], "css_layers": [ - "/shared/tokens.css?v=20260820-3", + "/shared/tokens.css?v=20260829-1", "/shared/base.css?v=20260806-1", "/shared/shell.css?v=20260820-8", - "/shared/auth.css?v=20260820-5", + "/shared/auth.css?v=20260829-1", "/shared/components/controls.css?v=20260820-2", "/shared/components/navigation.css?v=20260820-1", "/shared/components/cards.css?v=20260820-1", @@ -461,8 +465,8 @@ }, { "path": "frontend/index.html", - "bytes": 47871, - "lines": 661 + "bytes": 48037, + "lines": 663 }, { "path": "backend/features/screener/catalog.py", @@ -551,8 +555,8 @@ }, { "path": "frontend/shared/session.js", - "bytes": 13176, - "lines": 293 + "bytes": 12848, + "lines": 283 }, { "path": "backend/features/market/insights_auction_data.py", @@ -716,8 +720,8 @@ }, { "path": "backend/http/dispatch.py", - "bytes": 4118, - "lines": 115 + "bytes": 4196, + "lines": 117 }, { "path": "frontend/shared/table.js", @@ -734,16 +738,16 @@ "bytes": 3369, "lines": 81 }, + { + "path": "frontend/app.js", + "bytes": 3337, + "lines": 95 + }, { "path": "frontend/pages/themes/page.html", "bytes": 3316, "lines": 55 }, - { - "path": "frontend/app.js", - "bytes": 3201, - "lines": 93 - }, { "path": "backend/features/market/insights_context.py", "bytes": 3175, @@ -761,7 +765,7 @@ }, { "path": "backend/features/accounts/application.py", - "bytes": 2442, + "bytes": 2514, "lines": 63 }, { @@ -849,6 +853,11 @@ "bytes": 1143, "lines": 19 }, + { + "path": "backend/features/accounts/routes.py", + "bytes": 908, + "lines": 25 + }, { "path": "backend/features/popularity/routes.py", "bytes": 822, @@ -859,11 +868,6 @@ "bytes": 817, "lines": 23 }, - { - "path": "backend/features/accounts/routes.py", - "bytes": 803, - "lines": 22 - }, { "path": "backend/features/sentiment/routes.py", "bytes": 724, diff --git a/frontend/app.js b/frontend/app.js index 0fbfd37..90fbb7f 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -25,8 +25,10 @@ async function initialize() { try { const session = await apiRequest("/api/auth/me"); if (!session.authenticated) { - if (session.registration_required) selectAuthMode("register"); - showAuthGate(); + const params = new URLSearchParams(); + if (session.registration_required) params.set("mode", "register"); + const query = params.toString(); + window.location.replace("/login/" + (query ? `?${query}` : "")); return; } await applyAuthenticatedSession(session); diff --git a/frontend/index.html b/frontend/index.html index 2d85bfa..ed00604 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -24,7 +24,9 @@ (() => { let theme = "light"; try { - theme = localStorage.getItem("xiaobaiTheme") === "dark" ? "dark" : "light"; + const stored = localStorage.getItem("xiaobaiTheme"); + if (stored === "dark" || stored === "light") theme = stored; + else if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) theme = "dark"; } catch (_error) { theme = "light"; } @@ -32,10 +34,10 @@ document.documentElement.style.colorScheme = theme; })(); - + - + @@ -57,7 +59,7 @@ -
+