0eb6d7621e
Check Cross-Plugin Imports / check (push) Has been cancelled
P18: require_permission zu forgejo_error_reporter und ai_ui_control routes hinzugefügt P19: Cross-Tenant Permission-Cache-Invalidierung bei Rollenänderungen P20: Session/Permission-Cache-Invalidierung bei Gruppen-Änderungen P21: ENTITY_MODELS Registry um fehlende Plugin-Modelle erweitert P22: Entity-Links prüfen verknüpfte Entity-Permissions P23: authStore persist Middleware entfernt (kein localStorage mehr) P24: 5xx Retry nur noch für GET-Requests P25: KI-Kommentar in address.py (bekannte Inkonsistenz) P26: DeletionLog in EntityHistory gemerged (action=delete) P27: KI-Kommentar in entity_policy.py (ABAC nicht aktiv genutzt) P28: db.commit() aus bulk_permission_service entfernt P29: CSV-Export in export_service.py ausgelagert P30: plugins.py Business-Logik in plugin_install_service.py ausgelagert P31: KI-Kommentar in session.py (Dual-System dokumentiert) P32: Migration 0115: crm_platform_admin Role droppen P33: Cross-Plugin Imports über contracts.py behoben (10 Violations → 0)
181 lines
5.8 KiB
TypeScript
181 lines
5.8 KiB
TypeScript
import axios, { AxiosError, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
|
|
import { logError } from '@/utils/errorLogger';
|
|
|
|
export interface ApiError {
|
|
status: number;
|
|
message: string;
|
|
detail?: string;
|
|
validationErrors?: Record<string, string[]>;
|
|
}
|
|
|
|
export const apiClient = axios.create({
|
|
baseURL: '/api/v1',
|
|
withCredentials: true,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
// CSRF token storage — in-memory only (not persisted to prevent XSS theft)
|
|
let csrfToken: string | null = null;
|
|
|
|
export function setCsrfToken(token: string | null) {
|
|
csrfToken = token;
|
|
}
|
|
|
|
export function getCsrfToken(): string | null {
|
|
return csrfToken;
|
|
}
|
|
|
|
let onUnauthorized: (() => void) | null = null;
|
|
let onValidationError: ((errors: Record<string, string[]>) => void) | null = null;
|
|
|
|
// Workspace context — set by workspaceStore, sent as X-Workspace-ID header
|
|
let activeWorkspaceId: string | null = null;
|
|
|
|
export function setActiveWorkspaceId(id: string | null) {
|
|
activeWorkspaceId = id;
|
|
}
|
|
|
|
export function setUnauthorizedHandler(handler: () => void) {
|
|
onUnauthorized = handler;
|
|
}
|
|
|
|
export function setValidationErrorHandler(handler: (errors: Record<string, string[]>) => void) {
|
|
onValidationError = handler;
|
|
}
|
|
|
|
apiClient.interceptors.request.use(
|
|
(config: InternalAxiosRequestConfig) => {
|
|
// Attach CSRF token on unsafe methods
|
|
const unsafe = ['post', 'put', 'patch', 'delete'];
|
|
if (unsafe.includes(config.method?.toLowerCase() ?? '') && csrfToken) {
|
|
config.headers['X-CSRF-Token'] = csrfToken;
|
|
}
|
|
// Attach X-Workspace-ID header for workspace context (per-tab)
|
|
if (activeWorkspaceId) {
|
|
config.headers['X-Workspace-ID'] = activeWorkspaceId;
|
|
}
|
|
return config;
|
|
},
|
|
(error) => Promise.reject(error)
|
|
);
|
|
|
|
// Retry interceptor for 5xx errors (max 1 retry with short delay)
|
|
apiClient.interceptors.response.use(
|
|
(response) => response,
|
|
async (error: AxiosError) => {
|
|
const config = error.config as InternalAxiosRequestConfig & { _retried?: boolean };
|
|
const status = error.response?.status || 0;
|
|
|
|
// Retry 5xx errors once with a short delay (GET only — never retry mutations)
|
|
const method = config?.method?.toLowerCase() ?? '';
|
|
if (status >= 500 && status < 600 && method === 'get' && config && !config._retried) {
|
|
config._retried = true;
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
return apiClient.request(config);
|
|
}
|
|
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
|
|
// Main error-handling interceptor
|
|
apiClient.interceptors.response.use(
|
|
(response) => response,
|
|
(error: AxiosError) => {
|
|
const status = error.response?.status || 0;
|
|
const data = error.response?.data as any;
|
|
|
|
// Only call onUnauthorized for 401 — NOT for 403 or network errors
|
|
if (status === 401) {
|
|
if (onUnauthorized) {
|
|
onUnauthorized();
|
|
}
|
|
}
|
|
// 403: Permission error — do NOT logout, let the calling code handle it
|
|
// Network errors (status === 0): do NOT logout, transient issue
|
|
|
|
if (status === 422 && data?.detail) {
|
|
const validationErrors: Record<string, string[]> = {};
|
|
if (Array.isArray(data.detail)) {
|
|
for (const item of data.detail) {
|
|
if (item.loc && item.loc.length > 1) {
|
|
const field = item.loc[item.loc.length - 1];
|
|
if (!validationErrors[field]) {
|
|
validationErrors[field] = [];
|
|
}
|
|
validationErrors[field].push(item.msg || 'Invalid value');
|
|
}
|
|
}
|
|
}
|
|
if (onValidationError) {
|
|
onValidationError(validationErrors);
|
|
}
|
|
}
|
|
|
|
const apiError: ApiError = {
|
|
status,
|
|
message: data?.detail?.message || data?.message || error.message || 'An error occurred',
|
|
detail: typeof data?.detail === 'string' ? data.detail : undefined,
|
|
validationErrors: status === 422 ? extractValidationErrors(data) : undefined,
|
|
};
|
|
|
|
// Report API errors to backend error logger (for Forgejo issue creation)
|
|
// Skip 401 (auth) and 403 (permission) — these are expected, not bugs
|
|
// Report: 422 (validation), 404 (not found), 5xx (server), 0 (network)
|
|
if (status !== 401 && status !== 403) {
|
|
logError(apiError.message, {
|
|
status,
|
|
url: error.config?.url,
|
|
method: error.config?.method,
|
|
detail: apiError.detail,
|
|
});
|
|
}
|
|
|
|
return Promise.reject(apiError);
|
|
}
|
|
);
|
|
|
|
function extractValidationErrors(data: any): Record<string, string[]> | undefined {
|
|
if (!data?.detail || !Array.isArray(data.detail)) return undefined;
|
|
const errors: Record<string, string[]> = {};
|
|
for (const item of data.detail) {
|
|
if (item.loc && item.loc.length > 1) {
|
|
const field = item.loc[item.loc.length - 1];
|
|
if (!errors[field]) {
|
|
errors[field] = [];
|
|
}
|
|
errors[field].push(item.msg || 'Invalid value');
|
|
}
|
|
}
|
|
return Object.keys(errors).length > 0 ? errors : undefined;
|
|
}
|
|
|
|
export async function apiGet<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
|
const response = await apiClient.get<T>(url, config);
|
|
return response.data;
|
|
}
|
|
|
|
export async function apiPost<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
|
const response = await apiClient.post<T>(url, data, config);
|
|
return response.data;
|
|
}
|
|
|
|
export async function apiPut<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
|
const response = await apiClient.put<T>(url, data, config);
|
|
return response.data;
|
|
}
|
|
|
|
export async function apiPatch<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
|
const response = await apiClient.patch<T>(url, data, config);
|
|
return response.data;
|
|
}
|
|
|
|
export async function apiDelete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
|
const response = await apiClient.delete<T>(url, config);
|
|
return response.data;
|
|
}
|
|
|
|
export default apiClient;
|