From 4fc5b391e18c00409f24db11b3920a4e4c7bd35c Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 4 Jan 2026 13:18:38 -0600 Subject: [PATCH] feat: Add admin vehicle management and profile vehicles display (refs #11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GET /api/admin/stats endpoint for Total Vehicles widget - Add GET /api/admin/users/:auth0Sub/vehicles endpoint for user vehicle list - Update AdminUsersPage with Total Vehicles stat and expandable vehicle rows - Add My Vehicles section to SettingsPage (desktop) and MobileSettingsScreen - Update AdminUsersMobileScreen with stats header and vehicle expansion - Add defense-in-depth admin checks and error handling - Update admin README documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/src/features/admin/README.md | 56 ++++- .../src/features/admin/api/admin.routes.ts | 16 ++ .../features/admin/api/users.controller.ts | 100 ++++++++- .../data/user-profile.repository.ts | 68 +++++++ frontend/src/features/admin/api/admin.api.ts | 30 +++ frontend/src/features/admin/hooks/useUsers.ts | 39 ++++ .../admin/mobile/AdminUsersMobileScreen.tsx | 91 ++++++++- .../settings/mobile/MobileSettingsScreen.tsx | 54 +++++ frontend/src/pages/SettingsPage.tsx | 61 ++++++ frontend/src/pages/admin/AdminUsersPage.tsx | 191 +++++++++++++----- 10 files changed, 639 insertions(+), 67 deletions(-) diff --git a/backend/src/features/admin/README.md b/backend/src/features/admin/README.md index 4045953..23bcdab 100644 --- a/backend/src/features/admin/README.md +++ b/backend/src/features/admin/README.md @@ -62,14 +62,56 @@ Provides: - `admin-guard` plugin - Authorization enforcement (decorator on Fastify) - `request.userContext` - Enhanced with `isAdmin`, `adminRecord` -### Phase 2: Admin Management APIs +### Admin Dashboard Stats -Will provide: -- `/api/admin/admins` - List all admins (GET) -- `/api/admin/admins` - Add admin (POST) -- `/api/admin/admins/:auth0Sub/revoke` - Revoke admin (PATCH) -- `/api/admin/admins/:auth0Sub/reinstate` - Reinstate admin (PATCH) -- `/api/admin/audit-logs` - View audit trail (GET) +Provides admin dashboard statistics: + +- `GET /api/admin/stats` - Get total users and vehicles counts + +**Response:** +```json +{ + "totalUsers": 150, + "totalVehicles": 287 +} +``` + +### User Management APIs + +Provides: +- `GET /api/admin/users` - List all users with pagination/filters +- `GET /api/admin/users/:auth0Sub` - Get single user details +- `GET /api/admin/users/:auth0Sub/vehicles` - Get user's vehicles (admin view) +- `PATCH /api/admin/users/:auth0Sub/tier` - Update subscription tier +- `PATCH /api/admin/users/:auth0Sub/deactivate` - Deactivate user +- `PATCH /api/admin/users/:auth0Sub/reactivate` - Reactivate user +- `PATCH /api/admin/users/:auth0Sub/profile` - Update user profile +- `PATCH /api/admin/users/:auth0Sub/promote` - Promote to admin +- `DELETE /api/admin/users/:auth0Sub` - Hard delete user (GDPR) + +**User Vehicles Endpoint:** +``` +GET /api/admin/users/:auth0Sub/vehicles +``` + +Returns minimal vehicle data for privacy (Year/Make/Model only): +```json +{ + "vehicles": [ + { "year": 2022, "make": "Toyota", "model": "Camry" }, + { "year": 2020, "make": "Honda", "model": "Civic" } + ] +} +``` + +### Admin Management APIs + +Provides: +- `GET /api/admin/admins` - List all admins +- `POST /api/admin/admins` - Add admin +- `PATCH /api/admin/admins/:auth0Sub/revoke` - Revoke admin +- `PATCH /api/admin/admins/:auth0Sub/reinstate` - Reinstate admin +- `GET /api/admin/audit-logs` - View audit trail ### Phase 3: Platform Catalog CRUD (COMPLETED) diff --git a/backend/src/features/admin/api/admin.routes.ts b/backend/src/features/admin/api/admin.routes.ts index 342606e..d52d8c0 100644 --- a/backend/src/features/admin/api/admin.routes.ts +++ b/backend/src/features/admin/api/admin.routes.ts @@ -102,6 +102,16 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => { handler: adminController.bulkReinstateAdmins.bind(adminController) }); + // ============================================ + // Admin Stats endpoint (dashboard widgets) + // ============================================ + + // GET /api/admin/stats - Get admin dashboard stats (total users, total vehicles) + fastify.get('/admin/stats', { + preHandler: [fastify.requireAdmin], + handler: usersController.getAdminStats.bind(usersController) + }); + // ============================================ // User Management endpoints (subscription tiers, deactivation) // ============================================ @@ -118,6 +128,12 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => { handler: usersController.getUser.bind(usersController) }); + // GET /api/admin/users/:auth0Sub/vehicles - Get user's vehicles (admin view) + fastify.get<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub/vehicles', { + preHandler: [fastify.requireAdmin], + handler: usersController.getUserVehicles.bind(usersController) + }); + // PATCH /api/admin/users/:auth0Sub/tier - Update subscription tier fastify.patch<{ Params: UserAuth0SubInput; Body: UpdateTierInput }>('/admin/users/:auth0Sub/tier', { preHandler: [fastify.requireAdmin], diff --git a/backend/src/features/admin/api/users.controller.ts b/backend/src/features/admin/api/users.controller.ts index 0c65a42..525d737 100644 --- a/backend/src/features/admin/api/users.controller.ts +++ b/backend/src/features/admin/api/users.controller.ts @@ -28,16 +28,112 @@ import { AdminService } from '../domain/admin.service'; export class UsersController { private userProfileService: UserProfileService; private adminService: AdminService; + private userProfileRepository: UserProfileRepository; constructor() { - const userProfileRepository = new UserProfileRepository(pool); + this.userProfileRepository = new UserProfileRepository(pool); const adminRepository = new AdminRepository(pool); - this.userProfileService = new UserProfileService(userProfileRepository); + this.userProfileService = new UserProfileService(this.userProfileRepository); this.userProfileService.setAdminRepository(adminRepository); this.adminService = new AdminService(adminRepository); } + /** + * GET /api/admin/stats - Get admin dashboard stats + */ + async getAdminStats( + request: FastifyRequest, + reply: FastifyReply + ) { + try { + const actorId = request.userContext?.userId; + if (!actorId) { + return reply.code(401).send({ + error: 'Unauthorized', + message: 'User context missing', + }); + } + + // Defense-in-depth: verify admin status even with requireAdmin guard + if (!request.userContext?.isAdmin) { + return reply.code(403).send({ + error: 'Forbidden', + message: 'Admin access required', + }); + } + + const [totalVehicles, totalUsers] = await Promise.all([ + this.userProfileRepository.getTotalVehicleCount(), + this.userProfileRepository.getTotalUserCount(), + ]); + + return reply.code(200).send({ + totalVehicles, + totalUsers, + }); + } catch (error) { + logger.error('Error getting admin stats', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + + return reply.code(500).send({ + error: 'Internal server error', + message: 'Failed to get admin stats', + }); + } + } + + /** + * GET /api/admin/users/:auth0Sub/vehicles - Get user's vehicles (admin view) + */ + async getUserVehicles( + request: FastifyRequest<{ Params: UserAuth0SubInput }>, + reply: FastifyReply + ) { + try { + const actorId = request.userContext?.userId; + if (!actorId) { + return reply.code(401).send({ + error: 'Unauthorized', + message: 'User context missing', + }); + } + + // Defense-in-depth: verify admin status even with requireAdmin guard + if (!request.userContext?.isAdmin) { + return reply.code(403).send({ + error: 'Forbidden', + message: 'Admin access required', + }); + } + + // Validate path param + const parseResult = userAuth0SubSchema.safeParse(request.params); + if (!parseResult.success) { + return reply.code(400).send({ + error: 'Validation error', + message: parseResult.error.errors.map(e => e.message).join(', '), + }); + } + + const { auth0Sub } = parseResult.data; + const vehicles = await this.userProfileRepository.getUserVehiclesForAdmin(auth0Sub); + + return reply.code(200).send({ vehicles }); + } catch (error) { + logger.error('Error getting user vehicles', { + error: error instanceof Error ? error.message : 'Unknown error', + auth0Sub: request.params?.auth0Sub, + }); + + return reply.code(500).send({ + error: 'Internal server error', + message: 'Failed to get user vehicles', + }); + } + } + /** * GET /api/admin/users - List all users with pagination and filters */ diff --git a/backend/src/features/user-profile/data/user-profile.repository.ts b/backend/src/features/user-profile/data/user-profile.repository.ts index 181d43e..2bcdf22 100644 --- a/backend/src/features/user-profile/data/user-profile.repository.ts +++ b/backend/src/features/user-profile/data/user-profile.repository.ts @@ -640,4 +640,72 @@ export class UserProfileRepository { client.release(); } } + + /** + * Get total count of active vehicles across all users + * Used by admin stats endpoint for dashboard widget + */ + async getTotalVehicleCount(): Promise { + const query = ` + SELECT COUNT(*) as total + FROM vehicles + WHERE is_active = true + AND deleted_at IS NULL + `; + + try { + const result = await this.pool.query(query); + return parseInt(result.rows[0]?.total || '0', 10); + } catch (error) { + logger.error('Error getting total vehicle count', { error }); + throw error; + } + } + + /** + * Get total count of active users + * Used by admin stats endpoint for dashboard widget + */ + async getTotalUserCount(): Promise { + const query = ` + SELECT COUNT(*) as total + FROM user_profiles + WHERE deactivated_at IS NULL + `; + + try { + const result = await this.pool.query(query); + return parseInt(result.rows[0]?.total || '0', 10); + } catch (error) { + logger.error('Error getting total user count', { error }); + throw error; + } + } + + /** + * Get vehicles for a user (admin view) + * Returns only year, make, model for privacy + */ + async getUserVehiclesForAdmin(auth0Sub: string): Promise> { + const query = ` + SELECT year, make, model + FROM vehicles + WHERE user_id = $1 + AND is_active = true + AND deleted_at IS NULL + ORDER BY year DESC, make ASC, model ASC + `; + + try { + const result = await this.pool.query(query, [auth0Sub]); + return result.rows.map(row => ({ + year: row.year, + make: row.make, + model: row.model, + })); + } catch (error) { + logger.error('Error getting user vehicles for admin', { error, auth0Sub }); + throw error; + } + } } diff --git a/frontend/src/features/admin/api/admin.api.ts b/frontend/src/features/admin/api/admin.api.ts index 4fa90d5..72589c1 100644 --- a/frontend/src/features/admin/api/admin.api.ts +++ b/frontend/src/features/admin/api/admin.api.ts @@ -56,6 +56,23 @@ export interface AuditLogsResponse { total: number; } +// Admin stats response +export interface AdminStatsResponse { + totalVehicles: number; + totalUsers: number; +} + +// User vehicle (admin view) +export interface AdminUserVehicle { + year: number; + make: string; + model: string; +} + +export interface AdminUserVehiclesResponse { + vehicles: AdminUserVehicle[]; +} + // Admin access verification export const adminApi = { // Verify admin access @@ -64,6 +81,12 @@ export const adminApi = { return response.data; }, + // Admin dashboard stats + getStats: async (): Promise => { + const response = await apiClient.get('/admin/stats'); + return response.data; + }, + // Admin management listAdmins: async (): Promise => { const response = await apiClient.get('/admin/admins'); @@ -309,6 +332,13 @@ export const adminApi = { return response.data; }, + getVehicles: async (auth0Sub: string): Promise => { + const response = await apiClient.get( + `/admin/users/${encodeURIComponent(auth0Sub)}/vehicles` + ); + return response.data; + }, + updateTier: async (auth0Sub: string, data: UpdateUserTierRequest): Promise => { const response = await apiClient.patch( `/admin/users/${encodeURIComponent(auth0Sub)}/tier`, diff --git a/frontend/src/features/admin/hooks/useUsers.ts b/frontend/src/features/admin/hooks/useUsers.ts index 03b5979..d76079a 100644 --- a/frontend/src/features/admin/hooks/useUsers.ts +++ b/frontend/src/features/admin/hooks/useUsers.ts @@ -30,6 +30,12 @@ export const userQueryKeys = { all: ['admin-users'] as const, list: (params: ListUsersParams) => [...userQueryKeys.all, 'list', params] as const, detail: (auth0Sub: string) => [...userQueryKeys.all, 'detail', auth0Sub] as const, + vehicles: (auth0Sub: string) => [...userQueryKeys.all, 'vehicles', auth0Sub] as const, +}; + +// Query keys for admin stats +export const adminStatsQueryKeys = { + all: ['admin-stats'] as const, }; /** @@ -201,3 +207,36 @@ export const useHardDeleteUser = () => { }, }); }; + +/** + * Hook to get admin dashboard stats (total vehicles, total users) + */ +export const useAdminStats = () => { + const { isAuthenticated, isLoading } = useAuth0(); + + return useQuery({ + queryKey: adminStatsQueryKeys.all, + queryFn: () => adminApi.getStats(), + enabled: isAuthenticated && !isLoading, + staleTime: 2 * 60 * 1000, // 2 minutes + gcTime: 5 * 60 * 1000, // 5 minutes cache time + retry: 1, + refetchOnWindowFocus: false, + }); +}; + +/** + * Hook to get a user's vehicles (admin view - year, make, model only) + */ +export const useUserVehicles = (auth0Sub: string) => { + const { isAuthenticated, isLoading } = useAuth0(); + + return useQuery({ + queryKey: userQueryKeys.vehicles(auth0Sub), + queryFn: () => adminApi.users.getVehicles(auth0Sub), + enabled: isAuthenticated && !isLoading && !!auth0Sub, + staleTime: 2 * 60 * 1000, + gcTime: 5 * 60 * 1000, + retry: 1, + }); +}; diff --git a/frontend/src/features/admin/mobile/AdminUsersMobileScreen.tsx b/frontend/src/features/admin/mobile/AdminUsersMobileScreen.tsx index a8dd94c..0c552b0 100644 --- a/frontend/src/features/admin/mobile/AdminUsersMobileScreen.tsx +++ b/frontend/src/features/admin/mobile/AdminUsersMobileScreen.tsx @@ -16,6 +16,8 @@ import { useUpdateUserProfile, usePromoteToAdmin, useHardDeleteUser, + useAdminStats, + useUserVehicles, } from '../hooks/useUsers'; import { ManagedUser, @@ -82,12 +84,59 @@ const StatusBadge: React.FC<{ active: boolean }> = ({ active }) => ( ); // Vehicle count badge component -const VehicleCountBadge: React.FC<{ count: number }> = ({ count }) => ( - +const VehicleCountBadge: React.FC<{ count: number; onClick?: () => void }> = ({ count, onClick }) => ( + ); +// Expandable vehicle list component +const UserVehiclesList: React.FC<{ auth0Sub: string; isOpen: boolean }> = ({ auth0Sub, isOpen }) => { + const { data, isLoading, error } = useUserVehicles(auth0Sub); + + if (!isOpen) return null; + + return ( +
+

+ + + + Vehicles +

+ {isLoading ? ( +
+
+
+ ) : error ? ( +

Failed to load vehicles

+ ) : !data?.vehicles?.length ? ( +

No vehicles registered

+ ) : ( +
+ {data.vehicles.map((vehicle, idx) => ( +
+ {vehicle.year} {vehicle.make} {vehicle.model} +
+ ))} +
+ )} +
+ ); +}; + export const AdminUsersMobileScreen: React.FC = () => { const { isAdmin, loading: adminLoading } = useAdminAccess(); @@ -105,6 +154,12 @@ export const AdminUsersMobileScreen: React.FC = () => { // Query const { data, isLoading, error, refetch } = useUsers(params); + // Admin stats + const { data: statsData } = useAdminStats(); + + // Expanded user for vehicle list + const [expandedUserId, setExpandedUserId] = useState(null); + // Mutations const updateTierMutation = useUpdateUserTier(); const deactivateMutation = useDeactivateUser(); @@ -328,10 +383,17 @@ export const AdminUsersMobileScreen: React.FC = () => {
{/* Header */}
-

User Management

-

- {total} user{total !== 1 ? 's' : ''} -

+

User Management

+
+
+

{statsData?.totalUsers ?? total}

+

Users

+
+
+

{statsData?.totalVehicles ?? 0}

+

Vehicles

+
+
{/* Search Bar */} @@ -454,13 +516,18 @@ export const AdminUsersMobileScreen: React.FC = () => { >
-

{user.email}

+

{user.email}

{user.displayName && ( -

{user.displayName}

+

{user.displayName}

)}
- + 0 ? () => setExpandedUserId( + expandedUserId === user.auth0Sub ? null : user.auth0Sub + ) : undefined} + /> {user.isAdmin && ( @@ -474,6 +541,10 @@ export const AdminUsersMobileScreen: React.FC = () => {
+ ))} diff --git a/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx b/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx index 52d3467..e3083c5 100644 --- a/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx +++ b/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx @@ -5,6 +5,7 @@ import { MobileContainer } from '../../../shared-minimal/components/mobile/Mobil import { useSettings } from '../hooks/useSettings'; import { useProfile, useUpdateProfile } from '../hooks/useProfile'; import { useExportUserData } from '../hooks/useExportUserData'; +import { useVehicles } from '../../vehicles/hooks/useVehicles'; import { useAdminAccess } from '../../../core/auth/useAdminAccess'; import { useNavigationStore } from '../../../core/store'; import { DeleteAccountModal } from './DeleteAccountModal'; @@ -80,6 +81,7 @@ export const MobileSettingsScreen: React.FC = () => { const { data: profile, isLoading: profileLoading } = useProfile(); const updateProfileMutation = useUpdateProfile(); const exportMutation = useExportUserData(); + const { data: vehicles, isLoading: vehiclesLoading } = useVehicles(); const { isAdmin, loading: adminLoading } = useAdminAccess(); const [showDataExport, setShowDataExport] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); @@ -316,6 +318,58 @@ export const MobileSettingsScreen: React.FC = () => {
+ {/* My Vehicles Section */} + +
+
+
+ + + +

+ My Vehicles +

+ {!vehiclesLoading && vehicles && ( + ({vehicles.length}) + )} +
+ +
+ + {vehiclesLoading ? ( +
+
+
+ ) : !vehicles?.length ? ( +

+ No vehicles registered. Add your first vehicle to get started. +

+ ) : ( +
+ {vehicles.map((vehicle) => ( +
+

+ {vehicle.year} {vehicle.make} {vehicle.model} +

+ {vehicle.nickname && ( +

{vehicle.nickname}

+ )} +
+ ))} +
+ )} +
+
+ {/* Notifications Section */}
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 7ef173f..b685ea0 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -9,6 +9,7 @@ import { useUnits } from '../core/units/UnitsContext'; import { useAdminAccess } from '../core/auth/useAdminAccess'; import { useProfile, useUpdateProfile } from '../features/settings/hooks/useProfile'; import { useExportUserData } from '../features/settings/hooks/useExportUserData'; +import { useVehicles } from '../features/vehicles/hooks/useVehicles'; import { useTheme } from '../shared-minimal/theme/ThemeContext'; import { DeleteAccountDialog } from '../features/settings/components/DeleteAccountDialog'; import { PendingDeletionBanner } from '../features/settings/components/PendingDeletionBanner'; @@ -36,6 +37,7 @@ 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 DirectionsCarIcon from '@mui/icons-material/DirectionsCar'; import EditIcon from '@mui/icons-material/Edit'; import SaveIcon from '@mui/icons-material/Save'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -53,6 +55,9 @@ export const SettingsPage: React.FC = () => { // Profile state const { data: profile, isLoading: profileLoading } = useProfile(); const updateProfileMutation = useUpdateProfile(); + + // Vehicles state (for My Vehicles section) + const { data: vehicles, isLoading: vehiclesLoading } = useVehicles(); const [isEditingProfile, setIsEditingProfile] = useState(false); const [editedDisplayName, setEditedDisplayName] = useState(''); const [editedNotificationEmail, setEditedNotificationEmail] = useState(''); @@ -277,6 +282,62 @@ export const SettingsPage: React.FC = () => { )} + {/* My Vehicles Section */} + + + + + + My Vehicles + + {!vehiclesLoading && vehicles && ( + + ({vehicles.length}) + + )} + + navigate('/garage')} + sx={{ + backgroundColor: 'primary.main', + color: 'primary.contrastText', + '&:hover': { + backgroundColor: 'primary.dark' + } + }} + > + Manage + + + + {vehiclesLoading ? ( + + + + ) : !vehicles?.length ? ( + + No vehicles registered. Add your first vehicle to get started. + + ) : ( + + {vehicles.map((vehicle, index) => ( + + {index > 0 && } + + + + + ))} + + )} + + {/* Notifications Section */} diff --git a/frontend/src/pages/admin/AdminUsersPage.tsx b/frontend/src/pages/admin/AdminUsersPage.tsx index 1638f0d..d4e5f16 100644 --- a/frontend/src/pages/admin/AdminUsersPage.tsx +++ b/frontend/src/pages/admin/AdminUsersPage.tsx @@ -10,6 +10,7 @@ import { Button, Chip, CircularProgress, + Collapse, Dialog, DialogActions, DialogContent, @@ -44,6 +45,9 @@ import { Edit, Security, DeleteForever, + DirectionsCar, + KeyboardArrowDown, + KeyboardArrowUp, } from '@mui/icons-material'; import { useAdminAccess } from '../../core/auth/useAdminAccess'; import { @@ -54,6 +58,8 @@ import { useUpdateUserProfile, usePromoteToAdmin, useHardDeleteUser, + useAdminStats, + useUserVehicles, } from '../../features/admin/hooks/useUsers'; import { ManagedUser, @@ -64,6 +70,58 @@ import { AdminSectionHeader } from '../../features/admin/components'; const PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; +// Expandable vehicle row component +const UserVehiclesRow: React.FC<{ auth0Sub: string; isOpen: boolean }> = ({ auth0Sub, isOpen }) => { + const { data, isLoading, error } = useUserVehicles(auth0Sub); + + if (!isOpen) return null; + + return ( + + + + + + + Vehicles + + {isLoading ? ( + + ) : error ? ( + + Failed to load vehicles + + ) : !data?.vehicles?.length ? ( + + No vehicles registered + + ) : ( + + + + Year + Make + Model + + + + {data.vehicles.map((vehicle, idx) => ( + + {vehicle.year} + {vehicle.make} + {vehicle.model} + + ))} + +
+ )} +
+
+
+
+ ); +}; + export const AdminUsersPage: React.FC = () => { const { isAdmin, loading: adminLoading } = useAdminAccess(); @@ -110,6 +168,12 @@ export const AdminUsersPage: React.FC = () => { const [hardDeleteReason, setHardDeleteReason] = useState(''); const [hardDeleteConfirmText, setHardDeleteConfirmText] = useState(''); + // Expanded row state for vehicle list + const [expandedRow, setExpandedRow] = useState(null); + + // Admin stats query + const { data: statsData } = useAdminStats(); + // Handlers const handleSearch = useCallback(() => { setParams(prev => ({ ...prev, search: searchInput || undefined, page: 1 })); @@ -319,7 +383,10 @@ export const AdminUsersPage: React.FC = () => { {/* Filters Bar */} @@ -429,55 +496,83 @@ export const AdminUsersPage: React.FC = () => { {users.map((user) => ( - - {user.email} - {user.displayName || '-'} - - - + handleTierChange(user.auth0Sub, e.target.value as SubscriptionTier) + } + disabled={!!user.deactivatedAt || updateTierMutation.isPending} + size="small" + > + Free + Pro + Enterprise + + + + + + {user.vehicleCount > 0 && ( + setExpandedRow( + expandedRow === user.auth0Sub ? null : user.auth0Sub + )} + aria-label="show vehicles" + sx={{ minWidth: 44, minHeight: 44 }} + > + {expandedRow === user.auth0Sub ? ( + + ) : ( + + )} + + )} + {user.vehicleCount} + + + + + + + {user.isAdmin && ( + + + + )} + + + {new Date(user.createdAt).toLocaleDateString()} + + + handleMenuOpen(e, user)} > - Free - Pro - Enterprise - - - - {user.vehicleCount} - - - - - {user.isAdmin && ( - - - - )} - - - {new Date(user.createdAt).toLocaleDateString()} - - - handleMenuOpen(e, user)} - > - - - - + + + + + + ))}