feat(assets): add dimension analysis and authorized user previews
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
Refactor asset drilldowns with combined dimension filters, parent summaries and progressive loading. Add the hidden /who read-only permission preview with scoped authorization, identity mapping and audit logging. Refine inventory counts and AI问数 presentation.
This commit is contained in:
+135
-36
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, type ReactNode } from 'react';
|
||||
import { AuthContext, type AuthState } from './useAuth';
|
||||
import { setTokenGetter } from './api-client';
|
||||
import { AuthContext, type AuthState, type AuthUser } from './useAuth';
|
||||
import { setTokenGetter, setPreviewTokenGetter, invalidateAuthenticatedRequests, fetchPreviewControl } from './api-client';
|
||||
|
||||
const AUTH_API = '/api/auth';
|
||||
|
||||
@@ -13,43 +13,147 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
const tokenRef = useRef<string | null>(null);
|
||||
const authStarted = useRef(false);
|
||||
const previewTokenRef = useRef<string | null>(null);
|
||||
const actorRef = useRef<AuthUser | null>(null);
|
||||
const transitionRef = useRef(0);
|
||||
const [identityVersion, setIdentityVersion] = useState(0);
|
||||
const [preview, setPreview] = useState<{ user: AuthUser; expiresAt: string } | null>(null);
|
||||
const [canPreview, setCanPreview] = useState(false);
|
||||
const [previewNotice, setPreviewNotice] = useState<string | null>(null);
|
||||
|
||||
function resetPreview(message: string | null = null) {
|
||||
transitionRef.current += 1;
|
||||
previewTokenRef.current = null;
|
||||
sessionStorage.removeItem('bi_preview_token');
|
||||
invalidateAuthenticatedRequests();
|
||||
setIdentityVersion(v => v + 1);
|
||||
setPreview(null);
|
||||
setPreviewNotice(message);
|
||||
}
|
||||
|
||||
async function loadPreviewContext(user: AuthUser | null) {
|
||||
actorRef.current = user;
|
||||
try {
|
||||
const context = await fetchPreviewControl<{ canPreview: boolean; actor: AuthUser }>('context');
|
||||
actorRef.current = context.actor;
|
||||
setCanPreview(context.canPreview);
|
||||
if (context.canPreview) {
|
||||
const savedPreview = sessionStorage.getItem('bi_preview_token');
|
||||
if (savedPreview) {
|
||||
previewTokenRef.current = savedPreview;
|
||||
try {
|
||||
const current = await fetchPreviewControl<{ user: AuthUser; expiresAt: string }>('current', undefined, true);
|
||||
setPreview(current);
|
||||
} catch {
|
||||
resetPreview('上次权限视角已失效,已返回本人视角。');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
resetPreview();
|
||||
}
|
||||
} catch {
|
||||
resetPreview();
|
||||
setCanPreview(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function finishAuthentication(user: AuthUser | null) {
|
||||
await loadPreviewContext(user);
|
||||
if (!tokenRef.current) return;
|
||||
setState({ isLoading: false, isAuthenticated: true, user: actorRef.current, error: null });
|
||||
}
|
||||
|
||||
async function startPreview(userId: string) {
|
||||
const transition = ++transitionRef.current;
|
||||
const next = await fetchPreviewControl<{ previewToken: string; user: AuthUser; expiresAt: string }>('start', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }),
|
||||
});
|
||||
if (transition !== transitionRef.current) return;
|
||||
invalidateAuthenticatedRequests();
|
||||
previewTokenRef.current = next.previewToken;
|
||||
sessionStorage.setItem('bi_preview_token', next.previewToken);
|
||||
setPreview({ user: next.user, expiresAt: next.expiresAt });
|
||||
setIdentityVersion(v => v + 1);
|
||||
setPreviewNotice(null);
|
||||
}
|
||||
|
||||
async function stopPreview() {
|
||||
const stopping = fetchPreviewControl('stop', { method: 'POST' }, true);
|
||||
resetPreview();
|
||||
await stopping.catch(() => { setPreviewNotice('已退出本地预览;服务端凭证将自动到期。'); });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!preview) return;
|
||||
const token = previewTokenRef.current;
|
||||
const validate = async () => {
|
||||
try {
|
||||
const current = await fetchPreviewControl<{ user: AuthUser; expiresAt: string }>('current', undefined, true);
|
||||
if (token !== previewTokenRef.current) return;
|
||||
if (JSON.stringify(current.user) !== JSON.stringify(preview.user)) {
|
||||
invalidateAuthenticatedRequests();
|
||||
setPreview(current);
|
||||
setIdentityVersion(v => v + 1);
|
||||
}
|
||||
} catch {
|
||||
if (token === previewTokenRef.current) resetPreview('权限视角验证失败,已返回本人视角,请重新选择。');
|
||||
}
|
||||
};
|
||||
const timer = window.setInterval(validate, 60_000);
|
||||
const expiry = window.setTimeout(() => resetPreview('权限视角已到期,已返回本人视角。'), Math.max(0, Date.parse(preview.expiresAt) - Date.now()));
|
||||
window.addEventListener('focus', validate);
|
||||
return () => { clearInterval(timer); clearTimeout(expiry); window.removeEventListener('focus', validate); };
|
||||
}, [preview]);
|
||||
|
||||
useEffect(() => {
|
||||
// 设置全局 token getter
|
||||
setTokenGetter(() => tokenRef.current);
|
||||
|
||||
// 防止 StrictMode 双重调用(jumpToken 一次性使用)
|
||||
if (authStarted.current) return;
|
||||
authStarted.current = true;
|
||||
setPreviewTokenGetter(() => previewTokenRef.current);
|
||||
|
||||
// 监听 401 事件
|
||||
const onUnauthorized = () => {
|
||||
resetPreview();
|
||||
setCanPreview(false);
|
||||
tokenRef.current = null;
|
||||
sessionStorage.removeItem('bi_jwt');
|
||||
sessionStorage.removeItem('bi_user');
|
||||
actorRef.current = null;
|
||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: '会话已过期' });
|
||||
};
|
||||
window.addEventListener('auth:unauthorized', onUnauthorized);
|
||||
|
||||
authenticate();
|
||||
|
||||
return () => window.removeEventListener('auth:unauthorized', onUnauthorized);
|
||||
const onPreviewEnded = (event: Event) => {
|
||||
if ((event as CustomEvent).detail === 'PREVIEW_REVOKED') setCanPreview(false);
|
||||
resetPreview('预览权限已失效,已返回本人视角。');
|
||||
};
|
||||
const onReadOnly = () => setPreviewNotice('权限视角仅供查看数据,请退出预览后进行修改操作。');
|
||||
window.addEventListener('auth:preview-ended', onPreviewEnded);
|
||||
window.addEventListener('auth:preview-readonly', onReadOnly);
|
||||
// StrictMode 重新安装监听,但一次性 jumpToken 仅交换一次。
|
||||
if (!authStarted.current) { authStarted.current = true; void authenticate(); }
|
||||
return () => {
|
||||
window.removeEventListener('auth:unauthorized', onUnauthorized);
|
||||
window.removeEventListener('auth:preview-ended', onPreviewEnded);
|
||||
window.removeEventListener('auth:preview-readonly', onReadOnly);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function authenticate() {
|
||||
// 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
|
||||
setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
user: {
|
||||
userId: 'dev-local',
|
||||
userName: '本地开发',
|
||||
permissionLevel: 'full',
|
||||
depName: '',
|
||||
roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
const devUser: AuthUser = {
|
||||
userId: 'dev-local',
|
||||
userName: '本地开发',
|
||||
permissionLevel: 'full',
|
||||
depName: '',
|
||||
roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
|
||||
};
|
||||
// 预览授权仍由后端独立开发开关决定,不使用真实用户令牌。
|
||||
tokenRef.current = null;
|
||||
await loadPreviewContext(devUser);
|
||||
actorRef.current ||= devUser;
|
||||
setState({ isLoading: false, isAuthenticated: true, user: actorRef.current, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,22 +161,20 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const savedToken = sessionStorage.getItem('bi_jwt');
|
||||
if (savedToken) {
|
||||
tokenRef.current = savedToken;
|
||||
// 验证 token 是否仍然有效(尝试请求 health)
|
||||
// 从受保护的身份接口验证 JWT;不使用本地缓存用户决定权限。
|
||||
try {
|
||||
const res = await fetch('/api/health', {
|
||||
const res = await fetch('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${savedToken}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const savedUser = sessionStorage.getItem('bi_user');
|
||||
setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
user: savedUser ? JSON.parse(savedUser) : null,
|
||||
error: null,
|
||||
});
|
||||
const verifiedUser: AuthUser = await res.json();
|
||||
if (!verifiedUser.userId) throw new Error('无效的用户身份');
|
||||
await finishAuthentication(verifiedUser);
|
||||
return;
|
||||
}
|
||||
} catch { /* token 无效,继续流程 */ }
|
||||
tokenRef.current = null;
|
||||
resetPreview();
|
||||
sessionStorage.removeItem('bi_jwt');
|
||||
sessionStorage.removeItem('bi_user');
|
||||
}
|
||||
@@ -108,19 +210,16 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
: `${window.location.pathname}${window.location.hash}`;
|
||||
window.history.replaceState({}, '', cleanUrl);
|
||||
|
||||
setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
user: data.user,
|
||||
error: null,
|
||||
});
|
||||
await finishAuthentication(data.user);
|
||||
} catch (e) {
|
||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: '认证过程出错' });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={state}>
|
||||
<AuthContext.Provider value={{ ...state, actor: actorRef.current || state.user,
|
||||
user: preview?.user || state.user, preview, canPreview, identityVersion, previewNotice,
|
||||
startPreview, stopPreview, clearPreviewNotice: () => setPreviewNotice(null) }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user