From 2e207f726cabce1dca6ab2b85b6d0400f1da95f2 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 13 Aug 2026 16:32:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B4=A6=E5=8F=B7=E5=87=AD=E6=8D=AE?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E8=84=B1=E6=95=8F=E5=B9=B6=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=B8=BB=E5=8A=A8=E6=9F=A5=E7=9C=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_account_sensitive_fields.py | 73 +++++++++++++++++++++++++ web/backend/routers/accounts.py | 2 +- web/frontend/src/pages/AccountsPage.tsx | 39 +++++++++++-- 3 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 tests/test_account_sensitive_fields.py diff --git a/tests/test_account_sensitive_fields.py b/tests/test_account_sensitive_fields.py new file mode 100644 index 0000000..0520c66 --- /dev/null +++ b/tests/test_account_sensitive_fields.py @@ -0,0 +1,73 @@ +import os +import unittest + +os.environ.setdefault("DATABASE_URL", "sqlite://") +os.environ.setdefault("APP_ENCRYPTION_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=") + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from web.backend.database import Base +from web.backend.models import Account, User +from web.backend.routers.accounts import list_accounts + + +class AccountSensitiveFieldsTests(unittest.TestCase): + def setUp(self): + self.engine = create_engine("sqlite://") + Base.metadata.create_all(self.engine) + self.session = sessionmaker(bind=self.engine)() + self.admin = User(username="admin", password_hash="hash", role="super_admin") + self.session.add(self.admin) + self.session.add(Account( + username="account", + password="account-password", + email="account@example.com", + email_password="email-password", + )) + self.session.commit() + + def tearDown(self): + self.session.close() + Base.metadata.drop_all(self.engine) + self.engine.dispose() + + def test_sensitive_fields_are_hidden_by_default_even_for_admin(self): + result = list_accounts( + assigned_only=False, + tag=None, + has_cookie=False, + search="", + page=1, + page_size=20, + include_sensitive=False, + db=self.session, + current=self.admin, + ) + + item = result["items"][0] + self.assertIsNone(item.password) + self.assertIsNone(item.email) + self.assertIsNone(item.email_password) + + def test_admin_can_explicitly_request_sensitive_fields(self): + result = list_accounts( + assigned_only=False, + tag=None, + has_cookie=False, + search="", + page=1, + page_size=20, + include_sensitive=True, + db=self.session, + current=self.admin, + ) + + item = result["items"][0] + self.assertEqual(item.password, "account-password") + self.assertEqual(item.email, "account@example.com") + self.assertEqual(item.email_password, "email-password") + + +if __name__ == "__main__": + unittest.main() diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index ad86ad4..2bed7d2 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -100,7 +100,7 @@ def list_accounts( search: str = Query(""), page: int | None = Query(None, ge=1), page_size: int = Query(20, ge=1, le=200), - include_sensitive: bool = Query(True), + include_sensitive: bool = Query(False), db: Session = Depends(get_db), current: User = Depends(get_current_user), ): diff --git a/web/frontend/src/pages/AccountsPage.tsx b/web/frontend/src/pages/AccountsPage.tsx index 2c39b14..a6c9182 100644 --- a/web/frontend/src/pages/AccountsPage.tsx +++ b/web/frontend/src/pages/AccountsPage.tsx @@ -5,7 +5,7 @@ import { } from 'antd'; import { message } from '../utils/antdMessage'; import type { TableProps } from 'antd'; -import { ImportOutlined, DeleteOutlined, TagOutlined, FilterOutlined } from '@ant-design/icons'; +import { ImportOutlined, DeleteOutlined, TagOutlined, FilterOutlined, EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons'; import { accountApi, userApi, type AccountItem, type BasicSummary, type UserInfo } from '../api/modules'; import { usePermissions } from '../hooks/usePermissions'; import { formatTime } from '../utils/time'; @@ -33,6 +33,7 @@ export default function AccountsPage() { const [selectedAllMatching, setSelectedAllMatching] = useState(false); const [batchTagInput, setBatchTagInput] = useState(''); const [batchTagVisible, setBatchTagVisible] = useState(false); + const [sensitiveVisible, setSensitiveVisible] = useState(false); const [total, setTotal] = useState(0); const [summary, setSummary] = useState({ total: 0, assigned_count: 0, unassigned_count: 0, tag_count: 0 }); const [pageSize, setPageSize] = useState(() => { @@ -58,7 +59,7 @@ export default function AccountsPage() { page: currentPage, page_size: pageSize, search: searchText.trim() || undefined, - include_sensitive: canViewFull, + include_sensitive: canViewFull && sensitiveVisible, }); setAccounts(data.items); setTotal(data.total); @@ -67,7 +68,7 @@ export default function AccountsPage() { } finally { setLoading(false); } - }, [canViewFull, currentPage, pageSize, searchText, tagFilter]); + }, [canViewFull, currentPage, pageSize, searchText, sensitiveVisible, tagFilter]); const loadSummary = useCallback(async () => { try { @@ -224,6 +225,27 @@ export default function AccountsPage() { } }; + const handleSensitiveVisibility = () => { + if (sensitiveVisible) { + // 立即清除已加载的敏感内容,再由 effect 重新获取脱敏列表。 + setAccounts((previous) => previous.map((account) => ({ + ...account, + password: undefined, + email: undefined, + email_password: undefined, + }))); + setSensitiveVisible(false); + return; + } + Modal.confirm({ + title: '显示账号凭据?', + content: '密码、邮箱和邮箱密码将临时显示在当前页面。关闭或刷新页面后会恢复脱敏。', + okText: '显示', + cancelText: '取消', + onOk: () => setSensitiveVisible(true), + }); + }; + const columns: TableProps['columns'] = [ { title: 'ID', dataIndex: 'id', width: 60 }, { title: '用户名', dataIndex: 'username' }, @@ -277,8 +299,7 @@ export default function AccountsPage() { }, ]; - // 只有管理员可看完整字段(密码、邮箱等) - if (canViewFull) { + if (canViewFull && sensitiveVisible) { columns.push( { title: '密码', dataIndex: 'password', width: 120 }, { title: '邮箱', dataIndex: 'email' }, @@ -346,6 +367,14 @@ export default function AccountsPage() { setCurrentPage(1); }} /> + {canViewFull && ( + + )} {canImport && (