Some checks failed
Deploy to Staging / Build Images (pull_request) Successful in 4m42s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 37s
Deploy to Staging / Verify Staging (pull_request) Failing after 6s
Deploy to Staging / Notify Staging Ready (pull_request) Has been skipped
Deploy to Staging / Notify Staging Failure (pull_request) Successful in 6s
- Add audit_logs table with categories, severities, and indexes - Create AuditLogService and AuditLogRepository - Add REST API endpoints for viewing and exporting logs - Wire audit logging into auth, vehicles, admin, and backup features - Add desktop AdminLogsPage with filters and CSV export - Add mobile AdminLogsMobileScreen with card layout - Implement 90-day retention cleanup job - Remove old AuditLogPanel from AdminCatalogPage Security fixes: - Escape LIKE special characters to prevent pattern injection - Limit CSV export to 5000 records to prevent memory exhaustion - Add truncation warning headers for large exports 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
546 lines
18 KiB
TypeScript
546 lines
18 KiB
TypeScript
/**
|
|
* @ai-summary API client functions for admin feature
|
|
* @ai-context Communicates with backend admin endpoints
|
|
*/
|
|
|
|
import { apiClient } from '../../../core/api/client';
|
|
import {
|
|
AdminAccessResponse,
|
|
AdminUser,
|
|
CreateAdminRequest,
|
|
AdminAuditLog,
|
|
CatalogMake,
|
|
CatalogModel,
|
|
CatalogYear,
|
|
CatalogTrim,
|
|
CatalogEngine,
|
|
CreateCatalogMakeRequest,
|
|
UpdateCatalogMakeRequest,
|
|
CreateCatalogModelRequest,
|
|
UpdateCatalogModelRequest,
|
|
CreateCatalogYearRequest,
|
|
UpdateCatalogYearRequest,
|
|
CreateCatalogTrimRequest,
|
|
UpdateCatalogTrimRequest,
|
|
CreateCatalogEngineRequest,
|
|
UpdateCatalogEngineRequest,
|
|
CatalogSearchResponse,
|
|
ImportPreviewResult,
|
|
ImportApplyResult,
|
|
CascadeDeleteResult,
|
|
EmailTemplate,
|
|
UpdateEmailTemplateRequest,
|
|
PreviewTemplateResponse,
|
|
// User management types
|
|
ManagedUser,
|
|
ListUsersResponse,
|
|
ListUsersParams,
|
|
UpdateUserTierRequest,
|
|
DeactivateUserRequest,
|
|
UpdateUserProfileRequest,
|
|
PromoteToAdminRequest,
|
|
// Backup types
|
|
BackupHistory,
|
|
BackupSchedule,
|
|
BackupSettings,
|
|
ListBackupsResponse,
|
|
CreateBackupRequest,
|
|
CreateScheduleRequest,
|
|
UpdateScheduleRequest,
|
|
RestorePreviewResponse,
|
|
ExecuteRestoreRequest,
|
|
// Unified Audit Log types
|
|
UnifiedAuditLogsResponse,
|
|
AuditLogFilters,
|
|
} from '../types/admin.types';
|
|
|
|
export interface AuditLogsResponse {
|
|
logs: AdminAuditLog[];
|
|
total: number;
|
|
}
|
|
|
|
// Admin stats response
|
|
export interface AdminStatsResponse {
|
|
totalVehicles: number;
|
|
totalUsers: number;
|
|
}
|
|
|
|
// User vehicle (admin view)
|
|
export interface AdminUserVehicle {
|
|
year: number;
|
|
make: string;
|
|
model: string;
|
|
}
|
|
|
|
export interface AdminUserVehiclesResponse {
|
|
vehicles: AdminUserVehicle[];
|
|
}
|
|
|
|
// Admin access verification
|
|
export const adminApi = {
|
|
// Verify admin access
|
|
verifyAccess: async (): Promise<AdminAccessResponse> => {
|
|
const response = await apiClient.get<AdminAccessResponse>('/admin/verify');
|
|
return response.data;
|
|
},
|
|
|
|
// Admin dashboard stats
|
|
getStats: async (): Promise<AdminStatsResponse> => {
|
|
const response = await apiClient.get<AdminStatsResponse>('/admin/stats');
|
|
return response.data;
|
|
},
|
|
|
|
// Admin management
|
|
listAdmins: async (): Promise<AdminUser[]> => {
|
|
const response = await apiClient.get<AdminUser[]>('/admin/admins');
|
|
return response.data;
|
|
},
|
|
|
|
createAdmin: async (data: CreateAdminRequest): Promise<AdminUser> => {
|
|
const response = await apiClient.post<AdminUser>('/admin/admins', data);
|
|
return response.data;
|
|
},
|
|
|
|
revokeAdmin: async (auth0Sub: string): Promise<void> => {
|
|
await apiClient.patch(`/admin/admins/${auth0Sub}/revoke`);
|
|
},
|
|
|
|
reinstateAdmin: async (auth0Sub: string): Promise<void> => {
|
|
await apiClient.patch(`/admin/admins/${auth0Sub}/reinstate`);
|
|
},
|
|
|
|
// Audit logs
|
|
listAuditLogs: async (): Promise<AuditLogsResponse> => {
|
|
const response = await apiClient.get<AuditLogsResponse>('/admin/audit-logs');
|
|
return response.data;
|
|
},
|
|
|
|
// Catalog - Makes
|
|
listMakes: async (): Promise<CatalogMake[]> => {
|
|
const response = await apiClient.get<{ makes: CatalogMake[] }>('/admin/catalog/makes');
|
|
return response.data.makes;
|
|
},
|
|
|
|
createMake: async (data: CreateCatalogMakeRequest): Promise<CatalogMake> => {
|
|
const response = await apiClient.post<CatalogMake>('/admin/catalog/makes', data);
|
|
return response.data;
|
|
},
|
|
|
|
updateMake: async (id: string, data: UpdateCatalogMakeRequest): Promise<CatalogMake> => {
|
|
const response = await apiClient.put<CatalogMake>(`/admin/catalog/makes/${id}`, data);
|
|
return response.data;
|
|
},
|
|
|
|
deleteMake: async (id: string): Promise<void> => {
|
|
await apiClient.delete(`/admin/catalog/makes/${id}`);
|
|
},
|
|
|
|
// Catalog - Models
|
|
listModels: async (makeId: string): Promise<CatalogModel[]> => {
|
|
const response = await apiClient.get<{ models: CatalogModel[] }>(
|
|
`/admin/catalog/makes/${makeId}/models`
|
|
);
|
|
return response.data.models;
|
|
},
|
|
|
|
createModel: async (data: CreateCatalogModelRequest): Promise<CatalogModel> => {
|
|
const response = await apiClient.post<CatalogModel>('/admin/catalog/models', data);
|
|
return response.data;
|
|
},
|
|
|
|
updateModel: async (id: string, data: UpdateCatalogModelRequest): Promise<CatalogModel> => {
|
|
const response = await apiClient.put<CatalogModel>(`/admin/catalog/models/${id}`, data);
|
|
return response.data;
|
|
},
|
|
|
|
deleteModel: async (id: string): Promise<void> => {
|
|
await apiClient.delete(`/admin/catalog/models/${id}`);
|
|
},
|
|
|
|
// Catalog - Years
|
|
listYears: async (modelId: string): Promise<CatalogYear[]> => {
|
|
const response = await apiClient.get<{ years: CatalogYear[] }>(
|
|
`/admin/catalog/models/${modelId}/years`
|
|
);
|
|
return response.data.years;
|
|
},
|
|
|
|
createYear: async (data: CreateCatalogYearRequest): Promise<CatalogYear> => {
|
|
const response = await apiClient.post<CatalogYear>('/admin/catalog/years', data);
|
|
return response.data;
|
|
},
|
|
|
|
updateYear: async (id: string, data: UpdateCatalogYearRequest): Promise<CatalogYear> => {
|
|
const response = await apiClient.put<CatalogYear>(`/admin/catalog/years/${id}`, data);
|
|
return response.data;
|
|
},
|
|
|
|
deleteYear: async (id: string): Promise<void> => {
|
|
await apiClient.delete(`/admin/catalog/years/${id}`);
|
|
},
|
|
|
|
// Catalog - Trims
|
|
listTrims: async (yearId: string): Promise<CatalogTrim[]> => {
|
|
const response = await apiClient.get<{ trims: CatalogTrim[] }>(
|
|
`/admin/catalog/years/${yearId}/trims`
|
|
);
|
|
return response.data.trims;
|
|
},
|
|
|
|
createTrim: async (data: CreateCatalogTrimRequest): Promise<CatalogTrim> => {
|
|
const response = await apiClient.post<CatalogTrim>('/admin/catalog/trims', data);
|
|
return response.data;
|
|
},
|
|
|
|
updateTrim: async (id: string, data: UpdateCatalogTrimRequest): Promise<CatalogTrim> => {
|
|
const response = await apiClient.put<CatalogTrim>(`/admin/catalog/trims/${id}`, data);
|
|
return response.data;
|
|
},
|
|
|
|
deleteTrim: async (id: string): Promise<void> => {
|
|
await apiClient.delete(`/admin/catalog/trims/${id}`);
|
|
},
|
|
|
|
// Catalog - Engines
|
|
listEngines: async (trimId: string): Promise<CatalogEngine[]> => {
|
|
const response = await apiClient.get<{ engines: CatalogEngine[] }>(
|
|
`/admin/catalog/trims/${trimId}/engines`
|
|
);
|
|
return response.data.engines;
|
|
},
|
|
|
|
createEngine: async (data: CreateCatalogEngineRequest): Promise<CatalogEngine> => {
|
|
const response = await apiClient.post<CatalogEngine>('/admin/catalog/engines', data);
|
|
return response.data;
|
|
},
|
|
|
|
updateEngine: async (id: string, data: UpdateCatalogEngineRequest): Promise<CatalogEngine> => {
|
|
const response = await apiClient.put<CatalogEngine>(`/admin/catalog/engines/${id}`, data);
|
|
return response.data;
|
|
},
|
|
|
|
deleteEngine: async (id: string): Promise<void> => {
|
|
await apiClient.delete(`/admin/catalog/engines/${id}`);
|
|
},
|
|
|
|
// Catalog Search
|
|
searchCatalog: async (
|
|
query: string,
|
|
page: number = 1,
|
|
pageSize: number = 50
|
|
): Promise<CatalogSearchResponse> => {
|
|
const response = await apiClient.get<CatalogSearchResponse>('/admin/catalog/search', {
|
|
params: { q: query, page, pageSize },
|
|
});
|
|
return response.data;
|
|
},
|
|
|
|
// Catalog Import/Export
|
|
importPreview: async (file: File): Promise<ImportPreviewResult> => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
const response = await apiClient.post<ImportPreviewResult>(
|
|
'/admin/catalog/import/preview',
|
|
formData,
|
|
{
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
}
|
|
);
|
|
return response.data;
|
|
},
|
|
|
|
importApply: async (previewId: string): Promise<ImportApplyResult> => {
|
|
const response = await apiClient.post<ImportApplyResult>('/admin/catalog/import/apply', {
|
|
previewId,
|
|
});
|
|
return response.data;
|
|
},
|
|
|
|
exportCatalog: async (): Promise<Blob> => {
|
|
const response = await apiClient.get('/admin/catalog/export', {
|
|
responseType: 'blob',
|
|
});
|
|
return response.data;
|
|
},
|
|
|
|
// Cascade Delete
|
|
deleteMakeCascade: async (id: string): Promise<CascadeDeleteResult> => {
|
|
const response = await apiClient.delete<CascadeDeleteResult>(
|
|
`/admin/catalog/makes/${id}/cascade`
|
|
);
|
|
return response.data;
|
|
},
|
|
|
|
deleteModelCascade: async (id: string): Promise<CascadeDeleteResult> => {
|
|
const response = await apiClient.delete<CascadeDeleteResult>(
|
|
`/admin/catalog/models/${id}/cascade`
|
|
);
|
|
return response.data;
|
|
},
|
|
|
|
deleteYearCascade: async (id: string): Promise<CascadeDeleteResult> => {
|
|
const response = await apiClient.delete<CascadeDeleteResult>(
|
|
`/admin/catalog/years/${id}/cascade`
|
|
);
|
|
return response.data;
|
|
},
|
|
|
|
deleteTrimCascade: async (id: string): Promise<CascadeDeleteResult> => {
|
|
const response = await apiClient.delete<CascadeDeleteResult>(
|
|
`/admin/catalog/trims/${id}/cascade`
|
|
);
|
|
return response.data;
|
|
},
|
|
|
|
// Email Templates
|
|
emailTemplates: {
|
|
list: async (): Promise<EmailTemplate[]> => {
|
|
const response = await apiClient.get<EmailTemplate[]>('/admin/email-templates');
|
|
return response.data;
|
|
},
|
|
get: async (key: string): Promise<EmailTemplate> => {
|
|
const response = await apiClient.get<EmailTemplate>(`/admin/email-templates/${key}`);
|
|
return response.data;
|
|
},
|
|
update: async (key: string, data: UpdateEmailTemplateRequest): Promise<EmailTemplate> => {
|
|
const response = await apiClient.put<EmailTemplate>(`/admin/email-templates/${key}`, data);
|
|
return response.data;
|
|
},
|
|
preview: async (key: string, variables: Record<string, string>): Promise<PreviewTemplateResponse> => {
|
|
const response = await apiClient.post<PreviewTemplateResponse>(
|
|
`/admin/email-templates/${key}/preview`,
|
|
{ variables }
|
|
);
|
|
return response.data;
|
|
},
|
|
sendTest: async (key: string): Promise<{ message?: string; error?: string }> => {
|
|
const response = await apiClient.post<{ message?: string; error?: string }>(
|
|
`/admin/email-templates/${key}/test`
|
|
);
|
|
return response.data;
|
|
},
|
|
},
|
|
|
|
// User Management
|
|
users: {
|
|
list: async (params: ListUsersParams = {}): Promise<ListUsersResponse> => {
|
|
const response = await apiClient.get<ListUsersResponse>('/admin/users', { params });
|
|
return response.data;
|
|
},
|
|
|
|
get: async (auth0Sub: string): Promise<ManagedUser> => {
|
|
const response = await apiClient.get<ManagedUser>(
|
|
`/admin/users/${encodeURIComponent(auth0Sub)}`
|
|
);
|
|
return response.data;
|
|
},
|
|
|
|
getVehicles: async (auth0Sub: string): Promise<AdminUserVehiclesResponse> => {
|
|
const response = await apiClient.get<AdminUserVehiclesResponse>(
|
|
`/admin/users/${encodeURIComponent(auth0Sub)}/vehicles`
|
|
);
|
|
return response.data;
|
|
},
|
|
|
|
updateTier: async (auth0Sub: string, data: UpdateUserTierRequest): Promise<ManagedUser> => {
|
|
const response = await apiClient.patch<ManagedUser>(
|
|
`/admin/users/${encodeURIComponent(auth0Sub)}/tier`,
|
|
data
|
|
);
|
|
return response.data;
|
|
},
|
|
|
|
deactivate: async (auth0Sub: string, data?: DeactivateUserRequest): Promise<ManagedUser> => {
|
|
const response = await apiClient.patch<ManagedUser>(
|
|
`/admin/users/${encodeURIComponent(auth0Sub)}/deactivate`,
|
|
data || {}
|
|
);
|
|
return response.data;
|
|
},
|
|
|
|
reactivate: async (auth0Sub: string): Promise<ManagedUser> => {
|
|
const response = await apiClient.patch<ManagedUser>(
|
|
`/admin/users/${encodeURIComponent(auth0Sub)}/reactivate`
|
|
);
|
|
return response.data;
|
|
},
|
|
|
|
updateProfile: async (auth0Sub: string, data: UpdateUserProfileRequest): Promise<ManagedUser> => {
|
|
const response = await apiClient.patch<ManagedUser>(
|
|
`/admin/users/${encodeURIComponent(auth0Sub)}/profile`,
|
|
data
|
|
);
|
|
return response.data;
|
|
},
|
|
|
|
promoteToAdmin: async (auth0Sub: string, data?: PromoteToAdminRequest): Promise<AdminUser> => {
|
|
const response = await apiClient.patch<AdminUser>(
|
|
`/admin/users/${encodeURIComponent(auth0Sub)}/promote`,
|
|
data || {}
|
|
);
|
|
return response.data;
|
|
},
|
|
|
|
hardDelete: async (auth0Sub: string, reason?: string): Promise<{ message: string }> => {
|
|
const response = await apiClient.delete<{ message: string }>(
|
|
`/admin/users/${encodeURIComponent(auth0Sub)}`,
|
|
{ params: reason ? { reason } : undefined }
|
|
);
|
|
return response.data;
|
|
},
|
|
},
|
|
|
|
// Backup & Restore
|
|
backups: {
|
|
// List backups with pagination
|
|
list: async (params: { page?: number; pageSize?: number } = {}): Promise<ListBackupsResponse> => {
|
|
const response = await apiClient.get<ListBackupsResponse>('/admin/backups', { params });
|
|
return response.data;
|
|
},
|
|
|
|
// Get backup details
|
|
get: async (id: string): Promise<BackupHistory> => {
|
|
const response = await apiClient.get<BackupHistory>(`/admin/backups/${id}`);
|
|
return response.data;
|
|
},
|
|
|
|
// Create manual backup
|
|
create: async (data: CreateBackupRequest = {}): Promise<BackupHistory> => {
|
|
const response = await apiClient.post<BackupHistory>('/admin/backups', data);
|
|
return response.data;
|
|
},
|
|
|
|
// Download backup file
|
|
download: async (id: string): Promise<Blob> => {
|
|
const response = await apiClient.get(`/admin/backups/${id}/download`, {
|
|
responseType: 'blob',
|
|
});
|
|
return response.data;
|
|
},
|
|
|
|
// Delete backup
|
|
delete: async (id: string): Promise<void> => {
|
|
await apiClient.delete(`/admin/backups/${id}`);
|
|
},
|
|
|
|
// Upload backup file
|
|
upload: async (file: File): Promise<BackupHistory> => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
const response = await apiClient.post<BackupHistory>('/admin/backups/upload', formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
});
|
|
return response.data;
|
|
},
|
|
|
|
// Preview restore
|
|
previewRestore: async (id: string): Promise<RestorePreviewResponse> => {
|
|
const response = await apiClient.post<RestorePreviewResponse>(
|
|
`/admin/backups/${id}/restore/preview`
|
|
);
|
|
return response.data;
|
|
},
|
|
|
|
// Execute restore (longer timeout since this is a long-running operation)
|
|
restore: async (id: string, options?: ExecuteRestoreRequest): Promise<{ message: string }> => {
|
|
try {
|
|
const response = await apiClient.post<{ message: string }>(
|
|
`/admin/backups/${id}/restore`,
|
|
options,
|
|
{ timeout: 120000 } // 2 minute timeout for restore operations
|
|
);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
// If a restore is already in progress, treat it as success since it will complete
|
|
const errorMessage = error?.response?.data?.error || error?.message || '';
|
|
if (errorMessage.includes('already in progress')) {
|
|
return { message: 'Restore is in progress and will complete shortly' };
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
// Schedules
|
|
schedules: {
|
|
list: async (): Promise<BackupSchedule[]> => {
|
|
const response = await apiClient.get<BackupSchedule[]>('/admin/backups/schedules');
|
|
return response.data;
|
|
},
|
|
|
|
get: async (id: string): Promise<BackupSchedule> => {
|
|
const response = await apiClient.get<BackupSchedule>(`/admin/backups/schedules/${id}`);
|
|
return response.data;
|
|
},
|
|
|
|
create: async (data: CreateScheduleRequest): Promise<BackupSchedule> => {
|
|
const response = await apiClient.post<BackupSchedule>('/admin/backups/schedules', data);
|
|
return response.data;
|
|
},
|
|
|
|
update: async (id: string, data: UpdateScheduleRequest): Promise<BackupSchedule> => {
|
|
const response = await apiClient.put<BackupSchedule>(
|
|
`/admin/backups/schedules/${id}`,
|
|
data
|
|
);
|
|
return response.data;
|
|
},
|
|
|
|
delete: async (id: string): Promise<void> => {
|
|
await apiClient.delete(`/admin/backups/schedules/${id}`);
|
|
},
|
|
|
|
toggle: async (id: string): Promise<BackupSchedule> => {
|
|
const response = await apiClient.patch<BackupSchedule>(
|
|
`/admin/backups/schedules/${id}/toggle`
|
|
);
|
|
return response.data;
|
|
},
|
|
},
|
|
|
|
// Settings
|
|
settings: {
|
|
get: async (): Promise<BackupSettings> => {
|
|
const response = await apiClient.get<BackupSettings>('/admin/backups/settings');
|
|
return response.data;
|
|
},
|
|
|
|
update: async (data: Partial<BackupSettings>): Promise<BackupSettings> => {
|
|
const response = await apiClient.put<BackupSettings>('/admin/backups/settings', data);
|
|
return response.data;
|
|
},
|
|
},
|
|
},
|
|
|
|
// Unified Audit Logs (new centralized audit system)
|
|
unifiedAuditLogs: {
|
|
list: async (filters: AuditLogFilters = {}): Promise<UnifiedAuditLogsResponse> => {
|
|
const params: Record<string, string | number> = {};
|
|
if (filters.search) params.search = filters.search;
|
|
if (filters.category) params.category = filters.category;
|
|
if (filters.severity) params.severity = filters.severity;
|
|
if (filters.startDate) params.startDate = filters.startDate;
|
|
if (filters.endDate) params.endDate = filters.endDate;
|
|
if (filters.limit) params.limit = filters.limit;
|
|
if (filters.offset) params.offset = filters.offset;
|
|
|
|
const response = await apiClient.get<UnifiedAuditLogsResponse>('/admin/audit-logs', { params });
|
|
return response.data;
|
|
},
|
|
|
|
export: async (filters: AuditLogFilters = {}): Promise<Blob> => {
|
|
const params: Record<string, string | number> = {};
|
|
if (filters.search) params.search = filters.search;
|
|
if (filters.category) params.category = filters.category;
|
|
if (filters.severity) params.severity = filters.severity;
|
|
if (filters.startDate) params.startDate = filters.startDate;
|
|
if (filters.endDate) params.endDate = filters.endDate;
|
|
|
|
const response = await apiClient.get('/admin/audit-logs/export', {
|
|
params,
|
|
responseType: 'blob',
|
|
});
|
|
return response.data;
|
|
},
|
|
},
|
|
};
|