feat: user export service. bug and UX fixes. Complete minus outstanding email template fixes.
This commit is contained in:
@@ -3,9 +3,10 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { FormControl, Select, MenuItem } from '@mui/material';
|
||||
import { Button } from '../../../shared-minimal/components/Button';
|
||||
import { OnboardingPreferences } from '../types/onboarding.types';
|
||||
|
||||
@@ -22,7 +23,7 @@ interface PreferencesStepProps {
|
||||
|
||||
export const PreferencesStep: React.FC<PreferencesStepProps> = ({ onNext, loading }) => {
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
@@ -88,17 +89,38 @@ export const PreferencesStep: React.FC<PreferencesStepProps> = ({ onNext, loadin
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Currency
|
||||
</label>
|
||||
<select
|
||||
{...register('currencyCode')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-[44px]"
|
||||
style={{ fontSize: '16px' }}
|
||||
>
|
||||
<option value="USD">USD - US Dollar</option>
|
||||
<option value="EUR">EUR - Euro</option>
|
||||
<option value="GBP">GBP - British Pound</option>
|
||||
<option value="CAD">CAD - Canadian Dollar</option>
|
||||
<option value="AUD">AUD - Australian Dollar</option>
|
||||
</select>
|
||||
<FormControl fullWidth>
|
||||
<Controller
|
||||
name="currencyCode"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
displayEmpty
|
||||
sx={{
|
||||
minHeight: '44px',
|
||||
fontSize: '16px',
|
||||
borderRadius: '8px',
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#d1d5db',
|
||||
},
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#9ca3af',
|
||||
},
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#7A212A',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="USD">USD - US Dollar</MenuItem>
|
||||
<MenuItem value="EUR">EUR - Euro</MenuItem>
|
||||
<MenuItem value="GBP">GBP - British Pound</MenuItem>
|
||||
<MenuItem value="CAD">CAD - Canadian Dollar</MenuItem>
|
||||
<MenuItem value="AUD">AUD - Australian Dollar</MenuItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
{errors.currencyCode && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.currencyCode.message}</p>
|
||||
)}
|
||||
@@ -109,23 +131,44 @@ export const PreferencesStep: React.FC<PreferencesStepProps> = ({ onNext, loadin
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Time Zone
|
||||
</label>
|
||||
<select
|
||||
{...register('timeZone')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-[44px]"
|
||||
style={{ fontSize: '16px' }}
|
||||
>
|
||||
<option value="America/New_York">Eastern Time (ET)</option>
|
||||
<option value="America/Chicago">Central Time (CT)</option>
|
||||
<option value="America/Denver">Mountain Time (MT)</option>
|
||||
<option value="America/Los_Angeles">Pacific Time (PT)</option>
|
||||
<option value="America/Phoenix">Arizona Time (MST)</option>
|
||||
<option value="America/Anchorage">Alaska Time (AKT)</option>
|
||||
<option value="Pacific/Honolulu">Hawaii Time (HST)</option>
|
||||
<option value="Europe/London">London (GMT/BST)</option>
|
||||
<option value="Europe/Paris">Paris (CET/CEST)</option>
|
||||
<option value="Asia/Tokyo">Tokyo (JST)</option>
|
||||
<option value="Australia/Sydney">Sydney (AEST/AEDT)</option>
|
||||
</select>
|
||||
<FormControl fullWidth>
|
||||
<Controller
|
||||
name="timeZone"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
displayEmpty
|
||||
sx={{
|
||||
minHeight: '44px',
|
||||
fontSize: '16px',
|
||||
borderRadius: '8px',
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#d1d5db',
|
||||
},
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#9ca3af',
|
||||
},
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#7A212A',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="America/New_York">Eastern Time (ET)</MenuItem>
|
||||
<MenuItem value="America/Chicago">Central Time (CT)</MenuItem>
|
||||
<MenuItem value="America/Denver">Mountain Time (MT)</MenuItem>
|
||||
<MenuItem value="America/Los_Angeles">Pacific Time (PT)</MenuItem>
|
||||
<MenuItem value="America/Phoenix">Arizona Time (MST)</MenuItem>
|
||||
<MenuItem value="America/Anchorage">Alaska Time (AKT)</MenuItem>
|
||||
<MenuItem value="Pacific/Honolulu">Hawaii Time (HST)</MenuItem>
|
||||
<MenuItem value="Europe/London">London (GMT/BST)</MenuItem>
|
||||
<MenuItem value="Europe/Paris">Paris (CET/CEST)</MenuItem>
|
||||
<MenuItem value="Asia/Tokyo">Tokyo (JST)</MenuItem>
|
||||
<MenuItem value="Australia/Sydney">Sydney (AEST/AEDT)</MenuItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
{errors.timeZone && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.timeZone.message}</p>
|
||||
)}
|
||||
|
||||
16
frontend/src/features/settings/api/export.api.ts
Normal file
16
frontend/src/features/settings/api/export.api.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* @ai-summary API client for user data export
|
||||
* @ai-context Downloads user export as tar.gz blob
|
||||
*/
|
||||
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
|
||||
export const exportApi = {
|
||||
downloadUserExport: async (): Promise<Blob> => {
|
||||
const response = await apiClient.get('/user/export', {
|
||||
responseType: 'blob',
|
||||
timeout: 120000, // 2 minute timeout for large exports
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
11
frontend/src/features/settings/api/security.api.ts
Normal file
11
frontend/src/features/settings/api/security.api.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @ai-summary API client for security endpoints
|
||||
*/
|
||||
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
import { SecurityStatus, PasswordResetResponse } from '../types/security.types';
|
||||
|
||||
export const securityApi = {
|
||||
getSecurityStatus: () => apiClient.get<SecurityStatus>('/auth/security-status'),
|
||||
requestPasswordReset: () => apiClient.post<PasswordResetResponse>('/auth/request-password-reset'),
|
||||
};
|
||||
38
frontend/src/features/settings/hooks/useExportUserData.ts
Normal file
38
frontend/src/features/settings/hooks/useExportUserData.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @ai-summary React Query hook for user data export
|
||||
* @ai-context Downloads tar.gz archive with all user data
|
||||
*/
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import { exportApi } from '../api/export.api';
|
||||
|
||||
interface ApiError {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const useExportUserData = () => {
|
||||
return useMutation({
|
||||
mutationFn: () => exportApi.downloadUserExport(),
|
||||
onSuccess: (blob) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
link.download = `motovaultpro_export_${timestamp}.tar.gz`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success('Data exported successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to export data');
|
||||
},
|
||||
});
|
||||
};
|
||||
57
frontend/src/features/settings/hooks/useSecurity.ts
Normal file
57
frontend/src/features/settings/hooks/useSecurity.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @ai-summary React hooks for security settings management
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { securityApi } from '../api/security.api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface ApiError {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const useSecurityStatus = () => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['security-status'],
|
||||
queryFn: async () => {
|
||||
const response = await securityApi.getSecurityStatus();
|
||||
return response.data;
|
||||
},
|
||||
enabled: isAuthenticated && !isLoading,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes cache time
|
||||
retry: (failureCount, error: ApiError) => {
|
||||
if (error?.response?.data?.error === 'Unauthorized' && failureCount < 3) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const useRequestPasswordReset = () => {
|
||||
return useMutation({
|
||||
mutationFn: () => securityApi.requestPasswordReset(),
|
||||
onSuccess: (response) => {
|
||||
toast.success(response.data.message);
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
'Failed to send password reset email'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -4,6 +4,7 @@ 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 { useExportUserData } from '../hooks/useExportUserData';
|
||||
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
||||
import { useNavigationStore } from '../../../core/store';
|
||||
import { DeleteAccountModal } from './DeleteAccountModal';
|
||||
@@ -32,7 +33,7 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||
<button
|
||||
onClick={onChange}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
enabled ? 'bg-blue-600' : 'bg-gray-200'
|
||||
enabled ? 'bg-primary-500 dark:bg-primary-600' : 'bg-gray-200 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@@ -78,6 +79,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
const { settings, updateSetting, isLoading, error } = useSettings();
|
||||
const { data: profile, isLoading: profileLoading } = useProfile();
|
||||
const updateProfileMutation = useUpdateProfile();
|
||||
const exportMutation = useExportUserData();
|
||||
const { isAdmin, loading: adminLoading } = useAdminAccess();
|
||||
const [showDataExport, setShowDataExport] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
@@ -102,9 +104,8 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleExportData = () => {
|
||||
// TODO: Implement data export functionality
|
||||
console.log('Exporting user data...');
|
||||
setShowDataExport(false);
|
||||
exportMutation.mutate();
|
||||
};
|
||||
|
||||
|
||||
@@ -149,7 +150,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="text-slate-500 mb-2">Loading settings...</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-primary-500 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
@@ -167,7 +168,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
<p className="text-sm text-slate-600 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
|
||||
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg dark:bg-primary-600 dark:hover:bg-primary-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
@@ -198,7 +199,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
{!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"
|
||||
className="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-200 transition-colors dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
style={{ minHeight: '44px', minWidth: '44px' }}
|
||||
>
|
||||
Edit
|
||||
@@ -208,7 +209,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
|
||||
{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 className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
) : isEditingProfile ? (
|
||||
<div className="space-y-4">
|
||||
@@ -235,7 +236,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
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"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-nero dark:border-silverstone dark:text-avus"
|
||||
style={{ fontSize: '16px', minHeight: '44px' }}
|
||||
/>
|
||||
</div>
|
||||
@@ -249,7 +250,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
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"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-nero dark:border-silverstone dark:text-avus"
|
||||
style={{ fontSize: '16px', minHeight: '44px' }}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Optional: Use a different email for notifications</p>
|
||||
@@ -267,7 +268,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
<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"
|
||||
className="flex-1 py-2.5 px-4 bg-primary-500 text-white rounded-lg font-medium hover:bg-primary-600 transition-colors disabled:opacity-50 flex items-center justify-center dark:bg-primary-600 dark:hover:bg-primary-700"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
{updateProfileMutation.isPending ? (
|
||||
@@ -288,7 +289,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
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">
|
||||
<div className="w-12 h-12 rounded-full bg-primary-500 flex items-center justify-center text-white font-semibold">
|
||||
{profile?.displayName?.charAt(0) || user?.name?.charAt(0) || user?.email?.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
@@ -372,7 +373,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateSetting('unitSystem', settings.unitSystem === 'imperial' ? 'metric' : 'imperial')}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-700 rounded-lg text-sm font-medium hover:bg-blue-200 transition-colors"
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-200 transition-colors dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
{settings.unitSystem === 'imperial' ? 'Switch to Metric' : 'Switch to Imperial'}
|
||||
</button>
|
||||
@@ -388,7 +389,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setShowDataExport(true)}
|
||||
className="w-full text-left p-3 bg-blue-50 text-blue-700 rounded-lg font-medium hover:bg-blue-100 transition-colors"
|
||||
className="w-full text-left p-3 bg-primary-50 text-primary-700 rounded-lg font-medium hover:bg-primary-100 transition-colors dark:bg-primary-900/20 dark:text-primary-300 dark:hover:bg-primary-900/30"
|
||||
>
|
||||
Export My Data
|
||||
</button>
|
||||
@@ -399,43 +400,60 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Security & Privacy Section */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white mb-4">Security & Privacy</h2>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => navigateToScreen('Security')}
|
||||
className="w-full text-left p-4 bg-primary-50 text-primary-700 rounded-lg font-medium hover:bg-primary-100 transition-colors active:bg-primary-200 dark:bg-primary-900/20 dark:text-primary-300 dark:hover:bg-primary-900/30"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<div className="font-semibold">Security Settings</div>
|
||||
<div className="text-sm text-primary-500 mt-1 dark:text-primary-400">Password, passkeys, verification</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Admin Console Section */}
|
||||
{!adminLoading && isAdmin && (
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-blue-600 mb-4">Admin Console</h2>
|
||||
<h2 className="text-lg font-semibold text-primary-500 mb-4">Admin Console</h2>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => navigateToScreen('AdminUsers')}
|
||||
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"
|
||||
className="w-full text-left p-4 bg-primary-50 text-primary-700 rounded-lg font-medium hover:bg-primary-100 transition-colors active:bg-primary-200 dark:bg-primary-900/20 dark:text-primary-300 dark:hover:bg-primary-900/30"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<div className="font-semibold">User Management</div>
|
||||
<div className="text-sm text-blue-600 mt-1">Manage admin users and permissions</div>
|
||||
<div className="text-sm text-primary-500 mt-1 dark:text-primary-400">Manage admin users and permissions</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigateToScreen('AdminCatalog')}
|
||||
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"
|
||||
className="w-full text-left p-4 bg-primary-50 text-primary-700 rounded-lg font-medium hover:bg-primary-100 transition-colors active:bg-primary-200 dark:bg-primary-900/20 dark:text-primary-300 dark:hover:bg-primary-900/30"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<div className="font-semibold">Vehicle Catalog</div>
|
||||
<div className="text-sm text-blue-600 mt-1">Manage makes, models, and engines</div>
|
||||
<div className="text-sm text-primary-500 mt-1 dark:text-primary-400">Manage makes, models, and engines</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"
|
||||
className="w-full text-left p-4 bg-primary-50 text-primary-700 rounded-lg font-medium hover:bg-primary-100 transition-colors active:bg-primary-200 dark:bg-primary-900/20 dark:text-primary-300 dark:hover:bg-primary-900/30"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<div className="font-semibold">Email Templates</div>
|
||||
<div className="text-sm text-blue-600 mt-1">Manage notification email templates</div>
|
||||
<div className="text-sm text-primary-500 mt-1 dark:text-primary-400">Manage notification email templates</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigateToScreen('AdminBackup')}
|
||||
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"
|
||||
className="w-full text-left p-4 bg-primary-50 text-primary-700 rounded-lg font-medium hover:bg-primary-100 transition-colors active:bg-primary-200 dark:bg-primary-900/20 dark:text-primary-300 dark:hover:bg-primary-900/30"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<div className="font-semibold">Backup & Restore</div>
|
||||
<div className="text-sm text-blue-600 mt-1">Create backups and restore data</div>
|
||||
<div className="text-sm text-primary-500 mt-1 dark:text-primary-400">Create backups and restore data</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -481,9 +499,10 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportData}
|
||||
className="flex-1 py-2 px-4 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
disabled={exportMutation.isPending}
|
||||
className="flex-1 py-2 px-4 bg-primary-500 text-white rounded-lg font-medium hover:bg-primary-600 transition-colors disabled:opacity-50 dark:bg-primary-600 dark:hover:bg-primary-700"
|
||||
>
|
||||
Export
|
||||
{exportMutation.isPending ? 'Exporting...' : 'Export'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
202
frontend/src/features/settings/mobile/SecurityMobileScreen.tsx
Normal file
202
frontend/src/features/settings/mobile/SecurityMobileScreen.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* @ai-summary Security settings screen for mobile application
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||
import { useNavigationStore } from '../../../core/store';
|
||||
import { useSecurityStatus, useRequestPasswordReset } from '../hooks/useSecurity';
|
||||
|
||||
export const SecurityMobileScreen: React.FC = () => {
|
||||
const { goBack } = useNavigationStore();
|
||||
const { data: securityStatus, isLoading, error } = useSecurityStatus();
|
||||
const passwordResetMutation = useRequestPasswordReset();
|
||||
|
||||
const handlePasswordReset = () => {
|
||||
passwordResetMutation.mutate();
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
goBack();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-20 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center mb-6">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="mr-4 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
style={{ minHeight: '44px', minWidth: '44px' }}
|
||||
>
|
||||
<svg className="w-6 h-6 text-slate-600 dark:text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white">Security</h1>
|
||||
</div>
|
||||
|
||||
<GlassCard padding="md">
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-20 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center mb-6">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="mr-4 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
style={{ minHeight: '44px', minWidth: '44px' }}
|
||||
>
|
||||
<svg className="w-6 h-6 text-slate-600 dark:text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white">Security</h1>
|
||||
</div>
|
||||
|
||||
<GlassCard padding="md">
|
||||
<div className="text-center py-8">
|
||||
<p className="text-red-600 mb-4">Failed to load security settings</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg dark:bg-primary-600 dark:hover:bg-primary-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-20 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center mb-6">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="mr-4 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
style={{ minHeight: '44px', minWidth: '44px' }}
|
||||
>
|
||||
<svg className="w-6 h-6 text-slate-600 dark:text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white">Security</h1>
|
||||
</div>
|
||||
|
||||
{/* Email Verification Status */}
|
||||
<GlassCard padding="md">
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white mb-4">Account Verification</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Email Address</p>
|
||||
<p className="text-sm text-slate-800 dark:text-white">{securityStatus?.email || 'Not available'}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Email Verification</p>
|
||||
<p className="text-sm text-slate-800 dark:text-white">
|
||||
{securityStatus?.emailVerified ? 'Verified' : 'Not verified'}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
securityStatus?.emailVerified
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'
|
||||
}`}
|
||||
>
|
||||
{securityStatus?.emailVerified ? 'Verified' : 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Password Management */}
|
||||
<GlassCard padding="md">
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white mb-4">Password</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Click below to receive an email with a link to reset your password.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handlePasswordReset}
|
||||
disabled={passwordResetMutation.isPending}
|
||||
className="w-full py-3 px-4 bg-primary-500 text-white rounded-lg font-medium hover:bg-primary-600 transition-colors disabled:opacity-50 flex items-center justify-center dark:bg-primary-600 dark:hover:bg-primary-700"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
{passwordResetMutation.isPending ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||
) : (
|
||||
'Reset Password'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{passwordResetMutation.isSuccess && (
|
||||
<div className="p-3 bg-green-50 text-green-800 rounded-lg text-sm dark:bg-green-900/20 dark:text-green-300">
|
||||
Password reset email sent! Please check your inbox.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Passkeys Information */}
|
||||
<GlassCard padding="md">
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white mb-4">Passkeys</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Passkey Authentication</p>
|
||||
<p className="text-sm text-slate-800 dark:text-white">
|
||||
{securityStatus?.passkeysEnabled ? 'Available' : 'Not available'}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
securityStatus?.passkeysEnabled
|
||||
? 'bg-primary-100 text-primary-800 dark:bg-primary-900/30 dark:text-primary-300'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{securityStatus?.passkeysEnabled ? 'Available' : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-blue-50 text-blue-800 rounded-lg text-sm dark:bg-blue-900/20 dark:text-blue-300">
|
||||
<p className="font-medium mb-1">About Passkeys</p>
|
||||
<p>
|
||||
Passkeys are a secure, passwordless way to sign in using your device's biometric authentication (fingerprint, face recognition) or PIN.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
You can register a passkey during the sign-in process. When you see the option to "Create a passkey," follow the prompts to set up passwordless authentication.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecurityMobileScreen;
|
||||
15
frontend/src/features/settings/types/security.types.ts
Normal file
15
frontend/src/features/settings/types/security.types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @ai-summary Security types for settings feature
|
||||
*/
|
||||
|
||||
export interface SecurityStatus {
|
||||
emailVerified: boolean;
|
||||
email: string;
|
||||
passkeysEnabled: boolean;
|
||||
passwordLastChanged: string | null;
|
||||
}
|
||||
|
||||
export interface PasswordResetResponse {
|
||||
message: string;
|
||||
success: boolean;
|
||||
}
|
||||
@@ -132,7 +132,7 @@ export const Premium93TabContent: React.FC<Premium93TabContentProps> = ({
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: '#fafafa',
|
||||
backgroundColor: 'background.default',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
@@ -177,7 +177,7 @@ export const Premium93TabContent: React.FC<Premium93TabContentProps> = ({
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: '#fafafa',
|
||||
backgroundColor: 'background.default',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -338,7 +338,7 @@ export const StationMap: React.FC<StationMapProps> = ({
|
||||
width: '100%',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#e0e0e0'
|
||||
backgroundColor: 'grey.300'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
|
||||
@@ -136,7 +136,7 @@ export const SubmitFor93Dialog: React.FC<SubmitFor93DialogProps> = ({
|
||||
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
||||
{/* Station name for context */}
|
||||
<Box sx={{ backgroundColor: '#f5f5f5', p: 1.5, borderRadius: 1 }}>
|
||||
<Box sx={{ backgroundColor: 'action.hover', p: 1.5, borderRadius: 1 }}>
|
||||
<strong>{station.name}</strong>
|
||||
<Box sx={{ fontSize: '0.875rem', color: 'text.secondary', mt: 0.5 }}>
|
||||
{station.address}
|
||||
|
||||
@@ -86,7 +86,7 @@ export const VehicleImage: React.FC<VehicleImageProps> = ({
|
||||
|
||||
if (vehicle.imageUrl && !imgError && (blobUrl || isLoading)) {
|
||||
return (
|
||||
<Box sx={{ height, borderRadius, overflow: 'hidden', mb: 2, bgcolor: isLoading ? '#F2EAEA' : undefined }}>
|
||||
<Box sx={{ height, borderRadius, overflow: 'hidden', mb: 2, bgcolor: isLoading ? 'grey.100' : undefined }}>
|
||||
{blobUrl && (
|
||||
<img
|
||||
src={blobUrl}
|
||||
@@ -105,7 +105,7 @@ export const VehicleImage: React.FC<VehicleImageProps> = ({
|
||||
<Box sx={{
|
||||
height,
|
||||
borderRadius,
|
||||
bgcolor: '#F2EAEA',
|
||||
bgcolor: 'grey.100',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -126,7 +126,7 @@ export const VehicleImage: React.FC<VehicleImageProps> = ({
|
||||
<Box
|
||||
sx={{
|
||||
height,
|
||||
bgcolor: vehicle.color || '#F2EAEA',
|
||||
bgcolor: vehicle.color || 'grey.100',
|
||||
borderRadius,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
Reference in New Issue
Block a user