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
+222 -62
View File
@@ -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) {