Files
ln-bi/src/auth/api-client.ts
T
shishengliang f1b3f8d3da
ci/woodpecker/push/woodpecker Pipeline was successful
feat(assets): add dimension analysis and authorized user previews
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.
2026-09-28 12:29:51 +08:00

62 lines
3.3 KiB
TypeScript

/** 登录身份和预览身份分别传递;切换视角会取消旧视角的在途请求。 */
let tokenGetter: () => string | null = () => null;
let previewTokenGetter: () => string | null = () => null;
let identityAbort = new AbortController();
let identityVersion = 0;
export function setTokenGetter(fn: () => string | null) { tokenGetter = fn; }
export function setPreviewTokenGetter(fn: () => string | null) { previewTokenGetter = fn; }
export function invalidateAuthenticatedRequests() {
identityVersion += 1;
identityAbort.abort();
identityAbort = new AbortController();
}
const expiredPreviewCodes = new Set(['PREVIEW_EXPIRED', 'PREVIEW_REVOKED', 'PREVIEW_INVALID', 'PREVIEW_TARGET_UNAVAILABLE']);
async function authenticatedRequest(url: string, options: RequestInit | undefined, preview: boolean, business: boolean): Promise<Response> {
const version = identityVersion;
const headers = new Headers(options?.headers);
const token = tokenGetter();
if (token) headers.set('Authorization', `Bearer ${token}`);
headers.delete('X-BI-Preview');
const previewToken = preview ? previewTokenGetter() : null;
if (previewToken) headers.set('X-BI-Preview', previewToken);
const signal = business
? options?.signal ? AbortSignal.any([options.signal, identityAbort.signal]) : identityAbort.signal
: options?.signal;
const res = await fetch(url, { ...options, headers, signal });
if (business && version !== identityVersion) throw new DOMException('权限视角已切换', 'AbortError');
if (res.status === 401 || res.status === 403 || res.status === 410) {
const body = await res.clone().json().catch(() => ({}));
if (business && version !== identityVersion) throw new DOMException('权限视角已切换', 'AbortError');
if (expiredPreviewCodes.has(body.code)) {
// 已切换到其他人的旧响应不能结束新预览。
if (previewToken && previewToken === previewTokenGetter()) window.dispatchEvent(new CustomEvent('auth:preview-ended', { detail: body.code }));
throw new Error('权限视角已失效,请重新选择用户');
}
if (res.status === 401) {
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
throw new Error('Unauthorized');
}
if (body.code === 'PREVIEW_READ_ONLY') {
window.dispatchEvent(new CustomEvent('auth:preview-readonly'));
throw new Error('权限视角为只读,请退出预览后操作');
}
}
return res;
}
export function fetchAuthenticated(url: string, options?: RequestInit): Promise<Response> {
return authenticatedRequest(url, options, true, true);
}
/** 管理接口始终使用实际登录身份;仅 current/stop 附带当前预览凭证。 */
export async function fetchPreviewControl<T>(path: string, options?: RequestInit, includePreview = false): Promise<T> {
const res = await authenticatedRequest(`/api/permission-preview/${path}`, options, includePreview, false);
const data = await res.json();
if (!res.ok) throw new Error(data.error || data.message || '权限视角请求失败,请重试');
return data as T;
}
export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetchAuthenticated(url, options);
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
return res.json();
}