root 093fe85573 fix: frontend API mapping, security hardening, test suite
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
2026-03-20 23:15:07 +00:00

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;