Commit 9bf42156 authored by drigle's avatar drigle

fix: preserve login redirects and refine bulk actions

Centralize safe authentication redirects, restore user info from existing tokens, and retain sessions on permission errors. Add redirect tests and business flow documentation, show bulk action icons, and update app manifest and pnpm configuration.
parent 494e6f4a
This diff is collapsed.
allowBuilds:
"@heroui-pro/react": true
"@zowe/secrets-for-zowe-sdk": true
core-js: false
core-js-pure: false
es5-ext: false
esbuild: true
{ {
"name": "problem_rectification_new", "name": "problem_rectification_new",
"version": "1.0.9", "version": "1.0.3",
"build": "abc", "build": "abc",
"description": "merge master", "description": "问题整改",
"date": "2025-07-14 19:27:20", "date": "2025-07-14 19:27:20",
"author": "yang", "author": "yang",
"spa": true "spa": true
......
...@@ -483,6 +483,7 @@ function renderRowActionButton({ action, isDisabled, onPress }) { ...@@ -483,6 +483,7 @@ function renderRowActionButton({ action, isDisabled, onPress }) {
function BulkActionButton({ action, context }) { function BulkActionButton({ action, context }) {
const disabled = isActionDisabled(action, context); const disabled = isActionDisabled(action, context);
const Icon = action.icon;
return ( return (
<Button <Button
...@@ -492,6 +493,7 @@ function BulkActionButton({ action, context }) { ...@@ -492,6 +493,7 @@ function BulkActionButton({ action, context }) {
variant={getActionButtonVariant(action)} variant={getActionButtonVariant(action)}
onPress={() => action.onClick?.(context)} onPress={() => action.onClick?.(context)}
> >
{Icon ? <Icon aria-hidden="true" size={16} /> : null}
{action.label} {action.label}
</Button> </Button>
); );
......
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 { normalizeRedirect } 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();
...@@ -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('');
...@@ -124,6 +95,10 @@ export default function LoginPage() { ...@@ -124,6 +95,10 @@ export default function LoginPage() {
<strong>{APPLICATION_DISPLAY_NAME}</strong> <strong>{APPLICATION_DISPLAY_NAME}</strong>
</div> </div>
{redirect !== '/' && (
<p role="status">请先登录,登录成功后将自动返回您打开的页面。</p>
)}
<form className="login-form rc-login-form" onSubmit={handleSubmit}> <form className="login-form rc-login-form" onSubmit={handleSubmit}>
<TextField <TextField
className="login-field" className="login-field"
......
...@@ -137,6 +137,17 @@ ...@@ -137,6 +137,17 @@
5. 先业务闭环,再补平台能力 5. 先业务闭环,再补平台能力
`分组 / 填色 / 触发器 / 全屏` 这类平台能力不作为本期必做项 `分组 / 填色 / 触发器 / 全屏` 这类平台能力不作为本期必做项
### 3.1 推送链接与登录回跳
- 入口:OA 待办、内部通知中的整改填写、复核、最终审批和延期流程链接。
- 链接使用推送人当前浏览器的 `window.location.origin`(协议、域名、端口)+应用基准路径 `/problem_rectification_new/`+流程页路径;`id` 保存流程主单 `name`,可选 `record` 定位问题。推送应在接收人可访问的正式域名下进行,本地或内网域名不会自动替换为公网地址。
- 已登录且身份校验通过时直接进入原表单;只有 token 而用户缓存缺失时,通过现有 `GET /user/info` 恢复身份。
- 未登录或接口返回 401 时进入登录页,通过 URL 的 `redirect` 保留原路径、全部查询参数和锚点;刷新登录页或登录失败重试后仍保留目标。多个并发 401 只触发一次跳转。
- 登录页提示登录成功后自动返回;现有 `POST /auth/login` 成功后替换登录页历史记录并返回原页面,不丢失 `id`、`record`,不需要重新寻找待办链接。
- 回跳仅允许当前域名下的本应用路由;兼容带应用前缀的完整链接与旧 `/self_inspection_form` 地址,外站地址、无效路由或登录页自身回跳回退至首页,避免循环登录。
- 业务接口返回 403 时保留登录态,按接口错误展示权限不足;进入页面后仍遵守原有人员权限和流程状态约束,登录不代表获得审批权限。
- 部署服务器必须将应用下的直接访问路径回退至 SPA 入口,否则前端代码加载前的服务器 404 无法由登录回跳处理。
--- ---
## 4. 核心业务对象 ## 4. 核心业务对象
......
import { APPLICATION_BASE_PATH, ROUTE_TITLES } from '../config/application';
const APPLICATION_PATH = APPLICATION_BASE_PATH.replace(/\/+$/, '');
// 返回 Umi base 内的路由,避免重复添加应用前缀或回到登录页。
export function normalizeRedirect(value, origin = window.location.origin) {
if (!value) return '/';
try {
const target = new URL(value, origin);
if (target.origin !== origin) return '/';
let pathname = target.pathname;
if (pathname === APPLICATION_PATH) pathname = '/';
else if (pathname.startsWith(`${APPLICATION_PATH}/`)) {
pathname = pathname.slice(APPLICATION_PATH.length);
}
if (pathname === '/self_inspection_form') {
pathname = '/problem_rectification_flow_table';
}
if (pathname !== '/' && !Object.prototype.hasOwnProperty.call(ROUTE_TITLES, pathname)) return '/';
return `${pathname}${target.search}${target.hash}`;
} catch {
return '/';
}
}
export function getLoginRoute(location) {
const target = normalizeRedirect(`${location.pathname}${location.search || ''}${location.hash || ''}`);
return `/login?redirect=${encodeURIComponent(target)}`;
}
...@@ -2,35 +2,23 @@ import { extend } from 'umi-request'; ...@@ -2,35 +2,23 @@ import { extend } from 'umi-request';
import progressMiddleware from 'umi-request-progress'; import progressMiddleware from 'umi-request-progress';
import { APPLICATION_BASE_PATH } from '@/config/application'; import { APPLICATION_BASE_PATH } from '@/config/application';
import authService from '@/services/authService'; import authService from '@/services/authService';
import { getLoginRoute } from './authRedirect';
// 使用 .umirc.ts 中定义的全局变量 // 使用 .umirc.ts 中定义的全局变量
const baseUrl = STREAMS_API_URL; const baseUrl = STREAMS_API_URL;
const applicationBasePath = APPLICATION_BASE_PATH.replace(/\/+$/, ''); const applicationBasePath = APPLICATION_BASE_PATH.replace(/\/+$/, '');
function getCurrentApplicationRoute() { let redirectingToLogin = false;
const browserPath = `${location.pathname}${location.search}${location.hash}`;
if (
applicationBasePath &&
(location.pathname === applicationBasePath ||
location.pathname.startsWith(`${applicationBasePath}/`))
) {
return browserPath.slice(applicationBasePath.length) || '/';
}
return browserPath;
}
function redirectToLogin() { function redirectToLogin() {
const redirect = getCurrentApplicationRoute(); if (
redirectingToLogin ||
if (redirect.split(/[?#]/, 1)[0] === '/login') { location.pathname === `${applicationBasePath}/login` ||
return; location.pathname === '/login'
} ) return;
location.assign( redirectingToLogin = true;
`${applicationBasePath}/login?redirect=${encodeURIComponent(redirect)}`, location.replace(`${applicationBasePath}${getLoginRoute(location)}`);
);
} }
function createRequestWithPrefix(prefix = baseUrl) { function createRequestWithPrefix(prefix = baseUrl) {
...@@ -68,9 +56,7 @@ function createRequestWithPrefix(prefix = baseUrl) { ...@@ -68,9 +56,7 @@ function createRequestWithPrefix(prefix = baseUrl) {
console.warn('[request] authentication required (401)'); console.warn('[request] authentication required (401)');
redirectToLogin(); redirectToLogin();
} else if (response?.status === 403) {
authService.clearUser();
console.warn('[request] forbidden (403)');
} else if (response?.status >= 400) { } else if (response?.status >= 400) {
try { try {
const data = await response.clone().json(); const data = await response.clone().json();
......
import authService from '@/services/authService'; import authService from '@/services/authService';
import { getLoginRoute } from '@/utils/authRedirect';
import { getUserInfo, userLogout } from '@/services/DataService'; import { getUserInfo, userLogout } from '@/services/DataService';
import { Spinner } from '@heroui/react'; import { Spinner } from '@heroui/react';
import { createContext, useEffect, useMemo, useState } from 'react'; import { createContext, useEffect, useMemo, useState } from 'react';
...@@ -26,7 +27,7 @@ export default function AuthWrapper() { ...@@ -26,7 +27,7 @@ export default function AuthWrapper() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
if (!authService.getToken() || !userInfo) { if (!authService.getToken()) {
setLoaded(true); setLoaded(true);
return; return;
} }
...@@ -95,9 +96,7 @@ export default function AuthWrapper() { ...@@ -95,9 +96,7 @@ export default function AuthWrapper() {
if (!userInfo || !authService.getToken()) { if (!userInfo || !authService.getToken()) {
return ( return (
<Navigate <Navigate
to={`/login?redirect=${encodeURIComponent( to={getLoginRoute(location)}
location.pathname + location.search,
)}`}
replace replace
/> />
); );
......
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 { normalizeRedirect, getLoginRoute } = await import(asModule(source.replace("'../config/application'", JSON.stringify(config))));
const origin = 'https://example.com:8443';
globalThis.window = { location: { origin } };
const route = '/problem_rectification_flow_table?id=flow%2F123&record=record1#details';
test('direct links retain flow, record and anchor through login and refresh', () => {
for (const pathname of ['/problem_rectification_flow_table', '/problem_rectification_new/problem_rectification_flow_table']) {
const login = getLoginRoute({ pathname, search: '?id=flow%2F123&record=record1', hash: '#details' });
const target = new URL(login, origin).searchParams.get('redirect');
assert.equal(normalizeRedirect(target), route);
}
assert.equal(normalizeRedirect(`${origin}/problem_rectification_new${route}`), route);
});
test('legacy links and application root normalize correctly', () => {
assert.equal(normalizeRedirect('/problem_rectification_new/self_inspection_form?id=old#part'), '/problem_rectification_flow_table?id=old#part');
assert.equal(normalizeRedirect('/problem_rectification_new?from=oa'), '/?from=oa');
});
test('external, invalid and recursive login destinations fall back to home', () => {
for (const value of [null, 'https://other.example/problem_rectification', '//other.example', '/login?redirect=/login', '/problem_rectification_new/login', '/unknown', '/problem_rectification_new//other.example', 'javascript:alert(1)']) {
assert.equal(normalizeRedirect(value), '/');
}
});
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