Notification updates
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
395
frontend/src/pages/admin/AdminEmailTemplatesPage.tsx
Normal file
395
frontend/src/pages/admin/AdminEmailTemplatesPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user