feat: harden platform workflows and UI governance
This commit is contained in:
+49
-30
@@ -13,15 +13,20 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { adminApi } from '@/api/adminApi';
|
||||
import { portalSessionApi } from '@/api/adminApi';
|
||||
import {
|
||||
clearSession,
|
||||
clearSessionRecovery,
|
||||
dispatchSessionEvent,
|
||||
getLastUserActivityAt,
|
||||
markUserActivity,
|
||||
readSession,
|
||||
saveSessionRecovery,
|
||||
sessionChannel,
|
||||
sessionEvent,
|
||||
setReauthenticationHandler,
|
||||
updateSessionTiming,
|
||||
type Portal,
|
||||
} from '@/api/session';
|
||||
import { Button, Input, Modal } from '@/components/ui';
|
||||
|
||||
@@ -49,6 +54,7 @@ type AppShellProps = {
|
||||
subtitle: string;
|
||||
workspaceName: string;
|
||||
loginPath: string;
|
||||
portal: Portal;
|
||||
userName: string;
|
||||
userRole: string;
|
||||
navSections: ShellNavSection[];
|
||||
@@ -59,6 +65,7 @@ export function AppShell({
|
||||
title,
|
||||
workspaceName,
|
||||
loginPath,
|
||||
portal,
|
||||
userName,
|
||||
userRole,
|
||||
navSections,
|
||||
@@ -76,7 +83,8 @@ export function AppShell({
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [passwordSaving, setPasswordSaving] = useState(false);
|
||||
const [idleWarningSeconds, setIdleWarningSeconds] = useState<number | null>(null);
|
||||
const [locked, setLocked] = useState(false);
|
||||
const [locked, setLocked] = useState(() => Boolean(readSession(portal)?.locked));
|
||||
const [routesSuspended, setRoutesSuspended] = useState(() => Boolean(readSession(portal)?.locked));
|
||||
const [unlockPassword, setUnlockPassword] = useState('');
|
||||
const [unlockError, setUnlockError] = useState('');
|
||||
const [unlocking, setUnlocking] = useState(false);
|
||||
@@ -107,9 +115,10 @@ export function AppShell({
|
||||
setPasswordSaving(true);
|
||||
setPasswordError('');
|
||||
try {
|
||||
await adminApi.changeOwnPassword({ currentPassword, password: newPassword });
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout', { message: '密码修改成功,请重新登录' });
|
||||
await portalSessionApi.changeOwnPassword(portal, { currentPassword, password: newPassword });
|
||||
clearSession(portal);
|
||||
clearSessionRecovery(portal);
|
||||
dispatchSessionEvent(portal, 'logout', { message: '密码修改成功,请重新登录' });
|
||||
navigate(loginPath, { replace: true });
|
||||
} catch (error) {
|
||||
setPasswordError(error instanceof Error ? error.message : '修改密码失败');
|
||||
@@ -120,18 +129,19 @@ export function AppShell({
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await adminApi.logout();
|
||||
await portalSessionApi.logout(portal);
|
||||
} finally {
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout');
|
||||
clearSession(portal);
|
||||
clearSessionRecovery(portal);
|
||||
dispatchSessionEvent(portal, 'logout');
|
||||
navigate(loginPath, { replace: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function continueSession() {
|
||||
try {
|
||||
const timing = await adminApi.touchSession();
|
||||
updateSessionTiming(timing);
|
||||
const timing = await portalSessionApi.touch(portal);
|
||||
updateSessionTiming(portal, { ...timing, locked: false });
|
||||
markUserActivity();
|
||||
setIdleWarningSeconds(null);
|
||||
} catch {
|
||||
@@ -147,14 +157,15 @@ export function AppShell({
|
||||
setUnlocking(true);
|
||||
setUnlockError('');
|
||||
try {
|
||||
const timing = await adminApi.unlockSession(unlockPassword);
|
||||
updateSessionTiming(timing);
|
||||
const timing = await portalSessionApi.unlock(portal, unlockPassword);
|
||||
updateSessionTiming(portal, { ...timing, locked: false });
|
||||
markUserActivity();
|
||||
setLocked(false);
|
||||
setRoutesSuspended(false);
|
||||
lockRequested.current = false;
|
||||
setUnlockPassword('');
|
||||
setIdleWarningSeconds(null);
|
||||
dispatchSessionEvent('unlocked');
|
||||
dispatchSessionEvent(portal, 'unlocked');
|
||||
} catch (error) {
|
||||
setUnlockError(error instanceof Error ? error.message : '解锁失败');
|
||||
} finally {
|
||||
@@ -170,8 +181,8 @@ export function AppShell({
|
||||
setReauthenticating(true);
|
||||
setReauthenticationError('');
|
||||
try {
|
||||
const timing = await adminApi.reauthenticate(reauthenticationPassword);
|
||||
updateSessionTiming(timing);
|
||||
const timing = await portalSessionApi.reauthenticate(portal, reauthenticationPassword);
|
||||
updateSessionTiming(portal, timing);
|
||||
reauthenticationResolve.current?.();
|
||||
reauthenticationResolve.current = null;
|
||||
reauthenticationReject.current = null;
|
||||
@@ -204,14 +215,17 @@ export function AppShell({
|
||||
|
||||
const onLocked = () => setLocked(true);
|
||||
const onUnlocked = () => { setLocked(false); markUserActivity(); };
|
||||
const onLogout = () => { clearSession(); navigate(loginPath, { replace: true }); };
|
||||
window.addEventListener('cmpp-session-locked', onLocked);
|
||||
window.addEventListener('cmpp-session-unlocked', onUnlocked);
|
||||
window.addEventListener('cmpp-session-logout', onLogout);
|
||||
const onLogout = () => { clearSession(portal); navigate(loginPath, { replace: true }); };
|
||||
const lockedEvent = sessionEvent(portal, 'locked');
|
||||
const unlockedEvent = sessionEvent(portal, 'unlocked');
|
||||
const logoutEvent = sessionEvent(portal, 'logout');
|
||||
window.addEventListener(lockedEvent, onLocked);
|
||||
window.addEventListener(unlockedEvent, onUnlocked);
|
||||
window.addEventListener(logoutEvent, onLogout);
|
||||
|
||||
let channel: BroadcastChannel | undefined;
|
||||
try {
|
||||
channel = new BroadcastChannel('cmpp-session');
|
||||
channel = new BroadcastChannel(sessionChannel(portal));
|
||||
channel.onmessage = (event: MessageEvent<{ type?: string }>) => {
|
||||
if (event.data?.type === 'locked') onLocked();
|
||||
if (event.data?.type === 'unlocked') onUnlocked();
|
||||
@@ -230,12 +244,17 @@ export function AppShell({
|
||||
}));
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
const session = readSession();
|
||||
const session = readSession(portal);
|
||||
if (!session) return;
|
||||
const now = Date.now();
|
||||
if (now >= Date.parse(session.absoluteExpiresAt)) {
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout', { code: 'SESSION_ABSOLUTE_TIMEOUT' });
|
||||
saveSessionRecovery(portal, {
|
||||
returnUrl: `${location.pathname}${location.search}${location.hash}`,
|
||||
code: 'SESSION_ABSOLUTE_TIMEOUT',
|
||||
message: '登录已达到最长有效期,请重新登录后继续。',
|
||||
});
|
||||
clearSession(portal);
|
||||
dispatchSessionEvent(portal, 'logout', { code: 'SESSION_ABSOLUTE_TIMEOUT' });
|
||||
navigate(loginPath, { replace: true });
|
||||
return;
|
||||
}
|
||||
@@ -244,7 +263,7 @@ export function AppShell({
|
||||
lockRequested.current = true;
|
||||
setLocked(true);
|
||||
setIdleWarningSeconds(null);
|
||||
void adminApi.lockSession().catch(() => undefined);
|
||||
void portalSessionApi.lock(portal).catch(() => undefined);
|
||||
} else if (remaining <= 5 * 60 * 1000) {
|
||||
setIdleWarningSeconds(Math.ceil(remaining / 1000));
|
||||
} else {
|
||||
@@ -254,15 +273,15 @@ export function AppShell({
|
||||
|
||||
return () => {
|
||||
activityEvents.forEach((eventName) => window.removeEventListener(eventName, onActivity));
|
||||
window.removeEventListener('cmpp-session-locked', onLocked);
|
||||
window.removeEventListener('cmpp-session-unlocked', onUnlocked);
|
||||
window.removeEventListener('cmpp-session-logout', onLogout);
|
||||
window.removeEventListener(lockedEvent, onLocked);
|
||||
window.removeEventListener(unlockedEvent, onUnlocked);
|
||||
window.removeEventListener(logoutEvent, onLogout);
|
||||
channel?.close();
|
||||
window.clearInterval(timer);
|
||||
setReauthenticationHandler(undefined);
|
||||
reauthenticationReject.current?.(new Error('身份验证已取消'));
|
||||
};
|
||||
}, [loginPath, navigate]);
|
||||
}, [location.hash, location.pathname, location.search, loginPath, navigate, portal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (auditTotal <= 0 || typeof window === 'undefined') {
|
||||
@@ -431,7 +450,7 @@ export function AppShell({
|
||||
</header>
|
||||
|
||||
<div className="page-content">
|
||||
<Outlet />
|
||||
{routesSuspended ? null : <Outlet />}
|
||||
</div>
|
||||
</main>
|
||||
<Modal
|
||||
@@ -453,7 +472,7 @@ export function AppShell({
|
||||
open={locked}
|
||||
title="会话已安全锁定"
|
||||
>
|
||||
<p>由于长时间未操作,请输入当前密码继续使用。锁定超过 4 小时后需要完整登录。</p>
|
||||
<p>由于长时间未操作,请输入当前密码继续使用。解锁后将返回当前页面;锁定超过 4 小时后需要完整登录。</p>
|
||||
<Input label="当前密码" onChange={(event) => setUnlockPassword(event.target.value)} type="password" value={unlockPassword} />
|
||||
{unlockError ? <p className="login-error">{unlockError}</p> : null}
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user