Notification updates

This commit is contained in:
Eric Gullickson
2025-12-21 19:56:52 -06:00
parent 144f1d5bb0
commit 719c80ecd8
80 changed files with 7552 additions and 678 deletions

View File

@@ -7,6 +7,7 @@ import { useAuth0 } from '@auth0/auth0-react';
import { useNavigate } from 'react-router-dom';
import { useUnits } from '../core/units/UnitsContext';
import { useAdminAccess } from '../core/auth/useAdminAccess';
import { useProfile, useUpdateProfile } from '../features/settings/hooks/useProfile';
import {
Box,
Typography,
@@ -21,7 +22,9 @@ import {
Button as MuiButton,
Select,
MenuItem,
FormControl
FormControl,
TextField,
CircularProgress
} from '@mui/material';
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import NotificationsIcon from '@mui/icons-material/Notifications';
@@ -29,6 +32,9 @@ 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 EditIcon from '@mui/icons-material/Edit';
import SaveIcon from '@mui/icons-material/Save';
import CancelIcon from '@mui/icons-material/Cancel';
import { Card } from '../shared-minimal/components/Card';
export const SettingsPage: React.FC = () => {
@@ -40,10 +46,59 @@ export const SettingsPage: React.FC = () => {
const [emailUpdates, setEmailUpdates] = useState(false);
const [darkMode, setDarkMode] = useState(false);
// Profile state
const { data: profile, isLoading: profileLoading } = useProfile();
const updateProfileMutation = useUpdateProfile();
const [isEditingProfile, setIsEditingProfile] = useState(false);
const [editedDisplayName, setEditedDisplayName] = useState('');
const [editedNotificationEmail, setEditedNotificationEmail] = useState('');
// Initialize edit form when profile loads or edit mode starts
React.useEffect(() => {
if (profile && isEditingProfile) {
setEditedDisplayName(profile.displayName || '');
setEditedNotificationEmail(profile.notificationEmail || '');
}
}, [profile, isEditingProfile]);
const handleLogout = () => {
logout({ logoutParams: { returnTo: window.location.origin } });
};
const handleEditProfile = () => {
setIsEditingProfile(true);
};
const handleCancelEdit = () => {
setIsEditingProfile(false);
setEditedDisplayName(profile?.displayName || '');
setEditedNotificationEmail(profile?.notificationEmail || '');
};
const handleSaveProfile = async () => {
const updates: { displayName?: string; notificationEmail?: string } = {};
if (editedDisplayName !== (profile?.displayName || '')) {
updates.displayName = editedDisplayName;
}
if (editedNotificationEmail !== (profile?.notificationEmail || '')) {
updates.notificationEmail = editedNotificationEmail || undefined;
}
if (Object.keys(updates).length === 0) {
setIsEditingProfile(false);
return;
}
try {
await updateProfileMutation.mutateAsync(updates);
setIsEditingProfile(false);
} catch (error) {
// Error is handled by the mutation hook
}
};
return (
<Box sx={{ py: 2 }}>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary', mb: 4 }}>
@@ -51,69 +106,149 @@ export const SettingsPage: React.FC = () => {
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Account Section */}
{/* Profile Section */}
<Card>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
Account
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
<Avatar
sx={{
width: 64,
height: 64,
bgcolor: 'primary.main',
fontSize: '1.5rem',
fontWeight: 600,
mr: 3
}}
>
{user?.name?.charAt(0) || user?.email?.charAt(0)}
</Avatar>
<Box>
<Typography variant="h6" sx={{ fontWeight: 500 }}>
{user?.name || 'User'}
</Typography>
<Typography variant="body2" color="text.secondary">
{user?.email}
</Typography>
<Typography variant="caption" color="text.secondary">
Verified account
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Profile
</Typography>
{!isEditingProfile && !profileLoading && (
<MuiButton
variant="outlined"
size="small"
startIcon={<EditIcon />}
onClick={handleEditProfile}
>
Edit
</MuiButton>
)}
</Box>
<List disablePadding>
<ListItem>
<ListItemIcon>
<AccountCircleIcon />
</ListItemIcon>
<ListItemText
primary="Profile Information"
secondary="Manage your account details"
/>
<ListItemSecondaryAction>
<MuiButton variant="outlined" size="small">
Edit
</MuiButton>
</ListItemSecondaryAction>
</ListItem>
<Divider />
<ListItem>
<ListItemIcon>
<SecurityIcon />
</ListItemIcon>
<ListItemText
primary="Security & Privacy"
secondary="Password, two-factor authentication"
/>
<ListItemSecondaryAction>
<MuiButton variant="outlined" size="small">
Manage
</MuiButton>
</ListItemSecondaryAction>
</ListItem>
</List>
{profileLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : (
<>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
<Avatar
sx={{
width: 64,
height: 64,
bgcolor: 'primary.main',
fontSize: '1.5rem',
fontWeight: 600,
mr: 3
}}
>
{profile?.displayName?.charAt(0) || user?.name?.charAt(0) || user?.email?.charAt(0)}
</Avatar>
<Box>
<Typography variant="h6" sx={{ fontWeight: 500 }}>
{profile?.displayName || user?.name || 'User'}
</Typography>
<Typography variant="body2" color="text.secondary">
{profile?.email || user?.email}
</Typography>
<Typography variant="caption" color="text.secondary">
Verified account
</Typography>
</Box>
</Box>
{isEditingProfile ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<TextField
label="Email"
value={profile?.email || ''}
disabled
fullWidth
helperText="Email is managed by your account provider and cannot be changed here"
variant="outlined"
/>
<TextField
label="Display Name"
value={editedDisplayName}
onChange={(e) => setEditedDisplayName(e.target.value)}
fullWidth
placeholder="Enter your display name"
variant="outlined"
/>
<TextField
label="Notification Email"
value={editedNotificationEmail}
onChange={(e) => setEditedNotificationEmail(e.target.value)}
fullWidth
placeholder="Leave blank to use your primary email"
helperText="Optional: Use a different email address for notifications"
variant="outlined"
type="email"
/>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
<MuiButton
variant="outlined"
startIcon={<CancelIcon />}
onClick={handleCancelEdit}
disabled={updateProfileMutation.isPending}
>
Cancel
</MuiButton>
<MuiButton
variant="contained"
startIcon={updateProfileMutation.isPending ? <CircularProgress size={20} /> : <SaveIcon />}
onClick={handleSaveProfile}
disabled={updateProfileMutation.isPending}
>
Save
</MuiButton>
</Box>
</Box>
) : (
<List disablePadding>
<ListItem>
<ListItemIcon>
<AccountCircleIcon />
</ListItemIcon>
<ListItemText
primary="Email"
secondary={profile?.email || user?.email || 'Not available'}
/>
</ListItem>
<Divider />
<ListItem>
<ListItemText
primary="Display Name"
secondary={profile?.displayName || 'Not set'}
sx={{ pl: 7 }}
/>
</ListItem>
<Divider />
<ListItem>
<ListItemText
primary="Notification Email"
secondary={profile?.notificationEmail || 'Using primary email'}
sx={{ pl: 7 }}
/>
</ListItem>
<Divider />
<ListItem>
<ListItemIcon>
<SecurityIcon />
</ListItemIcon>
<ListItemText
primary="Security & Privacy"
secondary="Password, two-factor authentication"
/>
<ListItemSecondaryAction>
<MuiButton variant="outlined" size="small">
Manage
</MuiButton>
</ListItemSecondaryAction>
</ListItem>
</List>
)}
</>
)}
</Card>
{/* Notifications Section */}
@@ -306,6 +441,23 @@ export const SettingsPage: React.FC = () => {
</MuiButton>
</ListItemSecondaryAction>
</ListItem>
<Divider />
<ListItem>
<ListItemText
primary="Email Templates"
secondary="Manage notification email templates"
sx={{ pl: 7 }}
/>
<ListItemSecondaryAction>
<MuiButton
variant="outlined"
size="small"
onClick={() => navigate('/garage/settings/admin/email-templates')}
>
Manage
</MuiButton>
</ListItemSecondaryAction>
</ListItem>
</List>
</Card>
)}

View File

@@ -0,0 +1,395 @@
/**
* @ai-summary Admin Email Templates page for managing notification email templates
* @ai-context Desktop version with template list and edit dialog
*/
import React, { useState, useCallback } from 'react';
import { Navigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Box,
Button,
Card,
CardContent,
CardActions,
Chip,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
Grid,
Switch,
TextField,
Typography,
Alert,
Divider,
} from '@mui/material';
import { Email, Edit, Visibility, Send } from '@mui/icons-material';
import toast from 'react-hot-toast';
import { useAdminAccess } from '../../core/auth/useAdminAccess';
import { adminApi } from '../../features/admin/api/admin.api';
import { Box as MuiBox } from '@mui/material';
import { EmailTemplate, UpdateEmailTemplateRequest } from '../../features/admin/types/admin.types';
const SAMPLE_VARIABLES: Record<string, string> = {
userName: 'John Doe',
vehicleName: '2024 Toyota Camry',
category: 'Routine Maintenance',
subtypes: 'Oil Change, Air Filter',
dueDate: '2025-01-15',
dueMileage: '50,000',
documentType: 'Insurance',
documentTitle: 'State Farm Policy',
expirationDate: '2025-02-28',
};
export const AdminEmailTemplatesPage: React.FC = () => {
const { loading: authLoading, isAdmin } = useAdminAccess();
const queryClient = useQueryClient();
// State
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<EmailTemplate | null>(null);
const [editSubject, setEditSubject] = useState('');
const [editBody, setEditBody] = useState('');
const [editIsActive, setEditIsActive] = useState(true);
const [previewSubject, setPreviewSubject] = useState('');
const [previewBody, setPreviewBody] = useState('');
// Queries
const { data: templates, isLoading } = useQuery({
queryKey: ['admin', 'emailTemplates'],
queryFn: () => adminApi.emailTemplates.list(),
enabled: isAdmin,
});
// Mutations
const updateMutation = useMutation({
mutationFn: ({ key, data }: { key: string; data: UpdateEmailTemplateRequest }) =>
adminApi.emailTemplates.update(key, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'emailTemplates'] });
toast.success('Email template updated successfully');
handleCloseEditDialog();
},
onError: () => {
toast.error('Failed to update email template');
},
});
const previewMutation = useMutation({
mutationFn: ({ key, variables }: { key: string; variables: Record<string, string> }) =>
adminApi.emailTemplates.preview(key, variables),
onSuccess: (data) => {
setPreviewSubject(data.subject);
setPreviewBody(data.body);
setPreviewDialogOpen(true);
},
onError: () => {
toast.error('Failed to generate preview');
},
});
const sendTestMutation = useMutation({
mutationFn: (key: string) => adminApi.emailTemplates.sendTest(key),
onSuccess: (data) => {
if (data.error) {
toast.error(`Test email failed: ${data.error}`);
} else if (data.message) {
toast.success(data.message);
}
},
onError: () => {
toast.error('Failed to send test email');
},
});
// Handlers
const handleEditClick = useCallback((template: EmailTemplate) => {
setSelectedTemplate(template);
setEditSubject(template.subject);
setEditBody(template.body);
setEditIsActive(template.isActive);
setEditDialogOpen(true);
}, []);
const handlePreviewClick = useCallback((template: EmailTemplate) => {
setSelectedTemplate(template);
previewMutation.mutate({
key: template.templateKey,
variables: SAMPLE_VARIABLES,
});
}, [previewMutation]);
const handleSendTestClick = useCallback((template: EmailTemplate) => {
sendTestMutation.mutate(template.templateKey);
}, [sendTestMutation]);
const handleCloseEditDialog = useCallback(() => {
setEditDialogOpen(false);
setSelectedTemplate(null);
setEditSubject('');
setEditBody('');
setEditIsActive(true);
}, []);
const handleClosePreviewDialog = useCallback(() => {
setPreviewDialogOpen(false);
setPreviewSubject('');
setPreviewBody('');
}, []);
const handleSave = useCallback(() => {
if (!selectedTemplate) return;
const data: UpdateEmailTemplateRequest = {
subject: editSubject !== selectedTemplate.subject ? editSubject : undefined,
body: editBody !== selectedTemplate.body ? editBody : undefined,
isActive: editIsActive !== selectedTemplate.isActive ? editIsActive : undefined,
};
// Only update if there are changes
if (data.subject || data.body || data.isActive !== undefined) {
updateMutation.mutate({
key: selectedTemplate.templateKey,
data,
});
} else {
handleCloseEditDialog();
}
}, [selectedTemplate, editSubject, editBody, editIsActive, updateMutation, handleCloseEditDialog]);
// Auth loading
if (authLoading) {
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<CircularProgress />
</Box>
</Container>
);
}
// Not admin
if (!isAdmin) {
return <Navigate to="/garage/settings" replace />;
}
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<MuiBox sx={{ mb: 4 }}>
<Typography variant="h4" component="h1" sx={{ mb: 1, fontWeight: 600 }}>
<Box display="flex" alignItems="center" gap={2}>
<Email />
Email Templates
</Box>
</Typography>
<Typography variant="body1" color="text.secondary">
Manage notification email templates
</Typography>
</MuiBox>
<Box sx={{ mt: 4 }}>
{isLoading ? (
<Box display="flex" justifyContent="center" py={8}>
<CircularProgress />
</Box>
) : (
<Grid container spacing={3}>
{templates?.map((template) => (
<Grid item xs={12} md={6} key={template.id}>
<Card>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="start" mb={2}>
<Typography variant="h6" component="div">
{template.name}
</Typography>
<Chip
label={template.isActive ? 'Active' : 'Inactive'}
color={template.isActive ? 'success' : 'default'}
size="small"
/>
</Box>
{template.description && (
<Typography variant="body2" color="text.secondary" gutterBottom>
{template.description}
</Typography>
)}
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" gutterBottom>
Subject
</Typography>
<Typography
variant="body2"
color="text.secondary"
gutterBottom
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{template.subject}
</Typography>
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>
Available Variables
</Typography>
<Box display="flex" flexWrap="wrap" gap={0.5}>
{template.variables.map((variable) => (
<Chip
key={variable}
label={`{{${variable}}}`}
size="small"
variant="outlined"
/>
))}
</Box>
</CardContent>
<CardActions>
<Button
size="small"
startIcon={<Edit />}
onClick={() => handleEditClick(template)}
>
Edit
</Button>
<Button
size="small"
startIcon={<Visibility />}
onClick={() => handlePreviewClick(template)}
>
Preview
</Button>
<Button
size="small"
startIcon={<Send />}
onClick={() => handleSendTestClick(template)}
disabled={sendTestMutation.isPending}
>
{sendTestMutation.isPending ? 'Sending...' : 'Send Test'}
</Button>
</CardActions>
</Card>
</Grid>
))}
</Grid>
)}
</Box>
{/* Edit Dialog */}
<Dialog
open={editDialogOpen}
onClose={handleCloseEditDialog}
maxWidth="md"
fullWidth
>
<DialogTitle>
Edit Template: {selectedTemplate?.name}
</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 3 }}>
<FormControlLabel
control={
<Switch
checked={editIsActive}
onChange={(e) => setEditIsActive(e.target.checked)}
/>
}
label="Active"
/>
<TextField
label="Subject"
fullWidth
value={editSubject}
onChange={(e) => setEditSubject(e.target.value)}
helperText="Use {{variableName}} for dynamic content"
/>
<TextField
label="Body"
fullWidth
multiline
rows={12}
value={editBody}
onChange={(e) => setEditBody(e.target.value)}
helperText="Use {{variableName}} for dynamic content"
inputProps={{
style: { fontFamily: 'monospace' },
}}
/>
<Alert severity="info">
Available variables:{' '}
{selectedTemplate?.variables.map((v) => `{{${v}}}`).join(', ')}
</Alert>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseEditDialog}>Cancel</Button>
<Button
onClick={handleSave}
variant="contained"
disabled={updateMutation.isPending}
>
{updateMutation.isPending ? 'Saving...' : 'Save'}
</Button>
</DialogActions>
</Dialog>
{/* Preview Dialog */}
<Dialog
open={previewDialogOpen}
onClose={handleClosePreviewDialog}
maxWidth="md"
fullWidth
>
<DialogTitle>
Preview: {selectedTemplate?.name}
</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 3 }}>
<Alert severity="info">
This preview uses sample data to show how the template will appear.
</Alert>
<TextField
label="Subject"
fullWidth
value={previewSubject}
InputProps={{
readOnly: true,
}}
/>
<TextField
label="Body"
fullWidth
multiline
rows={12}
value={previewBody}
InputProps={{
readOnly: true,
}}
inputProps={{
style: { fontFamily: 'monospace' },
}}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleClosePreviewDialog}>Close</Button>
</DialogActions>
</Dialog>
</Container>
);
};

View File

@@ -1,18 +1,269 @@
/**
* @ai-summary Desktop admin page for user management
* @ai-context Manage admin users, revoke, reinstate, and view audit logs
* @ai-context List users, filter, search, change tiers, deactivate/reactivate
*/
import React from 'react';
import React, { useState, useCallback } from 'react';
import { Navigate } from 'react-router-dom';
import { Box, Typography, CircularProgress } from '@mui/material';
import { Card } from '../../shared-minimal/components/Card';
import {
Box,
Button,
Chip,
CircularProgress,
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,
} from '@mui/icons-material';
import { useAdminAccess } from '../../core/auth/useAdminAccess';
import {
useUsers,
useUpdateUserTier,
useDeactivateUser,
useReactivateUser,
useUpdateUserProfile,
usePromoteToAdmin,
} 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];
export const AdminUsersPage: React.FC = () => {
const { isAdmin, loading } = useAdminAccess();
const { isAdmin, loading: adminLoading } = useAdminAccess();
if (loading) {
// 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();
// 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');
// 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(
(auth0Sub: string, newTier: SubscriptionTier) => {
updateTierMutation.mutate({ auth0Sub, 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(
{ auth0Sub: selectedUser.auth0Sub, data: { reason: deactivateReason || undefined } },
{
onSuccess: () => {
setDeactivateDialogOpen(false);
setDeactivateReason('');
setSelectedUser(null);
},
}
);
}
}, [selectedUser, deactivateReason, deactivateMutation]);
const handleReactivate = useCallback(() => {
if (selectedUser) {
reactivateMutation.mutate(selectedUser.auth0Sub);
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(
{ auth0Sub: selectedUser.auth0Sub, 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(
{ auth0Sub: selectedUser.auth0Sub, data: { role: promoteRole } },
{
onSuccess: () => {
setPromoteDialogOpen(false);
setPromoteRole('admin');
setSelectedUser(null);
},
}
);
}
}, [selectedUser, promoteRole, promoteToAdminMutation]);
const handlePromoteCancel = useCallback(() => {
setPromoteDialogOpen(false);
setPromoteRole('admin');
setSelectedUser(null);
}, []);
// Loading state
if (adminLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
<CircularProgress />
@@ -20,34 +271,359 @@ export const AdminUsersPage: React.FC = () => {
);
}
// Not admin redirect
if (!isAdmin) {
return <Navigate to="/garage/settings" replace />;
}
return (
<Box sx={{ py: 2 }}>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary', mb: 4 }}>
User Management
</Typography>
const users = data?.users || [];
const total = data?.total || 0;
<Card>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
Admin Users
</Typography>
<Typography variant="body2" color="text.secondary">
Admin user management interface coming soon.
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
Features:
</Typography>
<ul>
<li>List all admin users</li>
<li>Add new admin users</li>
<li>Revoke admin access</li>
<li>Reinstate revoked admins</li>
<li>View audit logs</li>
</ul>
</Card>
return (
<Box sx={{ py: 2, display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminSectionHeader
title="User Management"
stats={[{ label: 'Total Users', value: total }]}
/>
{/* 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>Status</TableCell>
<TableCell>Admin</TableCell>
<TableCell>Created</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</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}
size="small"
>
<MenuItem value="free">Free</MenuItem>
<MenuItem value="pro">Pro</MenuItem>
<MenuItem value="enterprise">Enterprise</MenuItem>
</Select>
</FormControl>
</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>
))}
</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>
)}
</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>
</Box>
);
};