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:
@@ -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<AdminStatsResponse> => {
|
||||
const response = await apiClient.get<AdminStatsResponse>('/admin/stats');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Admin management
|
||||
listAdmins: async (): Promise<AdminUser[]> => {
|
||||
const response = await apiClient.get<AdminUser[]>('/admin/admins');
|
||||
@@ -309,6 +332,13 @@ export const adminApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getVehicles: async (auth0Sub: string): Promise<AdminUserVehiclesResponse> => {
|
||||
const response = await apiClient.get<AdminUserVehiclesResponse>(
|
||||
`/admin/users/${encodeURIComponent(auth0Sub)}/vehicles`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateTier: async (auth0Sub: string, data: UpdateUserTierRequest): Promise<ManagedUser> => {
|
||||
const response = await apiClient.patch<ManagedUser>(
|
||||
`/admin/users/${encodeURIComponent(auth0Sub)}/tier`,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 }) => (
|
||||
<span className="px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-700">
|
||||
const VehicleCountBadge: React.FC<{ count: number; onClick?: () => void }> = ({ count, onClick }) => (
|
||||
<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'}
|
||||
</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 = () => {
|
||||
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<string | null>(null);
|
||||
|
||||
// Mutations
|
||||
const updateTierMutation = useUpdateUserTier();
|
||||
const deactivateMutation = useDeactivateUser();
|
||||
@@ -328,10 +383,17 @@ export const AdminUsersMobileScreen: React.FC = () => {
|
||||
<div className="space-y-4 pb-20 p-4">
|
||||
{/* Header */}
|
||||
<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">
|
||||
{total} user{total !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-avus">User Management</h1>
|
||||
<div className="flex justify-center gap-4 mt-3">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/30 px-4 py-2 rounded-lg">
|
||||
<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>
|
||||
|
||||
{/* Search Bar */}
|
||||
@@ -454,13 +516,18 @@ export const AdminUsersMobileScreen: React.FC = () => {
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<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 && (
|
||||
<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">
|
||||
<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} />
|
||||
{user.isAdmin && (
|
||||
<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>
|
||||
</div>
|
||||
</button>
|
||||
<UserVehiclesList
|
||||
auth0Sub={user.auth0Sub}
|
||||
isOpen={expandedUserId === user.auth0Sub}
|
||||
/>
|
||||
</GlassCard>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
</div>
|
||||
</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 */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user