When accessing from VPN/Tailscale (e.g., 100.78.182.28), frontend was trying to reach backend on that same IP, but backend listens on SERVER_IP instead. Now uses SERVER_IP from network.json config, ensuring remote clients connect to the correct server address regardless of their access network.
292 lines
9.2 KiB
TypeScript
292 lines
9.2 KiB
TypeScript
import axios from 'axios';
|
|
import { getToken, clearAuth } from './auth';
|
|
|
|
// Cached config to avoid repeated fetches
|
|
let cachedConfig: any = null;
|
|
|
|
/**
|
|
* Fetches the network configuration from the public/network.json file.
|
|
* This file is generated at startup by start_server.sh or docker-compose.
|
|
*/
|
|
export const getNetworkConfig = async () => {
|
|
if (cachedConfig) return cachedConfig;
|
|
|
|
if (typeof window === 'undefined') {
|
|
return { SERVER_IP: 'localhost', BACKEND_PORT: 8000, BACKEND_SSL_PORT: 8908 };
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/network.json');
|
|
if (!response.ok) throw new Error("Config not found");
|
|
cachedConfig = await response.json();
|
|
return cachedConfig;
|
|
} catch (e) {
|
|
console.warn("Network config not found, using compiled defaults.");
|
|
// Defaults matching the initial reserve ports in case network.json is missing
|
|
return { SERVER_IP: 'localhost', BACKEND_PORT: 8916, BACKEND_SSL_PORT: 8918 };
|
|
}
|
|
};
|
|
|
|
export const getBackendUrl = async () => {
|
|
const config = await getNetworkConfig();
|
|
|
|
if (typeof window === 'undefined') return `http://localhost:${config.BACKEND_PORT}`;
|
|
|
|
// Use SERVER_IP from config (set during startup) instead of window.location.hostname
|
|
// This ensures VPN/remote clients connect to the correct server IP, not their access IP
|
|
const host = config.SERVER_IP || window.location.hostname;
|
|
|
|
// If we are on HTTPS (Proxy/Mobile mode), we use the SSL port for the backend
|
|
if (window.location.protocol === 'https:') {
|
|
if (host.includes('.loca.lt')) {
|
|
return 'https://inventory-ai-api.loca.lt';
|
|
}
|
|
return `https://${host}:${config.BACKEND_SSL_PORT}`;
|
|
}
|
|
|
|
return `http://${host}:${config.BACKEND_PORT}`;
|
|
};
|
|
|
|
/**
|
|
* [C-01] Axios instance cu JWT Bearer token în header
|
|
* și interceptor pentru 401 Unauthorized (token expired)
|
|
*/
|
|
const axiosInstance = axios.create({});
|
|
|
|
axiosInstance.interceptors.request.use(async (config) => {
|
|
if (!config.baseURL) {
|
|
config.baseURL = await getBackendUrl(); // called at request time — always correct
|
|
}
|
|
const token = getToken();
|
|
if (token) {
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
return config;
|
|
}, (error) => Promise.reject(error));
|
|
|
|
axiosInstance.interceptors.response.use(
|
|
(response) => response,
|
|
(error) => {
|
|
// [L-01] Handle 401 Unauthorized — token expired
|
|
if (error.response?.status === 401) {
|
|
clearAuth();
|
|
if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) {
|
|
window.location.href = '/login';
|
|
}
|
|
}
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
|
|
export const inventoryApi = {
|
|
getItems: async () => {
|
|
const res = await axiosInstance.get('/items/');
|
|
return res.data;
|
|
},
|
|
|
|
getStats: async () => {
|
|
const res = await axiosInstance.get('/items/stats');
|
|
return res.data;
|
|
},
|
|
|
|
syncBulkOperations: async (userId: number, operations: any[]) => {
|
|
try {
|
|
const res = await axiosInstance.post('/operations/bulk-sync', {
|
|
user_id: userId,
|
|
operations: operations
|
|
});
|
|
return res.data;
|
|
} catch (err: any) {
|
|
console.error("Sync API Error:", err);
|
|
if (err.response?.status === 404) {
|
|
throw new Error(`404: Endpoint not found`);
|
|
}
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
analyzeLabel: async (formData: FormData, mode: string = "item") => {
|
|
const res = await axiosInstance.post(`/items/extract-label?mode=${mode}`, formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' }
|
|
});
|
|
return res.data;
|
|
},
|
|
|
|
createItem: async (userId: number, itemData: any) => {
|
|
const res = await axiosInstance.post('/items/', itemData);
|
|
return res.data;
|
|
},
|
|
|
|
updateItem: async (itemId: number, itemData: any) => {
|
|
const res = await axiosInstance.put(`/items/${itemId}`, itemData);
|
|
return res.data;
|
|
},
|
|
|
|
adjustStock: async (endpoint: string, data: any) => {
|
|
const res = await axiosInstance.post(`/operations/${endpoint}`, data);
|
|
return res.data;
|
|
},
|
|
|
|
deleteItem: async (itemId: number) => {
|
|
const res = await axiosInstance.delete(`/items/${itemId}`);
|
|
return res.data;
|
|
},
|
|
|
|
getAuditLogs: async (limit: number = 50) => {
|
|
const res = await axiosInstance.get('/operations/logs', { params: { limit } });
|
|
return res.data;
|
|
},
|
|
|
|
// Users
|
|
getUsers: async () => {
|
|
// [C-01] Public endpoint — use plain axios to avoid JWT interceptor
|
|
const baseUrl = await getBackendUrl();
|
|
const res = await axios.get(`${baseUrl}/users/`);
|
|
return res.data;
|
|
},
|
|
|
|
createUser: async (userData: any) => {
|
|
const res = await axiosInstance.post('/users/', userData);
|
|
return res.data;
|
|
},
|
|
|
|
login: async (credentials: any) => {
|
|
// [C-01] Login endpoint — NU adaug token header (login e public)
|
|
const baseUrl = await getBackendUrl();
|
|
const res = await axios.post(`${baseUrl}/users/login`, credentials);
|
|
return res.data;
|
|
},
|
|
|
|
deleteUser: async (userId: number) => {
|
|
const res = await axiosInstance.delete(`/users/${userId}`);
|
|
return res.data;
|
|
},
|
|
|
|
updateUser: async (userId: number, data: any) => {
|
|
const res = await axiosInstance.put(`/users/${userId}`, data);
|
|
return res.data;
|
|
},
|
|
|
|
getLdapConfig: async () => {
|
|
const res = await axiosInstance.get('/users/ldap-config');
|
|
return res.data;
|
|
},
|
|
updateLdapConfig: async (config: any) => {
|
|
const res = await axiosInstance.post('/users/ldap-config', config);
|
|
return res.data;
|
|
},
|
|
testLdapConnection: async (config: any) => {
|
|
const res = await axiosInstance.post('/users/test-ldap', config);
|
|
return res.data;
|
|
},
|
|
|
|
// Categories
|
|
getCategories: async () => {
|
|
const res = await axiosInstance.get('/categories/');
|
|
return res.data;
|
|
},
|
|
createCategory: async (data: any) => {
|
|
const res = await axiosInstance.post('/categories/', data);
|
|
return res.data;
|
|
},
|
|
updateCategory: async (id: number, data: any) => {
|
|
const res = await axiosInstance.put(`/categories/${id}`, data);
|
|
return res.data;
|
|
},
|
|
deleteCategory: async (id: number) => {
|
|
const res = await axiosInstance.delete(`/categories/${id}`);
|
|
return res.data;
|
|
},
|
|
|
|
// Database Management
|
|
getDbBackups: async () => {
|
|
const res = await axiosInstance.get('/admin/db/backups');
|
|
return res.data;
|
|
},
|
|
getDbStats: async () => {
|
|
const res = await axiosInstance.get('/admin/db/stats');
|
|
return res.data;
|
|
},
|
|
triggerBackup: async () => {
|
|
const res = await axiosInstance.post('/admin/db/backup');
|
|
return res.data;
|
|
},
|
|
restoreDatabase: async (filename: string) => {
|
|
const res = await axiosInstance.post('/admin/db/restore', { filename, confirm: true });
|
|
return res.data;
|
|
},
|
|
getDbSettings: async () => {
|
|
const res = await axiosInstance.get('/admin/db/settings');
|
|
return res.data;
|
|
},
|
|
updateDbSettings: async (settings: any) => {
|
|
const res = await axiosInstance.patch('/admin/db/settings', settings);
|
|
return res.data;
|
|
},
|
|
exportDb: async () => {
|
|
const res = await axiosInstance.get('/admin/db/export', { responseType: 'blob' });
|
|
return res.data;
|
|
},
|
|
importDb: async (formData: FormData) => {
|
|
const res = await axiosInstance.post('/admin/db/import', formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' }
|
|
});
|
|
return res.data;
|
|
},
|
|
// System Settings
|
|
getSystemSettings: async () => {
|
|
// Note: Reusing the existing settings router pattern but adding AI Prompt
|
|
const res = await axiosInstance.get('/admin/db/settings');
|
|
// We need another endpoint for general settings or expand the DB one
|
|
// For now, I'll add a specific fetch for the prompt
|
|
const promptRes = await axiosInstance.get('/admin/ai/settings/prompt');
|
|
return { ...res.data, ai_extraction_prompt: promptRes.data.value };
|
|
},
|
|
getAiPrompt: async () => {
|
|
const res = await axiosInstance.get('/admin/ai/settings/prompt');
|
|
return res.data;
|
|
},
|
|
updateAiPrompt: async (prompt: string) => {
|
|
const res = await axiosInstance.post('/admin/ai/settings/prompt', { value: prompt });
|
|
return res.data;
|
|
},
|
|
getAiConfig: async () => {
|
|
const res = await axiosInstance.get('/admin/ai/settings');
|
|
return res.data;
|
|
},
|
|
updateAiProvider: async (provider: string) => {
|
|
const res = await axiosInstance.post('/admin/ai/settings', { provider });
|
|
return res.data;
|
|
},
|
|
updateAiKeys: async (keys: { gemini_api_key?: string, claude_api_key?: string }) => {
|
|
const res = await axiosInstance.post('/admin/ai/settings/keys', keys);
|
|
return res.data;
|
|
},
|
|
testAiKey: async (provider: string, key: string) => {
|
|
const res = await axiosInstance.post('/admin/ai/settings/test-key', { provider, key });
|
|
return res.data;
|
|
},
|
|
|
|
// Photo Upload
|
|
uploadItemPhoto: async (itemId: number, formData: FormData) => {
|
|
const res = await axiosInstance.post(`/items/${itemId}/photo`, formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' }
|
|
});
|
|
return res.data;
|
|
},
|
|
|
|
// Photo Replacement
|
|
replaceItemPhoto: async (itemId: number, formData: FormData) => {
|
|
const res = await axiosInstance.put(`/items/${itemId}/photo`, formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' }
|
|
});
|
|
return res.data;
|
|
},
|
|
|
|
// Photo Deletion
|
|
deleteItemPhoto: async (itemId: number) => {
|
|
const res = await axiosInstance.delete(`/items/${itemId}/photo`);
|
|
return res.data;
|
|
},
|
|
};
|