feat: harden platform workflows and UI governance
This commit is contained in:
+222
-62
@@ -1,11 +1,41 @@
|
||||
import { clearSession, dispatchSessionEvent, getSessionTenantId, hasRecentUserActivity, readSession, requestReauthentication, type LoginSession } from './session';
|
||||
import {
|
||||
clearSession,
|
||||
currentRouteForPortal,
|
||||
dispatchSessionEvent,
|
||||
getSessionTenantId,
|
||||
hasRecentUserActivity,
|
||||
portalFromPath,
|
||||
readSession,
|
||||
redirectToPortalLogin,
|
||||
requestReauthentication,
|
||||
saveSessionRecovery,
|
||||
type LoginSession,
|
||||
type Portal,
|
||||
} from './session';
|
||||
import { assertUploadFileSize } from '@/utils/fileUpload';
|
||||
|
||||
type RequestOptions = RequestInit & {
|
||||
tenantId?: string;
|
||||
reauthenticationAttempted?: boolean;
|
||||
suppressSessionRedirect?: boolean;
|
||||
};
|
||||
|
||||
export type DeletionTargetType = 'channel' | 'signature' | 'template';
|
||||
export type DeletionDependency = { kind: string; label: string; count: number; items: string[] };
|
||||
export type DeletionPreflight = {
|
||||
type: DeletionTargetType;
|
||||
id: string;
|
||||
expectedUpdatedAt: string;
|
||||
identity: Record<string, string>;
|
||||
dependencies: DeletionDependency[];
|
||||
impacts: string[];
|
||||
blockedReasons: string[];
|
||||
allowedActions: Array<'delete'>;
|
||||
recoverability: { mode: 'soft_delete'; description: string };
|
||||
};
|
||||
export type DeleteTargetRequest = { expectedUpdatedAt: string; idempotencyKey: string; reason: string };
|
||||
export type DeletionResult = { operationId: string; status: 'deleted'; replayed: boolean };
|
||||
|
||||
type ApiErrorBody = { message?: string | string[]; error?: string; code?: string };
|
||||
|
||||
async function readErrorBody(response: Response): Promise<ApiErrorBody> {
|
||||
@@ -20,6 +50,35 @@ async function readErrorBody(response: Response): Promise<ApiErrorBody> {
|
||||
|
||||
export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a';
|
||||
|
||||
type SessionTiming = Pick<LoginSession, 'idleTimeoutSeconds' | 'lockRecoverySeconds' | 'absoluteExpiresAt' | 'lastActivityAt' | 'recentAuthenticationExpiresAt'>;
|
||||
|
||||
function requestPortal(path: string): Portal | undefined {
|
||||
return portalFromPath(path);
|
||||
}
|
||||
|
||||
async function handleSessionFailure(response: Response, portal: Portal | undefined, suppressRedirect = false) {
|
||||
if (!portal) return false;
|
||||
const session = readSession(portal);
|
||||
const body = await readErrorBody(response.clone());
|
||||
if (body.code === 'SESSION_LOCKED' && session) {
|
||||
dispatchSessionEvent(portal, 'locked', { message: body.message });
|
||||
throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定');
|
||||
}
|
||||
if (suppressRedirect) return false;
|
||||
|
||||
if (session) {
|
||||
saveSessionRecovery(portal, {
|
||||
returnUrl: currentRouteForPortal(portal),
|
||||
code: body.code,
|
||||
message: typeof body.message === 'string' ? body.message : '登录会话已失效,请重新登录',
|
||||
});
|
||||
}
|
||||
clearSession(portal);
|
||||
dispatchSessionEvent(portal, 'logout', { code: body.code, message: body.message });
|
||||
redirectToPortalLogin(portal);
|
||||
throw new Error('登录会话已失效,请重新登录');
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response) {
|
||||
const fallback = `请求失败(${response.status})`;
|
||||
const text = await response.text();
|
||||
@@ -40,7 +99,8 @@ async function readErrorMessage(response: Response) {
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set('Content-Type', 'application/json');
|
||||
const session = readSession();
|
||||
const portal = requestPortal(path);
|
||||
const session = portal ? readSession(portal) : null;
|
||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
|
||||
if (tenantId) {
|
||||
@@ -48,16 +108,8 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
}
|
||||
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
|
||||
const isLoginAttempt = path === '/admin/auth/login' || path === '/client/auth/login';
|
||||
if (response.status === 401 && session && !isLoginAttempt) {
|
||||
const body = await readErrorBody(response.clone());
|
||||
if (body.code === 'SESSION_LOCKED') {
|
||||
dispatchSessionEvent('locked', { message: body.message });
|
||||
throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定');
|
||||
}
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout', { code: body.code, message: body.message });
|
||||
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
||||
throw new Error('登录会话已失效,请重新登录');
|
||||
if (response.status === 401 && !isLoginAttempt) {
|
||||
await handleSessionFailure(response, portal, options.suppressSessionRedirect);
|
||||
}
|
||||
if (response.status === 403 && session && !options.reauthenticationAttempted) {
|
||||
const body = await readErrorBody(response.clone());
|
||||
@@ -74,23 +126,16 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
|
||||
async function requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
|
||||
const headers = new Headers(options.headers);
|
||||
const session = readSession();
|
||||
const portal = requestPortal(path);
|
||||
const session = portal ? readSession(portal) : null;
|
||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
|
||||
if (tenantId) {
|
||||
headers.set('x-tenant-id', tenantId);
|
||||
}
|
||||
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
|
||||
if (response.status === 401 && session) {
|
||||
const body = await readErrorBody(response.clone());
|
||||
if (body.code === 'SESSION_LOCKED') {
|
||||
dispatchSessionEvent('locked', { message: body.message });
|
||||
throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定');
|
||||
}
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout', { code: body.code, message: body.message });
|
||||
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
||||
throw new Error('登录会话已失效,请重新登录');
|
||||
if (response.status === 401) {
|
||||
await handleSessionFailure(response, portal, options.suppressSessionRedirect);
|
||||
}
|
||||
if (response.status === 403 && session && !options.reauthenticationAttempted) {
|
||||
const body = await readErrorBody(response.clone());
|
||||
@@ -107,18 +152,12 @@ async function requestBlob(path: string, options: RequestOptions = {}): Promise<
|
||||
|
||||
async function requestForm<T>(path: string, form: FormData, reauthenticationAttempted = false): Promise<T> {
|
||||
const headers = new Headers();
|
||||
const session = readSession();
|
||||
const portal = requestPortal(path);
|
||||
const session = portal ? readSession(portal) : null;
|
||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||
const response = await fetch(`/api${path}`, { method: 'POST', headers, body: form, credentials: 'same-origin' });
|
||||
if (response.status === 401 && session) {
|
||||
const body = await readErrorBody(response.clone());
|
||||
if (body.code === 'SESSION_LOCKED') {
|
||||
dispatchSessionEvent('locked', { message: body.message });
|
||||
throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定');
|
||||
}
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout', { code: body.code, message: body.message });
|
||||
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
||||
if (response.status === 401) {
|
||||
await handleSessionFailure(response, portal);
|
||||
throw new Error('登录会话已失效,请重新登录');
|
||||
}
|
||||
if (response.status === 403 && session && !reauthenticationAttempted) {
|
||||
@@ -208,6 +247,27 @@ export type SmsTemplateAudit = {
|
||||
tenant?: { name: string };
|
||||
};
|
||||
|
||||
export type ReviewPreflight = {
|
||||
type: 'signature' | 'template';
|
||||
id: string;
|
||||
tenantId: string;
|
||||
status: string;
|
||||
expectedUpdatedAt: string;
|
||||
identity: Record<string, string>;
|
||||
impacts: string[];
|
||||
materialSummary: Record<string, string | number>;
|
||||
blockedReasons: string[];
|
||||
allowedActions: Array<'approve' | 'reject'>;
|
||||
};
|
||||
|
||||
export type ReviewDecisionResult = {
|
||||
operationId: string;
|
||||
replayed: boolean;
|
||||
decision: 'approve' | 'reject';
|
||||
status: string;
|
||||
item: ClientSmsSignature | SmsTemplateAudit;
|
||||
};
|
||||
|
||||
export type TenantOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -305,6 +365,25 @@ export type RechargeOrder = {
|
||||
tenant?: TenantOption;
|
||||
};
|
||||
|
||||
export type ManualRechargePreflight = {
|
||||
tenant: Pick<TenantOption, 'id' | 'name' | 'code'>;
|
||||
accountId: string;
|
||||
expectedAccountUpdatedAt: string;
|
||||
balanceCents: number;
|
||||
creditCents: number;
|
||||
amountCents: number;
|
||||
balanceAfterCents: number;
|
||||
direction: 'topup' | 'correction';
|
||||
allowedActions: Array<'confirm'>;
|
||||
blockedReasons: string[];
|
||||
};
|
||||
|
||||
export type ManualRechargeResult = RechargeOrder & {
|
||||
balanceAfterCents: number;
|
||||
operationId: string;
|
||||
replayed: boolean;
|
||||
};
|
||||
|
||||
export type ClientSmsApplication = {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
@@ -683,6 +762,50 @@ export type ReportMaterialPendingItem = {
|
||||
application?: ClientSmsApplication | null;
|
||||
};
|
||||
|
||||
export type ReportMaterialPreflightTarget = {
|
||||
id: string;
|
||||
name: string;
|
||||
carrier: string;
|
||||
businessKey: string;
|
||||
eligible: boolean;
|
||||
blockedReasons: string[];
|
||||
duplicateBatchId?: string;
|
||||
};
|
||||
|
||||
export type ReportMaterialPreflightItem = {
|
||||
id: string;
|
||||
reportType: 'signature' | 'drainage';
|
||||
signatureId: string;
|
||||
drainageItemId?: string;
|
||||
materialVersion: number;
|
||||
name: string;
|
||||
tenantName: string;
|
||||
applicationId?: string;
|
||||
applicationName: string;
|
||||
eligible: boolean;
|
||||
blockedReasons: string[];
|
||||
targets: ReportMaterialPreflightTarget[];
|
||||
};
|
||||
|
||||
export type ReportMaterialBatchPreflight = {
|
||||
checkedAt: string;
|
||||
eligible: boolean;
|
||||
eligibleItemCount: number;
|
||||
blockedItemCount: number;
|
||||
eligibleTargetCount: number;
|
||||
skippedTargetCount: number;
|
||||
items: ReportMaterialPreflightItem[];
|
||||
};
|
||||
|
||||
export type ReportMaterialBatchResult = Record<string, unknown> & {
|
||||
id: string;
|
||||
batchNo: string;
|
||||
status: string;
|
||||
operationId: string;
|
||||
replayed: boolean;
|
||||
result: { successCount: number; skippedCount: number; failedCount: number; items: ReportMaterialPreflightItem[] };
|
||||
};
|
||||
|
||||
export type ReportImportMapping = {
|
||||
sourceHeader: string;
|
||||
sourceHeaderPath?: string;
|
||||
@@ -771,8 +894,8 @@ export type FileRef = {
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export function fileDownloadUrl(fileObjectId: string, disposition: 'attachment' | 'inline' = 'attachment') {
|
||||
return `/api/admin/files/${encodeURIComponent(fileObjectId)}/download?disposition=${disposition}`;
|
||||
export function fileDownloadUrl(fileObjectId: string, disposition: 'attachment' | 'inline' = 'attachment', portal: Portal = 'admin') {
|
||||
return `/api/${portal}/files/${encodeURIComponent(fileObjectId)}/download?disposition=${disposition}`;
|
||||
}
|
||||
|
||||
export type RiskReviewTask = {
|
||||
@@ -810,6 +933,7 @@ export type TenantAccount = {
|
||||
balanceCents: number;
|
||||
creditCents: number;
|
||||
status: string;
|
||||
updatedAt?: string;
|
||||
tenant?: TenantOption;
|
||||
};
|
||||
|
||||
@@ -835,6 +959,16 @@ export type OperationLogResponse = {
|
||||
modules: string[];
|
||||
};
|
||||
|
||||
export type SystemLogExportResult = {
|
||||
operationId: string;
|
||||
status: 'completed';
|
||||
fileName: string;
|
||||
recordCount: number;
|
||||
truncated: boolean;
|
||||
content: string;
|
||||
filters: { keyword?: string; level?: string; module?: string; range?: string };
|
||||
};
|
||||
|
||||
export type PagedResponse<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
@@ -1180,17 +1314,28 @@ function withQuery(path: string, query: Record<string, string | number | undefin
|
||||
return `${path}${suffix}`;
|
||||
}
|
||||
|
||||
export const portalSessionApi = {
|
||||
current: (portal: Portal) => request<LoginSession>(`/${portal}/auth/session`, { suppressSessionRedirect: true }),
|
||||
touch: (portal: Portal) => request<SessionTiming>(`/${portal}/auth/session/touch`, { method: 'POST', body: '{}' }),
|
||||
lock: (portal: Portal) => request<{ locked: boolean }>(`/${portal}/auth/session/lock`, { method: 'POST', body: '{}' }),
|
||||
unlock: (portal: Portal, password: string) => request<SessionTiming>(`/${portal}/auth/session/unlock`, { method: 'POST', body: JSON.stringify({ password }) }),
|
||||
reauthenticate: (portal: Portal, password: string) => request<Pick<LoginSession, 'recentAuthenticationExpiresAt'>>(`/${portal}/auth/reauthenticate`, { method: 'POST', body: JSON.stringify({ password }), reauthenticationAttempted: true }),
|
||||
logout: (portal: Portal) => request<{ success: boolean }>(`/${portal}/auth/logout`, { method: 'POST', body: '{}' }),
|
||||
changeOwnPassword: (portal: Portal, body: { currentPassword: string; password: string }) =>
|
||||
request<ManagedUser>(`/${portal}/auth/password`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
};
|
||||
|
||||
export const adminApi = {
|
||||
getCaptcha: () => request<CaptchaResponse>('/admin/auth/captcha'),
|
||||
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
|
||||
request<LoginSession>('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }),
|
||||
touchSession: () => request<Pick<LoginSession, 'idleTimeoutSeconds' | 'lockRecoverySeconds' | 'absoluteExpiresAt' | 'lastActivityAt' | 'recentAuthenticationExpiresAt'>>('/auth/session/touch', { method: 'POST', body: '{}' }),
|
||||
lockSession: () => request<{ locked: boolean }>('/auth/session/lock', { method: 'POST', body: '{}' }),
|
||||
unlockSession: (password: string) => request<Pick<LoginSession, 'idleTimeoutSeconds' | 'lockRecoverySeconds' | 'absoluteExpiresAt' | 'lastActivityAt' | 'recentAuthenticationExpiresAt'>>('/auth/session/unlock', { method: 'POST', body: JSON.stringify({ password }) }),
|
||||
reauthenticate: (password: string) => request<Pick<LoginSession, 'recentAuthenticationExpiresAt'>>('/auth/reauthenticate', { method: 'POST', body: JSON.stringify({ password }), reauthenticationAttempted: true }),
|
||||
logout: () => request<{ success: boolean }>('/auth/logout', { method: 'POST', body: '{}' }),
|
||||
touchSession: () => portalSessionApi.touch('admin'),
|
||||
lockSession: () => portalSessionApi.lock('admin'),
|
||||
unlockSession: (password: string) => portalSessionApi.unlock('admin', password),
|
||||
reauthenticate: (password: string) => portalSessionApi.reauthenticate('admin', password),
|
||||
logout: () => portalSessionApi.logout('admin'),
|
||||
changeOwnPassword: (body: { currentPassword: string; password: string }) =>
|
||||
request<ManagedUser>('/auth/password', { method: 'POST', body: JSON.stringify(body) }),
|
||||
portalSessionApi.changeOwnPassword('admin', body),
|
||||
listTenants: () => request<TenantOption[]>('/admin/tenants'),
|
||||
listTenantManagementRows: () => request<TenantManagementRow[]>('/admin/tenants/management-list'),
|
||||
getTenant: (id: string) => request<TenantOption>(`/admin/tenants/${id}`),
|
||||
@@ -1212,12 +1357,16 @@ export const adminApi = {
|
||||
getDashboard: (tenantId?: string) => request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
|
||||
listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) =>
|
||||
request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
|
||||
exportSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string }) =>
|
||||
request<SystemLogExportResult>('/admin/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
|
||||
listAccounts: () => request<TenantAccount[]>('/admin/billing/accounts'),
|
||||
updateCreditLimit: (tenantId: string, body: { creditCents: number; operatorId?: string; remark?: string }) =>
|
||||
request<TenantAccount>(`/admin/billing/accounts/${tenantId}/credit-limit`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
listManualRecharges: (tenantId?: string) => request<RechargeOrder[]>(withQuery('/admin/billing/manual-recharges', { tenantId })),
|
||||
createManualRecharge: (body: { tenantId: string; amountCents: number; operatorId?: string; remark?: string }) =>
|
||||
request<RechargeOrder>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
|
||||
preflightManualRecharge: (body: { tenantId: string; amountCents: number }) =>
|
||||
request<ManualRechargePreflight>('/admin/billing/manual-recharges/preflight', { method: 'POST', body: JSON.stringify(body) }),
|
||||
createManualRecharge: (body: { tenantId: string; amountCents: number; expectedAccountUpdatedAt: string; idempotencyKey: string; remark?: string }) =>
|
||||
request<ManualRechargeResult>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listEnterpriseApplications: (query: { tenantId?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; status?: string } = {}) =>
|
||||
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
|
||||
getEnterpriseApplication: (id: string) =>
|
||||
@@ -1272,6 +1421,10 @@ export const adminApi = {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ reason }),
|
||||
}),
|
||||
getDeletionPreflight: (type: DeletionTargetType, id: string) =>
|
||||
request<DeletionPreflight>(`/admin/deletions/${type}/${id}/preflight`),
|
||||
deleteGovernedTarget: (type: DeletionTargetType, id: string, body: DeleteTargetRequest) =>
|
||||
request<DeletionResult>(`/admin/deletions/${type}/${id}`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
listChannelConnectionLogs: (id: string) => request<ChannelConnectionLogResponse>(`/admin/channels/${id}/connection-logs`),
|
||||
listTemplateAudits: (query: { keyword?: string; status?: string }) => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -1290,6 +1443,10 @@ export const adminApi = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason }),
|
||||
}),
|
||||
getReviewPreflight: (type: 'signature' | 'template', id: string) =>
|
||||
request<ReviewPreflight>(`/admin/reviews/${type}/${id}/preflight`),
|
||||
submitReviewDecision: (type: 'signature' | 'template', id: string, body: { decision: 'approve' | 'reject'; expectedUpdatedAt: string; idempotencyKey: string; reason?: string }) =>
|
||||
request<ReviewDecisionResult>(`/admin/reviews/${type}/${id}/decision`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string } = {}) =>
|
||||
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)),
|
||||
createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }) =>
|
||||
@@ -1370,8 +1527,10 @@ export const adminApi = {
|
||||
commitReportMaterialImport: (id: string, body: { mappings: ReportImportMapping[]; profile?: Omit<ReportImportProfile, 'id'> & { id?: string } }) =>
|
||||
request<Record<string, unknown>>(`/admin/report-materials/imports/${id}/commit`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
listReportMaterialBatches: () => request<Array<Record<string, unknown>>>('/admin/report-materials/batches'),
|
||||
createReportMaterialBatch: (body: { createdById?: string; items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string }> }) =>
|
||||
request<Record<string, unknown>>('/admin/report-materials/batches', { method: 'POST', body: JSON.stringify(body) }),
|
||||
preflightReportMaterialBatch: (body: { items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion?: number }> }) =>
|
||||
request<ReportMaterialBatchPreflight>('/admin/report-materials/batches/preflight', { method: 'POST', body: JSON.stringify(body) }),
|
||||
createReportMaterialBatch: (body: { idempotencyKey: string; items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion: number }> }) =>
|
||||
request<ReportMaterialBatchResult>('/admin/report-materials/batches', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)),
|
||||
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) =>
|
||||
request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }),
|
||||
@@ -1462,7 +1621,7 @@ export const adminApi = {
|
||||
form.set('prefix', body.prefix);
|
||||
}
|
||||
const headers = new Headers();
|
||||
const session = readSession();
|
||||
const session = readSession('admin');
|
||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||
if (tenantId) {
|
||||
headers.set('x-tenant-id', tenantId);
|
||||
@@ -1471,11 +1630,11 @@ export const adminApi = {
|
||||
if (response.status === 401 && session) {
|
||||
const error = await readErrorBody(response.clone());
|
||||
if (error.code === 'SESSION_LOCKED') {
|
||||
dispatchSessionEvent('locked', { message: error.message });
|
||||
dispatchSessionEvent('admin', 'locked', { message: error.message });
|
||||
} else {
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout', { code: error.code, message: error.message });
|
||||
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
||||
clearSession('admin');
|
||||
dispatchSessionEvent('admin', 'logout', { code: error.code, message: error.message });
|
||||
redirectToPortalLogin('admin');
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
@@ -1513,6 +1672,8 @@ export const clientApi = {
|
||||
}),
|
||||
listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }),
|
||||
exportSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string }) =>
|
||||
request<SystemLogExportResult>('/client/operations/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
|
||||
listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<RechargeOrder[]>('/client/billing/orders', { tenantId }),
|
||||
listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
@@ -1566,6 +1727,10 @@ export const clientApi = {
|
||||
request<ClientSmsTemplate>(`/client/templates/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
|
||||
changeTemplateStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsTemplate>(`/client/templates/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
|
||||
getDeletionPreflight: (type: Exclude<DeletionTargetType, 'channel'>, id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<DeletionPreflight>(`/client/deletions/${type}/${id}/preflight`, { tenantId }),
|
||||
deleteGovernedTarget: (type: Exclude<DeletionTargetType, 'channel'>, id: string, body: DeleteTargetRequest, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<DeletionResult>(`/client/deletions/${type}/${id}`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
|
||||
listBatchTasks: (query: { status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsBatchTask[]>(withQuery('/client/send/batch-tasks', query), { tenantId }),
|
||||
cancelBatchTask: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
@@ -1582,9 +1747,7 @@ export const clientApi = {
|
||||
request<SmsMessageRecord[]>(withQuery('/client/operations/messages', query), { tenantId }),
|
||||
listUplinkMessages: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsUplinkMessage[]>(withQuery('/client/operations/uplink-messages', query), { tenantId }),
|
||||
createFileObject: (body: { bucket?: string; objectKey: string; fileName: string; contentType: string; sizeBytes: number; purpose: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<FileObject>('/admin/files', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId, bucket: body.bucket ?? 'cmpp-platform' }) }),
|
||||
uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => {
|
||||
uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }) => {
|
||||
assertUploadFileSize(file);
|
||||
const form = new FormData();
|
||||
form.set('file', file);
|
||||
@@ -1593,20 +1756,17 @@ export const clientApi = {
|
||||
form.set('prefix', body.prefix);
|
||||
}
|
||||
const headers = new Headers();
|
||||
const session = readSession();
|
||||
const session = readSession('client');
|
||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||
if (tenantId) {
|
||||
headers.set('x-tenant-id', tenantId);
|
||||
}
|
||||
const response = await fetch('/api/admin/files/upload', { method: 'POST', headers, body: form, credentials: 'same-origin' });
|
||||
const response = await fetch('/api/client/files/upload', { method: 'POST', headers, body: form, credentials: 'same-origin' });
|
||||
if (response.status === 401 && session) {
|
||||
const error = await readErrorBody(response.clone());
|
||||
if (error.code === 'SESSION_LOCKED') {
|
||||
dispatchSessionEvent('locked', { message: error.message });
|
||||
dispatchSessionEvent('client', 'locked', { message: error.message });
|
||||
} else {
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout', { code: error.code, message: error.message });
|
||||
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
||||
clearSession('client');
|
||||
dispatchSessionEvent('client', 'logout', { code: error.code, message: error.message });
|
||||
redirectToPortalLogin('client');
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
|
||||
+106
-14
@@ -19,32 +19,116 @@ export type LoginSession = {
|
||||
absoluteExpiresAt: string;
|
||||
lastActivityAt: string;
|
||||
recentAuthenticationExpiresAt: string;
|
||||
locked?: boolean;
|
||||
};
|
||||
|
||||
const sessionKey = 'cmpp-auth-session';
|
||||
export type SessionRecovery = {
|
||||
returnUrl: string;
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export function readSession(): LoginSession | null {
|
||||
const legacySessionKey = 'cmpp-auth-session';
|
||||
const sessionKey = (portal: Portal) => `cmpp-auth-session:${portal}`;
|
||||
const recoveryKey = (portal: Portal) => `cmpp-session-recovery:${portal}`;
|
||||
const sessionEventName = (portal: Portal, type: 'locked' | 'unlocked' | 'logout') => `cmpp-session-${portal}-${type}`;
|
||||
|
||||
export function portalFromPath(path: string): Portal | undefined {
|
||||
if (/^\/?(?:api\/)?admin(?:\/|$)/.test(path)) return 'admin';
|
||||
if (/^\/?(?:api\/)?client(?:\/|$)/.test(path)) return 'client';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function readSession(portal: Portal): LoginSession | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(sessionKey);
|
||||
return raw ? JSON.parse(raw) as LoginSession : null;
|
||||
const raw = window.localStorage.getItem(sessionKey(portal));
|
||||
if (raw) return JSON.parse(raw) as LoginSession;
|
||||
|
||||
// One-time migration for sessions created before portal storage was isolated.
|
||||
const legacyRaw = window.localStorage.getItem(legacySessionKey);
|
||||
if (!legacyRaw) return null;
|
||||
const legacy = JSON.parse(legacyRaw) as LoginSession;
|
||||
if (legacy.portal !== portal) return null;
|
||||
window.localStorage.setItem(sessionKey(portal), legacyRaw);
|
||||
window.localStorage.removeItem(legacySessionKey);
|
||||
return legacy;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSession(session: LoginSession) {
|
||||
window.localStorage.setItem(sessionKey, JSON.stringify(session));
|
||||
window.localStorage.setItem(sessionKey(session.portal), JSON.stringify(session));
|
||||
window.localStorage.removeItem(legacySessionKey);
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
window.localStorage.removeItem(sessionKey);
|
||||
export function clearSession(portal: Portal) {
|
||||
window.localStorage.removeItem(sessionKey(portal));
|
||||
try {
|
||||
const legacyRaw = window.localStorage.getItem(legacySessionKey);
|
||||
if (legacyRaw && (JSON.parse(legacyRaw) as LoginSession).portal === portal) {
|
||||
window.localStorage.removeItem(legacySessionKey);
|
||||
}
|
||||
} catch {
|
||||
window.localStorage.removeItem(legacySessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSessionTiming(timing: Partial<Omit<LoginSession, 'portal' | 'user'>>) {
|
||||
const current = readSession();
|
||||
export function updateSessionTiming(portal: Portal, timing: Partial<Omit<LoginSession, 'portal' | 'user'>>) {
|
||||
const current = readSession(portal);
|
||||
if (current) writeSession({ ...current, ...timing });
|
||||
}
|
||||
|
||||
export function safeReturnUrl(portal: Portal, value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized.startsWith(`/${portal}`) || normalized.startsWith(`/${portal}/login`)) return undefined;
|
||||
if (normalized.startsWith('//') || normalized.includes('\\')) return undefined;
|
||||
try {
|
||||
const parsed = new URL(normalized, window.location.origin);
|
||||
if (parsed.origin !== window.location.origin || !parsed.pathname.startsWith(`/${portal}`)) return undefined;
|
||||
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSessionRecovery(portal: Portal, recovery: SessionRecovery) {
|
||||
const returnUrl = safeReturnUrl(portal, recovery.returnUrl);
|
||||
if (!returnUrl) return;
|
||||
window.sessionStorage.setItem(recoveryKey(portal), JSON.stringify({ ...recovery, returnUrl }));
|
||||
}
|
||||
|
||||
export function readSessionRecovery(portal: Portal): SessionRecovery | null {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(recoveryKey(portal));
|
||||
if (!raw) return null;
|
||||
const recovery = JSON.parse(raw) as SessionRecovery;
|
||||
const returnUrl = safeReturnUrl(portal, recovery.returnUrl);
|
||||
return returnUrl ? { ...recovery, returnUrl } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeSessionRecovery(portal: Portal) {
|
||||
const recovery = readSessionRecovery(portal);
|
||||
window.sessionStorage.removeItem(recoveryKey(portal));
|
||||
return recovery;
|
||||
}
|
||||
|
||||
export function clearSessionRecovery(portal: Portal) {
|
||||
window.sessionStorage.removeItem(recoveryKey(portal));
|
||||
}
|
||||
|
||||
export function currentRouteForPortal(portal: Portal) {
|
||||
const hashRoute = window.location.hash.startsWith('#/') ? window.location.hash.slice(1) : '';
|
||||
return safeReturnUrl(portal, hashRoute) ?? safeReturnUrl(portal, `${window.location.pathname}${window.location.search}${window.location.hash}`) ?? `/${portal}`;
|
||||
}
|
||||
|
||||
export function redirectToPortalLogin(portal: Portal) {
|
||||
window.location.assign(`${window.location.origin}/#/${portal}/login`);
|
||||
}
|
||||
|
||||
let lastUserActivityAt = Date.now();
|
||||
let reauthenticationHandler: (() => Promise<void>) | undefined;
|
||||
|
||||
@@ -69,10 +153,10 @@ export function requestReauthentication() {
|
||||
return reauthenticationHandler();
|
||||
}
|
||||
|
||||
export function dispatchSessionEvent(type: 'locked' | 'unlocked' | 'logout', detail?: Record<string, unknown>) {
|
||||
window.dispatchEvent(new CustomEvent(`cmpp-session-${type}`, { detail }));
|
||||
export function dispatchSessionEvent(portal: Portal, type: 'locked' | 'unlocked' | 'logout', detail?: Record<string, unknown>) {
|
||||
window.dispatchEvent(new CustomEvent(sessionEventName(portal, type), { detail }));
|
||||
try {
|
||||
const channel = new BroadcastChannel('cmpp-session');
|
||||
const channel = new BroadcastChannel(`cmpp-session:${portal}`);
|
||||
channel.postMessage({ type, detail });
|
||||
channel.close();
|
||||
} catch {
|
||||
@@ -80,6 +164,14 @@ export function dispatchSessionEvent(type: 'locked' | 'unlocked' | 'logout', det
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionTenantId() {
|
||||
return readSession()?.user.tenantId ?? undefined;
|
||||
export function sessionEvent(portal: Portal, type: 'locked' | 'unlocked' | 'logout') {
|
||||
return sessionEventName(portal, type);
|
||||
}
|
||||
|
||||
export function sessionChannel(portal: Portal) {
|
||||
return `cmpp-session:${portal}`;
|
||||
}
|
||||
|
||||
export function getSessionTenantId() {
|
||||
return readSession('client')?.user.tenantId ?? undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user