Notification updates
This commit is contained in:
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user