Frontend:
- Fix all API response unwrapping (backend wraps in {data:...})
- Fix workflow publish/archive to use PATCH /status endpoint
- Fix step reorder payload format
- Add tenantId to workflow creation
- Fix step count display using _count
Security (CRITICAL/HIGH):
- Add JWT algorithm restriction (HS256 only)
- Replace weak default JWT secrets
- Fix token blacklist memory leak
- Fix step reorder IDOR vulnerability
- Strengthen password policy (8 chars, mixed case + digit)
Tests:
- 68 tests: auth (17), workflows (26), tenants (25)
- Complete Prisma mock setup with test fixtures
321 lines
10 KiB
TypeScript
321 lines
10 KiB
TypeScript
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
|
import type {
|
|
AuthResponse,
|
|
Category,
|
|
PaginatedResponse,
|
|
Tenant,
|
|
User,
|
|
Workflow,
|
|
WorkflowStep,
|
|
} from '../types';
|
|
|
|
const api = axios.create({
|
|
baseURL: '/api',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
let isRefreshing = false;
|
|
let failedQueue: Array<{
|
|
resolve: (token: string) => void;
|
|
reject: (err: unknown) => void;
|
|
}> = [];
|
|
|
|
function processQueue(error: unknown, token: string | null = null) {
|
|
failedQueue.forEach((p) => {
|
|
if (error) {
|
|
p.reject(error);
|
|
} else {
|
|
p.resolve(token!);
|
|
}
|
|
});
|
|
failedQueue = [];
|
|
}
|
|
|
|
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
|
const token = localStorage.getItem('accessToken');
|
|
if (token && config.headers) {
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
return config;
|
|
});
|
|
|
|
api.interceptors.response.use(
|
|
(response) => response,
|
|
async (error: AxiosError) => {
|
|
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
|
_retry?: boolean;
|
|
};
|
|
|
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
|
if (isRefreshing) {
|
|
return new Promise((resolve, reject) => {
|
|
failedQueue.push({
|
|
resolve: (token: string) => {
|
|
if (originalRequest.headers) {
|
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
resolve(api(originalRequest));
|
|
},
|
|
reject,
|
|
});
|
|
});
|
|
}
|
|
|
|
originalRequest._retry = true;
|
|
isRefreshing = true;
|
|
|
|
const refreshToken = localStorage.getItem('refreshToken');
|
|
if (!refreshToken) {
|
|
localStorage.removeItem('accessToken');
|
|
localStorage.removeItem('refreshToken');
|
|
window.location.href = '/login';
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
try {
|
|
const { data } = await axios.post<{ accessToken: string; refreshToken: string }>(
|
|
'/api/auth/refresh',
|
|
{ refreshToken },
|
|
);
|
|
localStorage.setItem('accessToken', data.accessToken);
|
|
localStorage.setItem('refreshToken', data.refreshToken);
|
|
processQueue(null, data.accessToken);
|
|
if (originalRequest.headers) {
|
|
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
|
}
|
|
return api(originalRequest);
|
|
} catch (refreshError) {
|
|
processQueue(refreshError, null);
|
|
localStorage.removeItem('accessToken');
|
|
localStorage.removeItem('refreshToken');
|
|
window.location.href = '/login';
|
|
return Promise.reject(refreshError);
|
|
} finally {
|
|
isRefreshing = false;
|
|
}
|
|
}
|
|
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
|
|
// --- Auth ---
|
|
// Login returns { accessToken, refreshToken, user } directly
|
|
// /me returns user object directly
|
|
// /refresh returns { accessToken, refreshToken } (no user)
|
|
export const authApi = {
|
|
login: (email: string, password: string) =>
|
|
api.post<AuthResponse>('/auth/login', { email, password }).then((r) => r.data),
|
|
|
|
refresh: (refreshToken: string) =>
|
|
api
|
|
.post<{ accessToken: string; refreshToken: string }>('/auth/refresh', { refreshToken })
|
|
.then((r) => r.data),
|
|
|
|
me: () => api.get<User>('/auth/me').then((r) => r.data),
|
|
|
|
logout: (refreshToken?: string | null) =>
|
|
api.post('/auth/logout', { refreshToken }).then((r) => r.data),
|
|
};
|
|
|
|
// --- Tenants ---
|
|
// Backend returns { data: Tenant[] } (no pagination)
|
|
export const tenantsApi = {
|
|
list: (params?: { page?: number; pageSize?: number; search?: string }) =>
|
|
api
|
|
.get<{ data: Tenant[] }>('/tenants', { params })
|
|
.then((r) => {
|
|
const tenants = r.data.data;
|
|
// Client-side search if the backend doesn't support it
|
|
let filtered = tenants;
|
|
if (params?.search) {
|
|
const s = params.search.toLowerCase();
|
|
filtered = tenants.filter(
|
|
(t) =>
|
|
t.name.toLowerCase().includes(s) ||
|
|
t.slug.toLowerCase().includes(s)
|
|
);
|
|
}
|
|
// Client-side pagination
|
|
const page = params?.page || 1;
|
|
const pageSize = params?.pageSize || 20;
|
|
const total = filtered.length;
|
|
const totalPages = Math.ceil(total / pageSize) || 1;
|
|
const start = (page - 1) * pageSize;
|
|
const data = filtered.slice(start, start + pageSize);
|
|
return { data, total, page, pageSize, totalPages } as PaginatedResponse<Tenant>;
|
|
}),
|
|
|
|
get: (id: string) =>
|
|
api.get<{ data: Tenant }>(`/tenants/${id}`).then((r) => r.data.data),
|
|
|
|
create: (data: Partial<Tenant>) =>
|
|
api.post<{ data: Tenant }>('/tenants', data).then((r) => r.data.data),
|
|
|
|
update: (id: string, data: Partial<Tenant>) =>
|
|
api.put<{ data: Tenant }>(`/tenants/${id}`, data).then((r) => r.data.data),
|
|
|
|
delete: (id: string) => api.delete(`/tenants/${id}`).then((r) => r.data),
|
|
};
|
|
|
|
// --- Users ---
|
|
// Backend returns { data: User[] } (no pagination)
|
|
export const usersApi = {
|
|
list: (params?: {
|
|
page?: number;
|
|
pageSize?: number;
|
|
search?: string;
|
|
tenantId?: string;
|
|
role?: string;
|
|
}) =>
|
|
api
|
|
.get<{ data: User[] }>('/users', {
|
|
params: {
|
|
tenantId: params?.tenantId,
|
|
},
|
|
})
|
|
.then((r) => {
|
|
let users = r.data.data;
|
|
// Client-side search
|
|
if (params?.search) {
|
|
const s = params.search.toLowerCase();
|
|
users = users.filter(
|
|
(u) =>
|
|
u.firstName.toLowerCase().includes(s) ||
|
|
u.lastName.toLowerCase().includes(s) ||
|
|
u.email.toLowerCase().includes(s)
|
|
);
|
|
}
|
|
// Client-side role filter
|
|
if (params?.role) {
|
|
users = users.filter((u) => u.role === params.role);
|
|
}
|
|
// Client-side pagination
|
|
const page = params?.page || 1;
|
|
const pageSize = params?.pageSize || 20;
|
|
const total = users.length;
|
|
const totalPages = Math.ceil(total / pageSize) || 1;
|
|
const start = (page - 1) * pageSize;
|
|
const data = users.slice(start, start + pageSize);
|
|
return { data, total, page, pageSize, totalPages } as PaginatedResponse<User>;
|
|
}),
|
|
|
|
get: (id: string) =>
|
|
api.get<{ data: User }>(`/users/${id}`).then((r) => r.data.data),
|
|
|
|
create: (data: Partial<User> & { password?: string }) =>
|
|
api.post<{ data: User }>('/users', data).then((r) => r.data.data),
|
|
|
|
update: (id: string, data: Partial<User>) =>
|
|
api.put<{ data: User }>(`/users/${id}`, data).then((r) => r.data.data),
|
|
|
|
delete: (id: string) => api.delete(`/users/${id}`).then((r) => r.data),
|
|
};
|
|
|
|
// --- Workflows ---
|
|
// Backend list returns { data: Workflow[], pagination: { page, limit, total, totalPages } }
|
|
// Backend uses "limit" query param (not "pageSize")
|
|
// Single-item endpoints return { data: Workflow }
|
|
export const workflowsApi = {
|
|
list: (params?: {
|
|
page?: number;
|
|
pageSize?: number;
|
|
search?: string;
|
|
status?: string;
|
|
categoryId?: string;
|
|
tenantId?: string;
|
|
isTemplate?: boolean;
|
|
tags?: string;
|
|
}) =>
|
|
api
|
|
.get<{
|
|
data: Workflow[];
|
|
pagination: { page: number; limit: number; total: number; totalPages: number };
|
|
}>('/workflows', {
|
|
params: {
|
|
page: params?.page,
|
|
limit: params?.pageSize,
|
|
search: params?.search,
|
|
status: params?.status,
|
|
categoryId: params?.categoryId,
|
|
tenantId: params?.tenantId,
|
|
isTemplate: params?.isTemplate,
|
|
tags: params?.tags,
|
|
},
|
|
})
|
|
.then((r) => ({
|
|
data: r.data.data,
|
|
total: r.data.pagination.total,
|
|
page: r.data.pagination.page,
|
|
pageSize: r.data.pagination.limit,
|
|
totalPages: r.data.pagination.totalPages,
|
|
})),
|
|
|
|
get: (id: string) =>
|
|
api.get<{ data: Workflow }>(`/workflows/${id}`).then((r) => r.data.data),
|
|
|
|
create: (data: Partial<Workflow>) =>
|
|
api.post<{ data: Workflow }>('/workflows', data).then((r) => r.data.data),
|
|
|
|
update: (id: string, data: Partial<Workflow>) =>
|
|
api.put<{ data: Workflow }>(`/workflows/${id}`, data).then((r) => r.data.data),
|
|
|
|
delete: (id: string) => api.delete(`/workflows/${id}`).then((r) => r.data),
|
|
|
|
duplicate: (id: string) =>
|
|
api.post<{ data: Workflow }>(`/workflows/${id}/duplicate`).then((r) => r.data.data),
|
|
|
|
// Publish and archive use the PATCH /:id/status endpoint
|
|
publish: (id: string) =>
|
|
api
|
|
.patch<{ data: Workflow }>(`/workflows/${id}/status`, { status: 'PUBLISHED' })
|
|
.then((r) => r.data.data),
|
|
|
|
archive: (id: string) =>
|
|
api
|
|
.patch<{ data: Workflow }>(`/workflows/${id}/status`, { status: 'ARCHIVED' })
|
|
.then((r) => r.data.data),
|
|
|
|
changeStatus: (id: string, status: Workflow['status']) =>
|
|
api
|
|
.patch<{ data: Workflow }>(`/workflows/${id}/status`, { status })
|
|
.then((r) => r.data.data),
|
|
};
|
|
|
|
// --- Workflow Steps ---
|
|
export const stepsApi = {
|
|
create: (workflowId: string, data: Partial<WorkflowStep>) =>
|
|
api
|
|
.post<{ data: WorkflowStep }>(`/workflows/${workflowId}/steps`, data)
|
|
.then((r) => r.data.data),
|
|
|
|
update: (workflowId: string, stepId: string, data: Partial<WorkflowStep>) =>
|
|
api
|
|
.put<{ data: WorkflowStep }>(`/workflows/${workflowId}/steps/${stepId}`, data)
|
|
.then((r) => r.data.data),
|
|
|
|
delete: (workflowId: string, stepId: string) =>
|
|
api.delete(`/workflows/${workflowId}/steps/${stepId}`).then((r) => r.data),
|
|
|
|
reorder: (workflowId: string, steps: { id: string; order: number }[]) =>
|
|
api
|
|
.patch(`/workflows/${workflowId}/steps/reorder`, { steps })
|
|
.then((r) => r.data),
|
|
};
|
|
|
|
// --- Categories ---
|
|
export const categoriesApi = {
|
|
list: () =>
|
|
api.get<{ data: Category[] }>('/categories').then((r) => r.data.data),
|
|
|
|
create: (data: Partial<Category>) =>
|
|
api.post<{ data: Category }>('/categories', data).then((r) => r.data.data),
|
|
|
|
update: (id: string, data: Partial<Category>) =>
|
|
api.put<{ data: Category }>(`/categories/${id}`, data).then((r) => r.data.data),
|
|
|
|
delete: (id: string) => api.delete(`/categories/${id}`).then((r) => r.data),
|
|
};
|
|
|
|
export default api;
|