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

- 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:
Eric Gullickson
2026-01-04 13:18:38 -06:00
parent 2ec208e25a
commit 4fc5b391e1
10 changed files with 639 additions and 67 deletions

View File

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