Add portal SSO while retaining native financial role and company scope

This commit is contained in:
leefer
2026-09-22 10:27:19 +08:00
parent 6eccbe9765
commit e11eb252a4
7 changed files with 448 additions and 42 deletions
+27
View File
@@ -0,0 +1,27 @@
# Optional overlay for a separately approved, fresh finance integration instance.
# Never point FINANCE_DATA at the standalone server's real database.
services:
backend:
environment:
FINANCE_SSO_ENABLED: "true"
FINANCE_SSO_SECRET: ${FINANCE_SSO_SECRET}
FINANCE_SSO_CALLBACK_URL: ${FINANCE_PUBLIC_URL}/portal-sso
finance:
image: ${FINANCE_IMAGE}
container_name: jinniu-finance-portal-integration
ports: ["${TEST_HOST}:18124:4173"]
environment:
PORTAL_SSO_ENABLED: "1"
PORTAL_SSO_SECRET: ${FINANCE_SSO_SECRET}
PORTAL_INTERNAL_URL: http://backend:8000
PORTAL_PUBLIC_URL: http://${TEST_HOST}:18091
APP_DB_PATH: /app/data/app.db
APP_STORAGE_DIR: /app/data/files
volumes:
- ${FINANCE_SOURCE}/server.py:/app/server.py:ro
- ${FINANCE_SOURCE}/src:/app/src:ro
- ${FINANCE_SOURCE}/web:/app/web:ro
- ${FINANCE_DATA}:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:4173/healthz',timeout=3)"]
depends_on: [backend]
+13
View File
@@ -0,0 +1,13 @@
# 统一门户适配(2026-09-22)
分支 feature/portal-integration,基于 main 6eccbe9。代码完成但尚未替换外网测试实例;部署方向等待用户决定。
`PORTAL_SSO_ENABLED=1` 开启门户模式;必须配置独立的 PORTAL_SSO_SECRET、PORTAL_INTERNAL_URL、PORTAL_PUBLIC_URL。若使用 HTTPS,部署时还需相应安全 Cookie 配置。门户配置 FINANCE_SSO_ENABLED、FINANCE_SSO_SECRET、FINANCE_SSO_CALLBACK_URL,回调固定为 `/portal-sso`。
员工在门户中绑定已有财务用户的数字 ID;本地角色及 company_id 继续决定业务权限,不读取门户 admin 作为业务角色。每名员工应有独立本地账号,同一公司可有多名员工;不将多人映射到共享公司登录身份。账户停用及财务本地停用均拒绝访问。门户模式不允许原生登录、登出、改密、创建凭证或重置密码;管理员/出纳页面仍分开。
初始化会增量创建 portal_identity、portal_sessions 两表,不改银行流水、业务计算或原会话表。已有银行数据迁移不包含在本次工作中。全新实例须先配置测试公司和测试业务身份,再绑定门户员工;不会默认生成管理员密码。
候选内网部署 overlay 为 deploy/portal-integration.compose.yml,应叠加现有门户/OA融合 Compose,使用独立 FINANCE_DATA 目录并赋予容器用户 10001 写权限。健康检查使用 `/healthz`,原主页在门户模式跳回门户。FINANCE_IMAGE 应为已准备好依赖的 Python 3.12 财务镜像,代码目录通过 FINANCE_SOURCE 只读挂载。不得把候选测试实例数据路径指向现有正式或独立测试库。
验证命令:`PYTHONPATH=src python -m unittest discover -s tests`。本轮 395 项:391 通过、4 跳过,其中新增 6 项 SSO 回调/权限/隔离测试。外网到内网门户目前不可达,实际财务跨系统端到端部署验收待完成。
+28 -5
View File
@@ -19,6 +19,7 @@ from bank_importer import (
positions, reminders, settings, subjects, positions, reminders, settings, subjects,
) )
from bank_importer.db import connect, migrate, transaction, utc_now from bank_importer.db import connect, migrate, transaction, utc_now
from bank_importer import portal_sso
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
@@ -26,13 +27,13 @@ WEB_ROOT = ROOT / "web"
MAX_UPLOAD_BYTES = 20 * 1024 * 1024 MAX_UPLOAD_BYTES = 20 * 1024 * 1024
DB_PATH = Path(os.environ.get("APP_DB_PATH", ROOT / "data" / "app.db")) DB_PATH = Path(os.environ.get("APP_DB_PATH", ROOT / "data" / "app.db"))
STORAGE_DIR = Path(os.environ.get("APP_STORAGE_DIR", ROOT / "data" / "files")) STORAGE_DIR = Path(os.environ.get("APP_STORAGE_DIR", ROOT / "data" / "files"))
SESSION_COOKIE = "cw_session" SESSION_COOKIE = "cw_fusion_session" if portal_sso.enabled() else "cw_session"
# Local plain-HTTP deployment: the cookie intentionally carries no Secure # Local plain-HTTP deployment: the cookie intentionally carries no Secure
# flag (see docs/decisions/003-auth.md). # flag (see docs/decisions/003-auth.md).
COOKIE_FLAGS = "HttpOnly; SameSite=Lax; Path=/" COOKIE_FLAGS = "HttpOnly; SameSite=Lax; Path=/"
class AppHandler(SimpleHTTPRequestHandler): class AppHandler(portal_sso.PortalMixin, SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(WEB_ROOT), **kwargs) super().__init__(*args, directory=str(WEB_ROOT), **kwargs)
@@ -44,6 +45,15 @@ class AppHandler(SimpleHTTPRequestHandler):
parsed = urlparse(self.path) parsed = urlparse(self.path)
path = parsed.path path = parsed.path
query = parse_qs(parsed.query) query = parse_qs(parsed.query)
if path == "/healthz":
self._send_json(200, {"status": "ok"})
return
if path == "/portal-sso":
self._portal_callback(query, DB_PATH)
return
if portal_sso.enabled() and path in {"/", "/index.html", "/login-admin.html", "/login-company.html"}:
self._portal_redirect(portal_sso.base_url("PORTAL_PUBLIC_URL"))
return
if path == "/api/me": if path == "/api/me":
self._handle_me() self._handle_me()
@@ -274,6 +284,10 @@ class AppHandler(SimpleHTTPRequestHandler):
def do_POST(self) -> None: def do_POST(self) -> None:
path = urlparse(self.path).path path = urlparse(self.path).path
if portal_sso.enabled() and (path in {"/api/login", "/api/logout", "/api/password/change", "/api/admin/users"}
or re.fullmatch(r"/api/admin/users/\d+/reset-password", path)):
self._send_json(403, {"message": "账号和登录请前往统一门户管理。"})
return
if path == "/api/login": if path == "/api/login":
self._handle_login() self._handle_login()
@@ -463,6 +477,11 @@ class AppHandler(SimpleHTTPRequestHandler):
token = self._session_token() token = self._session_token()
if not token: if not token:
return None return None
if portal_sso.enabled():
try:
return portal_sso.resolve(connection, token)
except Exception:
return None # Fail closed; no local-password fallback.
return auth.resolve_session(connection, token) return auth.resolve_session(connection, token)
def _require_user(self, connection, *, pending_password_ok: bool = False): def _require_user(self, connection, *, pending_password_ok: bool = False):
@@ -470,7 +489,7 @@ class AppHandler(SimpleHTTPRequestHandler):
if user is None: if user is None:
self._send_json(401, {"status": "error", "message": "请先登录。"}) self._send_json(401, {"status": "error", "message": "请先登录。"})
return None return None
if user["must_change_password"] and not pending_password_ok: if user["must_change_password"] and not pending_password_ok and not portal_sso.enabled():
self._send_json( self._send_json(
403, 403,
{"status": "error", "message": "首次登录须修改密码后才能继续操作。"}, {"status": "error", "message": "首次登录须修改密码后才能继续操作。"},
@@ -619,7 +638,7 @@ class AppHandler(SimpleHTTPRequestHandler):
"status": "ok", "status": "ok",
"username": user["username"], "username": user["username"],
"role": user["role"], "role": user["role"],
"must_change_password": bool(user["must_change_password"]), "must_change_password": bool(user["must_change_password"]) and not portal_sso.enabled(),
"company_id": user["company_id"], "company_id": user["company_id"],
"company_name": company_name, "company_name": company_name,
} }
@@ -1142,6 +1161,9 @@ class AppHandler(SimpleHTTPRequestHandler):
return return
name = str(data.get("name") or "").strip() name = str(data.get("name") or "").strip()
username = str(data.get("username") or "").strip() username = str(data.get("username") or "").strip()
if portal_sso.enabled() and username:
self._send_json(403, {"message": "员工账号请在统一门户建立,再配置财务权限绑定。"})
return
try: try:
company_id = master_data.create_company( company_id = master_data.create_company(
connection, connection,
@@ -4416,7 +4438,8 @@ def main() -> None:
connection = connect(DB_PATH) connection = connect(DB_PATH)
try: try:
migrate(connection) migrate(connection)
initial_password = ensure_bootstrap_admin(connection) portal_sso.migrate(connection)
initial_password = None if portal_sso.enabled() else ensure_bootstrap_admin(connection)
finally: finally:
connection.close() connection.close()
if initial_password is not None: if initial_password is not None:
+185
View File
@@ -0,0 +1,185 @@
"""Optional portal identity bridge. Financial role and company scope stay local."""
import hashlib
import html
import io
import json
import os
import re
import secrets
from http.cookies import SimpleCookie
from urllib.parse import urlencode, urlsplit
from urllib.request import Request, build_opener, HTTPRedirectHandler
from . import auth
from .db import connect
def enabled():
return os.environ.get("PORTAL_SSO_ENABLED") == "1"
def base_url(name):
value = os.environ.get(name, "").rstrip("/")
url = urlsplit(value)
if (url.scheme not in {"http", "https"} or not url.hostname or url.username
or url.password or url.path or url.query or url.fragment):
raise ValueError("Portal URL unavailable")
return value
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def call(operation, payload):
secret = os.environ.get("PORTAL_SSO_SECRET", "")
if len(secret) < 32:
raise ValueError("Portal secret unavailable")
req = Request(base_url("PORTAL_INTERNAL_URL") + "/api/sso/apps/finance/" + operation,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + secret})
with build_opener(NoRedirect).open(req, timeout=5) as response:
body = response.read(32769)
if len(body) > 32768:
raise ValueError("Invalid portal response")
return json.loads(body)
def migrate(connection):
connection.executescript("""
CREATE TABLE IF NOT EXISTS portal_identity (
uid INTEGER PRIMARY KEY REFERENCES users(id), empid TEXT UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS portal_sessions (
token_hash TEXT PRIMARY KEY, handle TEXT NOT NULL,
empid TEXT NOT NULL, uid INTEGER NOT NULL REFERENCES users(id)
);
""")
def resolve(connection, token):
user = auth.resolve_session(connection, token)
if user is None:
return None
row = connection.execute("SELECT * FROM portal_sessions WHERE token_hash=?",
(hashlib.sha256(token.encode()).hexdigest(),)).fetchone()
if row is None:
return None
result = call("introspect", {"session_handle": row["handle"]})
who = result.get("identity") or {}
if (not result.get("active") or who.get("local_uid") != user["id"]
or who.get("empid") != row["empid"] or row["uid"] != user["id"]):
auth.revoke_session(connection, token)
return None
return user
class PortalMixin:
def _portal_error(self, status, message):
home = html.escape(base_url("PORTAL_PUBLIC_URL"), quote=True)
body = ('<!doctype html><html lang="zh-CN"><meta charset="utf-8">'
'<title>金牛财务往来</title><p>' + html.escape(message)
+ '</p><a href="' + home + '">返回统一门户</a></html>').encode()
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _portal_redirect(self, target, cookie=None):
self.send_response(303)
self.send_header("Location", target)
self.send_header("Cache-Control", "no-store")
self.send_header("Referrer-Policy", "no-referrer")
if cookie:
self.send_header("Set-Cookie", cookie)
self.send_header("Content-Length", "0")
self.end_headers()
def _portal_callback(self, query, db_path):
if not enabled():
self.send_error(404)
return
secure = "; Secure" if os.environ.get("PORTAL_COOKIE_SECURE") == "1" else ""
flags = "; HttpOnly; SameSite=Lax; Path=/portal-sso" + secure
if "ticket" not in query:
verifier = secrets.token_hex(32)
theme = "night" if query.get("theme") == ["night"] else "day"
self._portal_redirect(base_url("PORTAL_PUBLIC_URL") + "/api/sso/apps/finance/authorize?"
+ urlencode({"challenge": hashlib.sha256(verifier.encode()).hexdigest()}),
"cw_portal_proof=" + verifier + "." + theme + flags + "; Max-Age=120")
return
cookie = SimpleCookie()
cookie.load(self.headers.get("Cookie", ""))
proof = cookie.get("cw_portal_proof")
parts = proof.value.split(".") if proof else []
ticket = query["ticket"][0]
if len(parts) != 2 or not re.fullmatch("[a-f0-9]{64}", parts[0]) or not re.fullmatch("[a-f0-9]{64}", ticket):
self._portal_error(401, "登录请求已失效,请从统一门户重新进入。")
return
connection = connect(db_path)
try:
result = call("exchange", {"ticket": ticket, "verifier": parts[0]})
who = result["identity"]
handle = result["session_handle"]
if not re.fullmatch("[a-f0-9]{64}", handle) or not re.fullmatch("[a-f0-9-]{36}", who["empid"]):
raise ValueError("Invalid identity")
user = connection.execute("SELECT * FROM users WHERE id=? AND status='active'", (who["local_uid"],)).fetchone()
if user is None:
raise ValueError("Local account unavailable")
with connection:
connection.execute("INSERT OR IGNORE INTO portal_identity(uid,empid) VALUES (?,?)", (user["id"], who["empid"]))
binding = connection.execute("SELECT empid FROM portal_identity WHERE uid=?", (user["id"],)).fetchone()
if not binding or binding["empid"] != who["empid"]:
raise ValueError("Binding mismatch")
token = auth.create_session(connection, user["id"])
with connection:
connection.execute("INSERT INTO portal_sessions(token_hash,handle,empid,uid) VALUES (?,?,?,?)",
(hashlib.sha256(token.encode()).hexdigest(), handle, who["empid"], user["id"]))
connection.execute("DELETE FROM portal_sessions WHERE token_hash NOT IN (SELECT token_hash FROM sessions)")
auth.audit(connection, "portal_login", actor=user, detail=who["empid"])
target = "/admin.html" if user["role"] == "admin" else "/company.html"
theme = "night" if parts[1] == "night" else "day"
self.send_response(303)
self._set_session_cookie(token)
self.send_header("Set-Cookie", "cw_portal_proof=" + flags + "; Max-Age=0")
self.send_header("Location", target + "?theme=" + theme)
self.send_header("Cache-Control", "no-store")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Content-Length", "0")
self.end_headers()
except Exception:
self._portal_error(401, "无法进入,请检查门户授权、账号绑定及子系统可用状态。")
finally:
connection.close()
def send_head(self):
# Serve the existing two business surfaces, with account controls hidden
# before first paint. The API gate separately blocks native credentials.
path = urlsplit(self.path).path
if enabled() and path in {"/admin.html", "/company.html"}:
from pathlib import Path
body = Path(self.directory, path[1:]).read_text(encoding="utf-8")
home = base_url("PORTAL_PUBLIC_URL")
script = '<script>window.PORTAL_HOME=' + json.dumps(home) + ';</script>'
style = '<style>.account-box,.portal-native-account{display:none!important}</style>'
body = body.replace('</head>', script + style + '</head>')
body = body.replace('<nav class="side-nav">', '<nav class="side-nav"><a data-return-portal href="'
+ html.escape(home, quote=True) + '">← 返回统一门户</a>')
data = body.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
return io.BytesIO(data)
return super().send_head()
def log_message(self, fmt, *args):
# Callback URLs contain short-lived credentials. Never write them to logs.
if urlsplit(self.path).path == "/portal-sso":
return
super().log_message(fmt, *args)
+135
View File
@@ -0,0 +1,135 @@
import hashlib
from http.client import HTTPConnection
from http.cookies import SimpleCookie
import json
import os
from pathlib import Path
import tempfile
import threading
import unittest
from unittest.mock import patch
from bank_importer import auth, portal_sso
from bank_importer.db import connect, migrate, utc_now
import server
from test_server_auth import Client
class PortalSsoTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.db_path = Path(self.temp.name) / 'app.db'
self.db = connect(self.db_path)
self.addCleanup(self.db.close)
migrate(self.db)
portal_sso.migrate(self.db)
with self.db:
self.db.execute('INSERT INTO companies(name,created_at,updated_at) VALUES (?,?,?)', ('测试公司甲', utc_now(), utc_now()))
self.db.execute('INSERT INTO companies(name,created_at,updated_at) VALUES (?,?,?)', ('测试公司乙', utc_now(), utc_now()))
self.uid = auth.create_user(self.db, 'staff-a', 'UnusedPass123', 'company', company_id=1)
self.token = auth.create_session(self.db, self.uid)
self.empid = '11111111-2222-3333-4444-555555555555'
self.claims = {'active': True, 'identity': {'empid': self.empid, 'local_uid': self.uid}}
self.env = patch.dict(os.environ, {'PORTAL_SSO_ENABLED': '1', 'PORTAL_PUBLIC_URL': 'http://portal.test'})
self.env.start()
self.addCleanup(self.env.stop)
def bind_session(self):
with self.db:
self.db.execute('INSERT INTO portal_sessions VALUES (?,?,?,?)',
(hashlib.sha256(self.token.encode()).hexdigest(), 'a' * 64, self.empid, self.uid))
def test_native_session_does_not_bypass_portal(self):
with patch.object(portal_sso, 'call') as call:
self.assertIsNone(portal_sso.resolve(self.db, self.token))
call.assert_not_called()
def test_native_role_and_company_are_retained(self):
self.bind_session()
with patch.object(portal_sso, 'call', return_value=self.claims):
user = portal_sso.resolve(self.db, self.token)
self.assertEqual('company', user['role'])
self.assertEqual(1, user['company_id'])
def test_revocation_mismatch_and_local_disable_reject(self):
self.bind_session()
with patch.object(portal_sso, 'call', return_value={'active': False}):
self.assertIsNone(portal_sso.resolve(self.db, self.token))
self.assertIsNone(auth.resolve_session(self.db, self.token))
def test_portal_outage_does_not_fallback(self):
self.bind_session()
with patch.object(portal_sso, 'call', side_effect=TimeoutError):
with self.assertRaises(TimeoutError):
portal_sso.resolve(self.db, self.token)
def test_http_guards_company_scope_and_hidden_credentials(self):
self.bind_session()
class Quiet(server.AppHandler):
def log_message(self, *args): pass
with patch.object(server, 'DB_PATH', self.db_path), patch.object(portal_sso, 'call', return_value=self.claims):
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), Quiet)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
client = Client('127.0.0.1', httpd.server_port)
self.assertEqual(401, client.get('/api/company/accounts')[0])
client.cookies[server.SESSION_COOKIE] = self.token
self.assertEqual(200, client.get('/api/company/accounts?company_id=2')[0])
self.assertEqual(403, client.get('/api/admin/companies')[0])
status, _, data = client.get('/api/me')
self.assertEqual(200, status)
self.assertEqual(1, json.loads(data)['company_id'])
for path in ['/api/login', '/api/logout', '/api/password/change', '/api/admin/users', '/api/admin/users/1/reset-password']:
self.assertEqual(403, client.post_json(path, {})[0])
status, _, data = client.get('/company.html')
self.assertEqual(200, status)
self.assertIn(b'data-return-portal', data)
self.assertIn(b'display:none!important', data)
self.assertEqual('http://portal.test', client.get('/login-company.html')[1]['location'])
finally:
httpd.shutdown()
httpd.server_close()
thread.join()
def test_callback_checks_browser_proof_and_pins_local_identity(self):
class Quiet(server.AppHandler):
def log_message(self, *args): pass
def response(operation, payload):
if operation == 'exchange':
self.assertEqual('a' * 64, payload['verifier'])
return {'session_handle': 'b' * 64, 'identity': self.claims['identity']}
return self.claims
with patch.object(server, 'DB_PATH', self.db_path), patch.object(portal_sso, 'call', side_effect=response):
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), Quiet)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
client = Client('127.0.0.1', httpd.server_port)
status, headers, _ = client.get('/portal-sso?theme=night')
self.assertEqual(303, status)
self.assertIn('/api/sso/apps/finance/authorize?challenge=', headers['location'])
client.cookies.clear()
status, _, body = client.get('/portal-sso?ticket=' + 'c' * 64)
self.assertEqual(401, status)
self.assertIn(b'http://portal.test', body)
conn = HTTPConnection('127.0.0.1', httpd.server_port)
conn.request('GET', '/portal-sso?ticket=' + 'c' * 64,
headers={'Cookie': 'cw_portal_proof=' + 'a' * 64 + '.night'})
res = conn.getresponse()
self.assertEqual(303, res.status)
self.assertEqual('/company.html?theme=night', res.getheader('Location'))
for key, value in res.getheaders():
if key.lower() == 'set-cookie':
cookies = SimpleCookie(value)
if server.SESSION_COOKIE in cookies:
client.cookies[server.SESSION_COOKIE] = cookies[server.SESSION_COOKIE].value
res.read()
conn.close()
self.assertEqual(1, json.loads(client.get('/api/me')[2])['company_id'])
self.assertEqual(self.empid, self.db.execute('SELECT empid FROM portal_identity WHERE uid=?', (self.uid,)).fetchone()['empid'])
finally:
httpd.shutdown()
httpd.server_close()
thread.join()
+36 -36
View File
@@ -433,12 +433,12 @@ async function initAuthGuard() {
try { try {
const response = await fetch("/api/me"); const response = await fetch("/api/me");
if (response.status === 401) { if (response.status === 401) {
window.location.href = "index.html"; window.location.href = window.PORTAL_HOME || "index.html";
return false; return false;
} }
const me = await response.json(); const me = await response.json();
if (!response.ok || me.role !== portal) { if (!response.ok || me.role !== portal) {
window.location.href = "index.html"; window.location.href = window.PORTAL_HOME || "index.html";
return false; return false;
} }
state.me = me; state.me = me;
@@ -512,7 +512,7 @@ function initShell() {
try { try {
await fetch("/api/logout", { method: "POST" }); await fetch("/api/logout", { method: "POST" });
} catch { /* 网络异常时仍然回到登录页 */ } } catch { /* 网络异常时仍然回到登录页 */ }
window.location.href = "index.html"; window.location.href = window.PORTAL_HOME || "index.html";
})); }));
$$("[data-metric-link]").forEach((card) => { $$("[data-metric-link]").forEach((card) => {
const activate = () => showView(card.dataset.metricLink); const activate = () => showView(card.dataset.metricLink);
@@ -810,7 +810,7 @@ async function loadCompanyAccounts() {
showTableLoading(tbody, 6); showTableLoading(tbody, 6);
const response = await fetch("/api/company/accounts").catch(() => null); const response = await fetch("/api/company/accounts").catch(() => null);
if (response?.status === 401 || response?.status === 403) { if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html"; window.location.href = window.PORTAL_HOME || "index.html";
return; return;
} }
if (!response?.ok) { if (!response?.ok) {
@@ -984,7 +984,7 @@ async function loadAdminAuditQueue() {
]); ]);
if ([accountsRes, manualsRes, exceptionsRes].some((res) => res?.status === 401)) { if ([accountsRes, manualsRes, exceptionsRes].some((res) => res?.status === 401)) {
window.location.href = "index.html"; window.location.href = window.PORTAL_HOME || "index.html";
return; return;
} }
if (![accountsRes, manualsRes, exceptionsRes].every((res) => res?.ok)) { if (![accountsRes, manualsRes, exceptionsRes].every((res) => res?.ok)) {
@@ -1900,7 +1900,7 @@ async function loadAdminRemindersPending() {
const sendAll = $("#pending-send-all"); const sendAll = $("#pending-send-all");
if (!list) return; if (!list) return;
const response = await fetch("/api/admin/reminders/pending").catch(() => null); const response = await fetch("/api/admin/reminders/pending").catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; } if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
const result = await response?.json().catch(() => ({})); const result = await response?.json().catch(() => ({}));
if (!response?.ok) return; if (!response?.ok) return;
const findings = result.findings || []; const findings = result.findings || [];
@@ -1957,7 +1957,7 @@ async function loadAdminRemindersHistory(sourceFilter) {
if (!tbody) return; if (!tbody) return;
const query = sourceFilter && sourceFilter !== "all" ? `?source=${sourceFilter === "system" ? "auto" : "manual"}` : ""; const query = sourceFilter && sourceFilter !== "all" ? `?source=${sourceFilter === "system" ? "auto" : "manual"}` : "";
const response = await fetch(`/api/admin/reminders${query}`).catch(() => null); const response = await fetch(`/api/admin/reminders${query}`).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; } if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
const result = await response?.json().catch(() => ({})); const result = await response?.json().catch(() => ({}));
if (!response?.ok) return; if (!response?.ok) return;
const items = result.reminders || []; const items = result.reminders || [];
@@ -2002,7 +2002,7 @@ async function sendPendingReminders(keys) {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dedupe_keys: keys }), body: JSON.stringify({ dedupe_keys: keys }),
}).catch(() => null); }).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return null; } if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return null; }
const result = await response?.json().catch(() => ({})); const result = await response?.json().catch(() => ({}));
if (!response?.ok) { if (!response?.ok) {
showToast("发送失败", result?.message || "请稍后重试", "danger"); showToast("发送失败", result?.message || "请稍后重试", "danger");
@@ -2054,7 +2054,7 @@ function initAdminReminders() {
$("#reminder-scan-btn")?.addEventListener("click", async () => { $("#reminder-scan-btn")?.addEventListener("click", async () => {
const response = await fetch("/api/admin/reminders/scan", { method: "POST" }).catch(() => null); const response = await fetch("/api/admin/reminders/scan", { method: "POST" }).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; } if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
const result = await response?.json().catch(() => ({})); const result = await response?.json().catch(() => ({}));
if (!response?.ok) { if (!response?.ok) {
showToast("扫描失败", result?.message || "请稍后重试", "danger"); showToast("扫描失败", result?.message || "请稍后重试", "danger");
@@ -2138,7 +2138,7 @@ function initAdminReminders() {
deadline: deadline || null, deadline: deadline || null,
}), }),
}).catch(() => null); }).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; } if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
const result = await response?.json().catch(() => ({})); const result = await response?.json().catch(() => ({}));
if (!response?.ok) { if (!response?.ok) {
showToast("发送失败", result?.message || "请稍后重试", "danger"); showToast("发送失败", result?.message || "请稍后重试", "danger");
@@ -2179,7 +2179,7 @@ function initAdminReminders() {
if (!remindBtn) return; if (!remindBtn) return;
const tr = remindBtn.closest("tr"); const tr = remindBtn.closest("tr");
const response = await fetch(`/api/admin/reminders/${tr.dataset.reminderId}/resend`, { method: "POST" }).catch(() => null); const response = await fetch(`/api/admin/reminders/${tr.dataset.reminderId}/resend`, { method: "POST" }).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; } if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
const result = await response?.json().catch(() => ({})); const result = await response?.json().catch(() => ({}));
if (!response?.ok) { if (!response?.ok) {
showToast("再提醒失败", result?.message || "请稍后重试", "danger"); showToast("再提醒失败", result?.message || "请稍后重试", "danger");
@@ -2385,7 +2385,7 @@ function applyClosePanel(overview) {
async function loadPeriodClose() { async function loadPeriodClose() {
if (!$("#closingPanel")) return; if (!$("#closingPanel")) return;
const response = await fetch("/api/admin/period-closes").catch(() => null); const response = await fetch("/api/admin/period-closes").catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; } if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
const result = await response?.json().catch(() => null); const result = await response?.json().catch(() => null);
if (!response?.ok || result?.status !== "ok") { if (!response?.ok || result?.status !== "ok") {
showToast("结账状态加载失败", result?.message || "请稍后重试", "danger"); showToast("结账状态加载失败", result?.message || "请稍后重试", "danger");
@@ -2755,7 +2755,7 @@ function initAdmin() {
body: JSON.stringify({ decision: apiDecision, reason }), body: JSON.stringify({ decision: apiDecision, reason }),
}).catch(() => null); }).catch(() => null);
if (response?.status === 401) { if (response?.status === 401) {
window.location.href = "index.html"; window.location.href = window.PORTAL_HOME || "index.html";
return; return;
} }
const result = await response?.json().catch(() => ({})); const result = await response?.json().catch(() => ({}));
@@ -2779,7 +2779,7 @@ function initAdmin() {
}), }),
}).catch(() => null); }).catch(() => null);
if (response?.status === 401) { if (response?.status === 401) {
window.location.href = "index.html"; window.location.href = window.PORTAL_HOME || "index.html";
return; return;
} }
const result = await response?.json().catch(() => ({})); const result = await response?.json().catch(() => ({}));
@@ -2801,7 +2801,7 @@ function initAdmin() {
}), }),
}).catch(() => null); }).catch(() => null);
if (response?.status === 401) { if (response?.status === 401) {
window.location.href = "index.html"; window.location.href = window.PORTAL_HOME || "index.html";
return; return;
} }
const result = await response?.json().catch(() => ({})); const result = await response?.json().catch(() => ({}));
@@ -2921,7 +2921,7 @@ function initAdmin() {
const data = new FormData(form); const data = new FormData(form);
const companyName = String(data.get("companyName") || "").trim(); const companyName = String(data.get("companyName") || "").trim();
const loginName = String(data.get("loginName") || "").trim(); const loginName = String(data.get("loginName") || "").trim();
const createUser = data.get("createUser") !== null; const createUser = !window.PORTAL_HOME && data.get("createUser") !== null;
const payload = { const payload = {
name: companyName, name: companyName,
@@ -2936,7 +2936,7 @@ function initAdmin() {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}).catch(() => null); }).catch(() => null);
if (response?.status === 401) { if (response?.status === 401) {
window.location.href = "index.html"; window.location.href = window.PORTAL_HOME || "index.html";
return; return;
} }
const result = await response?.json().catch(() => ({})); const result = await response?.json().catch(() => ({}));
@@ -2993,7 +2993,7 @@ function initAdmin() {
const user = (usersResult?.users || []).find((u) => String(u.company_id) === String(companyId) && u.role === "company"); const user = (usersResult?.users || []).find((u) => String(u.company_id) === String(companyId) && u.role === "company");
if (!user) { showToast("重置失败", "该公司尚无公司登录账号", "danger"); return; } if (!user) { showToast("重置失败", "该公司尚无公司登录账号", "danger"); return; }
const response = await fetch(`/api/admin/users/${user.id}/reset-password`, { method: "POST" }).catch(() => null); const response = await fetch(`/api/admin/users/${user.id}/reset-password`, { method: "POST" }).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; } if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
const result = await response?.json().catch(() => ({})); const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) { showToast("重置失败", result?.message || "请稍后重试", "danger"); return; } if (!response || !response.ok) { showToast("重置失败", result?.message || "请稍后重试", "danger"); return; }
$("#btn-reset-pwd").disabled = true; $("#btn-reset-pwd").disabled = true;
@@ -3049,7 +3049,7 @@ function initAdmin() {
async function loadSystemSettings() { async function loadSystemSettings() {
const response = await fetch("/api/admin/settings").catch(() => null); const response = await fetch("/api/admin/settings").catch(() => null);
if (response?.status === 401) { if (response?.status === 401) {
window.location.href = "index.html"; window.location.href = window.PORTAL_HOME || "index.html";
return; return;
} }
const result = await response?.json().catch(() => ({})); const result = await response?.json().catch(() => ({}));
@@ -3084,7 +3084,7 @@ function initAdmin() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ calculation_start_date: nextStart, reason }), body: JSON.stringify({ calculation_start_date: nextStart, reason }),
}).catch(() => null); }).catch(() => null);
if (calcResp?.status === 401) { window.location.href = "index.html"; return; } if (calcResp?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
const calcResult = await calcResp?.json().catch(() => ({})); const calcResult = await calcResp?.json().catch(() => ({}));
if (!calcResp?.ok) { if (!calcResp?.ok) {
showToast("起算日保存失败", calcResult?.message || "请稍后重试", "danger"); showToast("起算日保存失败", calcResult?.message || "请稍后重试", "danger");
@@ -3106,7 +3106,7 @@ function initAdmin() {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}).catch(() => null); }).catch(() => null);
if (response?.status === 401) { if (response?.status === 401) {
window.location.href = "index.html"; window.location.href = window.PORTAL_HOME || "index.html";
return; return;
} }
const result = await response?.json().catch(() => ({})); const result = await response?.json().catch(() => ({}));
@@ -3125,7 +3125,7 @@ function initAdmin() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ settings: { monthly_start_day: String(day || 5), gap_days: String(gapDays) } }), body: JSON.stringify({ settings: { monthly_start_day: String(day || 5), gap_days: String(gapDays) } }),
}).catch(() => null); }).catch(() => null);
if (reminderResp?.status === 401) { window.location.href = "index.html"; return; } if (reminderResp?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
if (!reminderResp?.ok) { if (!reminderResp?.ok) {
showToast("提醒扫描参数保存失败", "", "danger"); showToast("提醒扫描参数保存失败", "", "danger");
return; return;
@@ -3234,7 +3234,7 @@ async function loadFlows() {
if ($("#flowKeyword")?.value.trim()) params.set("keyword", $("#flowKeyword").value.trim()); if ($("#flowKeyword")?.value.trim()) params.set("keyword", $("#flowKeyword").value.trim());
params.set("limit", "200"); params.set("limit", "200");
const response = await fetch(`/api/flows?${params.toString()}`).catch(() => null); const response = await fetch(`/api/flows?${params.toString()}`).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; } if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
const result = await response?.json().catch(() => null); const result = await response?.json().catch(() => null);
if (!response?.ok || result?.status !== "ok") { if (!response?.ok || result?.status !== "ok") {
showTableError(tbody, portal === "admin" ? 9 : 8); showTableError(tbody, portal === "admin" ? 9 : 8);
@@ -3415,7 +3415,7 @@ function renderCompanyManualRecords(records) {
async function loadCompanyManualRecords() { async function loadCompanyManualRecords() {
if (!$("#manualRecordRows")) return; if (!$("#manualRecordRows")) return;
const response = await fetch("/api/company/manual-records").catch(() => null); const response = await fetch("/api/company/manual-records").catch(() => null);
if (response?.status === 401 || response?.status === 403) { window.location.href = "index.html"; return; } if (response?.status === 401 || response?.status === 403) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
const result = await response?.json().catch(() => ({})); const result = await response?.json().catch(() => ({}));
if (!response?.ok) { if (!response?.ok) {
showToast("手工记录加载失败", result?.message || "请稍后重试", "danger"); showToast("手工记录加载失败", result?.message || "请稍后重试", "danger");
@@ -3493,7 +3493,7 @@ async function parseFile() {
try { try {
const response = await fetch("/api/parse", { method: "POST", body: formData }); const response = await fetch("/api/parse", { method: "POST", body: formData });
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
window.location.href = "index.html"; window.location.href = window.PORTAL_HOME || "index.html";
return; return;
} }
result = await response.json(); result = await response.json();
@@ -3634,7 +3634,7 @@ async function sheetReviewAction(batchId, sheetName, decision) {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}).catch(() => null); }).catch(() => null);
if (response?.status === 401 || response?.status === 403) { if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html"; window.location.href = window.PORTAL_HOME || "index.html";
return; return;
} }
const result = await response?.json().catch(() => ({})); const result = await response?.json().catch(() => ({}));
@@ -3682,7 +3682,7 @@ async function confirmImport() {
body: JSON.stringify({ sheets: pending }), body: JSON.stringify({ sheets: pending }),
}).catch(() => null); }).catch(() => null);
if (response?.status === 401 || response?.status === 403) { if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html"; window.location.href = window.PORTAL_HOME || "index.html";
return; return;
} }
const outcome = await response?.json().catch(() => ({})); const outcome = await response?.json().catch(() => ({}));
@@ -3764,7 +3764,7 @@ async function loadImportBatches() {
showTableLoading(tbody, 8); showTableLoading(tbody, 8);
const response = await fetch("/api/batches").catch(() => null); const response = await fetch("/api/batches").catch(() => null);
if (response?.status === 401 || response?.status === 403) { if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html"; window.location.href = window.PORTAL_HOME || "index.html";
return; return;
} }
if (!response?.ok) { if (!response?.ok) {
@@ -3894,7 +3894,7 @@ function applyCompanyWorkspace(payload) {
async function loadCompanyWorkspace() { async function loadCompanyWorkspace() {
const response = await fetch("/api/company/workspace").catch(() => null); const response = await fetch("/api/company/workspace").catch(() => null);
if (response?.status === 401 || response?.status === 403) { if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html"; window.location.href = window.PORTAL_HOME || "index.html";
return null; return null;
} }
const result = await response?.json().catch(() => null); const result = await response?.json().catch(() => null);
@@ -3970,7 +3970,7 @@ function renderUnilateralMatchCard(event, peers) {
}), }),
}).catch(() => null); }).catch(() => null);
if (response?.status === 401 || response?.status === 403) { if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html"; window.location.href = window.PORTAL_HOME || "index.html";
return; return;
} }
const result = await response?.json().catch(() => ({})); const result = await response?.json().catch(() => ({}));
@@ -4131,7 +4131,7 @@ function initNotifications() {
async function loadReminders() { async function loadReminders() {
const response = await fetch("/api/company/reminders").catch(() => null); const response = await fetch("/api/company/reminders").catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; } if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
const result = await response?.json().catch(() => ({})); const result = await response?.json().catch(() => ({}));
if (!response?.ok) return; if (!response?.ok) return;
const items = result.reminders || []; const items = result.reminders || [];
@@ -4184,7 +4184,7 @@ function initNotifications() {
if (!id) return; if (!id) return;
const action = readBtn ? "acknowledge" : "resolve"; const action = readBtn ? "acknowledge" : "resolve";
const response = await fetch(`/api/company/reminders/${id}/${action}`, { method: "POST" }).catch(() => null); const response = await fetch(`/api/company/reminders/${id}/${action}`, { method: "POST" }).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; } if (response?.status === 401) { window.location.href = window.PORTAL_HOME || "index.html"; return; }
if (!response?.ok) return; if (!response?.ok) return;
await loadReminders(); await loadReminders();
}); });
@@ -4340,7 +4340,7 @@ async function loadTransfersSummary({ asOf } = {}) {
const qs = params.toString(); const qs = params.toString();
const response = await fetch(`/api/company/intercompany/summary${qs ? `?${qs}` : ""}`).catch(() => null); const response = await fetch(`/api/company/intercompany/summary${qs ? `?${qs}` : ""}`).catch(() => null);
if (response?.status === 401 || response?.status === 403) { if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html"; window.location.href = window.PORTAL_HOME || "index.html";
return null; return null;
} }
const result = await response?.json().catch(() => null); const result = await response?.json().catch(() => null);
@@ -4570,7 +4570,7 @@ async function loadTransfersEvents({ reset = false } = {}) {
// Always need a distinguishing filter so HEL-176 path is used (counterparty_id is enough) // Always need a distinguishing filter so HEL-176 path is used (counterparty_id is enough)
const response = await fetch(`/api/company/intercompany/events?${params}`).catch(() => null); const response = await fetch(`/api/company/intercompany/events?${params}`).catch(() => null);
if (response?.status === 401 || response?.status === 403) { if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html"; window.location.href = window.PORTAL_HOME || "index.html";
return; return;
} }
const result = await response?.json().catch(() => null); const result = await response?.json().catch(() => null);
@@ -4641,7 +4641,7 @@ async function openTransferEvidence(eventId) {
drawer.setAttribute("aria-hidden", "false"); drawer.setAttribute("aria-hidden", "false");
const response = await fetch(`/api/company/transfer-events/${eventId}`).catch(() => null); const response = await fetch(`/api/company/transfer-events/${eventId}`).catch(() => null);
if (response?.status === 401 || response?.status === 403) { if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html"; window.location.href = window.PORTAL_HOME || "index.html";
return; return;
} }
const result = await response?.json().catch(() => null); const result = await response?.json().catch(() => null);
@@ -4974,7 +4974,7 @@ function initCompany() {
}), }),
}).catch(() => null); }).catch(() => null);
if (response?.status === 401 || response?.status === 403) { if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html"; window.location.href = window.PORTAL_HOME || "index.html";
return; return;
} }
const result = await response?.json().catch(() => ({})); const result = await response?.json().catch(() => ({}));
+24 -1
View File
@@ -43,7 +43,30 @@
apply(next, true); apply(next, true);
} }
apply(read(), false); var incoming = new URLSearchParams(location.search).get("theme");
if (incoming === "day" || incoming === "night") {
try { localStorage.setItem(KEY, incoming); } catch (e) {}
}
apply(incoming === "day" || incoming === "night" ? incoming : read(), false);
document.addEventListener("click", function(event) {
var link = event.target.closest("[data-return-portal]");
if (link) { var url = new URL(link.href); url.searchParams.set("theme", read()); link.href = url.toString(); }
});
document.addEventListener("DOMContentLoaded", function() {
if (!global.PORTAL_HOME) return;
var checkbox = document.querySelector('[name="createUser"]');
if (checkbox) { checkbox.checked = false; checkbox.disabled = true; checkbox.parentElement.classList.add("portal-native-account"); }
var login = document.querySelector('[name="loginName"]');
if (login) { login.required = false; login.closest(".field").classList.add("portal-native-account"); }
var reset = document.querySelector('#btn-reset-pwd');
if (reset) reset.classList.add("portal-native-account");
var sub = document.querySelector('#companyDialog .modal-sub');
if (sub) sub.textContent = "登记成员公司。员工账号由统一门户管理,财务角色与公司范围按员工单独绑定。";
var hint = document.querySelector('#companyForm .hint');
if (hint) hint.textContent = "完成公司登记后,为指定员工配置财务权限和公司范围。";
var submit = document.querySelector('#companyForm button[type="submit"]');
if (submit) submit.textContent = "创建公司";
});
document.addEventListener("click", function (event) { document.addEventListener("click", function (event) {
var btn = event.target.closest("[data-theme-set]"); var btn = event.target.closest("[data-theme-set]");
if (!btn) return; if (!btn) return;