Notification updates
This commit is contained in:
@@ -32,11 +32,13 @@ const DocumentsMobileScreen = lazy(() => import('./features/documents/mobile/Doc
|
||||
const AdminUsersPage = lazy(() => import('./pages/admin/AdminUsersPage').then(m => ({ default: m.AdminUsersPage })));
|
||||
const AdminCatalogPage = lazy(() => import('./pages/admin/AdminCatalogPage').then(m => ({ default: m.AdminCatalogPage })));
|
||||
const AdminStationsPage = lazy(() => import('./pages/admin/AdminStationsPage').then(m => ({ default: m.AdminStationsPage })));
|
||||
const AdminEmailTemplatesPage = lazy(() => import('./pages/admin/AdminEmailTemplatesPage').then(m => ({ default: m.AdminEmailTemplatesPage })));
|
||||
|
||||
// Admin mobile screens (lazy-loaded)
|
||||
const AdminUsersMobileScreen = lazy(() => import('./features/admin/mobile/AdminUsersMobileScreen').then(m => ({ default: m.AdminUsersMobileScreen })));
|
||||
const AdminCatalogMobileScreen = lazy(() => import('./features/admin/mobile/AdminCatalogMobileScreen').then(m => ({ default: m.AdminCatalogMobileScreen })));
|
||||
const AdminStationsMobileScreen = lazy(() => import('./features/admin/mobile/AdminStationsMobileScreen').then(m => ({ default: m.AdminStationsMobileScreen })));
|
||||
const AdminEmailTemplatesMobileScreen = lazy(() => import('./features/admin/mobile/AdminEmailTemplatesMobileScreen'));
|
||||
|
||||
// Admin Community Stations (lazy-loaded)
|
||||
const AdminCommunityStationsPage = lazy(() => import('./features/admin/pages/AdminCommunityStationsPage').then(m => ({ default: m.AdminCommunityStationsPage })));
|
||||
@@ -62,6 +64,7 @@ import { useNavigationStore, useUserStore } from './core/store';
|
||||
import { useDataSync } from './core/hooks/useDataSync';
|
||||
import { MobileDebugPanel } from './core/debug/MobileDebugPanel';
|
||||
import { MobileErrorBoundary } from './core/error-boundaries/MobileErrorBoundary';
|
||||
import { useLoginNotifications } from './features/notifications/hooks/useLoginNotifications';
|
||||
|
||||
// Hoisted mobile screen components to stabilize identity and prevent remounts
|
||||
const DashboardScreen: React.FC = () => (
|
||||
@@ -259,6 +262,9 @@ function App() {
|
||||
// Initialize data synchronization
|
||||
const { prefetchForNavigation } = useDataSync();
|
||||
|
||||
// Initialize login notifications
|
||||
useLoginNotifications();
|
||||
|
||||
// Enhanced navigation and user state management
|
||||
const {
|
||||
activeScreen,
|
||||
@@ -726,6 +732,31 @@ function App() {
|
||||
</MobileErrorBoundary>
|
||||
</motion.div>
|
||||
)}
|
||||
{activeScreen === "AdminEmailTemplates" && (
|
||||
<motion.div
|
||||
key="admin-email-templates"
|
||||
initial={{opacity:0, y:8}}
|
||||
animate={{opacity:1, y:0}}
|
||||
exit={{opacity:0, y:-8}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<MobileErrorBoundary screenName="AdminEmailTemplates">
|
||||
<React.Suspense fallback={
|
||||
<div className="space-y-4">
|
||||
<GlassCard>
|
||||
<div className="p-4">
|
||||
<div className="text-slate-500 py-6 text-center">
|
||||
Loading email templates...
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
}>
|
||||
<AdminEmailTemplatesMobileScreen />
|
||||
</React.Suspense>
|
||||
</MobileErrorBoundary>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<DebugInfo />
|
||||
</Layout>
|
||||
@@ -795,6 +826,7 @@ function App() {
|
||||
<Route path="/garage/settings/admin/catalog" element={<AdminCatalogPage />} />
|
||||
<Route path="/garage/settings/admin/stations" element={<AdminStationsPage />} />
|
||||
<Route path="/garage/settings/admin/community-stations" element={<AdminCommunityStationsPage />} />
|
||||
<Route path="/garage/settings/admin/email-templates" element={<AdminEmailTemplatesPage />} />
|
||||
<Route path="*" element={<Navigate to="/garage/vehicles" replace />} />
|
||||
</Routes>
|
||||
</RouteSuspense>
|
||||
|
||||
@@ -67,7 +67,7 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
|
||||
<div className="px-5 pt-5 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-lg font-semibold tracking-tight">MotoVaultPro</div>
|
||||
<div className="text-xs text-slate-500">v0.1</div>
|
||||
<div className="text-xs text-slate-500">v1.0</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Content area */}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { safeStorage } from '../utils/safe-storage';
|
||||
|
||||
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Stations' | 'Documents' | 'Settings' | 'AdminUsers' | 'AdminCatalog' | 'AdminStations' | 'AdminCommunityStations';
|
||||
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Stations' | 'Documents' | 'Settings' | 'AdminUsers' | 'AdminCatalog' | 'AdminStations' | 'AdminCommunityStations' | 'AdminEmailTemplates';
|
||||
export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit';
|
||||
|
||||
interface NavigationHistory {
|
||||
|
||||
@@ -31,6 +31,16 @@ import {
|
||||
ImportPreviewResult,
|
||||
ImportApplyResult,
|
||||
CascadeDeleteResult,
|
||||
EmailTemplate,
|
||||
UpdateEmailTemplateRequest,
|
||||
// User management types
|
||||
ManagedUser,
|
||||
ListUsersResponse,
|
||||
ListUsersParams,
|
||||
UpdateUserTierRequest,
|
||||
DeactivateUserRequest,
|
||||
UpdateUserProfileRequest,
|
||||
PromoteToAdminRequest,
|
||||
} from '../types/admin.types';
|
||||
|
||||
export interface AuditLogsResponse {
|
||||
@@ -267,4 +277,87 @@ export const adminApi = {
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Email Templates
|
||||
emailTemplates: {
|
||||
list: async (): Promise<EmailTemplate[]> => {
|
||||
const response = await apiClient.get<EmailTemplate[]>('/admin/email-templates');
|
||||
return response.data;
|
||||
},
|
||||
get: async (key: string): Promise<EmailTemplate> => {
|
||||
const response = await apiClient.get<EmailTemplate>(`/admin/email-templates/${key}`);
|
||||
return response.data;
|
||||
},
|
||||
update: async (key: string, data: UpdateEmailTemplateRequest): Promise<EmailTemplate> => {
|
||||
const response = await apiClient.put<EmailTemplate>(`/admin/email-templates/${key}`, data);
|
||||
return response.data;
|
||||
},
|
||||
preview: async (key: string, variables: Record<string, string>): Promise<{ subject: string; body: string }> => {
|
||||
const response = await apiClient.post<{ subject: string; body: string }>(
|
||||
`/admin/email-templates/${key}/preview`,
|
||||
{ variables }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
sendTest: async (key: string): Promise<{ message?: string; error?: string; subject: string; body: string }> => {
|
||||
const response = await apiClient.post<{ message?: string; error?: string; subject: string; body: string }>(
|
||||
`/admin/email-templates/${key}/test`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
|
||||
// User Management
|
||||
users: {
|
||||
list: async (params: ListUsersParams = {}): Promise<ListUsersResponse> => {
|
||||
const response = await apiClient.get<ListUsersResponse>('/admin/users', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (auth0Sub: string): Promise<ManagedUser> => {
|
||||
const response = await apiClient.get<ManagedUser>(
|
||||
`/admin/users/${encodeURIComponent(auth0Sub)}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateTier: async (auth0Sub: string, data: UpdateUserTierRequest): Promise<ManagedUser> => {
|
||||
const response = await apiClient.patch<ManagedUser>(
|
||||
`/admin/users/${encodeURIComponent(auth0Sub)}/tier`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deactivate: async (auth0Sub: string, data?: DeactivateUserRequest): Promise<ManagedUser> => {
|
||||
const response = await apiClient.patch<ManagedUser>(
|
||||
`/admin/users/${encodeURIComponent(auth0Sub)}/deactivate`,
|
||||
data || {}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
reactivate: async (auth0Sub: string): Promise<ManagedUser> => {
|
||||
const response = await apiClient.patch<ManagedUser>(
|
||||
`/admin/users/${encodeURIComponent(auth0Sub)}/reactivate`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateProfile: async (auth0Sub: string, data: UpdateUserProfileRequest): Promise<ManagedUser> => {
|
||||
const response = await apiClient.patch<ManagedUser>(
|
||||
`/admin/users/${encodeURIComponent(auth0Sub)}/profile`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
promoteToAdmin: async (auth0Sub: string, data?: PromoteToAdminRequest): Promise<AdminUser> => {
|
||||
const response = await apiClient.patch<AdminUser>(
|
||||
`/admin/users/${encodeURIComponent(auth0Sub)}/promote`,
|
||||
data || {}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ export type CatalogFormValues = {
|
||||
trimId?: string;
|
||||
displacement?: string;
|
||||
cylinders?: number;
|
||||
fuel_type?: string;
|
||||
fuelType?: string;
|
||||
};
|
||||
|
||||
export const makeSchema = z.object({
|
||||
@@ -63,7 +63,7 @@ export const engineSchema = z.object({
|
||||
.positive('Cylinders must be positive')
|
||||
.optional()
|
||||
),
|
||||
fuel_type: z.string().optional(),
|
||||
fuelType: z.string().optional(),
|
||||
});
|
||||
|
||||
export const getSchemaForLevel = (level: CatalogLevel) => {
|
||||
@@ -114,7 +114,7 @@ export const buildDefaultValues = (
|
||||
trimId: String((entity as CatalogEngine).trimId),
|
||||
displacement: (entity as CatalogEngine).displacement ?? undefined,
|
||||
cylinders: (entity as CatalogEngine).cylinders ?? undefined,
|
||||
fuel_type: (entity as CatalogEngine).fuel_type ?? undefined,
|
||||
fuelType: (entity as CatalogEngine).fuelType ?? undefined,
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
@@ -142,7 +142,7 @@ export const buildDefaultValues = (
|
||||
name: '',
|
||||
trimId: context.trim?.id ? String(context.trim.id) : '',
|
||||
displacement: '',
|
||||
fuel_type: '',
|
||||
fuelType: '',
|
||||
};
|
||||
case 'makes':
|
||||
default:
|
||||
|
||||
180
frontend/src/features/admin/hooks/useUsers.ts
Normal file
180
frontend/src/features/admin/hooks/useUsers.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @ai-summary React Query hooks for admin user management
|
||||
* @ai-context List users, change tiers, deactivate/reactivate
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { adminApi } from '../api/admin.api';
|
||||
import {
|
||||
ListUsersParams,
|
||||
UpdateUserTierRequest,
|
||||
DeactivateUserRequest,
|
||||
UpdateUserProfileRequest,
|
||||
PromoteToAdminRequest,
|
||||
} from '../types/admin.types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface ApiError {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Query keys for user management
|
||||
export const userQueryKeys = {
|
||||
all: ['admin-users'] as const,
|
||||
list: (params: ListUsersParams) => [...userQueryKeys.all, 'list', params] as const,
|
||||
detail: (auth0Sub: string) => [...userQueryKeys.all, 'detail', auth0Sub] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to list users with pagination and filters
|
||||
*/
|
||||
export const useUsers = (params: ListUsersParams = {}) => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
return useQuery({
|
||||
queryKey: userQueryKeys.list(params),
|
||||
queryFn: () => adminApi.users.list(params),
|
||||
enabled: isAuthenticated && !isLoading,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes cache time
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get a single user's details
|
||||
*/
|
||||
export const useUser = (auth0Sub: string) => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
return useQuery({
|
||||
queryKey: userQueryKeys.detail(auth0Sub),
|
||||
queryFn: () => adminApi.users.get(auth0Sub),
|
||||
enabled: isAuthenticated && !isLoading && !!auth0Sub,
|
||||
staleTime: 2 * 60 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to update a user's subscription tier
|
||||
*/
|
||||
export const useUpdateUserTier = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ auth0Sub, data }: { auth0Sub: string; data: UpdateUserTierRequest }) =>
|
||||
adminApi.users.updateTier(auth0Sub, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
|
||||
toast.success('Subscription tier updated');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
'Failed to update tier'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to deactivate a user (soft delete)
|
||||
*/
|
||||
export const useDeactivateUser = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ auth0Sub, data }: { auth0Sub: string; data?: DeactivateUserRequest }) =>
|
||||
adminApi.users.deactivate(auth0Sub, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
|
||||
toast.success('User deactivated');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
'Failed to deactivate user'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to reactivate a deactivated user
|
||||
*/
|
||||
export const useReactivateUser = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (auth0Sub: string) => adminApi.users.reactivate(auth0Sub),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
|
||||
toast.success('User reactivated');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
'Failed to reactivate user'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to update a user's profile (email, displayName)
|
||||
*/
|
||||
export const useUpdateUserProfile = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ auth0Sub, data }: { auth0Sub: string; data: UpdateUserProfileRequest }) =>
|
||||
adminApi.users.updateProfile(auth0Sub, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
|
||||
toast.success('User profile updated');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
'Failed to update user profile'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to promote a user to admin
|
||||
*/
|
||||
export const usePromoteToAdmin = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ auth0Sub, data }: { auth0Sub: string; data?: PromoteToAdminRequest }) =>
|
||||
adminApi.users.promoteToAdmin(auth0Sub, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
|
||||
toast.success('User promoted to admin');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
'Failed to promote user to admin'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* @ai-summary Admin Email Templates mobile screen for managing notification email templates
|
||||
* @ai-context Mobile-optimized version with touch-friendly UI
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Edit, Visibility, Close, Send } from '@mui/icons-material';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { adminApi } from '../api/admin.api';
|
||||
import { EmailTemplate, UpdateEmailTemplateRequest } from '../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 AdminEmailTemplatesMobileScreen: React.FC = () => {
|
||||
const { loading: authLoading, isAdmin } = useAdminAccess();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// State
|
||||
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
|
||||
const [previewTemplate, setPreviewTemplate] = 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('Template updated');
|
||||
handleCloseEdit();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update 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);
|
||||
},
|
||||
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) => {
|
||||
setEditingTemplate(template);
|
||||
setEditSubject(template.subject);
|
||||
setEditBody(template.body);
|
||||
setEditIsActive(template.isActive);
|
||||
}, []);
|
||||
|
||||
const handlePreviewClick = useCallback((template: EmailTemplate) => {
|
||||
setPreviewTemplate(template);
|
||||
previewMutation.mutate({
|
||||
key: template.templateKey,
|
||||
variables: SAMPLE_VARIABLES,
|
||||
});
|
||||
}, [previewMutation]);
|
||||
|
||||
const handleSendTestClick = useCallback((template: EmailTemplate) => {
|
||||
sendTestMutation.mutate(template.templateKey);
|
||||
}, [sendTestMutation]);
|
||||
|
||||
const handleCloseEdit = useCallback(() => {
|
||||
setEditingTemplate(null);
|
||||
setEditSubject('');
|
||||
setEditBody('');
|
||||
setEditIsActive(true);
|
||||
}, []);
|
||||
|
||||
const handleClosePreview = useCallback(() => {
|
||||
setPreviewTemplate(null);
|
||||
setPreviewSubject('');
|
||||
setPreviewBody('');
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!editingTemplate) return;
|
||||
|
||||
const data: UpdateEmailTemplateRequest = {
|
||||
subject: editSubject !== editingTemplate.subject ? editSubject : undefined,
|
||||
body: editBody !== editingTemplate.body ? editBody : undefined,
|
||||
isActive: editIsActive !== editingTemplate.isActive ? editIsActive : undefined,
|
||||
};
|
||||
|
||||
// Only update if there are changes
|
||||
if (data.subject || data.body || data.isActive !== undefined) {
|
||||
updateMutation.mutate({
|
||||
key: editingTemplate.templateKey,
|
||||
data,
|
||||
});
|
||||
} else {
|
||||
handleCloseEdit();
|
||||
}
|
||||
}, [editingTemplate, editSubject, editBody, editIsActive, updateMutation, handleCloseEdit]);
|
||||
|
||||
// Auth loading
|
||||
if (authLoading) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="text-slate-500 mb-2">Loading admin access...</div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Not admin
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/garage/settings" replace />;
|
||||
}
|
||||
|
||||
// Edit view
|
||||
if (editingTemplate) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-24 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Edit Template</h1>
|
||||
<p className="text-sm text-slate-500">{editingTemplate.name}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCloseEdit}
|
||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||
>
|
||||
<Close />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Edit Form */}
|
||||
<GlassCard>
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Active Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">Active</span>
|
||||
<button
|
||||
onClick={() => setEditIsActive(!editIsActive)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors min-h-[44px] min-w-[44px] ${
|
||||
editIsActive ? 'bg-blue-600' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
editIsActive ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Subject
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editSubject}
|
||||
onChange={(e) => setEditSubject(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[44px]"
|
||||
placeholder="Email subject"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Use variables like {editingTemplate.variables.map((v) => `{{${v}}}`).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Body
|
||||
</label>
|
||||
<textarea
|
||||
value={editBody}
|
||||
onChange={(e) => setEditBody(e.target.value)}
|
||||
rows={12}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
|
||||
placeholder="Email body"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Use variables like {editingTemplate.variables.map((v) => `{{${v}}}`).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Available Variables */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p className="text-xs font-medium text-blue-900 mb-2">Available Variables</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{editingTemplate.variables.map((variable) => (
|
||||
<span
|
||||
key={variable}
|
||||
className="inline-block px-2 py-1 bg-white border border-blue-300 rounded text-xs font-mono text-blue-700"
|
||||
>
|
||||
{`{{${variable}}}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={handleCloseEdit}
|
||||
className="flex-1 px-4 py-3 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors min-h-[44px]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
className="flex-1 px-4 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:bg-gray-300 min-h-[44px]"
|
||||
>
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Preview view
|
||||
if (previewTemplate) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-24 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Preview</h1>
|
||||
<p className="text-sm text-slate-500">{previewTemplate.name}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClosePreview}
|
||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||
>
|
||||
<Close />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preview Content */}
|
||||
<GlassCard>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p className="text-xs text-blue-700">
|
||||
This preview uses sample data to show how the template will appear.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{previewMutation.isPending ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
||||
<p className="text-slate-500 mt-2 text-sm">Generating preview...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Subject
|
||||
</label>
|
||||
<div className="px-3 py-2 bg-gray-50 border border-gray-300 rounded-lg min-h-[44px] flex items-center">
|
||||
{previewSubject}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Body
|
||||
</label>
|
||||
<div className="px-3 py-2 bg-gray-50 border border-gray-300 rounded-lg font-mono text-sm whitespace-pre-wrap">
|
||||
{previewBody}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleClosePreview}
|
||||
className="w-full px-4 py-3 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors min-h-[44px]"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// List view
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-24 p-4">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">Email Templates</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
{templates?.length || 0} notification templates
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Templates List */}
|
||||
{isLoading ? (
|
||||
<GlassCard>
|
||||
<div className="p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
||||
<p className="text-slate-500 mt-2 text-sm">Loading templates...</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{templates?.map((template) => (
|
||||
<GlassCard key={template.id}>
|
||||
<div className="p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-slate-800">{template.name}</h3>
|
||||
{template.description && (
|
||||
<p className="text-xs text-slate-500 mt-1">{template.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`ml-2 px-2 py-1 rounded-full text-xs font-medium ${
|
||||
template.isActive
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{template.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-medium text-slate-600 mb-1">Subject</p>
|
||||
<p className="text-sm text-slate-700 truncate">{template.subject}</p>
|
||||
</div>
|
||||
|
||||
{/* Variables */}
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-medium text-slate-600 mb-1">Variables</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{template.variables.map((variable) => (
|
||||
<span
|
||||
key={variable}
|
||||
className="inline-block px-2 py-1 bg-gray-100 border border-gray-300 rounded text-xs font-mono text-slate-600"
|
||||
>
|
||||
{`{{${variable}}}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => handleEditClick(template)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors min-h-[44px]"
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePreviewClick(template)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors min-h-[44px]"
|
||||
>
|
||||
<Visibility fontSize="small" />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSendTestClick(template)}
|
||||
disabled={sendTestMutation.isPending}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 transition-colors disabled:bg-gray-300 disabled:text-gray-500 min-h-[44px]"
|
||||
>
|
||||
<Send fontSize="small" />
|
||||
{sendTestMutation.isPending ? 'Sending...' : 'Send Test Email'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminEmailTemplatesMobileScreen;
|
||||
@@ -1,61 +1,721 @@
|
||||
/**
|
||||
* @ai-summary Mobile admin screen for user management
|
||||
* @ai-context Manage admin users with mobile-optimized interface
|
||||
* @ai-context List users, search, filter, change tiers, deactivate/reactivate
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
||||
import {
|
||||
useUsers,
|
||||
useUpdateUserTier,
|
||||
useDeactivateUser,
|
||||
useReactivateUser,
|
||||
useUpdateUserProfile,
|
||||
usePromoteToAdmin,
|
||||
} from '../hooks/useUsers';
|
||||
import {
|
||||
ManagedUser,
|
||||
SubscriptionTier,
|
||||
ListUsersParams,
|
||||
} from '../types/admin.types';
|
||||
|
||||
// Modal component for dialogs
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, actions }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl p-6 max-w-sm w-full shadow-xl">
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">{title}</h3>
|
||||
{children}
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
{actions || (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Tier badge component
|
||||
const TierBadge: React.FC<{ tier: SubscriptionTier }> = ({ tier }) => {
|
||||
const colors: Record<SubscriptionTier, string> = {
|
||||
free: 'bg-gray-100 text-gray-700',
|
||||
pro: 'bg-blue-100 text-blue-700',
|
||||
enterprise: 'bg-purple-100 text-purple-700',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colors[tier]}`}>
|
||||
{tier.charAt(0).toUpperCase() + tier.slice(1)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Status badge component
|
||||
const StatusBadge: React.FC<{ active: boolean }> = ({ active }) => (
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{active ? 'Active' : 'Deactivated'}
|
||||
</span>
|
||||
);
|
||||
|
||||
export const AdminUsersMobileScreen: 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('');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Query
|
||||
const { data, isLoading, error, refetch } = useUsers(params);
|
||||
|
||||
// Mutations
|
||||
const updateTierMutation = useUpdateUserTier();
|
||||
const deactivateMutation = useDeactivateUser();
|
||||
const reactivateMutation = useReactivateUser();
|
||||
const updateProfileMutation = useUpdateUserProfile();
|
||||
const promoteToAdminMutation = usePromoteToAdmin();
|
||||
|
||||
// Selected user for actions
|
||||
const [selectedUser, setSelectedUser] = useState<ManagedUser | null>(null);
|
||||
const [showUserActions, setShowUserActions] = useState(false);
|
||||
const [showTierPicker, setShowTierPicker] = useState(false);
|
||||
const [showDeactivateConfirm, setShowDeactivateConfirm] = useState(false);
|
||||
const [deactivateReason, setDeactivateReason] = useState('');
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editEmail, setEditEmail] = useState('');
|
||||
const [editDisplayName, setEditDisplayName] = useState('');
|
||||
const [showPromoteModal, setShowPromoteModal] = useState(false);
|
||||
const [promoteRole, setPromoteRole] = useState<'admin' | 'super_admin'>('admin');
|
||||
|
||||
// Handlers
|
||||
const handleSearch = useCallback(() => {
|
||||
setParams(prev => ({ ...prev, search: searchInput || undefined, page: 1 }));
|
||||
}, [searchInput]);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchInput('');
|
||||
setParams(prev => ({ ...prev, search: undefined, page: 1 }));
|
||||
}, []);
|
||||
|
||||
const handleTierFilterChange = useCallback((tier: SubscriptionTier | '') => {
|
||||
setParams(prev => ({
|
||||
...prev,
|
||||
tier: tier || undefined,
|
||||
page: 1,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleStatusFilterChange = useCallback((status: 'active' | 'deactivated' | 'all') => {
|
||||
setParams(prev => ({ ...prev, status, page: 1 }));
|
||||
}, []);
|
||||
|
||||
const handleUserClick = useCallback((user: ManagedUser) => {
|
||||
setSelectedUser(user);
|
||||
setShowUserActions(true);
|
||||
}, []);
|
||||
|
||||
const handleTierChange = useCallback(
|
||||
(newTier: SubscriptionTier) => {
|
||||
if (selectedUser) {
|
||||
updateTierMutation.mutate(
|
||||
{ auth0Sub: selectedUser.auth0Sub, data: { subscriptionTier: newTier } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowTierPicker(false);
|
||||
setShowUserActions(false);
|
||||
setSelectedUser(null);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
[selectedUser, updateTierMutation]
|
||||
);
|
||||
|
||||
const handleDeactivate = useCallback(() => {
|
||||
if (selectedUser) {
|
||||
deactivateMutation.mutate(
|
||||
{ auth0Sub: selectedUser.auth0Sub, data: { reason: deactivateReason || undefined } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowDeactivateConfirm(false);
|
||||
setShowUserActions(false);
|
||||
setDeactivateReason('');
|
||||
setSelectedUser(null);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [selectedUser, deactivateReason, deactivateMutation]);
|
||||
|
||||
const handleReactivate = useCallback(() => {
|
||||
if (selectedUser) {
|
||||
reactivateMutation.mutate(selectedUser.auth0Sub, {
|
||||
onSuccess: () => {
|
||||
setShowUserActions(false);
|
||||
setSelectedUser(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [selectedUser, reactivateMutation]);
|
||||
|
||||
const handleEditClick = useCallback(() => {
|
||||
if (selectedUser) {
|
||||
setEditEmail(selectedUser.email);
|
||||
setEditDisplayName(selectedUser.displayName || '');
|
||||
setShowUserActions(false);
|
||||
setShowEditModal(true);
|
||||
}
|
||||
}, [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: () => {
|
||||
setShowEditModal(false);
|
||||
setEditEmail('');
|
||||
setEditDisplayName('');
|
||||
setSelectedUser(null);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [selectedUser, editEmail, editDisplayName, updateProfileMutation]);
|
||||
|
||||
const handleEditCancel = useCallback(() => {
|
||||
setShowEditModal(false);
|
||||
setEditEmail('');
|
||||
setEditDisplayName('');
|
||||
setSelectedUser(null);
|
||||
}, []);
|
||||
|
||||
const handlePromoteClick = useCallback(() => {
|
||||
setPromoteRole('admin');
|
||||
setShowUserActions(false);
|
||||
setShowPromoteModal(true);
|
||||
}, []);
|
||||
|
||||
const handlePromoteConfirm = useCallback(() => {
|
||||
if (selectedUser) {
|
||||
promoteToAdminMutation.mutate(
|
||||
{ auth0Sub: selectedUser.auth0Sub, data: { role: promoteRole } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowPromoteModal(false);
|
||||
setPromoteRole('admin');
|
||||
setSelectedUser(null);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [selectedUser, promoteRole, promoteToAdminMutation]);
|
||||
|
||||
const handlePromoteCancel = useCallback(() => {
|
||||
setShowPromoteModal(false);
|
||||
setPromoteRole('admin');
|
||||
setSelectedUser(null);
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
setParams(prev => ({ ...prev, page: (prev.page || 1) + 1 }));
|
||||
}, []);
|
||||
|
||||
// Loading state
|
||||
if (adminLoading) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="text-slate-500 mb-2">Loading admin access...</div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Not admin redirect
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/garage/settings" replace />;
|
||||
}
|
||||
|
||||
const users = data?.users || [];
|
||||
const total = data?.total || 0;
|
||||
const hasMore = users.length < total;
|
||||
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-20 p-4">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-800">User Management</h1>
|
||||
<p className="text-slate-500 mt-2">Manage admin users and permissions</p>
|
||||
<p className="text-slate-500 mt-2">
|
||||
{total} user{total !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Admin Users</h2>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Admin user management interface coming soon.
|
||||
</p>
|
||||
<div className="space-y-2 text-sm text-slate-600">
|
||||
<p className="font-semibold">Features:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<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>
|
||||
{/* Search Bar */}
|
||||
<GlassCard padding="sm">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by email or name..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="w-full px-4 py-3 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[44px]"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="px-4 py-3 bg-blue-600 text-white rounded-lg font-medium min-h-[44px]"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter Toggle */}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="mt-3 text-blue-600 text-sm font-medium flex items-center gap-1 min-h-[44px]"
|
||||
>
|
||||
<svg className={`w-4 h-4 transition-transform ${showFilters ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
{showFilters ? 'Hide Filters' : 'Show Filters'}
|
||||
</button>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="mt-3 pt-3 border-t border-slate-200 space-y-3">
|
||||
{/* Tier Filter */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 block mb-1">Tier</label>
|
||||
<select
|
||||
value={params.tier || ''}
|
||||
onChange={(e) => handleTierFilterChange(e.target.value as SubscriptionTier | '')}
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 min-h-[44px]"
|
||||
>
|
||||
<option value="">All Tiers</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="pro">Pro</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 block mb-1">Status</label>
|
||||
<select
|
||||
value={params.status || 'all'}
|
||||
onChange={(e) => handleStatusFilterChange(e.target.value as 'active' | 'deactivated' | 'all')}
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 min-h-[44px]"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="deactivated">Deactivated</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && users.length === 0 && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<GlassCard padding="md">
|
||||
<div className="text-center text-red-600">
|
||||
<p>Failed to load users. Please try again.</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="mt-2 text-blue-600 font-medium min-h-[44px]"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && !error && users.length === 0 && (
|
||||
<GlassCard padding="md">
|
||||
<div className="text-center text-slate-500">
|
||||
<p>No users found matching your criteria.</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* User List */}
|
||||
{users.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{users.map((user) => (
|
||||
<GlassCard key={user.auth0Sub} padding="md">
|
||||
<button
|
||||
onClick={() => handleUserClick(user)}
|
||||
className="w-full text-left min-h-[44px]"
|
||||
style={{ opacity: user.deactivatedAt ? 0.6 : 1 }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-slate-800 truncate">{user.email}</p>
|
||||
{user.displayName && (
|
||||
<p className="text-sm text-slate-500 truncate">{user.displayName}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<TierBadge tier={user.subscriptionTier} />
|
||||
<StatusBadge active={!user.deactivatedAt} />
|
||||
{user.isAdmin && (
|
||||
<span className="px-2 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<svg className="w-5 h-5 text-slate-400 flex-shrink-0 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</GlassCard>
|
||||
))}
|
||||
|
||||
{/* Load More */}
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={isLoading}
|
||||
className="w-full py-3 text-blue-600 font-medium min-h-[44px]"
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Load More'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Actions Modal */}
|
||||
<Modal
|
||||
isOpen={showUserActions}
|
||||
onClose={() => {
|
||||
setShowUserActions(false);
|
||||
setSelectedUser(null);
|
||||
}}
|
||||
title="User Actions"
|
||||
actions={
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserActions(false);
|
||||
setSelectedUser(null);
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{selectedUser && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-slate-600">
|
||||
<p className="font-medium">{selectedUser.email}</p>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<TierBadge tier={selectedUser.subscriptionTier} />
|
||||
<StatusBadge active={!selectedUser.deactivatedAt} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 pt-3 space-y-2">
|
||||
<button
|
||||
onClick={handleEditClick}
|
||||
className="w-full py-3 text-left text-blue-600 font-medium min-h-[44px]"
|
||||
>
|
||||
Edit User
|
||||
</button>
|
||||
|
||||
{!selectedUser.isAdmin && (
|
||||
<button
|
||||
onClick={handlePromoteClick}
|
||||
className="w-full py-3 text-left text-purple-600 font-medium min-h-[44px]"
|
||||
>
|
||||
Promote to Admin
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserActions(false);
|
||||
setShowTierPicker(true);
|
||||
}}
|
||||
disabled={!!selectedUser.deactivatedAt}
|
||||
className="w-full py-3 text-left text-blue-600 font-medium disabled:text-slate-300 min-h-[44px]"
|
||||
>
|
||||
Change Subscription Tier
|
||||
</button>
|
||||
|
||||
{selectedUser.deactivatedAt ? (
|
||||
<button
|
||||
onClick={handleReactivate}
|
||||
disabled={reactivateMutation.isPending}
|
||||
className="w-full py-3 text-left text-green-600 font-medium disabled:opacity-50 min-h-[44px]"
|
||||
>
|
||||
{reactivateMutation.isPending ? 'Reactivating...' : 'Reactivate User'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserActions(false);
|
||||
setShowDeactivateConfirm(true);
|
||||
}}
|
||||
className="w-full py-3 text-left text-red-600 font-medium min-h-[44px]"
|
||||
>
|
||||
Deactivate User
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Tier Picker Modal */}
|
||||
<Modal
|
||||
isOpen={showTierPicker}
|
||||
onClose={() => setShowTierPicker(false)}
|
||||
title="Change Subscription Tier"
|
||||
actions={
|
||||
<button
|
||||
onClick={() => setShowTierPicker(false)}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{(['free', 'pro', 'enterprise'] as SubscriptionTier[]).map((tier) => (
|
||||
<button
|
||||
key={tier}
|
||||
onClick={() => handleTierChange(tier)}
|
||||
disabled={updateTierMutation.isPending || tier === selectedUser?.subscriptionTier}
|
||||
className={`w-full py-3 px-4 rounded-lg text-left font-medium min-h-[44px] ${
|
||||
tier === selectedUser?.subscriptionTier
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{tier.charAt(0).toUpperCase() + tier.slice(1)}
|
||||
{tier === selectedUser?.subscriptionTier && ' (Current)'}
|
||||
</button>
|
||||
))}
|
||||
{updateTierMutation.isPending && (
|
||||
<p className="text-center text-sm text-slate-500">Updating...</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Deactivate Confirmation Modal */}
|
||||
<Modal
|
||||
isOpen={showDeactivateConfirm}
|
||||
onClose={() => {
|
||||
setShowDeactivateConfirm(false);
|
||||
setDeactivateReason('');
|
||||
}}
|
||||
title="Deactivate User"
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDeactivateConfirm(false);
|
||||
setDeactivateReason('');
|
||||
}}
|
||||
disabled={deactivateMutation.isPending}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeactivate}
|
||||
disabled={deactivateMutation.isPending}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg font-medium min-h-[44px] disabled:opacity-50"
|
||||
>
|
||||
{deactivateMutation.isPending ? 'Deactivating...' : 'Deactivate'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-slate-600">
|
||||
Are you sure you want to deactivate{' '}
|
||||
<strong>{selectedUser?.email}</strong>?
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
The user will no longer be able to log in, but their data will be preserved.
|
||||
</p>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 block mb-1">
|
||||
Reason (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={deactivateReason}
|
||||
onChange={(e) => setDeactivateReason(e.target.value)}
|
||||
placeholder="Enter a reason for deactivation..."
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Edit User Modal */}
|
||||
<Modal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => !updateProfileMutation.isPending && handleEditCancel()}
|
||||
title="Edit User"
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
onClick={handleEditCancel}
|
||||
disabled={updateProfileMutation.isPending}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEditConfirm}
|
||||
disabled={
|
||||
updateProfileMutation.isPending ||
|
||||
(editEmail === selectedUser?.email && editDisplayName === (selectedUser?.displayName || ''))
|
||||
}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium min-h-[44px] disabled:opacity-50"
|
||||
>
|
||||
{updateProfileMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 block mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={editEmail}
|
||||
onChange={(e) => setEditEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 min-h-[44px]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 block mb-1">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editDisplayName}
|
||||
onChange={(e) => setEditDisplayName(e.target.value)}
|
||||
placeholder="Enter display name..."
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 min-h-[44px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Promote to Admin Modal */}
|
||||
<Modal
|
||||
isOpen={showPromoteModal}
|
||||
onClose={() => !promoteToAdminMutation.isPending && handlePromoteCancel()}
|
||||
title="Promote to Admin"
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
onClick={handlePromoteCancel}
|
||||
disabled={promoteToAdminMutation.isPending}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePromoteConfirm}
|
||||
disabled={promoteToAdminMutation.isPending}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg font-medium min-h-[44px] disabled:opacity-50"
|
||||
>
|
||||
{promoteToAdminMutation.isPending ? 'Promoting...' : 'Promote'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-slate-600">
|
||||
Promote <strong>{selectedUser?.email}</strong> to an administrator role.
|
||||
</p>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 block mb-1">Admin Role</label>
|
||||
<select
|
||||
value={promoteRole}
|
||||
onChange={(e) => setPromoteRole(e.target.value as 'admin' | 'super_admin')}
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 min-h-[44px]"
|
||||
>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="super_admin">Super Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">
|
||||
Admins can manage users, catalog data, and view audit logs.
|
||||
Super Admins have additional permissions to manage other administrators.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ export interface CatalogEngine {
|
||||
name: string;
|
||||
displacement: string | null;
|
||||
cylinders: number | null;
|
||||
fuel_type: string | null;
|
||||
fuelType: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -115,14 +115,14 @@ export interface CreateCatalogEngineRequest {
|
||||
name: string;
|
||||
displacement?: string;
|
||||
cylinders?: number;
|
||||
fuel_type?: string;
|
||||
fuelType?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCatalogEngineRequest {
|
||||
name?: string;
|
||||
displacement?: string;
|
||||
cylinders?: number;
|
||||
fuel_type?: string;
|
||||
fuelType?: string;
|
||||
}
|
||||
|
||||
// Station types for admin
|
||||
@@ -220,3 +220,86 @@ export interface CascadeDeleteResult {
|
||||
deletedEngines: number;
|
||||
totalDeleted: number;
|
||||
}
|
||||
|
||||
// Email template types
|
||||
export interface EmailTemplate {
|
||||
id: string;
|
||||
templateKey: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
variables: string[];
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UpdateEmailTemplateRequest {
|
||||
subject?: string;
|
||||
body?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// User Management types (subscription tiers)
|
||||
// ============================================
|
||||
|
||||
// Subscription tier enum
|
||||
export type SubscriptionTier = 'free' | 'pro' | 'enterprise';
|
||||
|
||||
// User with admin status for admin views
|
||||
export interface ManagedUser {
|
||||
id: string;
|
||||
auth0Sub: string;
|
||||
email: string;
|
||||
displayName: string | null;
|
||||
notificationEmail: string | null;
|
||||
subscriptionTier: SubscriptionTier;
|
||||
deactivatedAt: string | null;
|
||||
deactivatedBy: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isAdmin: boolean;
|
||||
adminRole: 'admin' | 'super_admin' | null;
|
||||
}
|
||||
|
||||
// List users response with pagination
|
||||
export interface ListUsersResponse {
|
||||
users: ManagedUser[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
// Query params for listing users
|
||||
export interface ListUsersParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
tier?: SubscriptionTier;
|
||||
status?: 'active' | 'deactivated' | 'all';
|
||||
sortBy?: 'email' | 'createdAt' | 'displayName' | 'subscriptionTier';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// Request to update subscription tier
|
||||
export interface UpdateUserTierRequest {
|
||||
subscriptionTier: SubscriptionTier;
|
||||
}
|
||||
|
||||
// Request to deactivate a user
|
||||
export interface DeactivateUserRequest {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// Request to update user profile (admin edit)
|
||||
export interface UpdateUserProfileRequest {
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// Request to promote user to admin
|
||||
export interface PromoteToAdminRequest {
|
||||
role?: 'admin' | 'super_admin';
|
||||
}
|
||||
|
||||
@@ -96,13 +96,13 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
|
||||
}
|
||||
|
||||
const created = await create.mutateAsync({
|
||||
vehicle_id: vehicleID,
|
||||
document_type: documentType,
|
||||
vehicleId: vehicleID,
|
||||
documentType: documentType,
|
||||
title: title.trim(),
|
||||
notes: notes.trim() || undefined,
|
||||
details,
|
||||
issued_date,
|
||||
expiration_date,
|
||||
issuedDate: issued_date,
|
||||
expirationDate: expiration_date,
|
||||
});
|
||||
|
||||
if (file) {
|
||||
|
||||
@@ -12,8 +12,8 @@ export const DocumentPreview: React.FC<Props> = ({ doc }) => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const previewable = useMemo(() => {
|
||||
return doc.content_type === 'application/pdf' || doc.content_type?.startsWith('image/');
|
||||
}, [doc.content_type]);
|
||||
return doc.contentType === 'application/pdf' || doc.contentType?.startsWith('image/');
|
||||
}, [doc.contentType]);
|
||||
|
||||
useEffect(() => {
|
||||
let revoked: string | null = null;
|
||||
@@ -37,7 +37,7 @@ export const DocumentPreview: React.FC<Props> = ({ doc }) => {
|
||||
if (error) return <div className="text-red-600 text-sm">{error}</div>;
|
||||
if (!blobUrl) return <div className="text-slate-500 text-sm">Loading preview...</div>;
|
||||
|
||||
if (doc.content_type === 'application/pdf') {
|
||||
if (doc.contentType === 'application/pdf') {
|
||||
return (
|
||||
<object data={blobUrl} type="application/pdf" className="w-full h-[60vh] rounded-lg border" aria-label="PDF Preview">
|
||||
<a href={blobUrl} target="_blank" rel="noopener noreferrer">Open PDF</a>
|
||||
|
||||
@@ -36,23 +36,23 @@ export function useCreateDocument() {
|
||||
// Create optimistic document record
|
||||
const optimisticDocument: DocumentRecord = {
|
||||
id: `temp-${Date.now()}`, // Temporary ID
|
||||
user_id: '', // Will be filled by server
|
||||
vehicle_id: newDocument.vehicle_id,
|
||||
document_type: newDocument.document_type,
|
||||
userId: '', // Will be filled by server
|
||||
vehicleId: newDocument.vehicleId,
|
||||
documentType: newDocument.documentType,
|
||||
title: newDocument.title,
|
||||
notes: newDocument.notes || null,
|
||||
details: newDocument.details || null,
|
||||
storage_bucket: null,
|
||||
storage_key: null,
|
||||
file_name: null,
|
||||
content_type: null,
|
||||
file_size: null,
|
||||
file_hash: null,
|
||||
issued_date: newDocument.issued_date || null,
|
||||
expiration_date: newDocument.expiration_date || null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
deleted_at: null,
|
||||
storageBucket: null,
|
||||
storageKey: null,
|
||||
fileName: null,
|
||||
contentType: null,
|
||||
fileSize: null,
|
||||
fileHash: null,
|
||||
issuedDate: newDocument.issuedDate || null,
|
||||
expirationDate: newDocument.expirationDate || null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
// Optimistically update cache
|
||||
@@ -96,7 +96,7 @@ export function useUpdateDocument(id: string) {
|
||||
return {
|
||||
...old,
|
||||
...updateData,
|
||||
updated_at: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -105,7 +105,7 @@ export function useUpdateDocument(id: string) {
|
||||
if (!old) return old;
|
||||
return old.map(doc =>
|
||||
doc.id === id
|
||||
? { ...doc, ...updateData, updated_at: new Date().toISOString() }
|
||||
? { ...doc, ...updateData, updatedAt: new Date().toISOString() }
|
||||
: doc
|
||||
);
|
||||
});
|
||||
@@ -186,10 +186,10 @@ export function useUploadDocument(id: string) {
|
||||
|
||||
// Optimistically update with upload in progress state
|
||||
const optimisticUpdate = {
|
||||
file_name: file.name,
|
||||
content_type: file.type,
|
||||
file_size: file.size,
|
||||
updated_at: new Date().toISOString(),
|
||||
fileName: file.name,
|
||||
contentType: file.type,
|
||||
fileSize: file.size,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Update individual document
|
||||
|
||||
@@ -177,12 +177,12 @@ export const DocumentsMobileScreen: React.FC = () => {
|
||||
{!isLoading && !hasError && data && data.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{data.map((doc) => {
|
||||
const vehicleLabel = doc.vehicle_id ? `${doc.vehicle_id.slice(0, 8)}...` : '—';
|
||||
const vehicleLabel = doc.vehicleId ? `${doc.vehicleId.slice(0, 8)}...` : '—';
|
||||
return (
|
||||
<div key={doc.id} className="flex items-center justify-between border rounded-xl p-3">
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">{doc.title}</div>
|
||||
<div className="text-xs text-slate-500">{doc.document_type} • {vehicleLabel}</div>
|
||||
<div className="text-xs text-slate-500">{doc.documentType} • {vehicleLabel}</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button onClick={() => navigate(`/garage/documents/${doc.id}`)}>Open</Button>
|
||||
|
||||
@@ -140,8 +140,8 @@ export const DocumentDetailPage: React.FC = () => {
|
||||
<Card>
|
||||
<div className="p-4 space-y-2">
|
||||
<h2 className="text-xl font-semibold">{doc.title}</h2>
|
||||
<div className="text-sm text-slate-500">Type: {doc.document_type}</div>
|
||||
<div className="text-sm text-slate-500">Vehicle: {doc.vehicle_id}</div>
|
||||
<div className="text-sm text-slate-500">Type: {doc.documentType}</div>
|
||||
<div className="text-sm text-slate-500">Vehicle: {doc.vehicleId}</div>
|
||||
<div className="pt-2">
|
||||
<DocumentPreview doc={doc} />
|
||||
</div>
|
||||
|
||||
@@ -128,8 +128,8 @@ export const DocumentsPage: React.FC = () => {
|
||||
<Card key={doc.id}>
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="font-medium">{doc.title}</div>
|
||||
<div className="text-sm text-slate-500">Type: {doc.document_type}</div>
|
||||
<div className="text-sm text-slate-500">Vehicle: {doc.vehicle_id}</div>
|
||||
<div className="text-sm text-slate-500">Type: {doc.documentType}</div>
|
||||
<div className="text-sm text-slate-500">Vehicle: {doc.vehicleId}</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button onClick={() => navigate(`/garage/documents/${doc.id}`)}>Open</Button>
|
||||
<Button variant="danger" onClick={() => removeDoc.mutate(doc.id)}>Delete</Button>
|
||||
|
||||
@@ -2,40 +2,43 @@ export type DocumentType = 'insurance' | 'registration';
|
||||
|
||||
export interface DocumentRecord {
|
||||
id: string;
|
||||
user_id: string;
|
||||
vehicle_id: string;
|
||||
document_type: DocumentType;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
documentType: DocumentType;
|
||||
title: string;
|
||||
notes?: string | null;
|
||||
details?: Record<string, any> | null;
|
||||
storage_bucket?: string | null;
|
||||
storage_key?: string | null;
|
||||
file_name?: string | null;
|
||||
content_type?: string | null;
|
||||
file_size?: number | null;
|
||||
file_hash?: string | null;
|
||||
issued_date?: string | null;
|
||||
expiration_date?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at?: string | null;
|
||||
storageBucket?: string | null;
|
||||
storageKey?: string | null;
|
||||
fileName?: string | null;
|
||||
contentType?: string | null;
|
||||
fileSize?: number | null;
|
||||
fileHash?: string | null;
|
||||
issuedDate?: string | null;
|
||||
expirationDate?: string | null;
|
||||
emailNotifications?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateDocumentRequest {
|
||||
vehicle_id: string;
|
||||
document_type: DocumentType;
|
||||
vehicleId: string;
|
||||
documentType: DocumentType;
|
||||
title: string;
|
||||
notes?: string;
|
||||
details?: Record<string, any>;
|
||||
issued_date?: string;
|
||||
expiration_date?: string;
|
||||
issuedDate?: string;
|
||||
expirationDate?: string;
|
||||
emailNotifications?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateDocumentRequest {
|
||||
title?: string;
|
||||
notes?: string | null;
|
||||
details?: Record<string, any>;
|
||||
issued_date?: string | null;
|
||||
expiration_date?: string | null;
|
||||
issuedDate?: string | null;
|
||||
expirationDate?: string | null;
|
||||
emailNotifications?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,9 +63,9 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
|
||||
category: record.category,
|
||||
subtypes: record.subtypes,
|
||||
date: record.date,
|
||||
odometer_reading: record.odometer_reading || undefined,
|
||||
odometerReading: record.odometerReading || undefined,
|
||||
cost: record.cost ? Number(record.cost) : undefined,
|
||||
shop_name: record.shop_name || undefined,
|
||||
shopName: record.shopName || undefined,
|
||||
notes: record.notes || undefined,
|
||||
});
|
||||
setError(null);
|
||||
@@ -172,7 +172,7 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
|
||||
fullWidth
|
||||
disabled
|
||||
value={(() => {
|
||||
const vehicle = vehicles?.find((v: Vehicle) => v.id === record.vehicle_id);
|
||||
const vehicle = vehicles?.find((v: Vehicle) => v.id === record.vehicleId);
|
||||
if (!vehicle) return 'Unknown Vehicle';
|
||||
if (vehicle.nickname?.trim()) return vehicle.nickname.trim();
|
||||
const parts = [vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean);
|
||||
@@ -246,10 +246,10 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
|
||||
label="Odometer Reading"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={formData.odometer_reading || ''}
|
||||
value={formData.odometerReading || ''}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
'odometer_reading',
|
||||
'odometerReading',
|
||||
e.target.value ? parseInt(e.target.value) : undefined
|
||||
)
|
||||
}
|
||||
@@ -278,8 +278,8 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
|
||||
<TextField
|
||||
label="Shop/Location"
|
||||
fullWidth
|
||||
value={formData.shop_name || ''}
|
||||
onChange={(e) => handleInputChange('shop_name', e.target.value || undefined)}
|
||||
value={formData.shopName || ''}
|
||||
onChange={(e) => handleInputChange('shopName', e.target.value || undefined)}
|
||||
helperText="Service location"
|
||||
inputProps={{ maxLength: 200 }}
|
||||
/>
|
||||
|
||||
@@ -92,13 +92,13 @@ export const MaintenanceRecordForm: React.FC = () => {
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
const payload: CreateMaintenanceRecordRequest = {
|
||||
vehicle_id: data.vehicle_id,
|
||||
vehicleId: data.vehicle_id,
|
||||
category: data.category as MaintenanceCategory,
|
||||
subtypes: data.subtypes,
|
||||
date: data.date,
|
||||
odometer_reading: data.odometer_reading ? Number(data.odometer_reading) : undefined,
|
||||
odometerReading: data.odometer_reading ? Number(data.odometer_reading) : undefined,
|
||||
cost: data.cost ? Number(data.cost) : undefined,
|
||||
shop_name: data.shop_name || undefined,
|
||||
shopName: data.shop_name || undefined,
|
||||
notes: data.notes || undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ export const MaintenanceRecordsList: React.FC<MaintenanceRecordsListProps> = ({
|
||||
{sortedRecords.map((record) => {
|
||||
const dateText = new Date(record.date).toLocaleDateString();
|
||||
const categoryDisplay = getCategoryDisplayName(record.category);
|
||||
const subtypeCount = record.subtype_count || record.subtypes?.length || 0;
|
||||
const subtypeCount = record.subtypeCount || record.subtypes?.length || 0;
|
||||
|
||||
return (
|
||||
<Card key={record.id} variant="outlined">
|
||||
@@ -105,9 +105,9 @@ export const MaintenanceRecordsList: React.FC<MaintenanceRecordsListProps> = ({
|
||||
{categoryDisplay} ({subtypeCount})
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" sx={{ mt: 1 }}>
|
||||
{record.odometer_reading && (
|
||||
{record.odometerReading && (
|
||||
<Chip
|
||||
label={`${Number(record.odometer_reading).toLocaleString()} miles`}
|
||||
label={`${Number(record.odometerReading).toLocaleString()} miles`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
@@ -120,9 +120,9 @@ export const MaintenanceRecordsList: React.FC<MaintenanceRecordsListProps> = ({
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
{record.shop_name && (
|
||||
{record.shopName && (
|
||||
<Chip
|
||||
label={record.shop_name}
|
||||
label={record.shopName}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
@@ -81,9 +81,9 @@ export const useMaintenanceRecords = (vehicleId?: string) => {
|
||||
const createRecordMutation = useMutation({
|
||||
mutationFn: (data: CreateMaintenanceRecordRequest) => maintenanceApi.createRecord(data),
|
||||
onSuccess: (_res, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceRecords', variables.vehicle_id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceRecords', variables.vehicleId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceRecords', 'all'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceUpcoming', variables.vehicle_id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceUpcoming', variables.vehicleId] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -110,8 +110,8 @@ export const useMaintenanceRecords = (vehicleId?: string) => {
|
||||
const createScheduleMutation = useMutation({
|
||||
mutationFn: (data: CreateScheduleRequest) => maintenanceApi.createSchedule(data),
|
||||
onSuccess: (_res, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceSchedules', variables.vehicle_id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceUpcoming', variables.vehicle_id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceSchedules', variables.vehicleId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceUpcoming', variables.vehicleId] });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -53,48 +53,49 @@ export const PERFORMANCE_UPGRADE_SUBTYPES = [
|
||||
'Exterior'
|
||||
] as const;
|
||||
|
||||
// Database record types
|
||||
// Database record types (camelCase)
|
||||
export interface MaintenanceRecord {
|
||||
id: string;
|
||||
user_id: string;
|
||||
vehicle_id: string;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
category: MaintenanceCategory;
|
||||
subtypes: string[];
|
||||
date: string;
|
||||
odometer_reading?: number;
|
||||
odometerReading?: number;
|
||||
cost?: number;
|
||||
shop_name?: string;
|
||||
shopName?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MaintenanceSchedule {
|
||||
id: string;
|
||||
user_id: string;
|
||||
vehicle_id: string;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
category: MaintenanceCategory;
|
||||
subtypes: string[];
|
||||
interval_months?: number;
|
||||
interval_miles?: number;
|
||||
last_service_date?: string;
|
||||
last_service_mileage?: number;
|
||||
next_due_date?: string;
|
||||
next_due_mileage?: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
intervalMonths?: number;
|
||||
intervalMiles?: number;
|
||||
lastServiceDate?: string;
|
||||
lastServiceMileage?: number;
|
||||
nextDueDate?: string;
|
||||
nextDueMileage?: number;
|
||||
isActive: boolean;
|
||||
emailNotifications?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Request types
|
||||
// Request types (camelCase)
|
||||
export interface CreateMaintenanceRecordRequest {
|
||||
vehicle_id: string;
|
||||
vehicleId: string;
|
||||
category: MaintenanceCategory;
|
||||
subtypes: string[];
|
||||
date: string;
|
||||
odometer_reading?: number;
|
||||
odometerReading?: number;
|
||||
cost?: number;
|
||||
shop_name?: string;
|
||||
shopName?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
@@ -102,37 +103,39 @@ export interface UpdateMaintenanceRecordRequest {
|
||||
category?: MaintenanceCategory;
|
||||
subtypes?: string[];
|
||||
date?: string;
|
||||
odometer_reading?: number | null;
|
||||
odometerReading?: number | null;
|
||||
cost?: number | null;
|
||||
shop_name?: string | null;
|
||||
shopName?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateScheduleRequest {
|
||||
vehicle_id: string;
|
||||
vehicleId: string;
|
||||
category: MaintenanceCategory;
|
||||
subtypes: string[];
|
||||
interval_months?: number;
|
||||
interval_miles?: number;
|
||||
intervalMonths?: number;
|
||||
intervalMiles?: number;
|
||||
emailNotifications?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateScheduleRequest {
|
||||
category?: MaintenanceCategory;
|
||||
subtypes?: string[];
|
||||
interval_months?: number | null;
|
||||
interval_miles?: number | null;
|
||||
is_active?: boolean;
|
||||
intervalMonths?: number | null;
|
||||
intervalMiles?: number | null;
|
||||
isActive?: boolean;
|
||||
emailNotifications?: boolean;
|
||||
}
|
||||
|
||||
// Response types
|
||||
// Response types (camelCase)
|
||||
export interface MaintenanceRecordResponse extends MaintenanceRecord {
|
||||
subtype_count: number;
|
||||
subtypeCount: number;
|
||||
}
|
||||
|
||||
export interface MaintenanceScheduleResponse extends MaintenanceSchedule {
|
||||
subtype_count: number;
|
||||
is_due_soon?: boolean;
|
||||
is_overdue?: boolean;
|
||||
subtypeCount: number;
|
||||
isDueSoon?: boolean;
|
||||
isOverdue?: boolean;
|
||||
}
|
||||
|
||||
// Validation helpers
|
||||
|
||||
23
frontend/src/features/notifications/api/notifications.api.ts
Normal file
23
frontend/src/features/notifications/api/notifications.api.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @ai-summary API calls for notifications feature
|
||||
*/
|
||||
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
import { NotificationSummary, DueMaintenanceItem, ExpiringDocument } from '../types/notifications.types';
|
||||
|
||||
export const notificationsApi = {
|
||||
getSummary: async (): Promise<NotificationSummary> => {
|
||||
const response = await apiClient.get('/notifications/summary');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getDueMaintenanceItems: async (): Promise<DueMaintenanceItem[]> => {
|
||||
const response = await apiClient.get('/notifications/maintenance');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getExpiringDocuments: async (): Promise<ExpiringDocument[]> => {
|
||||
const response = await apiClient.get('/notifications/documents');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @ai-summary Email notification toggle component
|
||||
* @ai-context Mobile-first responsive toggle switch for email notifications
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface EmailNotificationToggleProps {
|
||||
enabled: boolean;
|
||||
onChange: (enabled: boolean) => void;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const EmailNotificationToggle: React.FC<EmailNotificationToggleProps> = ({
|
||||
enabled,
|
||||
onChange,
|
||||
label = 'Email notifications',
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`flex items-center justify-between gap-3 ${className}`}>
|
||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{label}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
aria-label={label}
|
||||
onClick={() => onChange(!enabled)}
|
||||
className={`
|
||||
relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full
|
||||
border-2 border-transparent transition-colors duration-200 ease-in-out
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2
|
||||
${enabled ? 'bg-primary-600' : 'bg-slate-300 dark:bg-slate-600'}
|
||||
`}
|
||||
style={{ minWidth: '44px', minHeight: '44px', padding: '9px 0' }}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
pointer-events-none inline-block h-5 w-5 transform rounded-full
|
||||
bg-white shadow ring-0 transition duration-200 ease-in-out
|
||||
${enabled ? 'translate-x-5' : 'translate-x-0'}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @ai-summary Hook to show login notifications toast based on notification summary
|
||||
* @ai-context Shows once per session on successful authentication
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { notificationsApi } from '../api/notifications.api';
|
||||
|
||||
export function useLoginNotifications() {
|
||||
const { isAuthenticated } = useAuth0();
|
||||
const hasShownToast = useRef(false);
|
||||
|
||||
const { data: summary } = useQuery({
|
||||
queryKey: ['notificationSummary'],
|
||||
queryFn: notificationsApi.getSummary,
|
||||
enabled: isAuthenticated && !hasShownToast.current,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (summary && !hasShownToast.current) {
|
||||
const maintenanceCount = summary.maintenanceDueSoon + summary.maintenanceOverdue;
|
||||
const documentCount = summary.documentsExpiringSoon + summary.documentsExpired;
|
||||
const total = maintenanceCount + documentCount;
|
||||
|
||||
if (total > 0) {
|
||||
const parts: string[] = [];
|
||||
if (maintenanceCount > 0) {
|
||||
parts.push(`${maintenanceCount} maintenance item${maintenanceCount > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (documentCount > 0) {
|
||||
parts.push(`${documentCount} document${documentCount > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
toast(`You have ${parts.join(' and ')} requiring attention`, {
|
||||
duration: 6000,
|
||||
icon: '🔔',
|
||||
});
|
||||
}
|
||||
hasShownToast.current = true;
|
||||
}
|
||||
}, [summary]);
|
||||
|
||||
return summary;
|
||||
}
|
||||
8
frontend/src/features/notifications/index.ts
Normal file
8
frontend/src/features/notifications/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @ai-summary Notifications feature exports
|
||||
*/
|
||||
|
||||
export * from './api/notifications.api';
|
||||
export * from './types/notifications.types';
|
||||
export * from './hooks/useLoginNotifications';
|
||||
export * from './components/EmailNotificationToggle';
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @ai-summary Type definitions for notifications feature
|
||||
* @ai-context Supports maintenance due/overdue and document expiring/expired notifications
|
||||
*/
|
||||
|
||||
export interface NotificationSummary {
|
||||
maintenanceDueSoon: number;
|
||||
maintenanceOverdue: number;
|
||||
documentsExpiringSoon: number;
|
||||
documentsExpired: number;
|
||||
}
|
||||
|
||||
export interface DueMaintenanceItem {
|
||||
scheduleId: string;
|
||||
vehicleId: string;
|
||||
vehicleName: string;
|
||||
category: string;
|
||||
subtypes: string[];
|
||||
dueDate?: string;
|
||||
dueMileage?: number;
|
||||
isDueSoon: boolean;
|
||||
isOverdue: boolean;
|
||||
emailNotifications: boolean;
|
||||
}
|
||||
|
||||
export interface ExpiringDocument {
|
||||
documentId: string;
|
||||
vehicleId: string;
|
||||
vehicleName: string;
|
||||
documentType: string;
|
||||
title: string;
|
||||
expirationDate: string;
|
||||
isExpiringSoon: boolean;
|
||||
isExpired: boolean;
|
||||
emailNotifications: boolean;
|
||||
}
|
||||
11
frontend/src/features/settings/api/profile.api.ts
Normal file
11
frontend/src/features/settings/api/profile.api.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @ai-summary API client for user profile endpoints
|
||||
*/
|
||||
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
import { UserProfile, UpdateProfileRequest } from '../types/profile.types';
|
||||
|
||||
export const profileApi = {
|
||||
getProfile: () => apiClient.get<UserProfile>('/user/profile'),
|
||||
updateProfile: (data: UpdateProfileRequest) => apiClient.put<UserProfile>('/user/profile', data),
|
||||
};
|
||||
69
frontend/src/features/settings/hooks/useProfile.ts
Normal file
69
frontend/src/features/settings/hooks/useProfile.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @ai-summary React hooks for user profile management
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { profileApi } from '../api/profile.api';
|
||||
import { UpdateProfileRequest } from '../types/profile.types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface ApiError {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const useProfile = () => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['user-profile'],
|
||||
queryFn: async () => {
|
||||
const response = await profileApi.getProfile();
|
||||
return response.data;
|
||||
},
|
||||
enabled: isAuthenticated && !isLoading,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes cache time
|
||||
retry: (failureCount, error: any) => {
|
||||
if (error?.response?.status === 401 && failureCount < 3) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (query.data) {
|
||||
console.log('[useProfile] Profile loaded successfully');
|
||||
}
|
||||
if (query.error) {
|
||||
console.error('[useProfile] Error loading profile:', query.error);
|
||||
}
|
||||
}, [query.data, query.error]);
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
export const useUpdateProfile = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: UpdateProfileRequest) => profileApi.updateProfile(data),
|
||||
onSuccess: (response) => {
|
||||
queryClient.setQueryData(['user-profile'], response.data);
|
||||
toast.success('Profile updated successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to update profile');
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
import { useProfile, useUpdateProfile } from '../hooks/useProfile';
|
||||
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
||||
import { useNavigationStore } from '../../../core/store';
|
||||
|
||||
@@ -73,9 +74,22 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
const { user, logout } = useAuth0();
|
||||
const { navigateToScreen } = useNavigationStore();
|
||||
const { settings, updateSetting, isLoading, error } = useSettings();
|
||||
const { data: profile, isLoading: profileLoading } = useProfile();
|
||||
const updateProfileMutation = useUpdateProfile();
|
||||
const { isAdmin, loading: adminLoading } = useAdminAccess();
|
||||
const [showDataExport, setShowDataExport] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
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({
|
||||
@@ -97,6 +111,40 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -142,28 +190,128 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
<p className="text-slate-500 mt-2">Manage your account and preferences</p>
|
||||
</div>
|
||||
|
||||
{/* Account Section */}
|
||||
{/* Profile Section */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Account</h2>
|
||||
<div className="flex items-center space-x-3">
|
||||
{user?.picture && (
|
||||
<img
|
||||
src={user.picture}
|
||||
alt="Profile"
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold text-slate-800">Profile</h2>
|
||||
{!isEditingProfile && !profileLoading && (
|
||||
<button
|
||||
onClick={handleEditProfile}
|
||||
className="px-3 py-1.5 bg-blue-100 text-blue-700 rounded-lg text-sm font-medium hover:bg-blue-200 transition-colors"
|
||||
style={{ minHeight: '44px', minWidth: '44px' }}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-slate-800">{user?.name}</p>
|
||||
<p className="text-sm text-slate-500">{user?.email}</p>
|
||||
</div>
|
||||
|
||||
{profileLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-3 mt-3 border-t border-slate-200">
|
||||
<p className="text-sm text-slate-600">
|
||||
Member since {user?.updated_at ? new Date(user.updated_at).toLocaleDateString() : 'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
) : isEditingProfile ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={profile?.email || ''}
|
||||
disabled
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg bg-slate-100 text-slate-500"
|
||||
style={{ fontSize: '16px', minHeight: '44px' }}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Email is managed by your account provider</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedDisplayName}
|
||||
onChange={(e) => setEditedDisplayName(e.target.value)}
|
||||
placeholder="Enter your display name"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
style={{ fontSize: '16px', minHeight: '44px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Notification Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={editedNotificationEmail}
|
||||
onChange={(e) => setEditedNotificationEmail(e.target.value)}
|
||||
placeholder="Leave blank to use your primary email"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
style={{ fontSize: '16px', minHeight: '44px' }}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Optional: Use a different email for notifications</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-2">
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
disabled={updateProfileMutation.isPending}
|
||||
className="flex-1 py-2.5 px-4 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors disabled:opacity-50"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveProfile}
|
||||
disabled={updateProfileMutation.isPending}
|
||||
className="flex-1 py-2.5 px-4 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center justify-center"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
{updateProfileMutation.isPending ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||
) : (
|
||||
'Save'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
{user?.picture ? (
|
||||
<img
|
||||
src={user.picture}
|
||||
alt="Profile"
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold">
|
||||
{profile?.displayName?.charAt(0) || user?.name?.charAt(0) || user?.email?.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-slate-800">
|
||||
{profile?.displayName || user?.name || 'User'}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">{profile?.email || user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-3 border-t border-slate-200">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase">Display Name</p>
|
||||
<p className="text-sm text-slate-800">{profile?.displayName || 'Not set'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase">Notification Email</p>
|
||||
<p className="text-sm text-slate-800">{profile?.notificationEmail || 'Using primary email'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
@@ -281,6 +429,14 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
<div className="font-semibold">Station Management</div>
|
||||
<div className="text-sm text-blue-600 mt-1">Manage gas station data and locations</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigateToScreen('AdminEmailTemplates')}
|
||||
className="w-full text-left p-4 bg-blue-50 text-blue-700 rounded-lg font-medium hover:bg-blue-100 transition-colors active:bg-blue-200"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<div className="font-semibold">Email Templates</div>
|
||||
<div className="text-sm text-blue-600 mt-1">Manage notification email templates</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
18
frontend/src/features/settings/types/profile.types.ts
Normal file
18
frontend/src/features/settings/types/profile.types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @ai-summary User profile types for settings feature
|
||||
*/
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
auth0Sub: string;
|
||||
email: string;
|
||||
displayName?: string;
|
||||
notificationEmail?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UpdateProfileRequest {
|
||||
displayName?: string;
|
||||
notificationEmail?: string;
|
||||
}
|
||||
@@ -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