Commit 197a63dc authored by drigle's avatar drigle

fix: stabilize login redirects and user lookup

Preserve notification targets through login, retain credentials for non-401 errors, and reuse pending user lookups. Hide the display table menu, update the application manifest to 1.0.3, and document the flows with redirect regression tests.
parent 7818b2e9
{ {
"name": "integrity_risk_prevention_new", "name": "integrity_risk_prevention_new",
"version": "1.0.2", "version": "1.0.3",
"build": "abc", "build": "abc",
"description": "廉洁从业管理", "description": "廉洁风险防控",
"date": "2025-07-14 19:27:20", "date": "2025-07-14 19:27:20",
"author": "yang", "author": "yang",
"spa": true "spa": true
......
...@@ -28,7 +28,7 @@ export default function UserSearchSelect({ ...@@ -28,7 +28,7 @@ export default function UserSearchSelect({
const [profiles, setProfiles] = useState({}); const [profiles, setProfiles] = useState({});
const [failedLookupIds, setFailedLookupIds] = useState(new Set()); const [failedLookupIds, setFailedLookupIds] = useState(new Set());
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const lookedUpIdsRef = useRef(new Set()); const userLookupPromisesRef = useRef(new Map());
const selectedIds = useMemo(() => toArray(value).map(getUserId).filter(Boolean), [value]); const selectedIds = useMemo(() => toArray(value).map(getUserId).filter(Boolean), [value]);
useEffect(() => { useEffect(() => {
...@@ -73,32 +73,35 @@ export default function UserSearchSelect({ ...@@ -73,32 +73,35 @@ export default function UserSearchSelect({
useEffect(() => { useEffect(() => {
const missing = selectedIds.filter( const missing = selectedIds.filter(
(id) => !hasUserPresentation(profiles[id]) && !lookedUpIdsRef.current.has(id), (id) => !hasUserPresentation(profiles[id]) && !failedLookupIds.has(id),
); );
if (!missing.length) return undefined; if (!missing.length) return undefined;
let cancelled = false; let cancelled = false;
missing.forEach((id) => lookedUpIdsRef.current.add(id)); // Effects can restart while a lookup is pending; keep the promise so the
// current effect can receive its result without issuing another request.
Promise.all(missing.map(async (id) => { Promise.all(missing.map((id) => {
try { if (!userLookupPromisesRef.current.has(id)) {
const result = await loadUser(id); const lookup = (async () => {
const user = result?.user || result; try {
return hasUserPresentation(user) ? { id, user } : { id, user: null }; const result = await loadUser(id);
} catch { const user = result?.user || result;
return { id, user: null }; return hasUserPresentation(user) ? { id, user } : { id, user: null };
} catch {
return { id, user: null };
}
})();
userLookupPromisesRef.current.set(id, lookup);
} }
return userLookupPromisesRef.current.get(id);
})).then((items) => { })).then((items) => {
if (cancelled) { if (cancelled) return;
missing.forEach((id) => lookedUpIdsRef.current.delete(id));
return;
}
const loadedUsers = items.filter((item) => item.user); const loadedUsers = items.filter((item) => item.user);
if (loadedUsers.length) { if (loadedUsers.length) {
setProfiles((current) => ({ setProfiles((current) => ({
...current, ...current,
...Object.fromEntries(loadedUsers.map(({ user }) => [getUserId(user), user])), ...Object.fromEntries(loadedUsers.map(({ id, user }) => [id, user])),
})); }));
} }
...@@ -108,7 +111,7 @@ export default function UserSearchSelect({ ...@@ -108,7 +111,7 @@ export default function UserSearchSelect({
} }
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [profiles, selectedIds]); }, [failedLookupIds, profiles, selectedIds]);
const options = useMemo(() => { const options = useMemo(() => {
const usersById = new Map(users.map((user) => [getUserId(user), user])); const usersById = new Map(users.map((user) => [getUserId(user), user]));
......
import xyLogoRed from '@/assets/xy-logo-red.png'; import xyLogoRed from '@/assets/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png'; import xyLogoWhite from '@/assets/xy-logo-white.png';
import { import {
APPLICATION_BASE_PATH,
APPLICATION_DISPLAY_NAME, APPLICATION_DISPLAY_NAME,
} from '@/config/application'; } from '@/config/application';
import authService from '@/services/authService'; import authService from '@/services/authService';
import { normalizeLoginRedirect } from '@/utils/authRedirect';
import { getCaptcha, userLogin } from '@/services/DataService'; import { getCaptcha, userLogin } from '@/services/DataService';
import { SiteContext } from '@/wrapper/Site'; import { SiteContext } from '@/wrapper/Site';
import { Alert, Button, Card, Input, Label, TextField } from '@heroui/react'; import { Alert, Button, Card, Input, Label, TextField } from '@heroui/react';
...@@ -17,35 +17,6 @@ function buildPassword(password, siteKey) { ...@@ -17,35 +17,6 @@ function buildPassword(password, siteKey) {
return siteKey ? CryptoJS.AES.encrypt(password, siteKey).toString() : password; return siteKey ? CryptoJS.AES.encrypt(password, siteKey).toString() : password;
} }
function normalizeRedirect(value) {
if (!value) {
return '/';
}
try {
const target = new URL(value, window.location.origin);
if (target.origin !== window.location.origin) {
return '/';
}
const applicationBasePath = APPLICATION_BASE_PATH.replace(/\/+$/, '');
const targetPath = `${target.pathname}${target.search}${target.hash}`;
if (
applicationBasePath &&
(target.pathname === applicationBasePath ||
target.pathname.startsWith(`${applicationBasePath}/`))
) {
return targetPath.slice(applicationBasePath.length) || '/';
}
return targetPath;
} catch {
return '/';
}
}
export default function LoginPage() { export default function LoginPage() {
const site = useContext(SiteContext); const site = useContext(SiteContext);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
...@@ -56,7 +27,7 @@ export default function LoginPage() { ...@@ -56,7 +27,7 @@ export default function LoginPage() {
const [captchaLoading, setCaptchaLoading] = useState(false); const [captchaLoading, setCaptchaLoading] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const redirect = normalizeRedirect(searchParams.get('redirect')); const redirect = normalizeLoginRedirect(searchParams.get('redirect'));
const refreshCaptcha = async () => { const refreshCaptcha = async () => {
setCaptchaLoading(true); setCaptchaLoading(true);
...@@ -93,7 +64,7 @@ export default function LoginPage() { ...@@ -93,7 +64,7 @@ export default function LoginPage() {
authService.setCurrentUser(loginInfo); authService.setCurrentUser(loginInfo);
if (loginInfo?.token) authService.setToken(loginInfo.token); if (loginInfo?.token) authService.setToken(loginInfo.token);
history.push(redirect); history.replace(redirect);
} catch (requestError) { } catch (requestError) {
setError(requestError?.message || '登录失败,请重试'); setError(requestError?.message || '登录失败,请重试');
setCaptchaCode(''); setCaptchaCode('');
......
import { APPLICATION_DISPLAY_NAME, ROUTE_TITLES } from '@/config/application'; import { APPLICATION_DISPLAY_NAME, ROUTE_TITLES } from '@/config/application';
import AppShell from '@/components/AppShell'; import AppShell from '@/components/AppShell';
import { Archive, ClipboardDocumentListIcon, Database, TableCellsIcon } from '@/components/AppIcons'; import { Archive, ClipboardDocumentListIcon, Database } from '@/components/AppIcons';
import ApplicationSwitcherDialog from './ApplicationSwitcherDialog'; import ApplicationSwitcherDialog from './ApplicationSwitcherDialog';
import { import {
loadUnreadNotificationCount, loadUnreadNotificationCount,
...@@ -16,7 +16,6 @@ const MENU_GROUPS = [{ ...@@ -16,7 +16,6 @@ const MENU_GROUPS = [{
showLabel: false, showLabel: false,
items: [ items: [
{ name: '廉洁风险防控表 (过程表)', path: '/risk_prevention_control_measures_table', icon: ClipboardDocumentListIcon }, { name: '廉洁风险防控表 (过程表)', path: '/risk_prevention_control_measures_table', icon: ClipboardDocumentListIcon },
{ name: '廉洁风险防控表 (展示表)', path: '/risk_prevention_control_measures_table_show', icon: TableCellsIcon },
{ name: '廉洁风险防控措施数据库', path: '/measure_description', icon: Database }, { name: '廉洁风险防控措施数据库', path: '/measure_description', icon: Database },
{ name: '往期材料归档', path: '/integrity_risk_control_archive', icon: Archive }, { name: '往期材料归档', path: '/integrity_risk_control_archive', icon: Archive },
], ],
......
...@@ -8,14 +8,16 @@ ...@@ -8,14 +8,16 @@
| 页面 | 路由 | 用途 | | 页面 | 路由 | 用途 |
| --- | --- | --- | | --- | --- | --- |
| 廉洁风险防控表(过程表) | `/risk_prevention_control_measures_table` | 维护风险点、推送填报、导出和归档 | | 廉洁风险防控表(过程表) | `/risk_prevention_control_measures_table` | 维护风险点、推送填报、导出和归档 |
| 廉洁风险防控表(展示表) | `/risk_prevention_control_measures_table_show` | 导入过程表、汇总展示、导出和归档 | | 廉洁风险防控表(展示表,菜单已隐藏) | `/risk_prevention_control_measures_table_show` | 保留原页面路由及导入、汇总展示、导出和归档功能 |
| 廉洁风险防控措施数据库 | `/measure_description` | 维护、导入、导出和归档防控措施 | | 廉洁风险防控措施数据库 | `/measure_description` | 维护、导入、导出和归档防控措施 |
| 往期材料归档 | `/integrity_risk_control_archive` | 只读查看归档批次和历史快照 | | 往期材料归档 | `/integrity_risk_control_archive` | 只读查看归档批次和历史快照 |
| 廉洁风险防控填写表 | `/control_measure_form?id=<flow-record>` | 填报、一级审核和最终审核 | | 廉洁风险防控填写表 | `/control_measure_form?id=<flow-record>` | 填报、一级审核和最终审核 |
| 通知消息 | `/notifications` | 查看消息/待办并进入关联流程 | | 通知消息 | `/notifications` | 查看消息/待办并进入关联流程 |
侧栏只注册上述四个业务入口。`streams-admin` 中的自查底稿、廉洁从业报告和其他廉洁 桌面端和移动端侧栏只展示过程表、措施数据库、往期材料归档三个业务入口。展示表已停止
风险页面不在本应用配置中,不注册为路由。 作为常用入口,菜单项统一隐藏;原路由保留,已有链接仍可直接访问原页面,历史记录及
归档数据不受影响。`streams-admin` 中的自查底稿、廉洁从业报告和其他廉洁风险页面不在
本应用配置中,不注册为路由。
## 2. 表格与详情边界 ## 2. 表格与详情边界
...@@ -132,6 +134,23 @@ ...@@ -132,6 +134,23 @@
## 3. 过程表操作 ## 3. 过程表操作
### 3.0 推送链接与登录回跳
- OA 待办和站内通知的表单链接使用发起推送时浏览器的 `window.location.origin`
(协议、域名及端口),拼接应用路径 `/integrity_risk_prevention_new/` 和
`control_measure_form?id=<流程主单>`;流程主单 ID 进行 URL 编码。
推送应从接收人可访问的部署地址发起,本地或内网地址不会自动转换为正式域名。
- 接收人打开链接后先验证登录状态;未登录或接口返回 401 时进入本应用登录页,
`redirect` 保留目标页面、完整查询参数和 hash,流程 ID 不丢失。
- 登录成功后以替换历史记录的方式返回目标表单,不需要重新点击 OA/站内通知。
兼容相对应用路由、包含应用前缀的路径和同源完整 URL;外站、登录页自身及非本应用
页面不能作为回跳目标,无效目标回到应用首页。
- 登录页上的接口错误(包括账号登录返回 401)直接展示错误,不再次跳转登录页,
原始 `redirect` 保留,重试登录后仍返回原表单。
- 用户信息校验遇到网络或服务器错误时保留登录凭据,展示错误并支持重新验证,
验证成功前不加载表单。403 按无权限提示处理,不清除登录状态或触发重新登录。
- 登录回跳只恢复访问入口;表单记录不存在或当前账号无权访问时仍显示对应错误。
### 3.1 推送风险防控表 ### 3.1 推送风险防控表
1. 用户勾选一条或多条过程表记录,点击“推送风险防控表”。 1. 用户勾选一条或多条过程表记录,点击“推送风险防控表”。
...@@ -145,6 +164,10 @@ ...@@ -145,6 +164,10 @@
推送弹窗采用与问题整改推送一致的紧凑配置方式:责任单位列表在弹窗内独立滚动,用户 推送弹窗采用与问题整改推送一致的紧凑配置方式:责任单位列表在弹窗内独立滚动,用户
选择使用搜索下拉浮层,所选风险记录在下方摘要展示,避免用户列表或多条责任单位把弹窗 选择使用搜索下拉浮层,所选风险记录在下方摘要展示,避免用户列表或多条责任单位把弹窗
直接撑长。 直接撑长。
再次推送已有部门子项时,回显其原填报人和审批人;已选用户不在当前搜索结果中时,按
已保存的用户 ID 补查用户信息。用户列表返回、搜索词变化或选中值变化时继续接收同一
用户查询的结果,成功后显示姓名;用户不存在、信息不完整或查询失败时显示“用户信息
不可用”,结束加载提示,并允许重新选择用户。回显仅用于展示,提交仍只保存用户 ID。
4. 推送数据来源按场景区分: 4. 推送数据来源按场景区分:
- 首次推送且不存在对应部门子项时,使用父过程表当前业务字段仅初始化该责任单位的新 - 首次推送且不存在对应部门子项时,使用父过程表当前业务字段仅初始化该责任单位的新
部门快照,并将新子项追加到现有 `department_control_items[]`;不得因为创建新子项而替换、 部门快照,并将新子项追加到现有 `department_control_items[]`;不得因为创建新子项而替换、
...@@ -198,6 +221,9 @@ ...@@ -198,6 +221,9 @@
## 4. 展示表操作 ## 4. 展示表操作
展示表菜单已隐藏,以下流程仅适用于通过原路由直接访问的保留页面;本次菜单调整
不改变导入、导出、归档的状态流转、字段规则及异常处理。
### 4.1 导入过程表 ### 4.1 导入过程表
1. 仅接受 `.xlsx` / `.xls`,文件必须包含“原表序号”列。 1. 仅接受 `.xlsx` / `.xls`,文件必须包含“原表序号”列。
......
import { APPLICATION_BASE_PATH } from '../config/application';
export const LOGIN_PATH = `${APPLICATION_BASE_PATH.replace(/\/+$/, '')}/login`;
// Return a router-relative path; accept both existing router links and full app URLs.
export function normalizeLoginRedirect(value, origin = window.location.origin) {
if (!value || /[\\\u0000-\u001f]/.test(value)) return '/';
try {
const target = new URL(value, origin);
if (target.origin !== origin) return '/';
const basePath = APPLICATION_BASE_PATH.replace(/\/+$/, '');
const path = target.pathname === basePath
? '/'
: target.pathname.startsWith(`${basePath}/`)
? target.pathname.slice(basePath.length)
: target.pathname;
const routes = ['/', '/control_measure_form', '/risk_prevention_control_measures_table',
'/risk_prevention_control_measures_table_show', '/measure_description',
'/integrity_risk_control_archive', '/notifications'];
if (!routes.includes(path)) return '/';
return `${path}${target.search}${target.hash}`;
} catch {
return '/';
}
}
export function buildLoginUrl(location = window.location) {
const target = normalizeLoginRedirect(
`${location.pathname}${location.search || ''}${location.hash || ''}`,
location.origin,
);
return `${LOGIN_PATH}?redirect=${encodeURIComponent(target)}`;
}
export function isLoginPage(pathname) {
return pathname.replace(/\/+$/, '') === LOGIN_PATH || pathname === '/login';
}
import { extend } from 'umi-request'; import { extend } from 'umi-request';
import progressMiddleware from 'umi-request-progress'; import progressMiddleware from 'umi-request-progress';
import authService from '@/services/authService'; import authService from '@/services/authService';
import { APPLICATION_BASE_PATH } from '@/config/application'; import { buildLoginUrl, isLoginPage } from './authRedirect';
// 使用 .umirc.ts 中定义的全局变量 // 使用 .umirc.ts 中定义的全局变量
const baseUrl = STREAMS_API_URL; const baseUrl = STREAMS_API_URL;
const loginPath = `${APPLICATION_BASE_PATH.replace(/\/+$/, '')}/login`;
function createRequestWithPrefix(prefix = baseUrl) { function createRequestWithPrefix(prefix = baseUrl) {
const requestInstance = extend({ const requestInstance = extend({
...@@ -35,28 +34,23 @@ function createRequestWithPrefix(prefix = baseUrl) { ...@@ -35,28 +34,23 @@ function createRequestWithPrefix(prefix = baseUrl) {
}); });
requestInstance.interceptors.response.use(async (response) => { requestInstance.interceptors.response.use(async (response) => {
if (response?.status === 401) { if (response?.status >= 400) {
// 使用 authService 清除用户信息 if (response.status === 401 && !isLoginPage(window.location.pathname)) {
authService.clearUser(); authService.clearUser();
window.location.replace(buildLoginUrl());
console.warn('[request] authentication required (401)'); }
let data;
// 跳转到登录页(可选,根据业务需求调整)
location.href = `${loginPath}?redirect=${encodeURIComponent(
location.pathname + location.search + location.hash,
)}`;
} else if (response?.status === 403) {
authService.clearUser();
console.warn('[request] forbidden (403)');
} else if (response?.status >= 400) {
try { try {
const data = await response.clone().json(); data = await response.clone().json();
console.error('[request] error', data); } catch {
throw new Error(data?.message || '请求失败'); // Some gateways return an HTML error page.
} catch (error) {
console.error('[request] request failed', error);
throw error;
} }
const error = new Error(data?.message || (
response.status === 401 ? '登录状态已失效,请重新登录'
: response.status === 403 ? '当前账号无权访问此内容' : '请求失败,请稍后重试'
));
error.status = response.status;
throw error;
} }
return response; return response;
......
import authService from '@/services/authService'; import authService from '@/services/authService';
import { APPLICATION_BASE_PATH } from '@/config/application'; import { buildLoginUrl, LOGIN_PATH } from '@/utils/authRedirect';
import { Alert, Button } from '@heroui/react';
import { getUserInfo, userLogout } from '@/services/DataService'; import { getUserInfo, userLogout } from '@/services/DataService';
import { createContext, useEffect, useMemo, useState } from 'react'; import { createContext, useEffect, useMemo, useState } from 'react';
import { history, Outlet, useLocation } from 'umi'; import { history, Outlet } from 'umi';
export const UserContext = createContext(null); export const UserContext = createContext(null);
const applicationBasePath = APPLICATION_BASE_PATH.replace(/\/+$/, '');
const loginPath = `${applicationBasePath}/login`;
function getStoredUser() { function getStoredUser() {
const value = authService.getCurrentUser(); const value = authService.getCurrentUser();
...@@ -22,12 +20,15 @@ function getStoredUser() { ...@@ -22,12 +20,15 @@ function getStoredUser() {
} }
export default function AuthWrapper() { export default function AuthWrapper() {
const location = useLocation(); const [loadError, setLoadError] = useState('');
const [attempt, setAttempt] = useState(0);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [userInfo, setUserInfo] = useState(() => getStoredUser()); const [userInfo, setUserInfo] = useState(() => getStoredUser());
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoaded(false);
setLoadError('');
if (!authService.getToken() || !userInfo) { if (!authService.getToken() || !userInfo) {
setLoaded(true); setLoaded(true);
...@@ -51,8 +52,12 @@ export default function AuthWrapper() { ...@@ -51,8 +52,12 @@ export default function AuthWrapper() {
setUserInfo(mergedUser); setUserInfo(mergedUser);
} catch (error) { } catch (error) {
if (!cancelled) { if (!cancelled) {
authService.clearUser(); if (error?.status === 401) {
setUserInfo(null); authService.clearUser();
setUserInfo(null);
} else {
setLoadError(error?.message || '验证登录状态失败,请重试');
}
} }
} finally { } finally {
if (!cancelled) { if (!cancelled) {
...@@ -64,7 +69,7 @@ export default function AuthWrapper() { ...@@ -64,7 +69,7 @@ export default function AuthWrapper() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, [attempt]);
const value = useMemo(() => { const value = useMemo(() => {
if (!userInfo) { if (!userInfo) {
...@@ -81,7 +86,7 @@ export default function AuthWrapper() { ...@@ -81,7 +86,7 @@ export default function AuthWrapper() {
} finally { } finally {
authService.clearUser(); authService.clearUser();
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.location.assign(loginPath); window.location.assign(LOGIN_PATH);
} else { } else {
history.push('/login'); history.push('/login');
} }
...@@ -92,11 +97,18 @@ export default function AuthWrapper() { ...@@ -92,11 +97,18 @@ export default function AuthWrapper() {
useEffect(() => { useEffect(() => {
if (loaded && (!userInfo || !authService.getToken()) && typeof window !== 'undefined') { if (loaded && (!userInfo || !authService.getToken()) && typeof window !== 'undefined') {
window.location.replace(`${loginPath}?redirect=${encodeURIComponent( window.location.replace(buildLoginUrl());
location.pathname + location.search,
)}`);
} }
}, [loaded, location.pathname, location.search, userInfo]); }, [loaded, userInfo]);
if (loadError) {
return (
<div className="mx-auto flex min-h-screen max-w-lg flex-col justify-center gap-4 p-6">
<Alert status="danger"><Alert.Content><Alert.Description>{loadError}</Alert.Description></Alert.Content></Alert>
<Button onPress={() => setAttempt((value) => value + 1)}>重新验证登录状态</Button>
</div>
);
}
if (!loaded) { if (!loaded) {
return ( return (
......
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const asModule = (source) => `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`;
const config = asModule(await readFile(new URL('../src/config/application.js', import.meta.url), 'utf8'));
const source = await readFile(new URL('../src/utils/authRedirect.js', import.meta.url), 'utf8');
const { buildLoginUrl, normalizeLoginRedirect, isLoginPage } = await import(
asModule(source.replace("'../config/application'", JSON.stringify(config)))
);
const origin = 'https://example.com';
const base = '/integrity_risk_prevention_new';
const form = '/control_measure_form?id=flow%2F123&tab=detail#department-1';
test('notification link survives login, query encoding and hash intact', () => {
const location = new URL(`${origin}${base}${form}`);
const login = new URL(buildLoginUrl(location), origin);
assert.equal(login.pathname, `${base}/login`);
assert.equal(normalizeLoginRedirect(login.searchParams.get('redirect'), origin), form);
});
test('accepts old router paths, app paths and same-origin absolute links', () => {
for (const target of [form, `${base}${form}`, `${origin}${base}${form}`]) {
assert.equal(normalizeLoginRedirect(target, origin), form);
}
});
test('rejects external, login-loop and unrelated application targets', () => {
for (const target of ['https://evil.example/control_measure_form', '//evil.example',
'/login?redirect=/login', `${base}/login`, '/other_app/page',
'/\\evil.example', 'javascript:alert(1)', '/control_measure_form\n']) {
assert.equal(normalizeLoginRedirect(target, origin), '/');
}
});
test('login page detection covers application and legacy login', () => {
assert.equal(isLoginPage(`${base}/login`), true);
assert.equal(isLoginPage(`${base}/login/`), true);
assert.equal(isLoginPage('/login'), true);
assert.equal(isLoginPage(`${base}/control_measure_form`), false);
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment