feat: harden platform workflows and UI governance

This commit is contained in:
hectorzhao
2026-07-22 14:14:55 +08:00
parent ef957f7daa
commit 0f223f7f91
80 changed files with 4958 additions and 764 deletions
+9 -9
View File
@@ -32,13 +32,16 @@ import {
Users,
UserX,
} from 'lucide-react';
import { Navigate } from 'react-router-dom';
import { adminApi } from '@/api/adminApi';
import { readSession } from '@/api/session';
import type { LoginSession } from '@/api/session';
import { AppShell } from '@/layouts/AppShell';
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
export function AdminLayout() {
const session = readSession();
return <PortalSessionBoundary portal="admin">{(session) => <AdminAuthenticatedLayout session={session} />}</PortalSessionBoundary>;
}
function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
const [pendingAudits, setPendingAudits] = useState({ enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0 });
const loadPendingAuditCount = useCallback(() => {
adminApi.getDashboard()
@@ -51,7 +54,7 @@ export function AdminLayout() {
}, []);
useEffect(() => {
if (session?.portal !== 'admin') {
if (session.portal !== 'admin' || session.locked) {
return;
}
loadPendingAuditCount();
@@ -65,11 +68,7 @@ export function AdminLayout() {
window.removeEventListener('focus', onFocus);
window.removeEventListener('cmpp-audit-count-refresh', onAuditRefresh);
};
}, [loadPendingAuditCount, session?.portal]);
if (session?.portal !== 'admin') {
return <Navigate to="/admin/login" replace />;
}
}, [loadPendingAuditCount, session.locked, session.portal]);
return (
<AppShell
@@ -77,6 +76,7 @@ export function AdminLayout() {
subtitle="平台运营管理中心"
workspaceName="平台运营工作区"
loginPath="/admin/login"
portal="admin"
userName={session.user.displayName}
userRole="平台管理员"
auditNotifications={[
+49 -30
View File
@@ -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>
+5 -6
View File
@@ -10,22 +10,21 @@ import {
ShieldCheck,
Users,
} from 'lucide-react';
import { Navigate } from 'react-router-dom';
import { readSession } from '@/api/session';
import { AppShell } from '@/layouts/AppShell';
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
export function ClientLayout() {
const session = readSession();
if (session?.portal !== 'client') {
return <Navigate to="/client/login" replace />;
}
return <PortalSessionBoundary portal="client">{(session) => <ClientAuthenticatedLayout session={session} />}</PortalSessionBoundary>;
}
function ClientAuthenticatedLayout({ session }: { session: import('@/api/session').LoginSession }) {
return (
<AppShell
title="短信服务平台"
subtitle="短信服务控制台"
workspaceName={session.user.tenantName ?? '企业客户空间'}
loginPath="/client/login"
portal="client"
userName={session.user.displayName}
userRole="企业管理员"
navSections={[
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { portalSessionApi } from '@/api/adminApi';
import {
clearSession,
readSession,
saveSessionRecovery,
writeSession,
type LoginSession,
type Portal,
} from '@/api/session';
type PortalSessionBoundaryProps = {
portal: Portal;
children(session: LoginSession): ReactNode;
};
export function PortalSessionBoundary({ portal, children }: PortalSessionBoundaryProps) {
const location = useLocation();
const targetRoute = useRef(`${location.pathname}${location.search}${location.hash}`);
const [state, setState] = useState<{ checking: boolean; session: LoginSession | null }>({
checking: true,
session: readSession(portal),
});
useEffect(() => {
let active = true;
setState((current) => ({ ...current, checking: true }));
portalSessionApi.current(portal)
.then((session) => {
if (!active) return;
writeSession(session);
setState({ checking: false, session });
})
.catch((error: unknown) => {
if (!active) return;
clearSession(portal);
saveSessionRecovery(portal, {
returnUrl: targetRoute.current,
message: error instanceof Error ? error.message : '登录会话已失效,请重新登录',
});
setState({ checking: false, session: null });
});
return () => { active = false; };
}, [portal]);
if (state.checking) {
return (
<main aria-busy="true" aria-live="polite" className="page-loading-state">
<p></p>
</main>
);
}
if (!state.session) return <Navigate to={`/${portal}/login`} replace />;
return children(state.session);
}