Add portal SSO while retaining native financial role and company scope
This commit is contained in:
@@ -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]
|
||||||
@@ -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 回调/权限/隔离测试。外网到内网门户目前不可达,实际财务跨系统端到端部署验收待完成。
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user