feat: Add admin vehicle management and profile vehicles display (refs #11)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m34s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 37s
Deploy to Staging / Verify Staging (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m34s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 37s
Deploy to Staging / Verify Staging (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -62,14 +62,56 @@ Provides:
|
|||||||
- `admin-guard` plugin - Authorization enforcement (decorator on Fastify)
|
- `admin-guard` plugin - Authorization enforcement (decorator on Fastify)
|
||||||
- `request.userContext` - Enhanced with `isAdmin`, `adminRecord`
|
- `request.userContext` - Enhanced with `isAdmin`, `adminRecord`
|
||||||
|
|
||||||
### Phase 2: Admin Management APIs
|
### Admin Dashboard Stats
|
||||||
|
|
||||||
Will provide:
|
Provides admin dashboard statistics:
|
||||||
- `/api/admin/admins` - List all admins (GET)
|
|
||||||
- `/api/admin/admins` - Add admin (POST)
|
- `GET /api/admin/stats` - Get total users and vehicles counts
|
||||||
- `/api/admin/admins/:auth0Sub/revoke` - Revoke admin (PATCH)
|
|
||||||
- `/api/admin/admins/:auth0Sub/reinstate` - Reinstate admin (PATCH)
|
**Response:**
|
||||||
- `/api/admin/audit-logs` - View audit trail (GET)
|
```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)
|
### Phase 3: Platform Catalog CRUD (COMPLETED)
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,16 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
handler: adminController.bulkReinstateAdmins.bind(adminController)
|
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)
|
// User Management endpoints (subscription tiers, deactivation)
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -118,6 +128,12 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
handler: usersController.getUser.bind(usersController)
|
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
|
// PATCH /api/admin/users/:auth0Sub/tier - Update subscription tier
|
||||||
fastify.patch<{ Params: UserAuth0SubInput; Body: UpdateTierInput }>('/admin/users/:auth0Sub/tier', {
|
fastify.patch<{ Params: UserAuth0SubInput; Body: UpdateTierInput }>('/admin/users/:auth0Sub/tier', {
|
||||||
preHandler: [fastify.requireAdmin],
|
preHandler: [fastify.requireAdmin],
|
||||||
|
|||||||
@@ -28,16 +28,112 @@ import { AdminService } from '../domain/admin.service';
|
|||||||
export class UsersController {
|
export class UsersController {
|
||||||
private userProfileService: UserProfileService;
|
private userProfileService: UserProfileService;
|
||||||
private adminService: AdminService;
|
private adminService: AdminService;
|
||||||
|
private userProfileRepository: UserProfileRepository;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const userProfileRepository = new UserProfileRepository(pool);
|
this.userProfileRepository = new UserProfileRepository(pool);
|
||||||
const adminRepository = new AdminRepository(pool);
|
const adminRepository = new AdminRepository(pool);
|
||||||
|
|
||||||
this.userProfileService = new UserProfileService(userProfileRepository);
|
this.userProfileService = new UserProfileService(this.userProfileRepository);
|
||||||
this.userProfileService.setAdminRepository(adminRepository);
|
this.userProfileService.setAdminRepository(adminRepository);
|
||||||
this.adminService = new AdminService(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
|
* GET /api/admin/users - List all users with pagination and filters
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -640,4 +640,72 @@ export class UserProfileRepository {
|
|||||||
client.release();
|
client.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total count of active vehicles across all users
|
||||||
|
* Used by admin stats endpoint for dashboard widget
|
||||||
|
*/
|
||||||
|
async getTotalVehicleCount(): Promise<number> {
|
||||||
|
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<number> {
|
||||||
|
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<Array<{ year: number; make: string; model: string }>> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,23 @@ export interface AuditLogsResponse {
|
|||||||
total: number;
|
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
|
// Admin access verification
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
// Verify admin access
|
// Verify admin access
|
||||||
@@ -64,6 +81,12 @@ export const adminApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Admin dashboard stats
|
||||||
|
getStats: async (): Promise<AdminStatsResponse> => {
|
||||||
|
const response = await apiClient.get<AdminStatsResponse>('/admin/stats');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Admin management
|
// Admin management
|
||||||
listAdmins: async (): Promise<AdminUser[]> => {
|
listAdmins: async (): Promise<AdminUser[]> => {
|
||||||
const response = await apiClient.get<AdminUser[]>('/admin/admins');
|
const response = await apiClient.get<AdminUser[]>('/admin/admins');
|
||||||
@@ -309,6 +332,13 @@ export const adminApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getVehicles: async (auth0Sub: string): Promise<AdminUserVehiclesResponse> => {
|
||||||
|
const response = await apiClient.get<AdminUserVehiclesResponse>(
|
||||||
|
`/admin/users/${encodeURIComponent(auth0Sub)}/vehicles`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
updateTier: async (auth0Sub: string, data: UpdateUserTierRequest): Promise<ManagedUser> => {
|
updateTier: async (auth0Sub: string, data: UpdateUserTierRequest): Promise<ManagedUser> => {
|
||||||
const response = await apiClient.patch<ManagedUser>(
|
const response = await apiClient.patch<ManagedUser>(
|
||||||
`/admin/users/${encodeURIComponent(auth0Sub)}/tier`,
|
`/admin/users/${encodeURIComponent(auth0Sub)}/tier`,
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export const userQueryKeys = {
|
|||||||
all: ['admin-users'] as const,
|
all: ['admin-users'] as const,
|
||||||
list: (params: ListUsersParams) => [...userQueryKeys.all, 'list', params] as const,
|
list: (params: ListUsersParams) => [...userQueryKeys.all, 'list', params] as const,
|
||||||
detail: (auth0Sub: string) => [...userQueryKeys.all, 'detail', auth0Sub] 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
useUpdateUserProfile,
|
useUpdateUserProfile,
|
||||||
usePromoteToAdmin,
|
usePromoteToAdmin,
|
||||||
useHardDeleteUser,
|
useHardDeleteUser,
|
||||||
|
useAdminStats,
|
||||||
|
useUserVehicles,
|
||||||
} from '../hooks/useUsers';
|
} from '../hooks/useUsers';
|
||||||
import {
|
import {
|
||||||
ManagedUser,
|
ManagedUser,
|
||||||
@@ -82,12 +84,59 @@ const StatusBadge: React.FC<{ active: boolean }> = ({ active }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Vehicle count badge component
|
// Vehicle count badge component
|
||||||
const VehicleCountBadge: React.FC<{ count: number }> = ({ count }) => (
|
const VehicleCountBadge: React.FC<{ count: number; onClick?: () => void }> = ({ count, onClick }) => (
|
||||||
<span className="px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-700">
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.();
|
||||||
|
}}
|
||||||
|
disabled={count === 0 || !onClick}
|
||||||
|
className={`px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-700 flex items-center gap-1 ${count > 0 && onClick ? 'cursor-pointer' : ''}`}
|
||||||
|
>
|
||||||
{count} {count === 1 ? 'vehicle' : 'vehicles'}
|
{count} {count === 1 ? 'vehicle' : 'vehicles'}
|
||||||
</span>
|
{count > 0 && onClick && (
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="mt-3 pt-3 border-t border-slate-200 dark:border-silverstone">
|
||||||
|
<p className="text-xs font-semibold text-slate-500 dark:text-silverstone uppercase mb-2 flex items-center gap-1">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 4H5c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h4l3 3 3-3h4c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM8 11V9h8v2m-8 4v-2h5v2" />
|
||||||
|
</svg>
|
||||||
|
Vehicles
|
||||||
|
</p>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-2">
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-xs text-red-500">Failed to load vehicles</p>
|
||||||
|
) : !data?.vehicles?.length ? (
|
||||||
|
<p className="text-xs text-slate-400">No vehicles registered</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{data.vehicles.map((vehicle, idx) => (
|
||||||
|
<div key={idx} className="text-sm text-slate-600 dark:text-silverstone bg-slate-50 dark:bg-carbon px-2 py-1 rounded">
|
||||||
|
{vehicle.year} {vehicle.make} {vehicle.model}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const AdminUsersMobileScreen: React.FC = () => {
|
export const AdminUsersMobileScreen: React.FC = () => {
|
||||||
const { isAdmin, loading: adminLoading } = useAdminAccess();
|
const { isAdmin, loading: adminLoading } = useAdminAccess();
|
||||||
|
|
||||||
@@ -105,6 +154,12 @@ export const AdminUsersMobileScreen: React.FC = () => {
|
|||||||
// Query
|
// Query
|
||||||
const { data, isLoading, error, refetch } = useUsers(params);
|
const { data, isLoading, error, refetch } = useUsers(params);
|
||||||
|
|
||||||
|
// Admin stats
|
||||||
|
const { data: statsData } = useAdminStats();
|
||||||
|
|
||||||
|
// Expanded user for vehicle list
|
||||||
|
const [expandedUserId, setExpandedUserId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const updateTierMutation = useUpdateUserTier();
|
const updateTierMutation = useUpdateUserTier();
|
||||||
const deactivateMutation = useDeactivateUser();
|
const deactivateMutation = useDeactivateUser();
|
||||||
@@ -328,10 +383,17 @@ export const AdminUsersMobileScreen: React.FC = () => {
|
|||||||
<div className="space-y-4 pb-20 p-4">
|
<div className="space-y-4 pb-20 p-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<h1 className="text-2xl font-bold text-slate-800">User Management</h1>
|
<h1 className="text-2xl font-bold text-slate-800 dark:text-avus">User Management</h1>
|
||||||
<p className="text-slate-500 mt-2">
|
<div className="flex justify-center gap-4 mt-3">
|
||||||
{total} user{total !== 1 ? 's' : ''}
|
<div className="bg-blue-50 dark:bg-blue-900/30 px-4 py-2 rounded-lg">
|
||||||
</p>
|
<p className="text-xl font-bold text-blue-700 dark:text-blue-400">{statsData?.totalUsers ?? total}</p>
|
||||||
|
<p className="text-xs text-blue-600 dark:text-blue-300">Users</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/30 px-4 py-2 rounded-lg">
|
||||||
|
<p className="text-xl font-bold text-green-700 dark:text-green-400">{statsData?.totalVehicles ?? 0}</p>
|
||||||
|
<p className="text-xs text-green-600 dark:text-green-300">Vehicles</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
@@ -454,13 +516,18 @@ export const AdminUsersMobileScreen: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium text-slate-800 truncate">{user.email}</p>
|
<p className="font-medium text-slate-800 dark:text-avus truncate">{user.email}</p>
|
||||||
{user.displayName && (
|
{user.displayName && (
|
||||||
<p className="text-sm text-slate-500 truncate">{user.displayName}</p>
|
<p className="text-sm text-slate-500 dark:text-silverstone truncate">{user.displayName}</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-2 mt-2">
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
<TierBadge tier={user.subscriptionTier} />
|
<TierBadge tier={user.subscriptionTier} />
|
||||||
<VehicleCountBadge count={user.vehicleCount} />
|
<VehicleCountBadge
|
||||||
|
count={user.vehicleCount}
|
||||||
|
onClick={user.vehicleCount > 0 ? () => setExpandedUserId(
|
||||||
|
expandedUserId === user.auth0Sub ? null : user.auth0Sub
|
||||||
|
) : undefined}
|
||||||
|
/>
|
||||||
<StatusBadge active={!user.deactivatedAt} />
|
<StatusBadge active={!user.deactivatedAt} />
|
||||||
{user.isAdmin && (
|
{user.isAdmin && (
|
||||||
<span className="px-2 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
|
<span className="px-2 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
|
||||||
@@ -474,6 +541,10 @@ export const AdminUsersMobileScreen: React.FC = () => {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
<UserVehiclesList
|
||||||
|
auth0Sub={user.auth0Sub}
|
||||||
|
isOpen={expandedUserId === user.auth0Sub}
|
||||||
|
/>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { MobileContainer } from '../../../shared-minimal/components/mobile/Mobil
|
|||||||
import { useSettings } from '../hooks/useSettings';
|
import { useSettings } from '../hooks/useSettings';
|
||||||
import { useProfile, useUpdateProfile } from '../hooks/useProfile';
|
import { useProfile, useUpdateProfile } from '../hooks/useProfile';
|
||||||
import { useExportUserData } from '../hooks/useExportUserData';
|
import { useExportUserData } from '../hooks/useExportUserData';
|
||||||
|
import { useVehicles } from '../../vehicles/hooks/useVehicles';
|
||||||
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
||||||
import { useNavigationStore } from '../../../core/store';
|
import { useNavigationStore } from '../../../core/store';
|
||||||
import { DeleteAccountModal } from './DeleteAccountModal';
|
import { DeleteAccountModal } from './DeleteAccountModal';
|
||||||
@@ -80,6 +81,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
|||||||
const { data: profile, isLoading: profileLoading } = useProfile();
|
const { data: profile, isLoading: profileLoading } = useProfile();
|
||||||
const updateProfileMutation = useUpdateProfile();
|
const updateProfileMutation = useUpdateProfile();
|
||||||
const exportMutation = useExportUserData();
|
const exportMutation = useExportUserData();
|
||||||
|
const { data: vehicles, isLoading: vehiclesLoading } = useVehicles();
|
||||||
const { isAdmin, loading: adminLoading } = useAdminAccess();
|
const { isAdmin, loading: adminLoading } = useAdminAccess();
|
||||||
const [showDataExport, setShowDataExport] = useState(false);
|
const [showDataExport, setShowDataExport] = useState(false);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
@@ -316,6 +318,58 @@ export const MobileSettingsScreen: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|
||||||
|
{/* My Vehicles Section */}
|
||||||
|
<GlassCard padding="md">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-primary-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 4H5c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h4l3 3 3-3h4c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM8 11V9h8v2m-8 4v-2h5v2" />
|
||||||
|
</svg>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-800 dark:text-avus">
|
||||||
|
My Vehicles
|
||||||
|
</h2>
|
||||||
|
{!vehiclesLoading && vehicles && (
|
||||||
|
<span className="text-sm text-slate-500 dark:text-titanio">({vehicles.length})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigateToScreen('Vehicles')}
|
||||||
|
className="px-3 py-1.5 bg-primary-500 text-white rounded-lg text-sm font-medium hover:bg-primary-600 transition-colors dark:bg-primary-600 dark:hover:bg-primary-700"
|
||||||
|
style={{ minHeight: '44px', minWidth: '44px' }}
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{vehiclesLoading ? (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-500"></div>
|
||||||
|
</div>
|
||||||
|
) : !vehicles?.length ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-titanio py-2">
|
||||||
|
No vehicles registered. Add your first vehicle to get started.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{vehicles.map((vehicle) => (
|
||||||
|
<div
|
||||||
|
key={vehicle.id}
|
||||||
|
className="p-3 bg-slate-50 dark:bg-nero rounded-lg"
|
||||||
|
>
|
||||||
|
<p className="font-medium text-slate-800 dark:text-avus">
|
||||||
|
{vehicle.year} {vehicle.make} {vehicle.model}
|
||||||
|
</p>
|
||||||
|
{vehicle.nickname && (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-titanio">{vehicle.nickname}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
{/* Notifications Section */}
|
{/* Notifications Section */}
|
||||||
<GlassCard padding="md">
|
<GlassCard padding="md">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useUnits } from '../core/units/UnitsContext';
|
|||||||
import { useAdminAccess } from '../core/auth/useAdminAccess';
|
import { useAdminAccess } from '../core/auth/useAdminAccess';
|
||||||
import { useProfile, useUpdateProfile } from '../features/settings/hooks/useProfile';
|
import { useProfile, useUpdateProfile } from '../features/settings/hooks/useProfile';
|
||||||
import { useExportUserData } from '../features/settings/hooks/useExportUserData';
|
import { useExportUserData } from '../features/settings/hooks/useExportUserData';
|
||||||
|
import { useVehicles } from '../features/vehicles/hooks/useVehicles';
|
||||||
import { useTheme } from '../shared-minimal/theme/ThemeContext';
|
import { useTheme } from '../shared-minimal/theme/ThemeContext';
|
||||||
import { DeleteAccountDialog } from '../features/settings/components/DeleteAccountDialog';
|
import { DeleteAccountDialog } from '../features/settings/components/DeleteAccountDialog';
|
||||||
import { PendingDeletionBanner } from '../features/settings/components/PendingDeletionBanner';
|
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 SecurityIcon from '@mui/icons-material/Security';
|
||||||
import StorageIcon from '@mui/icons-material/Storage';
|
import StorageIcon from '@mui/icons-material/Storage';
|
||||||
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
||||||
|
import DirectionsCarIcon from '@mui/icons-material/DirectionsCar';
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import SaveIcon from '@mui/icons-material/Save';
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
@@ -53,6 +55,9 @@ export const SettingsPage: React.FC = () => {
|
|||||||
// Profile state
|
// Profile state
|
||||||
const { data: profile, isLoading: profileLoading } = useProfile();
|
const { data: profile, isLoading: profileLoading } = useProfile();
|
||||||
const updateProfileMutation = useUpdateProfile();
|
const updateProfileMutation = useUpdateProfile();
|
||||||
|
|
||||||
|
// Vehicles state (for My Vehicles section)
|
||||||
|
const { data: vehicles, isLoading: vehiclesLoading } = useVehicles();
|
||||||
const [isEditingProfile, setIsEditingProfile] = useState(false);
|
const [isEditingProfile, setIsEditingProfile] = useState(false);
|
||||||
const [editedDisplayName, setEditedDisplayName] = useState('');
|
const [editedDisplayName, setEditedDisplayName] = useState('');
|
||||||
const [editedNotificationEmail, setEditedNotificationEmail] = useState('');
|
const [editedNotificationEmail, setEditedNotificationEmail] = useState('');
|
||||||
@@ -277,6 +282,62 @@ export const SettingsPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* My Vehicles Section */}
|
||||||
|
<Card>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<DirectionsCarIcon color="primary" />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||||
|
My Vehicles
|
||||||
|
</Typography>
|
||||||
|
{!vehiclesLoading && vehicles && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
({vehicles.length})
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<MuiButton
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
onClick={() => navigate('/garage')}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
color: 'primary.contrastText',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'primary.dark'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</MuiButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{vehiclesLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
</Box>
|
||||||
|
) : !vehicles?.length ? (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
|
||||||
|
No vehicles registered. Add your first vehicle to get started.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<List disablePadding>
|
||||||
|
{vehicles.map((vehicle, index) => (
|
||||||
|
<React.Fragment key={vehicle.id}>
|
||||||
|
{index > 0 && <Divider />}
|
||||||
|
<ListItem sx={{ py: 1.5 }}>
|
||||||
|
<ListItemText
|
||||||
|
primary={`${vehicle.year} ${vehicle.make} ${vehicle.model}`}
|
||||||
|
secondary={vehicle.nickname || undefined}
|
||||||
|
primaryTypographyProps={{ fontWeight: 500 }}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Notifications Section */}
|
{/* Notifications Section */}
|
||||||
<Card>
|
<Card>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
|
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
|
Collapse,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -44,6 +45,9 @@ import {
|
|||||||
Edit,
|
Edit,
|
||||||
Security,
|
Security,
|
||||||
DeleteForever,
|
DeleteForever,
|
||||||
|
DirectionsCar,
|
||||||
|
KeyboardArrowDown,
|
||||||
|
KeyboardArrowUp,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useAdminAccess } from '../../core/auth/useAdminAccess';
|
import { useAdminAccess } from '../../core/auth/useAdminAccess';
|
||||||
import {
|
import {
|
||||||
@@ -54,6 +58,8 @@ import {
|
|||||||
useUpdateUserProfile,
|
useUpdateUserProfile,
|
||||||
usePromoteToAdmin,
|
usePromoteToAdmin,
|
||||||
useHardDeleteUser,
|
useHardDeleteUser,
|
||||||
|
useAdminStats,
|
||||||
|
useUserVehicles,
|
||||||
} from '../../features/admin/hooks/useUsers';
|
} from '../../features/admin/hooks/useUsers';
|
||||||
import {
|
import {
|
||||||
ManagedUser,
|
ManagedUser,
|
||||||
@@ -64,6 +70,58 @@ import { AdminSectionHeader } from '../../features/admin/components';
|
|||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
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 (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} sx={{ py: 0, bgcolor: 'action.hover' }}>
|
||||||
|
<Collapse in={isOpen} timeout="auto" unmountOnExit>
|
||||||
|
<Box sx={{ py: 2, px: 3 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<DirectionsCar fontSize="small" />
|
||||||
|
Vehicles
|
||||||
|
</Typography>
|
||||||
|
{isLoading ? (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
) : error ? (
|
||||||
|
<Typography variant="body2" color="error">
|
||||||
|
Failed to load vehicles
|
||||||
|
</Typography>
|
||||||
|
) : !data?.vehicles?.length ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
No vehicles registered
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Table size="small" sx={{ maxWidth: 500 }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>Year</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>Make</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>Model</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{data.vehicles.map((vehicle, idx) => (
|
||||||
|
<TableRow key={idx}>
|
||||||
|
<TableCell>{vehicle.year}</TableCell>
|
||||||
|
<TableCell>{vehicle.make}</TableCell>
|
||||||
|
<TableCell>{vehicle.model}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const AdminUsersPage: React.FC = () => {
|
export const AdminUsersPage: React.FC = () => {
|
||||||
const { isAdmin, loading: adminLoading } = useAdminAccess();
|
const { isAdmin, loading: adminLoading } = useAdminAccess();
|
||||||
|
|
||||||
@@ -110,6 +168,12 @@ export const AdminUsersPage: React.FC = () => {
|
|||||||
const [hardDeleteReason, setHardDeleteReason] = useState('');
|
const [hardDeleteReason, setHardDeleteReason] = useState('');
|
||||||
const [hardDeleteConfirmText, setHardDeleteConfirmText] = useState('');
|
const [hardDeleteConfirmText, setHardDeleteConfirmText] = useState('');
|
||||||
|
|
||||||
|
// Expanded row state for vehicle list
|
||||||
|
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Admin stats query
|
||||||
|
const { data: statsData } = useAdminStats();
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
const handleSearch = useCallback(() => {
|
const handleSearch = useCallback(() => {
|
||||||
setParams(prev => ({ ...prev, search: searchInput || undefined, page: 1 }));
|
setParams(prev => ({ ...prev, search: searchInput || undefined, page: 1 }));
|
||||||
@@ -319,7 +383,10 @@ export const AdminUsersPage: React.FC = () => {
|
|||||||
<Box sx={{ py: 2, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<Box sx={{ py: 2, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
<AdminSectionHeader
|
<AdminSectionHeader
|
||||||
title="User Management"
|
title="User Management"
|
||||||
stats={[{ label: 'Total Users', value: total }]}
|
stats={[
|
||||||
|
{ label: 'Total Users', value: statsData?.totalUsers ?? total },
|
||||||
|
{ label: 'Total Vehicles', value: statsData?.totalVehicles ?? 0 },
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Filters Bar */}
|
{/* Filters Bar */}
|
||||||
@@ -429,55 +496,83 @@ export const AdminUsersPage: React.FC = () => {
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<TableRow
|
<React.Fragment key={user.auth0Sub}>
|
||||||
key={user.auth0Sub}
|
<TableRow
|
||||||
sx={{ opacity: user.deactivatedAt ? 0.6 : 1 }}
|
sx={{
|
||||||
>
|
opacity: user.deactivatedAt ? 0.6 : 1,
|
||||||
<TableCell>{user.email}</TableCell>
|
'& > *': { borderBottom: expandedRow === user.auth0Sub ? 'unset' : undefined },
|
||||||
<TableCell>{user.displayName || '-'}</TableCell>
|
}}
|
||||||
<TableCell>
|
>
|
||||||
<FormControl size="small" sx={{ minWidth: 100 }}>
|
<TableCell>{user.email}</TableCell>
|
||||||
<Select
|
<TableCell>{user.displayName || '-'}</TableCell>
|
||||||
value={user.subscriptionTier}
|
<TableCell>
|
||||||
onChange={(e) =>
|
<FormControl size="small" sx={{ minWidth: 100 }}>
|
||||||
handleTierChange(user.auth0Sub, e.target.value as SubscriptionTier)
|
<Select
|
||||||
}
|
value={user.subscriptionTier}
|
||||||
disabled={!!user.deactivatedAt || updateTierMutation.isPending}
|
onChange={(e) =>
|
||||||
|
handleTierChange(user.auth0Sub, e.target.value as SubscriptionTier)
|
||||||
|
}
|
||||||
|
disabled={!!user.deactivatedAt || updateTierMutation.isPending}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<MenuItem value="free">Free</MenuItem>
|
||||||
|
<MenuItem value="pro">Pro</MenuItem>
|
||||||
|
<MenuItem value="enterprise">Enterprise</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
{user.vehicleCount > 0 && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setExpandedRow(
|
||||||
|
expandedRow === user.auth0Sub ? null : user.auth0Sub
|
||||||
|
)}
|
||||||
|
aria-label="show vehicles"
|
||||||
|
sx={{ minWidth: 44, minHeight: 44 }}
|
||||||
|
>
|
||||||
|
{expandedRow === user.auth0Sub ? (
|
||||||
|
<KeyboardArrowUp fontSize="small" />
|
||||||
|
) : (
|
||||||
|
<KeyboardArrowDown fontSize="small" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
<Typography variant="body2">{user.vehicleCount}</Typography>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={user.deactivatedAt ? 'Deactivated' : 'Active'}
|
||||||
|
color={user.deactivatedAt ? 'error' : 'success'}
|
||||||
size="small"
|
size="small"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.isAdmin && (
|
||||||
|
<Tooltip title={`Admin (${user.adminRole})`}>
|
||||||
|
<AdminPanelSettings color="primary" />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(user.createdAt).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => handleMenuOpen(e, user)}
|
||||||
>
|
>
|
||||||
<MenuItem value="free">Free</MenuItem>
|
<MoreVert />
|
||||||
<MenuItem value="pro">Pro</MenuItem>
|
</IconButton>
|
||||||
<MenuItem value="enterprise">Enterprise</MenuItem>
|
</TableCell>
|
||||||
</Select>
|
</TableRow>
|
||||||
</FormControl>
|
<UserVehiclesRow
|
||||||
</TableCell>
|
auth0Sub={user.auth0Sub}
|
||||||
<TableCell>{user.vehicleCount}</TableCell>
|
isOpen={expandedRow === user.auth0Sub}
|
||||||
<TableCell>
|
/>
|
||||||
<Chip
|
</React.Fragment>
|
||||||
label={user.deactivatedAt ? 'Deactivated' : 'Active'}
|
|
||||||
color={user.deactivatedAt ? 'error' : 'success'}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{user.isAdmin && (
|
|
||||||
<Tooltip title={`Admin (${user.adminRole})`}>
|
|
||||||
<AdminPanelSettings color="primary" />
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{new Date(user.createdAt).toLocaleDateString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="right">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => handleMenuOpen(e, user)}
|
|
||||||
>
|
|
||||||
<MoreVert />
|
|
||||||
</IconButton>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|||||||
Reference in New Issue
Block a user