diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 37e1b610..4c8bf632 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -22,6 +22,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: + toolchain: stable components: rustfmt - name: Check formatting run: cargo fmt --all -- --check @@ -34,6 +35,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: + toolchain: stable components: clippy - name: Cache cargo registry uses: actions/cache@v4 @@ -55,6 +57,8 @@ jobs: - uses: actions/checkout@v4 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable - name: Cache cargo registry uses: actions/cache@v4 with: @@ -76,6 +80,8 @@ jobs: - uses: actions/checkout@v4 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable - name: Cache cargo registry uses: actions/cache@v4 with: diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/frontend/lib/api/assets.ts b/frontend/lib/api/assets.ts index 8eb8e9aa..f1eca5b5 100644 --- a/frontend/lib/api/assets.ts +++ b/frontend/lib/api/assets.ts @@ -1,4 +1,4 @@ -import { apiClient } from '@/lib/api/client'; +import apiClient from '@/lib/api/client'; import { Asset, AssetDocument, @@ -33,135 +33,116 @@ export const assetApiClient = { limit: number; totalPages: number; }> { - const searchParams = new URLSearchParams(); - if (params) { - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - searchParams.append(key, String(value)); - } - }); - } - const qs = searchParams.toString(); - return apiClient.request<{ - assets: Asset[]; - total: number; - page: number; - limit: number; - totalPages: number; - }>(`/assets${qs ? `?${qs}` : ''}`); + return apiClient + .get<{ + assets: Asset[]; + total: number; + page: number; + limit: number; + totalPages: number; + }>('/assets', { params }) + .then((res) => res.data); }, getAsset(id: string): Promise { - return apiClient.request(`/assets/${id}`); + return apiClient.get(`/assets/${id}`).then((res) => res.data); }, getAssetHistory(id: string, filters?: AssetHistoryFilters): Promise { - const params = new URLSearchParams(); - if (filters) { - Object.entries(filters).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - params.append(key, String(value)); - } - }); - } - const qs = params.toString(); - return apiClient.request( - `/assets/${id}/history${qs ? `?${qs}` : ''}` - ); + return apiClient + .get(`/assets/${id}/history`, { params: filters }) + .then((res) => res.data); }, getAssetDocuments(id: string): Promise { - return apiClient.request(`/assets/${id}/documents`); + return apiClient + .get(`/assets/${id}/documents`) + .then((res) => res.data); }, getMaintenanceRecords(id: string): Promise { - return apiClient.request(`/assets/${id}/maintenance`); + return apiClient + .get(`/assets/${id}/maintenance`) + .then((res) => res.data); }, getAssetNotes(id: string): Promise { - return apiClient.request(`/assets/${id}/notes`); + return apiClient.get(`/assets/${id}/notes`).then((res) => res.data); }, getDepartments(): Promise { - return apiClient.request('/departments'); + return apiClient + .get('/departments') + .then((res) => res.data); }, createDepartment(data: { name: string; description?: string }): Promise { - return apiClient.request('/departments', { - method: 'POST', - body: JSON.stringify(data), - }); + return apiClient.post('/departments', data).then((res) => res.data); }, deleteDepartment(id: string): Promise { - return apiClient.request(`/departments/${id}`, { method: 'DELETE' }); + return apiClient.delete(`/departments/${id}`).then((res) => res.data); }, getCategories(): Promise { - return apiClient.request('/categories'); + return apiClient + .get('/categories') + .then((res) => res.data); }, createCategory(data: { name: string; description?: string }): Promise { - return apiClient.request('/categories', { - method: 'POST', - body: JSON.stringify(data), - }); + return apiClient.post('/categories', data).then((res) => res.data); }, deleteCategory(id: string): Promise { - return apiClient.request(`/categories/${id}`, { method: 'DELETE' }); + return apiClient.delete(`/categories/${id}`).then((res) => res.data); }, getUsers(): Promise { - return apiClient.request('/users'); + return apiClient.get('/users').then((res) => res.data); }, updateAssetStatus(id: string, data: UpdateAssetStatusInput): Promise { - return apiClient.request(`/assets/${id}/status`, { - method: 'PATCH', - body: JSON.stringify(data), - }); + return apiClient + .patch(`/assets/${id}/status`, data) + .then((res) => res.data); }, transferAsset(id: string, data: TransferAssetInput): Promise { - return apiClient.request(`/assets/${id}/transfer`, { - method: 'POST', - body: JSON.stringify(data), - }); + return apiClient.post(`/assets/${id}/transfer`, data).then((res) => res.data); }, deleteAsset(id: string): Promise { - return apiClient.request(`/assets/${id}`, { method: 'DELETE' }); + return apiClient.delete(`/assets/${id}`).then((res) => res.data); }, uploadDocument(assetId: string, file: File, name?: string): Promise { const form = new FormData(); form.append('file', file); if (name) form.append('name', name); - return apiClient.request(`/assets/${assetId}/documents`, { - method: 'POST', - body: form, - headers: {}, - }); + return apiClient + .post(`/assets/${assetId}/documents`, form) + .then((res) => res.data); }, deleteDocument(assetId: string, documentId: string): Promise { - return apiClient.request(`/assets/${assetId}/documents/${documentId}`, { - method: 'DELETE', - }); + return apiClient + .delete(`/assets/${assetId}/documents/${documentId}`) + .then((res) => res.data); }, - createMaintenanceRecord(assetId: string, data: CreateMaintenanceInput): Promise { - return apiClient.request(`/assets/${assetId}/maintenance`, { - method: 'POST', - body: JSON.stringify(data), - }); + createMaintenanceRecord( + assetId: string, + data: CreateMaintenanceInput + ): Promise { + return apiClient + .post(`/assets/${assetId}/maintenance`, data) + .then((res) => res.data); }, createNote(assetId: string, data: CreateNoteInput): Promise { - return apiClient.request(`/assets/${assetId}/notes`, { - method: 'POST', - body: JSON.stringify(data), - }); + return apiClient + .post(`/assets/${assetId}/notes`, data) + .then((res) => res.data); }, }; diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index dd8aa069..5fb9c227 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -1,50 +1,125 @@ +import axios, { AxiosInstance, AxiosError } from 'axios'; import { RegisterInput, LoginInput, AuthResponse } from '@/lib/query/types'; -const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; +const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:6003/api'; -async function request(path: string, options: RequestInit = {}): Promise { - const url = `${BASE_URL}${path}`; - - const token = - typeof window !== 'undefined' ? localStorage.getItem('token') : null; +// Separate auth client without interceptors (prevents circular refresh loops) +export const authApiClient: AxiosInstance = axios.create({ + baseURL: BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); - const headers: Record = { +// Main API client with JWT interceptors +const apiClient: AxiosInstance = axios.create({ + baseURL: BASE_URL, + headers: { 'Content-Type': 'application/json', - ...(options.headers as Record), - }; + }, +}); - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } +// Track refresh attempts to avoid infinite loops +let isRefreshing = false; +let failedQueue: Array<{ + onSuccess: (token: string) => void; + onError: (error: unknown) => void; +}> = []; - const res = await fetch(url, { ...options, headers }); +const processQueue = (error: unknown, token: string | null = null) => { + failedQueue.forEach((prom) => { + if (error) { + prom.onError(error); + } else { + prom.onSuccess(token || ''); + } + }); - if (!res.ok) { - const error = await res.json().catch(() => ({ message: res.statusText })); - throw { message: error.message ?? res.statusText, statusCode: res.status }; - } + failedQueue = []; +}; - if (res.status === 204) { - return undefined as T; - } +// Request interceptor: attach JWT token +apiClient.interceptors.request.use( + (config) => { + if (typeof window !== 'undefined') { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + } + return config; + }, + (error) => Promise.reject(error) +); + +// Response interceptor: handle 401 with refresh +apiClient.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as any; - return res.json() as Promise; -} + if (error.response?.status === 401 && originalRequest && !originalRequest._retry) { + if (isRefreshing) { + // Queue request to retry after refresh completes + return new Promise((onSuccess, onError) => { + failedQueue.push({ onSuccess, onError }); + }) + .then((token) => { + originalRequest.headers.Authorization = `Bearer ${token}`; + return apiClient(originalRequest); + }) + .catch((err) => Promise.reject(err)); + } + + originalRequest._retry = true; + isRefreshing = true; + + try { + const response = await authApiClient.post('/auth/refresh'); + const { accessToken } = response.data; + + // Store new token + if (typeof window !== 'undefined') { + localStorage.setItem('token', accessToken); + } + + // Update authorization header and retry original request + originalRequest.headers.Authorization = `Bearer ${accessToken}`; + processQueue(null, accessToken); + + return apiClient(originalRequest); + } catch (refreshError) { + processQueue(refreshError, null); + isRefreshing = false; + + // Logout on refresh failure + if (typeof window !== 'undefined') { + const { useAuthStore } = await import('@/store/auth.store'); + useAuthStore.getState().logout(); + } + + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + } + + return Promise.reject(error); + } +); -export const apiClient = { - request, +export default apiClient; +export const authApi = { register(data: RegisterInput): Promise { - return request('/auth/register', { - method: 'POST', - body: JSON.stringify(data), - }); + return authApiClient + .post('/auth/register', data) + .then((res) => res.data); }, login(data: LoginInput): Promise { - return request('/auth/login', { - method: 'POST', - body: JSON.stringify(data), - }); + return authApiClient + .post('/auth/login', data) + .then((res) => res.data); }, }; diff --git a/frontend/lib/api/reportsApi.ts b/frontend/lib/api/reportsApi.ts index c8564927..ab9cf9d2 100644 --- a/frontend/lib/api/reportsApi.ts +++ b/frontend/lib/api/reportsApi.ts @@ -1,7 +1,7 @@ -import api from './client'; +import apiClient from './client'; import { ReportSummary } from '../users'; export async function getReportsSummary(): Promise { - const res = await api.get('/api/reports/summary'); + const res = await apiClient.get('/reports/summary'); return res.data; } diff --git a/frontend/lib/api/usersApi.ts b/frontend/lib/api/usersApi.ts index 8737ae3b..ba401522 100644 --- a/frontend/lib/api/usersApi.ts +++ b/frontend/lib/api/usersApi.ts @@ -1,17 +1,17 @@ -import api from './client'; +import apiClient from './client'; import { User } from '../users'; export async function getUsers(): Promise { - const res = await api.get('/api/users'); + const res = await apiClient.get('/users'); return res.data; } export async function updateUserRole(id: string, role: string): Promise { - const res = await api.patch(`/api/users/${id}/role`, { role }); + const res = await apiClient.patch(`/users/${id}/role`, { role }); return res.data; } export async function updateProfile(id: string, payload: Partial): Promise { - const res = await api.patch(`/api/users/${id}`, payload); + const res = await apiClient.patch(`/users/${id}`, payload); return res.data; } diff --git a/frontend/lib/query/mutations/auth.ts b/frontend/lib/query/mutations/auth.ts index bc5b1584..b31c980c 100644 --- a/frontend/lib/query/mutations/auth.ts +++ b/frontend/lib/query/mutations/auth.ts @@ -1,5 +1,5 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query'; -import { apiClient } from '@/lib/api/client'; +import { authApi } from '@/lib/api/client'; import { queryKeys } from '../keys'; import { RegisterInput, @@ -18,7 +18,7 @@ export function useRegisterMutation( ) { return useMutation({ mutationKey: queryKeys.auth.register, - mutationFn: (data: RegisterInput) => apiClient.register(data), + mutationFn: (data: RegisterInput) => authApi.register(data), ...options, }); } @@ -33,7 +33,7 @@ export function useLoginMutation( ) { return useMutation({ mutationKey: queryKeys.auth.login, - mutationFn: (data: LoginInput) => apiClient.login(data), + mutationFn: (data: LoginInput) => authApi.login(data), ...options, }); } \ No newline at end of file