Admin User v1
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @ai-summary Tests for AdminUsersPage component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AdminUsersPage } from '../../../pages/admin/AdminUsersPage';
|
||||
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
||||
|
||||
jest.mock('../../../core/auth/useAdminAccess');
|
||||
|
||||
const mockUseAdminAccess = useAdminAccess as jest.MockedFunction<typeof useAdminAccess>;
|
||||
|
||||
const renderWithRouter = (component: React.ReactElement) => {
|
||||
return render(<BrowserRouter>{component}</BrowserRouter>);
|
||||
};
|
||||
|
||||
describe('AdminUsersPage', () => {
|
||||
it('should show loading state', () => {
|
||||
mockUseAdminAccess.mockReturnValue({
|
||||
isAdmin: false,
|
||||
adminRecord: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithRouter(<AdminUsersPage />);
|
||||
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should redirect non-admin users', () => {
|
||||
mockUseAdminAccess.mockReturnValue({
|
||||
isAdmin: false,
|
||||
adminRecord: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithRouter(<AdminUsersPage />);
|
||||
|
||||
// Component redirects, so we won't see the page content
|
||||
expect(screen.queryByText('User Management')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render page for admin users', () => {
|
||||
mockUseAdminAccess.mockReturnValue({
|
||||
isAdmin: true,
|
||||
adminRecord: {
|
||||
auth0Sub: 'auth0|123',
|
||||
email: 'admin@example.com',
|
||||
role: 'admin',
|
||||
createdAt: '2024-01-01',
|
||||
createdBy: 'system',
|
||||
revokedAt: null,
|
||||
updatedAt: '2024-01-01',
|
||||
},
|
||||
loading: false,
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithRouter(<AdminUsersPage />);
|
||||
|
||||
expect(screen.getByText('User Management')).toBeInTheDocument();
|
||||
expect(screen.getByText('Admin Users')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
133
frontend/src/features/admin/__tests__/useAdminAccess.test.ts
Normal file
133
frontend/src/features/admin/__tests__/useAdminAccess.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @ai-summary Tests for useAdminAccess hook
|
||||
*/
|
||||
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
||||
import { adminApi } from '../api/admin.api';
|
||||
|
||||
jest.mock('@auth0/auth0-react');
|
||||
jest.mock('../api/admin.api');
|
||||
|
||||
const mockUseAuth0 = useAuth0 as jest.MockedFunction<typeof useAuth0>;
|
||||
const mockAdminApi = adminApi as jest.Mocked<typeof adminApi>;
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('useAdminAccess', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return loading state initially', () => {
|
||||
mockUseAuth0.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useAdminAccess(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.isAdmin).toBe(false);
|
||||
});
|
||||
|
||||
it('should return isAdmin true when user is admin', async () => {
|
||||
mockUseAuth0.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
mockAdminApi.verifyAccess.mockResolvedValue({
|
||||
isAdmin: true,
|
||||
adminRecord: {
|
||||
auth0Sub: 'auth0|123',
|
||||
email: 'admin@example.com',
|
||||
role: 'admin',
|
||||
createdAt: '2024-01-01',
|
||||
createdBy: 'system',
|
||||
revokedAt: null,
|
||||
updatedAt: '2024-01-01',
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAdminAccess(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.isAdmin).toBe(true);
|
||||
expect(result.current.adminRecord).toBeTruthy();
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return isAdmin false when user is not admin', async () => {
|
||||
mockUseAuth0.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
mockAdminApi.verifyAccess.mockResolvedValue({
|
||||
isAdmin: false,
|
||||
adminRecord: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAdminAccess(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.isAdmin).toBe(false);
|
||||
expect(result.current.adminRecord).toBeNull();
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockUseAuth0.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const error = new Error('API error');
|
||||
mockAdminApi.verifyAccess.mockRejectedValue(error);
|
||||
|
||||
const { result } = renderHook(() => useAdminAccess(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.isAdmin).toBe(false);
|
||||
expect(result.current.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not query when user is not authenticated', () => {
|
||||
mockUseAuth0.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useAdminAccess(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(mockAdminApi.verifyAccess).not.toHaveBeenCalled();
|
||||
expect(result.current.isAdmin).toBe(false);
|
||||
});
|
||||
});
|
||||
159
frontend/src/features/admin/__tests__/useAdmins.test.ts
Normal file
159
frontend/src/features/admin/__tests__/useAdmins.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* @ai-summary Tests for admin user management hooks
|
||||
*/
|
||||
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { useAdmins, useCreateAdmin, useRevokeAdmin, useReinstateAdmin } from '../hooks/useAdmins';
|
||||
import { adminApi } from '../api/admin.api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
jest.mock('@auth0/auth0-react');
|
||||
jest.mock('../api/admin.api');
|
||||
jest.mock('react-hot-toast');
|
||||
|
||||
const mockUseAuth0 = useAuth0 as jest.MockedFunction<typeof useAuth0>;
|
||||
const mockAdminApi = adminApi as jest.Mocked<typeof adminApi>;
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Admin user management hooks', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseAuth0.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
});
|
||||
|
||||
describe('useAdmins', () => {
|
||||
it('should fetch admin users', async () => {
|
||||
const mockAdmins = [
|
||||
{
|
||||
auth0Sub: 'auth0|123',
|
||||
email: 'admin1@example.com',
|
||||
role: 'admin',
|
||||
createdAt: '2024-01-01',
|
||||
createdBy: 'system',
|
||||
revokedAt: null,
|
||||
updatedAt: '2024-01-01',
|
||||
},
|
||||
];
|
||||
|
||||
mockAdminApi.listAdmins.mockResolvedValue(mockAdmins);
|
||||
|
||||
const { result } = renderHook(() => useAdmins(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(result.current.data).toEqual(mockAdmins);
|
||||
expect(mockAdminApi.listAdmins).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateAdmin', () => {
|
||||
it('should create admin and show success toast', async () => {
|
||||
const newAdmin = {
|
||||
auth0Sub: 'auth0|456',
|
||||
email: 'newadmin@example.com',
|
||||
role: 'admin',
|
||||
createdAt: '2024-01-01',
|
||||
createdBy: 'auth0|123',
|
||||
revokedAt: null,
|
||||
updatedAt: '2024-01-01',
|
||||
};
|
||||
|
||||
mockAdminApi.createAdmin.mockResolvedValue(newAdmin);
|
||||
|
||||
const { result } = renderHook(() => useCreateAdmin(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
email: 'newadmin@example.com',
|
||||
role: 'admin',
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(mockAdminApi.createAdmin).toHaveBeenCalledWith({
|
||||
email: 'newadmin@example.com',
|
||||
role: 'admin',
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Admin added successfully');
|
||||
});
|
||||
|
||||
it('should handle create admin error', async () => {
|
||||
const error = {
|
||||
response: {
|
||||
data: {
|
||||
error: 'Admin already exists',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockAdminApi.createAdmin.mockRejectedValue(error);
|
||||
|
||||
const { result } = renderHook(() => useCreateAdmin(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
email: 'newadmin@example.com',
|
||||
role: 'admin',
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Admin already exists');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useRevokeAdmin', () => {
|
||||
it('should revoke admin and show success toast', async () => {
|
||||
mockAdminApi.revokeAdmin.mockResolvedValue();
|
||||
|
||||
const { result } = renderHook(() => useRevokeAdmin(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate('auth0|123');
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(mockAdminApi.revokeAdmin).toHaveBeenCalledWith('auth0|123');
|
||||
expect(toast.success).toHaveBeenCalledWith('Admin revoked successfully');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useReinstateAdmin', () => {
|
||||
it('should reinstate admin and show success toast', async () => {
|
||||
mockAdminApi.reinstateAdmin.mockResolvedValue();
|
||||
|
||||
const { result } = renderHook(() => useReinstateAdmin(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate('auth0|123');
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(mockAdminApi.reinstateAdmin).toHaveBeenCalledWith('auth0|123');
|
||||
expect(toast.success).toHaveBeenCalledWith('Admin reinstated successfully');
|
||||
});
|
||||
});
|
||||
});
|
||||
182
frontend/src/features/admin/api/admin.api.ts
Normal file
182
frontend/src/features/admin/api/admin.api.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @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,
|
||||
CreateCatalogTrimRequest,
|
||||
UpdateCatalogTrimRequest,
|
||||
CreateCatalogEngineRequest,
|
||||
UpdateCatalogEngineRequest,
|
||||
StationOverview,
|
||||
CreateStationRequest,
|
||||
UpdateStationRequest,
|
||||
} from '../types/admin.types';
|
||||
|
||||
// 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 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<AdminAuditLog[]> => {
|
||||
const response = await apiClient.get<AdminAuditLog[]>('/admin/audit-logs');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Catalog - Makes
|
||||
listMakes: async (): Promise<CatalogMake[]> => {
|
||||
const response = await apiClient.get<CatalogMake[]>('/admin/catalog/makes');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
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 url = makeId ? `/admin/catalog/models?make_id=${makeId}` : '/admin/catalog/models';
|
||||
const response = await apiClient.get<CatalogModel[]>(url);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
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 url = modelId ? `/admin/catalog/years?model_id=${modelId}` : '/admin/catalog/years';
|
||||
const response = await apiClient.get<CatalogYear[]>(url);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createYear: async (data: CreateCatalogYearRequest): Promise<CatalogYear> => {
|
||||
const response = await apiClient.post<CatalogYear>('/admin/catalog/years', 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 url = yearId ? `/admin/catalog/trims?year_id=${yearId}` : '/admin/catalog/trims';
|
||||
const response = await apiClient.get<CatalogTrim[]>(url);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
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 url = trimId ? `/admin/catalog/engines?trim_id=${trimId}` : '/admin/catalog/engines';
|
||||
const response = await apiClient.get<CatalogEngine[]>(url);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
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}`);
|
||||
},
|
||||
|
||||
// Stations
|
||||
listStations: async (): Promise<StationOverview[]> => {
|
||||
const response = await apiClient.get<StationOverview[]>('/admin/stations');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createStation: async (data: CreateStationRequest): Promise<StationOverview> => {
|
||||
const response = await apiClient.post<StationOverview>('/admin/stations', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateStation: async (id: string, data: UpdateStationRequest): Promise<StationOverview> => {
|
||||
const response = await apiClient.put<StationOverview>(`/admin/stations/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteStation: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/admin/stations/${id}`);
|
||||
},
|
||||
};
|
||||
92
frontend/src/features/admin/hooks/useAdmins.ts
Normal file
92
frontend/src/features/admin/hooks/useAdmins.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @ai-summary React Query hooks for admin user management
|
||||
* @ai-context List, create, revoke, and reinstate admins
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { adminApi } from '../api/admin.api';
|
||||
import { CreateAdminRequest } from '../types/admin.types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface ApiError {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const useAdmins = () => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['admins'],
|
||||
queryFn: () => adminApi.listAdmins(),
|
||||
enabled: isAuthenticated && !isLoading,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes cache time
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateAdmin = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateAdminRequest) => adminApi.createAdmin(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admins'] });
|
||||
toast.success('Admin added successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to add admin');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRevokeAdmin = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (auth0Sub: string) => adminApi.revokeAdmin(auth0Sub),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admins'] });
|
||||
toast.success('Admin revoked successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to revoke admin');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useReinstateAdmin = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (auth0Sub: string) => adminApi.reinstateAdmin(auth0Sub),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admins'] });
|
||||
toast.success('Admin reinstated successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to reinstate admin');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useAuditLogs = () => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['auditLogs'],
|
||||
queryFn: () => adminApi.listAuditLogs(),
|
||||
enabled: isAuthenticated && !isLoading,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes - audit logs should be relatively fresh
|
||||
gcTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
};
|
||||
318
frontend/src/features/admin/hooks/useCatalog.ts
Normal file
318
frontend/src/features/admin/hooks/useCatalog.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* @ai-summary React Query hooks for platform catalog management
|
||||
* @ai-context CRUD operations for makes, models, years, trims, engines
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { adminApi } from '../api/admin.api';
|
||||
import {
|
||||
CreateCatalogMakeRequest,
|
||||
UpdateCatalogMakeRequest,
|
||||
CreateCatalogModelRequest,
|
||||
UpdateCatalogModelRequest,
|
||||
CreateCatalogYearRequest,
|
||||
CreateCatalogTrimRequest,
|
||||
UpdateCatalogTrimRequest,
|
||||
CreateCatalogEngineRequest,
|
||||
UpdateCatalogEngineRequest,
|
||||
} from '../types/admin.types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface ApiError {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Makes
|
||||
export const useMakes = () => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['catalogMakes'],
|
||||
queryFn: () => adminApi.listMakes(),
|
||||
enabled: isAuthenticated && !isLoading,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes - catalog data changes infrequently
|
||||
gcTime: 30 * 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateMake = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateCatalogMakeRequest) => adminApi.createMake(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogMakes'] });
|
||||
toast.success('Make created successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to create make');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateMake = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateCatalogMakeRequest }) =>
|
||||
adminApi.updateMake(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogMakes'] });
|
||||
toast.success('Make updated successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to update make');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteMake = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => adminApi.deleteMake(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogMakes'] });
|
||||
toast.success('Make deleted successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to delete make');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Models
|
||||
export const useModels = (makeId?: string) => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['catalogModels', makeId],
|
||||
queryFn: () => adminApi.listModels(makeId),
|
||||
enabled: isAuthenticated && !isLoading,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateModel = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateCatalogModelRequest) => adminApi.createModel(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogModels'] });
|
||||
toast.success('Model created successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to create model');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateModel = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateCatalogModelRequest }) =>
|
||||
adminApi.updateModel(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogModels'] });
|
||||
toast.success('Model updated successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to update model');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteModel = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => adminApi.deleteModel(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogModels'] });
|
||||
toast.success('Model deleted successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to delete model');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Years
|
||||
export const useYears = (modelId?: string) => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['catalogYears', modelId],
|
||||
queryFn: () => adminApi.listYears(modelId),
|
||||
enabled: isAuthenticated && !isLoading,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateYear = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateCatalogYearRequest) => adminApi.createYear(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogYears'] });
|
||||
toast.success('Year created successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to create year');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteYear = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => adminApi.deleteYear(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogYears'] });
|
||||
toast.success('Year deleted successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to delete year');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Trims
|
||||
export const useTrims = (yearId?: string) => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['catalogTrims', yearId],
|
||||
queryFn: () => adminApi.listTrims(yearId),
|
||||
enabled: isAuthenticated && !isLoading,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateTrim = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateCatalogTrimRequest) => adminApi.createTrim(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogTrims'] });
|
||||
toast.success('Trim created successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to create trim');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateTrim = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateCatalogTrimRequest }) =>
|
||||
adminApi.updateTrim(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogTrims'] });
|
||||
toast.success('Trim updated successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to update trim');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteTrim = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => adminApi.deleteTrim(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogTrims'] });
|
||||
toast.success('Trim deleted successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to delete trim');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Engines
|
||||
export const useEngines = (trimId?: string) => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['catalogEngines', trimId],
|
||||
queryFn: () => adminApi.listEngines(trimId),
|
||||
enabled: isAuthenticated && !isLoading,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateEngine = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateCatalogEngineRequest) => adminApi.createEngine(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogEngines'] });
|
||||
toast.success('Engine created successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to create engine');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateEngine = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateCatalogEngineRequest }) =>
|
||||
adminApi.updateEngine(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogEngines'] });
|
||||
toast.success('Engine updated successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to update engine');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteEngine = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => adminApi.deleteEngine(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogEngines'] });
|
||||
toast.success('Engine deleted successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to delete engine');
|
||||
},
|
||||
});
|
||||
};
|
||||
79
frontend/src/features/admin/hooks/useStationOverview.ts
Normal file
79
frontend/src/features/admin/hooks/useStationOverview.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @ai-summary React Query hooks for station overview (admin)
|
||||
* @ai-context CRUD operations for global station management
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { adminApi } from '../api/admin.api';
|
||||
import { CreateStationRequest, UpdateStationRequest } from '../types/admin.types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface ApiError {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const useStationOverview = () => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['adminStations'],
|
||||
queryFn: () => adminApi.listStations(),
|
||||
enabled: isAuthenticated && !isLoading,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateStation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateStationRequest) => adminApi.createStation(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['adminStations'] });
|
||||
toast.success('Station created successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to create station');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateStation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateStationRequest }) =>
|
||||
adminApi.updateStation(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['adminStations'] });
|
||||
toast.success('Station updated successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to update station');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteStation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => adminApi.deleteStation(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['adminStations'] });
|
||||
toast.success('Station deleted successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to delete station');
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @ai-summary Mobile admin screen for vehicle catalog management
|
||||
* @ai-context CRUD operations for makes, models, years, trims, engines with mobile UI
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
||||
|
||||
export const AdminCatalogMobileScreen: React.FC = () => {
|
||||
const { isAdmin, loading } = useAdminAccess();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="text-slate-500 mb-2">Loading admin access...</div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/garage/settings" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-20 p-4">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Vehicle Catalog</h1>
|
||||
<p className="text-slate-500 mt-2">Manage platform vehicle data</p>
|
||||
</div>
|
||||
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Platform Catalog</h2>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Vehicle catalog management interface coming soon.
|
||||
</p>
|
||||
<div className="space-y-2 text-sm text-slate-600">
|
||||
<p className="font-semibold">Features:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Manage vehicle makes</li>
|
||||
<li>Manage vehicle models</li>
|
||||
<li>Manage model years</li>
|
||||
<li>Manage trims</li>
|
||||
<li>Manage engine specifications</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @ai-summary Mobile admin screen for gas station management
|
||||
* @ai-context CRUD operations for global station data with mobile UI
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
||||
|
||||
export const AdminStationsMobileScreen: React.FC = () => {
|
||||
const { isAdmin, loading } = useAdminAccess();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="text-slate-500 mb-2">Loading admin access...</div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/garage/settings" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-20 p-4">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Station Management</h1>
|
||||
<p className="text-slate-500 mt-2">Manage gas station data</p>
|
||||
</div>
|
||||
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Gas Stations</h2>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Station management interface coming soon.
|
||||
</p>
|
||||
<div className="space-y-2 text-sm text-slate-600">
|
||||
<p className="font-semibold">Features:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>View all gas stations</li>
|
||||
<li>Create new stations</li>
|
||||
<li>Update station information</li>
|
||||
<li>Delete stations</li>
|
||||
<li>View station usage statistics</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @ai-summary Mobile admin screen for user management
|
||||
* @ai-context Manage admin users with mobile-optimized interface
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
||||
|
||||
export const AdminUsersMobileScreen: React.FC = () => {
|
||||
const { isAdmin, loading } = useAdminAccess();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="text-slate-500 mb-2">Loading admin access...</div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/garage/settings" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-20 p-4">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-800">User Management</h1>
|
||||
<p className="text-slate-500 mt-2">Manage admin users and permissions</p>
|
||||
</div>
|
||||
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Admin Users</h2>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Admin user management interface coming soon.
|
||||
</p>
|
||||
<div className="space-y-2 text-sm text-slate-600">
|
||||
<p className="font-semibold">Features:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>List all admin users</li>
|
||||
<li>Add new admin users</li>
|
||||
<li>Revoke admin access</li>
|
||||
<li>Reinstate revoked admins</li>
|
||||
<li>View audit logs</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
155
frontend/src/features/admin/types/admin.types.ts
Normal file
155
frontend/src/features/admin/types/admin.types.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @ai-summary TypeScript types for admin feature
|
||||
* @ai-context Mirrors backend admin types for frontend use
|
||||
*/
|
||||
|
||||
// Admin user types
|
||||
export interface AdminUser {
|
||||
auth0Sub: string;
|
||||
email: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
revokedAt: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateAdminRequest {
|
||||
email: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
// Admin audit log types
|
||||
export interface AdminAuditLog {
|
||||
id: string;
|
||||
actorAdminId: string;
|
||||
targetAdminId: string | null;
|
||||
action: string;
|
||||
resourceType: string | null;
|
||||
resourceId: string | null;
|
||||
context: Record<string, any> | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Platform catalog types
|
||||
export interface CatalogMake {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CatalogModel {
|
||||
id: string;
|
||||
makeId: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CatalogYear {
|
||||
id: string;
|
||||
modelId: string;
|
||||
year: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CatalogTrim {
|
||||
id: string;
|
||||
yearId: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CatalogEngine {
|
||||
id: string;
|
||||
trimId: string;
|
||||
name: string;
|
||||
displacement: string | null;
|
||||
cylinders: number | null;
|
||||
fuel_type: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateCatalogMakeRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UpdateCatalogMakeRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CreateCatalogModelRequest {
|
||||
makeId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UpdateCatalogModelRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CreateCatalogYearRequest {
|
||||
modelId: string;
|
||||
year: number;
|
||||
}
|
||||
|
||||
export interface CreateCatalogTrimRequest {
|
||||
yearId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UpdateCatalogTrimRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CreateCatalogEngineRequest {
|
||||
trimId: string;
|
||||
name: string;
|
||||
displacement?: string;
|
||||
cylinders?: number;
|
||||
fuel_type?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCatalogEngineRequest {
|
||||
name?: string;
|
||||
displacement?: string;
|
||||
cylinders?: number;
|
||||
fuel_type?: string;
|
||||
}
|
||||
|
||||
// Station types for admin
|
||||
export interface StationOverview {
|
||||
id: string;
|
||||
name: string;
|
||||
placeId: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateStationRequest {
|
||||
name: string;
|
||||
placeId: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface UpdateStationRequest {
|
||||
name?: string;
|
||||
address?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
|
||||
// Admin access verification
|
||||
export interface AdminAccessResponse {
|
||||
isAdmin: boolean;
|
||||
adminRecord: AdminUser | null;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
enabled: boolean;
|
||||
@@ -69,7 +71,9 @@ const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
|
||||
|
||||
export const MobileSettingsScreen: React.FC = () => {
|
||||
const { user, logout } = useAuth0();
|
||||
const navigate = useNavigate();
|
||||
const { settings, updateSetting, isLoading, error } = useSettings();
|
||||
const { isAdmin, loading: adminLoading } = useAdminAccess();
|
||||
const [showDataExport, setShowDataExport] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
@@ -247,6 +251,41 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Admin Console Section */}
|
||||
{!adminLoading && isAdmin && (
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-blue-600 mb-4">Admin Console</h2>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => navigate('/garage/settings/admin/users')}
|
||||
className="w-full text-left p-4 bg-blue-50 text-blue-700 rounded-lg font-medium hover:bg-blue-100 transition-colors active:bg-blue-200"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<div className="font-semibold">User Management</div>
|
||||
<div className="text-sm text-blue-600 mt-1">Manage admin users and permissions</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/garage/settings/admin/catalog')}
|
||||
className="w-full text-left p-4 bg-blue-50 text-blue-700 rounded-lg font-medium hover:bg-blue-100 transition-colors active:bg-blue-200"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<div className="font-semibold">Vehicle Catalog</div>
|
||||
<div className="text-sm text-blue-600 mt-1">Manage makes, models, and engines</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/garage/settings/admin/stations')}
|
||||
className="w-full text-left p-4 bg-blue-50 text-blue-700 rounded-lg font-medium hover:bg-blue-100 transition-colors active:bg-blue-200"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<div className="font-semibold">Station Management</div>
|
||||
<div className="text-sm text-blue-600 mt-1">Manage gas station data and locations</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Account Actions Section */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
BottomNavigation as MuiBottomNavigation,
|
||||
BottomNavigationAction,
|
||||
Tabs,
|
||||
Tab,
|
||||
SwipeableDrawer,
|
||||
Fab,
|
||||
IconButton,
|
||||
@@ -151,6 +151,48 @@ export const StationsMobileScreen: React.FC = () => {
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Tab controls */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: theme.zIndex.appBar,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
variant="fullWidth"
|
||||
textColor="primary"
|
||||
indicatorColor="primary"
|
||||
aria-label="Stations views"
|
||||
>
|
||||
<Tab
|
||||
value={TAB_SEARCH}
|
||||
icon={<SearchIcon fontSize="small" />}
|
||||
iconPosition="start"
|
||||
label="Search"
|
||||
sx={{ minHeight: 56 }}
|
||||
/>
|
||||
<Tab
|
||||
value={TAB_SAVED}
|
||||
icon={<BookmarkIcon fontSize="small" />}
|
||||
iconPosition="start"
|
||||
label="Saved"
|
||||
sx={{ minHeight: 56 }}
|
||||
/>
|
||||
<Tab
|
||||
value={TAB_MAP}
|
||||
icon={<MapIcon fontSize="small" />}
|
||||
iconPosition="start"
|
||||
label="Map"
|
||||
sx={{ minHeight: 56 }}
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Tab content area */}
|
||||
<Box
|
||||
sx={{
|
||||
@@ -231,48 +273,6 @@ export const StationsMobileScreen: React.FC = () => {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<MuiBottomNavigation
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
showLabels
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
height: 56,
|
||||
zIndex: theme.zIndex.appBar
|
||||
}}
|
||||
>
|
||||
<BottomNavigationAction
|
||||
label="Search"
|
||||
icon={<SearchIcon />}
|
||||
sx={{
|
||||
minWidth: '44px',
|
||||
minHeight: '44px'
|
||||
}}
|
||||
/>
|
||||
<BottomNavigationAction
|
||||
label="Saved"
|
||||
icon={<BookmarkIcon />}
|
||||
sx={{
|
||||
minWidth: '44px',
|
||||
minHeight: '44px'
|
||||
}}
|
||||
/>
|
||||
<BottomNavigationAction
|
||||
label="Map"
|
||||
icon={<MapIcon />}
|
||||
sx={{
|
||||
minWidth: '44px',
|
||||
minHeight: '44px'
|
||||
}}
|
||||
/>
|
||||
</MuiBottomNavigation>
|
||||
|
||||
{/* Bottom Sheet for Station Details */}
|
||||
<SwipeableDrawer
|
||||
anchor="bottom"
|
||||
|
||||
Reference in New Issue
Block a user