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:
@@ -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 = () => {
|
||||
)}
|
||||
</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 */}
|
||||
<Card>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
|
||||
|
||||
@@ -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 (
|
||||
<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 = () => {
|
||||
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<string | null>(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 = () => {
|
||||
<Box sx={{ py: 2, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminSectionHeader
|
||||
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 */}
|
||||
@@ -429,55 +496,83 @@ export const AdminUsersPage: React.FC = () => {
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow
|
||||
key={user.auth0Sub}
|
||||
sx={{ opacity: user.deactivatedAt ? 0.6 : 1 }}
|
||||
>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>{user.displayName || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<FormControl size="small" sx={{ minWidth: 100 }}>
|
||||
<Select
|
||||
value={user.subscriptionTier}
|
||||
onChange={(e) =>
|
||||
handleTierChange(user.auth0Sub, e.target.value as SubscriptionTier)
|
||||
}
|
||||
disabled={!!user.deactivatedAt || updateTierMutation.isPending}
|
||||
<React.Fragment key={user.auth0Sub}>
|
||||
<TableRow
|
||||
sx={{
|
||||
opacity: user.deactivatedAt ? 0.6 : 1,
|
||||
'& > *': { borderBottom: expandedRow === user.auth0Sub ? 'unset' : undefined },
|
||||
}}
|
||||
>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>{user.displayName || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<FormControl size="small" sx={{ minWidth: 100 }}>
|
||||
<Select
|
||||
value={user.subscriptionTier}
|
||||
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"
|
||||
/>
|
||||
</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>
|
||||
<MenuItem value="pro">Pro</MenuItem>
|
||||
<MenuItem value="enterprise">Enterprise</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</TableCell>
|
||||
<TableCell>{user.vehicleCount}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
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>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<UserVehiclesRow
|
||||
auth0Sub={user.auth0Sub}
|
||||
isOpen={expandedRow === user.auth0Sub}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
Reference in New Issue
Block a user