Some checks failed
Deploy to Staging / Build Images (pull_request) Successful in 6m41s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Failing after 4m7s
Deploy to Staging / Notify Staging Ready (pull_request) Has been skipped
Deploy to Staging / Notify Staging Failure (pull_request) Successful in 9s
Backend test fixtures: - Replace auth0|xxx format with UUID in all test userId values - Update admin tests for new id/userProfileId schema - Add missing deletionRequestedAt/deletionScheduledFor to auth test mocks - Fix admin integration test supertest usage (app.server) Frontend: - AdminUser type: auth0Sub -> id + userProfileId - admin.api.ts: all user management methods use userId (UUID) params - useUsers/useAdmins hooks: auth0Sub -> userId/id in mutations - AdminUsersPage + AdminUsersMobileScreen: user.auth0Sub -> user.id - Remove encodeURIComponent (UUIDs don't need encoding) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
844 lines
28 KiB
TypeScript
844 lines
28 KiB
TypeScript
/**
|
|
* @ai-summary Desktop admin page for user management
|
|
* @ai-context List users, filter, search, change tiers, deactivate/reactivate
|
|
*/
|
|
|
|
import React, { useState, useCallback } from 'react';
|
|
import { Navigate } from 'react-router-dom';
|
|
import {
|
|
Box,
|
|
Button,
|
|
Chip,
|
|
CircularProgress,
|
|
Collapse,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogContent,
|
|
DialogTitle,
|
|
FormControl,
|
|
IconButton,
|
|
InputAdornment,
|
|
InputLabel,
|
|
Menu,
|
|
MenuItem,
|
|
Paper,
|
|
Select,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableContainer,
|
|
TableHead,
|
|
TablePagination,
|
|
TableRow,
|
|
TextField,
|
|
Tooltip,
|
|
Typography,
|
|
SelectChangeEvent,
|
|
} from '@mui/material';
|
|
import {
|
|
Search,
|
|
Clear,
|
|
MoreVert,
|
|
AdminPanelSettings,
|
|
PersonOff,
|
|
PersonAdd,
|
|
Edit,
|
|
Security,
|
|
DeleteForever,
|
|
DirectionsCar,
|
|
KeyboardArrowDown,
|
|
KeyboardArrowUp,
|
|
} from '@mui/icons-material';
|
|
import { useAdminAccess } from '../../core/auth/useAdminAccess';
|
|
import {
|
|
useUsers,
|
|
useUpdateUserTier,
|
|
useDeactivateUser,
|
|
useReactivateUser,
|
|
useUpdateUserProfile,
|
|
usePromoteToAdmin,
|
|
useHardDeleteUser,
|
|
useAdminStats,
|
|
useUserVehicles,
|
|
} from '../../features/admin/hooks/useUsers';
|
|
import {
|
|
ManagedUser,
|
|
SubscriptionTier,
|
|
ListUsersParams,
|
|
} from '../../features/admin/types/admin.types';
|
|
import { AdminSectionHeader } from '../../features/admin/components';
|
|
|
|
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
|
|
|
// Expandable vehicle row component
|
|
const UserVehiclesRow: React.FC<{ userId: string; isOpen: boolean }> = ({ userId, isOpen }) => {
|
|
const { data, isLoading, error } = useUserVehicles(userId);
|
|
|
|
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();
|
|
|
|
// Filter state
|
|
const [params, setParams] = useState<ListUsersParams>({
|
|
page: 1,
|
|
pageSize: 20,
|
|
status: 'all',
|
|
sortBy: 'createdAt',
|
|
sortOrder: 'desc',
|
|
});
|
|
const [searchInput, setSearchInput] = useState('');
|
|
|
|
// Query
|
|
const { data, isLoading, error } = useUsers(params);
|
|
|
|
// Mutations
|
|
const updateTierMutation = useUpdateUserTier();
|
|
const deactivateMutation = useDeactivateUser();
|
|
const reactivateMutation = useReactivateUser();
|
|
const updateProfileMutation = useUpdateUserProfile();
|
|
const promoteToAdminMutation = usePromoteToAdmin();
|
|
const hardDeleteMutation = useHardDeleteUser();
|
|
|
|
// Action menu state
|
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
|
const [selectedUser, setSelectedUser] = useState<ManagedUser | null>(null);
|
|
|
|
// Deactivate dialog state
|
|
const [deactivateDialogOpen, setDeactivateDialogOpen] = useState(false);
|
|
const [deactivateReason, setDeactivateReason] = useState('');
|
|
|
|
// Edit dialog state
|
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
|
const [editEmail, setEditEmail] = useState('');
|
|
const [editDisplayName, setEditDisplayName] = useState('');
|
|
|
|
// Promote to admin dialog state
|
|
const [promoteDialogOpen, setPromoteDialogOpen] = useState(false);
|
|
const [promoteRole, setPromoteRole] = useState<'admin' | 'super_admin'>('admin');
|
|
|
|
// Hard delete dialog state
|
|
const [hardDeleteDialogOpen, setHardDeleteDialogOpen] = useState(false);
|
|
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 }));
|
|
}, [searchInput]);
|
|
|
|
const handleSearchKeyPress = useCallback(
|
|
(event: React.KeyboardEvent) => {
|
|
if (event.key === 'Enter') {
|
|
handleSearch();
|
|
}
|
|
},
|
|
[handleSearch]
|
|
);
|
|
|
|
const handleClearSearch = useCallback(() => {
|
|
setSearchInput('');
|
|
setParams(prev => ({ ...prev, search: undefined, page: 1 }));
|
|
}, []);
|
|
|
|
const handlePageChange = useCallback((_: unknown, newPage: number) => {
|
|
setParams(prev => ({ ...prev, page: newPage + 1 }));
|
|
}, []);
|
|
|
|
const handleRowsPerPageChange = useCallback(
|
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
setParams(prev => ({ ...prev, pageSize: parseInt(event.target.value, 10), page: 1 }));
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleTierFilterChange = useCallback((event: SelectChangeEvent<string>) => {
|
|
const value = event.target.value;
|
|
setParams(prev => ({
|
|
...prev,
|
|
tier: value ? (value as SubscriptionTier) : undefined,
|
|
page: 1,
|
|
}));
|
|
}, []);
|
|
|
|
const handleStatusFilterChange = useCallback((event: SelectChangeEvent<string>) => {
|
|
setParams(prev => ({
|
|
...prev,
|
|
status: event.target.value as 'active' | 'deactivated' | 'all',
|
|
page: 1,
|
|
}));
|
|
}, []);
|
|
|
|
const handleTierChange = useCallback(
|
|
(userId: string, newTier: SubscriptionTier) => {
|
|
updateTierMutation.mutate({ userId, data: { subscriptionTier: newTier } });
|
|
},
|
|
[updateTierMutation]
|
|
);
|
|
|
|
const handleMenuOpen = useCallback((event: React.MouseEvent<HTMLElement>, user: ManagedUser) => {
|
|
setAnchorEl(event.currentTarget);
|
|
setSelectedUser(user);
|
|
}, []);
|
|
|
|
const handleMenuClose = useCallback(() => {
|
|
setAnchorEl(null);
|
|
setSelectedUser(null);
|
|
}, []);
|
|
|
|
const handleDeactivateClick = useCallback(() => {
|
|
setDeactivateDialogOpen(true);
|
|
setAnchorEl(null);
|
|
}, []);
|
|
|
|
const handleDeactivateConfirm = useCallback(() => {
|
|
if (selectedUser) {
|
|
deactivateMutation.mutate(
|
|
{ userId: selectedUser.id, data: { reason: deactivateReason || undefined } },
|
|
{
|
|
onSuccess: () => {
|
|
setDeactivateDialogOpen(false);
|
|
setDeactivateReason('');
|
|
setSelectedUser(null);
|
|
},
|
|
}
|
|
);
|
|
}
|
|
}, [selectedUser, deactivateReason, deactivateMutation]);
|
|
|
|
const handleReactivate = useCallback(() => {
|
|
if (selectedUser) {
|
|
reactivateMutation.mutate(selectedUser.id);
|
|
setAnchorEl(null);
|
|
setSelectedUser(null);
|
|
}
|
|
}, [selectedUser, reactivateMutation]);
|
|
|
|
const handleEditClick = useCallback(() => {
|
|
if (selectedUser) {
|
|
setEditEmail(selectedUser.email);
|
|
setEditDisplayName(selectedUser.displayName || '');
|
|
setEditDialogOpen(true);
|
|
setAnchorEl(null);
|
|
}
|
|
}, [selectedUser]);
|
|
|
|
const handleEditConfirm = useCallback(() => {
|
|
if (selectedUser) {
|
|
const updates: { email?: string; displayName?: string } = {};
|
|
if (editEmail !== selectedUser.email) {
|
|
updates.email = editEmail;
|
|
}
|
|
if (editDisplayName !== (selectedUser.displayName || '')) {
|
|
updates.displayName = editDisplayName;
|
|
}
|
|
if (Object.keys(updates).length > 0) {
|
|
updateProfileMutation.mutate(
|
|
{ userId: selectedUser.id, data: updates },
|
|
{
|
|
onSuccess: () => {
|
|
setEditDialogOpen(false);
|
|
setEditEmail('');
|
|
setEditDisplayName('');
|
|
setSelectedUser(null);
|
|
},
|
|
}
|
|
);
|
|
}
|
|
}
|
|
}, [selectedUser, editEmail, editDisplayName, updateProfileMutation]);
|
|
|
|
const handleEditCancel = useCallback(() => {
|
|
setEditDialogOpen(false);
|
|
setEditEmail('');
|
|
setEditDisplayName('');
|
|
setSelectedUser(null);
|
|
}, []);
|
|
|
|
const handlePromoteClick = useCallback(() => {
|
|
setPromoteRole('admin');
|
|
setPromoteDialogOpen(true);
|
|
setAnchorEl(null);
|
|
}, []);
|
|
|
|
const handlePromoteConfirm = useCallback(() => {
|
|
if (selectedUser) {
|
|
promoteToAdminMutation.mutate(
|
|
{ userId: selectedUser.id, data: { role: promoteRole } },
|
|
{
|
|
onSuccess: () => {
|
|
setPromoteDialogOpen(false);
|
|
setPromoteRole('admin');
|
|
setSelectedUser(null);
|
|
},
|
|
}
|
|
);
|
|
}
|
|
}, [selectedUser, promoteRole, promoteToAdminMutation]);
|
|
|
|
const handlePromoteCancel = useCallback(() => {
|
|
setPromoteDialogOpen(false);
|
|
setPromoteRole('admin');
|
|
setSelectedUser(null);
|
|
}, []);
|
|
|
|
const handleHardDeleteClick = useCallback(() => {
|
|
setHardDeleteDialogOpen(true);
|
|
setAnchorEl(null);
|
|
}, []);
|
|
|
|
const handleHardDeleteConfirm = useCallback(() => {
|
|
if (selectedUser && hardDeleteConfirmText === 'DELETE') {
|
|
hardDeleteMutation.mutate(
|
|
{ userId: selectedUser.id, reason: hardDeleteReason || undefined },
|
|
{
|
|
onSuccess: () => {
|
|
setHardDeleteDialogOpen(false);
|
|
setHardDeleteReason('');
|
|
setHardDeleteConfirmText('');
|
|
setSelectedUser(null);
|
|
},
|
|
}
|
|
);
|
|
}
|
|
}, [selectedUser, hardDeleteReason, hardDeleteConfirmText, hardDeleteMutation]);
|
|
|
|
const handleHardDeleteCancel = useCallback(() => {
|
|
setHardDeleteDialogOpen(false);
|
|
setHardDeleteReason('');
|
|
setHardDeleteConfirmText('');
|
|
setSelectedUser(null);
|
|
}, []);
|
|
|
|
// Loading state
|
|
if (adminLoading) {
|
|
return (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
|
|
<CircularProgress />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Not admin redirect
|
|
if (!isAdmin) {
|
|
return <Navigate to="/garage/settings" replace />;
|
|
}
|
|
|
|
const users = data?.users || [];
|
|
const total = data?.total || 0;
|
|
|
|
return (
|
|
<Box sx={{ py: 2, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
<AdminSectionHeader
|
|
title="User Management"
|
|
stats={[
|
|
{ label: 'Total Users', value: statsData?.totalUsers ?? total },
|
|
{ label: 'Total Vehicles', value: statsData?.totalVehicles ?? 0 },
|
|
]}
|
|
/>
|
|
|
|
{/* Filters Bar */}
|
|
<Paper elevation={1} sx={{ p: 2, borderRadius: 1.5 }}>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: { xs: 'column', md: 'row' },
|
|
gap: 2,
|
|
alignItems: { xs: 'stretch', md: 'center' },
|
|
}}
|
|
>
|
|
{/* Search Input */}
|
|
<TextField
|
|
placeholder="Search by email or name..."
|
|
value={searchInput}
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
onKeyPress={handleSearchKeyPress}
|
|
size="small"
|
|
sx={{ flex: 1, minWidth: 250 }}
|
|
InputProps={{
|
|
startAdornment: (
|
|
<InputAdornment position="start">
|
|
<Search color="action" />
|
|
</InputAdornment>
|
|
),
|
|
endAdornment: searchInput && (
|
|
<InputAdornment position="end">
|
|
<IconButton size="small" onClick={handleClearSearch}>
|
|
<Clear />
|
|
</IconButton>
|
|
</InputAdornment>
|
|
),
|
|
}}
|
|
/>
|
|
|
|
{/* Search Button */}
|
|
<Button
|
|
variant="contained"
|
|
onClick={handleSearch}
|
|
disabled={isLoading}
|
|
sx={{ textTransform: 'none', minWidth: 100 }}
|
|
>
|
|
{isLoading ? <CircularProgress size={20} /> : 'Search'}
|
|
</Button>
|
|
|
|
{/* Tier Filter */}
|
|
<FormControl size="small" sx={{ minWidth: 120 }}>
|
|
<InputLabel>Tier</InputLabel>
|
|
<Select
|
|
value={params.tier || ''}
|
|
label="Tier"
|
|
onChange={handleTierFilterChange}
|
|
>
|
|
<MenuItem value="">All</MenuItem>
|
|
<MenuItem value="free">Free</MenuItem>
|
|
<MenuItem value="pro">Pro</MenuItem>
|
|
<MenuItem value="enterprise">Enterprise</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
|
|
{/* Status Filter */}
|
|
<FormControl size="small" sx={{ minWidth: 140 }}>
|
|
<InputLabel>Status</InputLabel>
|
|
<Select
|
|
value={params.status || 'all'}
|
|
label="Status"
|
|
onChange={handleStatusFilterChange}
|
|
>
|
|
<MenuItem value="all">All</MenuItem>
|
|
<MenuItem value="active">Active</MenuItem>
|
|
<MenuItem value="deactivated">Deactivated</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
</Box>
|
|
</Paper>
|
|
|
|
{/* Users Table */}
|
|
<Paper elevation={1} sx={{ borderRadius: 1.5 }}>
|
|
{isLoading ? (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
|
<CircularProgress />
|
|
</Box>
|
|
) : error ? (
|
|
<Box sx={{ p: 4, textAlign: 'center' }}>
|
|
<Typography color="error">Failed to load users. Please try again.</Typography>
|
|
</Box>
|
|
) : users.length === 0 ? (
|
|
<Box sx={{ p: 4, textAlign: 'center' }}>
|
|
<Typography color="text.secondary">No users found matching your criteria.</Typography>
|
|
</Box>
|
|
) : (
|
|
<>
|
|
<TableContainer>
|
|
<Table>
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>Email</TableCell>
|
|
<TableCell>Display Name</TableCell>
|
|
<TableCell>Tier</TableCell>
|
|
<TableCell>Vehicles</TableCell>
|
|
<TableCell>Status</TableCell>
|
|
<TableCell>Admin</TableCell>
|
|
<TableCell>Created</TableCell>
|
|
<TableCell align="right">Actions</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{users.map((user) => (
|
|
<React.Fragment key={user.id}>
|
|
<TableRow
|
|
sx={{
|
|
opacity: user.deactivatedAt ? 0.6 : 1,
|
|
'& > *': { borderBottom: expandedRow === user.id ? '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.id, 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.id ? null : user.id
|
|
)}
|
|
aria-label="show vehicles"
|
|
sx={{ minWidth: 44, minHeight: 44 }}
|
|
>
|
|
{expandedRow === user.id ? (
|
|
<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)}
|
|
>
|
|
<MoreVert />
|
|
</IconButton>
|
|
</TableCell>
|
|
</TableRow>
|
|
<UserVehiclesRow
|
|
userId={user.id}
|
|
isOpen={expandedRow === user.id}
|
|
/>
|
|
</React.Fragment>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
|
|
<TablePagination
|
|
component="div"
|
|
count={total}
|
|
page={(params.page || 1) - 1}
|
|
rowsPerPage={params.pageSize || 20}
|
|
onPageChange={handlePageChange}
|
|
onRowsPerPageChange={handleRowsPerPageChange}
|
|
rowsPerPageOptions={PAGE_SIZE_OPTIONS}
|
|
/>
|
|
</>
|
|
)}
|
|
</Paper>
|
|
|
|
{/* Action Menu */}
|
|
<Menu
|
|
anchorEl={anchorEl}
|
|
open={Boolean(anchorEl)}
|
|
onClose={handleMenuClose}
|
|
>
|
|
<MenuItem onClick={handleEditClick}>
|
|
<Edit sx={{ mr: 1 }} fontSize="small" />
|
|
Edit User
|
|
</MenuItem>
|
|
{!selectedUser?.isAdmin && (
|
|
<MenuItem onClick={handlePromoteClick}>
|
|
<Security sx={{ mr: 1 }} fontSize="small" />
|
|
Promote to Admin
|
|
</MenuItem>
|
|
)}
|
|
{selectedUser?.deactivatedAt ? (
|
|
<MenuItem onClick={handleReactivate}>
|
|
<PersonAdd sx={{ mr: 1 }} fontSize="small" />
|
|
Reactivate User
|
|
</MenuItem>
|
|
) : (
|
|
<MenuItem onClick={handleDeactivateClick} sx={{ color: 'error.main' }}>
|
|
<PersonOff sx={{ mr: 1 }} fontSize="small" />
|
|
Deactivate User
|
|
</MenuItem>
|
|
)}
|
|
{!selectedUser?.isAdmin && (
|
|
<MenuItem onClick={handleHardDeleteClick} sx={{ color: 'error.main' }}>
|
|
<DeleteForever sx={{ mr: 1 }} fontSize="small" />
|
|
Delete Permanently
|
|
</MenuItem>
|
|
)}
|
|
</Menu>
|
|
|
|
{/* Deactivate Confirmation Dialog */}
|
|
<Dialog
|
|
open={deactivateDialogOpen}
|
|
onClose={() => !deactivateMutation.isPending && setDeactivateDialogOpen(false)}
|
|
>
|
|
<DialogTitle>Deactivate User</DialogTitle>
|
|
<DialogContent>
|
|
<Typography sx={{ mb: 2 }}>
|
|
Are you sure you want to deactivate{' '}
|
|
<strong>{selectedUser?.email}</strong>?
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
The user will no longer be able to log in, but their data will be preserved.
|
|
</Typography>
|
|
<TextField
|
|
label="Reason (optional)"
|
|
value={deactivateReason}
|
|
onChange={(e) => setDeactivateReason(e.target.value)}
|
|
fullWidth
|
|
multiline
|
|
rows={2}
|
|
placeholder="Enter a reason for deactivation..."
|
|
/>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button
|
|
onClick={() => setDeactivateDialogOpen(false)}
|
|
disabled={deactivateMutation.isPending}
|
|
sx={{ textTransform: 'none' }}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleDeactivateConfirm}
|
|
disabled={deactivateMutation.isPending}
|
|
color="error"
|
|
variant="contained"
|
|
sx={{ textTransform: 'none' }}
|
|
>
|
|
{deactivateMutation.isPending ? <CircularProgress size={20} /> : 'Deactivate'}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
{/* Edit User Dialog */}
|
|
<Dialog
|
|
open={editDialogOpen}
|
|
onClose={() => !updateProfileMutation.isPending && handleEditCancel()}
|
|
maxWidth="sm"
|
|
fullWidth
|
|
>
|
|
<DialogTitle>Edit User</DialogTitle>
|
|
<DialogContent>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
|
<TextField
|
|
label="Email"
|
|
type="email"
|
|
value={editEmail}
|
|
onChange={(e) => setEditEmail(e.target.value)}
|
|
fullWidth
|
|
/>
|
|
<TextField
|
|
label="Display Name"
|
|
value={editDisplayName}
|
|
onChange={(e) => setEditDisplayName(e.target.value)}
|
|
fullWidth
|
|
placeholder="Enter display name..."
|
|
/>
|
|
</Box>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button
|
|
onClick={handleEditCancel}
|
|
disabled={updateProfileMutation.isPending}
|
|
sx={{ textTransform: 'none' }}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleEditConfirm}
|
|
disabled={updateProfileMutation.isPending || (editEmail === selectedUser?.email && editDisplayName === (selectedUser?.displayName || ''))}
|
|
color="primary"
|
|
variant="contained"
|
|
sx={{ textTransform: 'none' }}
|
|
>
|
|
{updateProfileMutation.isPending ? <CircularProgress size={20} /> : 'Save'}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
{/* Promote to Admin Dialog */}
|
|
<Dialog
|
|
open={promoteDialogOpen}
|
|
onClose={() => !promoteToAdminMutation.isPending && handlePromoteCancel()}
|
|
maxWidth="sm"
|
|
fullWidth
|
|
>
|
|
<DialogTitle>Promote to Admin</DialogTitle>
|
|
<DialogContent>
|
|
<Typography sx={{ mb: 3 }}>
|
|
Promote <strong>{selectedUser?.email}</strong> to an administrator role.
|
|
</Typography>
|
|
<FormControl fullWidth>
|
|
<InputLabel>Admin Role</InputLabel>
|
|
<Select
|
|
value={promoteRole}
|
|
label="Admin Role"
|
|
onChange={(e) => setPromoteRole(e.target.value as 'admin' | 'super_admin')}
|
|
>
|
|
<MenuItem value="admin">Admin</MenuItem>
|
|
<MenuItem value="super_admin">Super Admin</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
|
Admins can manage users, catalog data, and view audit logs.
|
|
Super Admins have additional permissions to manage other administrators.
|
|
</Typography>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button
|
|
onClick={handlePromoteCancel}
|
|
disabled={promoteToAdminMutation.isPending}
|
|
sx={{ textTransform: 'none' }}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handlePromoteConfirm}
|
|
disabled={promoteToAdminMutation.isPending}
|
|
color="primary"
|
|
variant="contained"
|
|
sx={{ textTransform: 'none' }}
|
|
>
|
|
{promoteToAdminMutation.isPending ? <CircularProgress size={20} /> : 'Promote'}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
{/* Hard Delete Confirmation Dialog */}
|
|
<Dialog
|
|
open={hardDeleteDialogOpen}
|
|
onClose={() => !hardDeleteMutation.isPending && handleHardDeleteCancel()}
|
|
maxWidth="sm"
|
|
fullWidth
|
|
>
|
|
<DialogTitle sx={{ color: 'error.main' }}>
|
|
Permanently Delete User
|
|
</DialogTitle>
|
|
<DialogContent>
|
|
<Box sx={{ bgcolor: 'error.light', color: 'error.contrastText', p: 2, borderRadius: 1, mb: 3 }}>
|
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
|
Warning: This action cannot be undone!
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ mt: 1 }}>
|
|
All user data will be permanently deleted, including vehicles, fuel logs,
|
|
maintenance records, and documents. The user's Auth0 account will also be deleted.
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Typography sx={{ mb: 2 }}>
|
|
Are you sure you want to permanently delete{' '}
|
|
<strong>{selectedUser?.email}</strong>?
|
|
</Typography>
|
|
|
|
<TextField
|
|
label="Reason for deletion"
|
|
value={hardDeleteReason}
|
|
onChange={(e) => setHardDeleteReason(e.target.value)}
|
|
fullWidth
|
|
multiline
|
|
rows={2}
|
|
placeholder="GDPR request, user request, etc..."
|
|
sx={{ mb: 3 }}
|
|
/>
|
|
|
|
<Box>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
|
Type <strong>DELETE</strong> to confirm:
|
|
</Typography>
|
|
<TextField
|
|
value={hardDeleteConfirmText}
|
|
onChange={(e) => setHardDeleteConfirmText(e.target.value.toUpperCase())}
|
|
fullWidth
|
|
placeholder="Type DELETE"
|
|
error={hardDeleteConfirmText.length > 0 && hardDeleteConfirmText !== 'DELETE'}
|
|
helperText={
|
|
hardDeleteConfirmText.length > 0 && hardDeleteConfirmText !== 'DELETE'
|
|
? 'Please type DELETE exactly'
|
|
: ''
|
|
}
|
|
/>
|
|
</Box>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button
|
|
onClick={handleHardDeleteCancel}
|
|
disabled={hardDeleteMutation.isPending}
|
|
sx={{ textTransform: 'none' }}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleHardDeleteConfirm}
|
|
disabled={hardDeleteMutation.isPending || hardDeleteConfirmText !== 'DELETE'}
|
|
color="error"
|
|
variant="contained"
|
|
sx={{ textTransform: 'none' }}
|
|
>
|
|
{hardDeleteMutation.isPending ? <CircularProgress size={20} /> : 'Delete Permanently'}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Box>
|
|
);
|
|
};
|