Skip to content

Commit fff185c

Browse files
Merge pull request #516 from Mkalbani/feat/axios-jwt-interceptor
feat: Axios instance with JWT Bearer token interceptors
2 parents 56fda04 + dbe85c7 commit fff185c

File tree

7 files changed

+176
-113
lines changed

7 files changed

+176
-113
lines changed

.github/workflows/CI.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ jobs:
2222
- name: Install Rust toolchain
2323
uses: dtolnay/rust-toolchain@stable
2424
with:
25+
toolchain: stable
2526
components: rustfmt
2627
- name: Check formatting
2728
run: cargo fmt --all -- --check
@@ -34,6 +35,7 @@ jobs:
3435
- name: Install Rust toolchain
3536
uses: dtolnay/rust-toolchain@stable
3637
with:
38+
toolchain: stable
3739
components: clippy
3840
- name: Cache cargo registry
3941
uses: actions/cache@v4
@@ -55,6 +57,8 @@ jobs:
5557
- uses: actions/checkout@v4
5658
- name: Install Rust toolchain
5759
uses: dtolnay/rust-toolchain@stable
60+
with:
61+
toolchain: stable
5862
- name: Cache cargo registry
5963
uses: actions/cache@v4
6064
with:
@@ -76,6 +80,8 @@ jobs:
7680
- uses: actions/checkout@v4
7781
- name: Install Rust toolchain
7882
uses: dtolnay/rust-toolchain@stable
83+
with:
84+
toolchain: stable
7985
- name: Cache cargo registry
8086
uses: actions/cache@v4
8187
with:

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules/

frontend/lib/api/assets.ts

Lines changed: 53 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { apiClient } from '@/lib/api/client';
1+
import apiClient from '@/lib/api/client';
22
import {
33
Asset,
44
AssetDocument,
@@ -33,135 +33,116 @@ export const assetApiClient = {
3333
limit: number;
3434
totalPages: number;
3535
}> {
36-
const searchParams = new URLSearchParams();
37-
if (params) {
38-
Object.entries(params).forEach(([key, value]) => {
39-
if (value !== undefined && value !== null) {
40-
searchParams.append(key, String(value));
41-
}
42-
});
43-
}
44-
const qs = searchParams.toString();
45-
return apiClient.request<{
46-
assets: Asset[];
47-
total: number;
48-
page: number;
49-
limit: number;
50-
totalPages: number;
51-
}>(`/assets${qs ? `?${qs}` : ''}`);
36+
return apiClient
37+
.get<{
38+
assets: Asset[];
39+
total: number;
40+
page: number;
41+
limit: number;
42+
totalPages: number;
43+
}>('/assets', { params })
44+
.then((res) => res.data);
5245
},
5346

5447
getAsset(id: string): Promise<Asset> {
55-
return apiClient.request<Asset>(`/assets/${id}`);
48+
return apiClient.get<Asset>(`/assets/${id}`).then((res) => res.data);
5649
},
5750

5851
getAssetHistory(id: string, filters?: AssetHistoryFilters): Promise<AssetHistoryEvent[]> {
59-
const params = new URLSearchParams();
60-
if (filters) {
61-
Object.entries(filters).forEach(([key, value]) => {
62-
if (value !== undefined && value !== null) {
63-
params.append(key, String(value));
64-
}
65-
});
66-
}
67-
const qs = params.toString();
68-
return apiClient.request<AssetHistoryEvent[]>(
69-
`/assets/${id}/history${qs ? `?${qs}` : ''}`
70-
);
52+
return apiClient
53+
.get<AssetHistoryEvent[]>(`/assets/${id}/history`, { params: filters })
54+
.then((res) => res.data);
7155
},
7256

7357
getAssetDocuments(id: string): Promise<AssetDocument[]> {
74-
return apiClient.request<AssetDocument[]>(`/assets/${id}/documents`);
58+
return apiClient
59+
.get<AssetDocument[]>(`/assets/${id}/documents`)
60+
.then((res) => res.data);
7561
},
7662

7763
getMaintenanceRecords(id: string): Promise<MaintenanceRecord[]> {
78-
return apiClient.request<MaintenanceRecord[]>(`/assets/${id}/maintenance`);
64+
return apiClient
65+
.get<MaintenanceRecord[]>(`/assets/${id}/maintenance`)
66+
.then((res) => res.data);
7967
},
8068

8169
getAssetNotes(id: string): Promise<AssetNote[]> {
82-
return apiClient.request<AssetNote[]>(`/assets/${id}/notes`);
70+
return apiClient.get<AssetNote[]>(`/assets/${id}/notes`).then((res) => res.data);
8371
},
8472

8573
getDepartments(): Promise<DepartmentWithCount[]> {
86-
return apiClient.request<DepartmentWithCount[]>('/departments');
74+
return apiClient
75+
.get<DepartmentWithCount[]>('/departments')
76+
.then((res) => res.data);
8777
},
8878

8979
createDepartment(data: { name: string; description?: string }): Promise<Department> {
90-
return apiClient.request<Department>('/departments', {
91-
method: 'POST',
92-
body: JSON.stringify(data),
93-
});
80+
return apiClient.post<Department>('/departments', data).then((res) => res.data);
9481
},
9582

9683
deleteDepartment(id: string): Promise<void> {
97-
return apiClient.request<void>(`/departments/${id}`, { method: 'DELETE' });
84+
return apiClient.delete<void>(`/departments/${id}`).then((res) => res.data);
9885
},
9986

10087
getCategories(): Promise<CategoryWithCount[]> {
101-
return apiClient.request<CategoryWithCount[]>('/categories');
88+
return apiClient
89+
.get<CategoryWithCount[]>('/categories')
90+
.then((res) => res.data);
10291
},
10392

10493
createCategory(data: { name: string; description?: string }): Promise<Category> {
105-
return apiClient.request<Category>('/categories', {
106-
method: 'POST',
107-
body: JSON.stringify(data),
108-
});
94+
return apiClient.post<Category>('/categories', data).then((res) => res.data);
10995
},
11096

11197
deleteCategory(id: string): Promise<void> {
112-
return apiClient.request<void>(`/categories/${id}`, { method: 'DELETE' });
98+
return apiClient.delete<void>(`/categories/${id}`).then((res) => res.data);
11399
},
114100

115101
getUsers(): Promise<AssetUser[]> {
116-
return apiClient.request<AssetUser[]>('/users');
102+
return apiClient.get<AssetUser[]>('/users').then((res) => res.data);
117103
},
118104

119105
updateAssetStatus(id: string, data: UpdateAssetStatusInput): Promise<Asset> {
120-
return apiClient.request<Asset>(`/assets/${id}/status`, {
121-
method: 'PATCH',
122-
body: JSON.stringify(data),
123-
});
106+
return apiClient
107+
.patch<Asset>(`/assets/${id}/status`, data)
108+
.then((res) => res.data);
124109
},
125110

126111
transferAsset(id: string, data: TransferAssetInput): Promise<Asset> {
127-
return apiClient.request<Asset>(`/assets/${id}/transfer`, {
128-
method: 'POST',
129-
body: JSON.stringify(data),
130-
});
112+
return apiClient.post<Asset>(`/assets/${id}/transfer`, data).then((res) => res.data);
131113
},
132114

133115
deleteAsset(id: string): Promise<void> {
134-
return apiClient.request<void>(`/assets/${id}`, { method: 'DELETE' });
116+
return apiClient.delete<void>(`/assets/${id}`).then((res) => res.data);
135117
},
136118

137119
uploadDocument(assetId: string, file: File, name?: string): Promise<AssetDocument> {
138120
const form = new FormData();
139121
form.append('file', file);
140122
if (name) form.append('name', name);
141-
return apiClient.request<AssetDocument>(`/assets/${assetId}/documents`, {
142-
method: 'POST',
143-
body: form,
144-
headers: {},
145-
});
123+
return apiClient
124+
.post<AssetDocument>(`/assets/${assetId}/documents`, form)
125+
.then((res) => res.data);
146126
},
147127

148128
deleteDocument(assetId: string, documentId: string): Promise<void> {
149-
return apiClient.request<void>(`/assets/${assetId}/documents/${documentId}`, {
150-
method: 'DELETE',
151-
});
129+
return apiClient
130+
.delete<void>(`/assets/${assetId}/documents/${documentId}`)
131+
.then((res) => res.data);
152132
},
153133

154-
createMaintenanceRecord(assetId: string, data: CreateMaintenanceInput): Promise<MaintenanceRecord> {
155-
return apiClient.request<MaintenanceRecord>(`/assets/${assetId}/maintenance`, {
156-
method: 'POST',
157-
body: JSON.stringify(data),
158-
});
134+
createMaintenanceRecord(
135+
assetId: string,
136+
data: CreateMaintenanceInput
137+
): Promise<MaintenanceRecord> {
138+
return apiClient
139+
.post<MaintenanceRecord>(`/assets/${assetId}/maintenance`, data)
140+
.then((res) => res.data);
159141
},
160142

161143
createNote(assetId: string, data: CreateNoteInput): Promise<AssetNote> {
162-
return apiClient.request<AssetNote>(`/assets/${assetId}/notes`, {
163-
method: 'POST',
164-
body: JSON.stringify(data),
165-
});
144+
return apiClient
145+
.post<AssetNote>(`/assets/${assetId}/notes`, data)
146+
.then((res) => res.data);
166147
},
167148
};

frontend/lib/api/client.ts

Lines changed: 107 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,125 @@
1+
import axios, { AxiosInstance, AxiosError } from 'axios';
12
import { RegisterInput, LoginInput, AuthResponse } from '@/lib/query/types';
23

3-
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
4+
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:6003/api';
45

5-
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
6-
const url = `${BASE_URL}${path}`;
7-
8-
const token =
9-
typeof window !== 'undefined' ? localStorage.getItem('token') : null;
6+
// Separate auth client without interceptors (prevents circular refresh loops)
7+
export const authApiClient: AxiosInstance = axios.create({
8+
baseURL: BASE_URL,
9+
headers: {
10+
'Content-Type': 'application/json',
11+
},
12+
});
1013

11-
const headers: Record<string, string> = {
14+
// Main API client with JWT interceptors
15+
const apiClient: AxiosInstance = axios.create({
16+
baseURL: BASE_URL,
17+
headers: {
1218
'Content-Type': 'application/json',
13-
...(options.headers as Record<string, string>),
14-
};
19+
},
20+
});
1521

16-
if (token) {
17-
headers['Authorization'] = `Bearer ${token}`;
18-
}
22+
// Track refresh attempts to avoid infinite loops
23+
let isRefreshing = false;
24+
let failedQueue: Array<{
25+
onSuccess: (token: string) => void;
26+
onError: (error: unknown) => void;
27+
}> = [];
1928

20-
const res = await fetch(url, { ...options, headers });
29+
const processQueue = (error: unknown, token: string | null = null) => {
30+
failedQueue.forEach((prom) => {
31+
if (error) {
32+
prom.onError(error);
33+
} else {
34+
prom.onSuccess(token || '');
35+
}
36+
});
2137

22-
if (!res.ok) {
23-
const error = await res.json().catch(() => ({ message: res.statusText }));
24-
throw { message: error.message ?? res.statusText, statusCode: res.status };
25-
}
38+
failedQueue = [];
39+
};
2640

27-
if (res.status === 204) {
28-
return undefined as T;
29-
}
41+
// Request interceptor: attach JWT token
42+
apiClient.interceptors.request.use(
43+
(config) => {
44+
if (typeof window !== 'undefined') {
45+
const token = localStorage.getItem('token');
46+
if (token) {
47+
config.headers.Authorization = `Bearer ${token}`;
48+
}
49+
}
50+
return config;
51+
},
52+
(error) => Promise.reject(error)
53+
);
54+
55+
// Response interceptor: handle 401 with refresh
56+
apiClient.interceptors.response.use(
57+
(response) => response,
58+
async (error: AxiosError) => {
59+
const originalRequest = error.config as any;
3060

31-
return res.json() as Promise<T>;
32-
}
61+
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
62+
if (isRefreshing) {
63+
// Queue request to retry after refresh completes
64+
return new Promise((onSuccess, onError) => {
65+
failedQueue.push({ onSuccess, onError });
66+
})
67+
.then((token) => {
68+
originalRequest.headers.Authorization = `Bearer ${token}`;
69+
return apiClient(originalRequest);
70+
})
71+
.catch((err) => Promise.reject(err));
72+
}
73+
74+
originalRequest._retry = true;
75+
isRefreshing = true;
76+
77+
try {
78+
const response = await authApiClient.post<AuthResponse>('/auth/refresh');
79+
const { accessToken } = response.data;
80+
81+
// Store new token
82+
if (typeof window !== 'undefined') {
83+
localStorage.setItem('token', accessToken);
84+
}
85+
86+
// Update authorization header and retry original request
87+
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
88+
processQueue(null, accessToken);
89+
90+
return apiClient(originalRequest);
91+
} catch (refreshError) {
92+
processQueue(refreshError, null);
93+
isRefreshing = false;
94+
95+
// Logout on refresh failure
96+
if (typeof window !== 'undefined') {
97+
const { useAuthStore } = await import('@/store/auth.store');
98+
useAuthStore.getState().logout();
99+
}
100+
101+
return Promise.reject(refreshError);
102+
} finally {
103+
isRefreshing = false;
104+
}
105+
}
106+
107+
return Promise.reject(error);
108+
}
109+
);
33110

34-
export const apiClient = {
35-
request,
111+
export default apiClient;
36112

113+
export const authApi = {
37114
register(data: RegisterInput): Promise<AuthResponse> {
38-
return request<AuthResponse>('/auth/register', {
39-
method: 'POST',
40-
body: JSON.stringify(data),
41-
});
115+
return authApiClient
116+
.post<AuthResponse>('/auth/register', data)
117+
.then((res) => res.data);
42118
},
43119

44120
login(data: LoginInput): Promise<AuthResponse> {
45-
return request<AuthResponse>('/auth/login', {
46-
method: 'POST',
47-
body: JSON.stringify(data),
48-
});
121+
return authApiClient
122+
.post<AuthResponse>('/auth/login', data)
123+
.then((res) => res.data);
49124
},
50125
};

frontend/lib/api/reportsApi.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import api from './client';
1+
import apiClient from './client';
22
import { ReportSummary } from '../users';
33

44
export async function getReportsSummary(): Promise<ReportSummary> {
5-
const res = await api.get('/api/reports/summary');
5+
const res = await apiClient.get('/reports/summary');
66
return res.data;
77
}

0 commit comments

Comments
 (0)