Admin User v1

This commit is contained in:
Eric Gullickson
2025-11-05 19:04:06 -06:00
parent e4e7e32a4f
commit 8174e0d5f9
48 changed files with 11289 additions and 1112 deletions

View File

@@ -32,6 +32,11 @@ const StationsMobileScreen = lazy(() => import('./features/stations/mobile/Stati
const VehiclesMobileScreen = lazy(() => import('./features/vehicles/mobile/VehiclesMobileScreen').then(m => ({ default: m.VehiclesMobileScreen })));
const VehicleDetailMobile = lazy(() => import('./features/vehicles/mobile/VehicleDetailMobile').then(m => ({ default: m.VehicleDetailMobile })));
const DocumentsMobileScreen = lazy(() => import('./features/documents/mobile/DocumentsMobileScreen'));
// Admin pages (lazy-loaded)
const AdminUsersPage = lazy(() => import('./pages/admin/AdminUsersPage').then(m => ({ default: m.AdminUsersPage })));
const AdminCatalogPage = lazy(() => import('./pages/admin/AdminCatalogPage').then(m => ({ default: m.AdminCatalogPage })));
const AdminStationsPage = lazy(() => import('./pages/admin/AdminStationsPage').then(m => ({ default: m.AdminStationsPage })));
import { HomePage } from './pages/HomePage';
import { BottomNavigation, NavigationItem } from './shared-minimal/components/mobile/BottomNavigation';
import { GlassCard } from './shared-minimal/components/mobile/GlassCard';
@@ -644,6 +649,9 @@ function App() {
<Route path="/garage/maintenance" element={<MaintenancePage />} />
<Route path="/garage/stations" element={<StationsPage />} />
<Route path="/garage/settings" element={<SettingsPage />} />
<Route path="/garage/settings/admin/users" element={<AdminUsersPage />} />
<Route path="/garage/settings/admin/catalog" element={<AdminCatalogPage />} />
<Route path="/garage/settings/admin/stations" element={<AdminStationsPage />} />
<Route path="*" element={<Navigate to="/garage/vehicles" replace />} />
</Routes>
</RouteSuspense>

View File

@@ -0,0 +1,30 @@
/**
* @ai-summary React hook for admin access verification
* @ai-context Calls /api/admin/verify and caches result
*/
import { useQuery } from '@tanstack/react-query';
import { useAuth0 } from '@auth0/auth0-react';
import { adminApi } from '../../features/admin/api/admin.api';
export const useAdminAccess = () => {
const { isAuthenticated, isLoading: authLoading } = useAuth0();
const query = useQuery({
queryKey: ['adminAccess'],
queryFn: () => adminApi.verifyAccess(),
enabled: isAuthenticated && !authLoading,
staleTime: 5 * 60 * 1000, // 5 minutes - admin status doesn't change often
gcTime: 10 * 60 * 1000, // 10 minutes cache time
retry: 1, // Only retry once for admin checks
refetchOnWindowFocus: false,
refetchOnMount: false,
});
return {
isAdmin: query.data?.isAdmin ?? false,
adminRecord: query.data?.adminRecord ?? null,
loading: query.isLoading,
error: query.error,
};
};

View File

@@ -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();
});
});

View 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);
});
});

View 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');
});
});
});

View 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}`);
},
};

View 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,
});
};

View 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');
},
});
};

View 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');
},
});
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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;
}

View File

@@ -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>

View File

@@ -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"

View File

@@ -4,12 +4,14 @@
import React, { useState } from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import { useNavigate } from 'react-router-dom';
import { useUnits } from '../core/units/UnitsContext';
import {
Box,
Typography,
Switch,
Divider,
import { useAdminAccess } from '../core/auth/useAdminAccess';
import {
Box,
Typography,
Switch,
Divider,
Avatar,
List,
ListItem,
@@ -26,11 +28,14 @@ import NotificationsIcon from '@mui/icons-material/Notifications';
import PaletteIcon from '@mui/icons-material/Palette';
import SecurityIcon from '@mui/icons-material/Security';
import StorageIcon from '@mui/icons-material/Storage';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
import { Card } from '../shared-minimal/components/Card';
export const SettingsPage: React.FC = () => {
const { user, logout } = useAuth0();
const navigate = useNavigate();
const { unitSystem, setUnitSystem } = useUnits();
const { isAdmin, loading: adminLoading } = useAdminAccess();
const [notifications, setNotifications] = useState(true);
const [emailUpdates, setEmailUpdates] = useState(false);
const [darkMode, setDarkMode] = useState(false);
@@ -209,14 +214,14 @@ export const SettingsPage: React.FC = () => {
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
Data & Storage
</Typography>
<List disablePadding>
<ListItem>
<ListItemIcon>
<StorageIcon />
</ListItemIcon>
<ListItemText
primary="Export Data"
<ListItemText
primary="Export Data"
secondary="Download your vehicle and fuel log data"
/>
<ListItemSecondaryAction>
@@ -227,8 +232,8 @@ export const SettingsPage: React.FC = () => {
</ListItem>
<Divider />
<ListItem>
<ListItemText
primary="Clear Cache"
<ListItemText
primary="Clear Cache"
secondary="Remove cached data to free up space"
sx={{ pl: 7 }}
/>
@@ -241,6 +246,70 @@ export const SettingsPage: React.FC = () => {
</List>
</Card>
{/* Admin Console Section */}
{!adminLoading && isAdmin && (
<Card>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, color: 'primary.main' }}>
Admin Console
</Typography>
<List disablePadding>
<ListItem>
<ListItemIcon>
<AdminPanelSettingsIcon />
</ListItemIcon>
<ListItemText
primary="User Management"
secondary="Manage admin users and permissions"
/>
<ListItemSecondaryAction>
<MuiButton
variant="outlined"
size="small"
onClick={() => navigate('/garage/settings/admin/users')}
>
Manage
</MuiButton>
</ListItemSecondaryAction>
</ListItem>
<Divider />
<ListItem>
<ListItemText
primary="Vehicle Catalog"
secondary="Manage makes, models, years, trims, and engines"
sx={{ pl: 7 }}
/>
<ListItemSecondaryAction>
<MuiButton
variant="outlined"
size="small"
onClick={() => navigate('/garage/settings/admin/catalog')}
>
Manage
</MuiButton>
</ListItemSecondaryAction>
</ListItem>
<Divider />
<ListItem>
<ListItemText
primary="Station Management"
secondary="Manage gas station data and locations"
sx={{ pl: 7 }}
/>
<ListItemSecondaryAction>
<MuiButton
variant="outlined"
size="small"
onClick={() => navigate('/garage/settings/admin/stations')}
>
Manage
</MuiButton>
</ListItemSecondaryAction>
</ListItem>
</List>
</Card>
)}
{/* Account Actions */}
<Card>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, color: 'error.main' }}>

View File

@@ -0,0 +1,53 @@
/**
* @ai-summary Desktop admin page for vehicle catalog management
* @ai-context CRUD operations for makes, models, years, trims, engines
*/
import React from 'react';
import { Navigate } from 'react-router-dom';
import { Box, Typography, CircularProgress } from '@mui/material';
import { Card } from '../../shared-minimal/components/Card';
import { useAdminAccess } from '../../core/auth/useAdminAccess';
export const AdminCatalogPage: React.FC = () => {
const { isAdmin, loading } = useAdminAccess();
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
<CircularProgress />
</Box>
);
}
if (!isAdmin) {
return <Navigate to="/garage/settings" replace />;
}
return (
<Box sx={{ py: 2 }}>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary', mb: 4 }}>
Vehicle Catalog Management
</Typography>
<Card>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
Platform Catalog
</Typography>
<Typography variant="body2" color="text.secondary">
Vehicle catalog management interface coming soon.
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
Features:
</Typography>
<ul>
<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>
</Card>
</Box>
);
};

View File

@@ -0,0 +1,53 @@
/**
* @ai-summary Desktop admin page for gas station management
* @ai-context CRUD operations for global station data
*/
import React from 'react';
import { Navigate } from 'react-router-dom';
import { Box, Typography, CircularProgress } from '@mui/material';
import { Card } from '../../shared-minimal/components/Card';
import { useAdminAccess } from '../../core/auth/useAdminAccess';
export const AdminStationsPage: React.FC = () => {
const { isAdmin, loading } = useAdminAccess();
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
<CircularProgress />
</Box>
);
}
if (!isAdmin) {
return <Navigate to="/garage/settings" replace />;
}
return (
<Box sx={{ py: 2 }}>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary', mb: 4 }}>
Station Management
</Typography>
<Card>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
Gas Stations
</Typography>
<Typography variant="body2" color="text.secondary">
Station management interface coming soon.
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
Features:
</Typography>
<ul>
<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>
</Card>
</Box>
);
};

View File

@@ -0,0 +1,53 @@
/**
* @ai-summary Desktop admin page for user management
* @ai-context Manage admin users, revoke, reinstate, and view audit logs
*/
import React from 'react';
import { Navigate } from 'react-router-dom';
import { Box, Typography, CircularProgress } from '@mui/material';
import { Card } from '../../shared-minimal/components/Card';
import { useAdminAccess } from '../../core/auth/useAdminAccess';
export const AdminUsersPage: React.FC = () => {
const { isAdmin, loading } = useAdminAccess();
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
<CircularProgress />
</Box>
);
}
if (!isAdmin) {
return <Navigate to="/garage/settings" replace />;
}
return (
<Box sx={{ py: 2 }}>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary', mb: 4 }}>
User Management
</Typography>
<Card>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
Admin Users
</Typography>
<Typography variant="body2" color="text.secondary">
Admin user management interface coming soon.
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
Features:
</Typography>
<ul>
<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>
</Card>
</Box>
);
};