feat: user export service. bug and UX fixes. Complete minus outstanding email template fixes.

This commit is contained in:
Eric Gullickson
2025-12-26 14:06:03 -06:00
parent 8c13dc0a55
commit fb52ce398b
35 changed files with 1686 additions and 118 deletions

View File

@@ -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>

View 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;