Files
motovaultpro/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx
Eric Gullickson fbde51b8fd
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m42s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 38s
Deploy to Staging / Verify Staging (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
feat: Add login/logout audit logging (refs #10)
Backend:
- Add login event logging to getUserStatus() controller method
- Create POST /auth/track-logout endpoint for logout tracking

Frontend:
- Create useLogout hook that wraps Auth0 logout with audit tracking
- Update all logout locations to use the new hook (SettingsPage,
  Layout, MobileSettingsScreen, useDeletion)

Login events are logged when the frontend calls /auth/user-status after
Auth0 callback. Logout events are logged via fire-and-forget call to
/auth/track-logout before Auth0 logout.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:08:41 -06:00

578 lines
25 KiB
TypeScript

import React, { useState } from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import { useLogout } from '../../../core/auth/useLogout';
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 { useVehicles } from '../../vehicles/hooks/useVehicles';
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
import { useNavigationStore } from '../../../core/store';
import { DeleteAccountModal } from './DeleteAccountModal';
import { PendingDeletionBanner } from './PendingDeletionBanner';
interface ToggleSwitchProps {
enabled: boolean;
onChange: () => void;
label: string;
description?: string;
}
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
enabled,
onChange,
label,
description
}) => (
<div className="flex items-center justify-between py-2">
<div>
<p className="font-medium text-slate-800 dark:text-avus">{label}</p>
{description && (
<p className="text-sm text-slate-500 dark:text-titanio">{description}</p>
)}
</div>
<button
onClick={onChange}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? 'bg-primary-500 dark:bg-primary-600' : 'bg-gray-200 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
);
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 dark:bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-scuro rounded-lg p-6 max-w-sm w-full">
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">{title}</h3>
{children}
<div className="flex justify-end mt-4">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg font-medium"
>
Close
</button>
</div>
</div>
</div>
);
};
export const MobileSettingsScreen: React.FC = () => {
const { user } = useAuth0();
const { logout } = useLogout();
const { navigateToScreen } = useNavigationStore();
const { settings, updateSetting, isLoading, error } = useSettings();
const { data: profile, isLoading: profileLoading } = useProfile();
const updateProfileMutation = useUpdateProfile();
const exportMutation = useExportUserData();
const { data: vehicles, isLoading: vehiclesLoading } = useVehicles();
const { isAdmin, loading: adminLoading } = useAdminAccess();
const [showDataExport, setShowDataExport] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isEditingProfile, setIsEditingProfile] = useState(false);
const [editedDisplayName, setEditedDisplayName] = useState('');
const [editedNotificationEmail, setEditedNotificationEmail] = useState('');
// Initialize edit form when profile loads or edit mode starts
React.useEffect(() => {
if (profile && isEditingProfile) {
setEditedDisplayName(profile.displayName || '');
setEditedNotificationEmail(profile.notificationEmail || '');
}
}, [profile, isEditingProfile]);
const handleLogout = () => {
logout();
};
const handleExportData = () => {
setShowDataExport(false);
exportMutation.mutate();
};
const handleEditProfile = () => {
setIsEditingProfile(true);
};
const handleCancelEdit = () => {
setIsEditingProfile(false);
setEditedDisplayName(profile?.displayName || '');
setEditedNotificationEmail(profile?.notificationEmail || '');
};
const handleSaveProfile = async () => {
const updates: { displayName?: string; notificationEmail?: string } = {};
if (editedDisplayName !== (profile?.displayName || '')) {
updates.displayName = editedDisplayName;
}
if (editedNotificationEmail !== (profile?.notificationEmail || '')) {
updates.notificationEmail = editedNotificationEmail || undefined;
}
if (Object.keys(updates).length === 0) {
setIsEditingProfile(false);
return;
}
try {
await updateProfileMutation.mutateAsync(updates);
setIsEditingProfile(false);
} catch (error) {
// Error is handled by the mutation hook
}
};
// Loading state
if (isLoading) {
return (
<MobileContainer>
<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-primary-500 mx-auto"></div>
</div>
</div>
</MobileContainer>
);
}
// Error state
if (error) {
return (
<MobileContainer>
<div className="space-y-4 pb-20 p-4">
<GlassCard padding="md">
<div className="text-center py-8">
<p className="text-red-600 mb-4">Failed to load settings</p>
<p className="text-sm text-slate-600 mb-4">{error}</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="text-center mb-6">
<h1 className="text-2xl font-bold text-slate-800 dark:text-avus">Settings</h1>
<p className="text-slate-500 dark:text-titanio mt-2">Manage your account and preferences</p>
</div>
{/* Pending Deletion Banner */}
<PendingDeletionBanner />
{/* Profile Section */}
<GlassCard padding="md">
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-slate-800 dark:text-avus">Profile</h2>
{!isEditingProfile && !profileLoading && (
<button
onClick={handleEditProfile}
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
</button>
)}
</div>
{profileLoading ? (
<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>
) : isEditingProfile ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-avus mb-1">
Email
</label>
<input
type="email"
value={profile?.email || ''}
disabled
className="w-full px-3 py-2 border border-slate-300 dark:border-silverstone rounded-lg bg-slate-100 dark:bg-gray-800 text-slate-500 dark:text-gray-400"
style={{ fontSize: '16px', minHeight: '44px' }}
/>
<p className="text-xs text-slate-500 dark:text-titanio mt-1">Email is managed by your account provider</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-avus mb-1">
Display Name
</label>
<input
type="text"
value={editedDisplayName}
onChange={(e) => setEditedDisplayName(e.target.value)}
placeholder="Enter your display name"
className="w-full px-3 py-2 border border-slate-300 dark:border-silverstone rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-nero dark:text-avus"
style={{ fontSize: '16px', minHeight: '44px' }}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-avus mb-1">
Notification Email
</label>
<input
type="email"
value={editedNotificationEmail}
onChange={(e) => setEditedNotificationEmail(e.target.value)}
placeholder="Leave blank to use your primary email"
className="w-full px-3 py-2 border border-slate-300 dark:border-silverstone rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-nero dark:text-avus"
style={{ fontSize: '16px', minHeight: '44px' }}
/>
<p className="text-xs text-slate-500 dark:text-titanio mt-1">Optional: Use a different email for notifications</p>
</div>
<div className="flex space-x-3 pt-2">
<button
onClick={handleCancelEdit}
disabled={updateProfileMutation.isPending}
className="flex-1 py-2.5 px-4 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg font-medium hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
style={{ minHeight: '44px' }}
>
Cancel
</button>
<button
onClick={handleSaveProfile}
disabled={updateProfileMutation.isPending}
className="flex-1 py-2.5 px-4 bg-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 ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
) : (
'Save'
)}
</button>
</div>
</div>
) : (
<div>
<div className="flex items-center space-x-3 mb-4">
{user?.picture ? (
<img
src={user.picture}
alt="Profile"
className="w-12 h-12 rounded-full"
/>
) : (
<div className="w-12 h-12 rounded-full bg-primary-500 flex items-center justify-center text-white font-semibold">
{profile?.displayName?.charAt(0) || user?.name?.charAt(0) || user?.email?.charAt(0)}
</div>
)}
<div className="min-w-0 flex-1">
<p className="font-medium text-slate-800 dark:text-avus truncate">
{profile?.displayName || user?.name || 'User'}
</p>
<p className="text-sm text-slate-500 dark:text-titanio truncate">{profile?.email || user?.email}</p>
</div>
</div>
<div className="space-y-2 pt-3 border-t border-slate-200 dark:border-silverstone">
<div>
<p className="text-xs font-medium text-slate-500 dark:text-canna uppercase">Display Name</p>
<p className="text-sm text-slate-800 dark:text-avus">{profile?.displayName || 'Not set'}</p>
</div>
<div>
<p className="text-xs font-medium text-slate-500 dark:text-canna uppercase">Notification Email</p>
<p className="text-sm text-slate-800 dark:text-avus">{profile?.notificationEmail || 'Using primary email'}</p>
</div>
</div>
</div>
)}
</div>
</GlassCard>
{/* My Vehicles Section */}
<GlassCard padding="md">
<div>
<div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-primary-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 4H5c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h4l3 3 3-3h4c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM8 11V9h8v2m-8 4v-2h5v2" />
</svg>
<h2 className="text-lg font-semibold text-slate-800 dark:text-avus">
My Vehicles
</h2>
{!vehiclesLoading && vehicles && (
<span className="text-sm text-slate-500 dark:text-titanio">({vehicles.length})</span>
)}
</div>
<button
onClick={() => navigateToScreen('Vehicles')}
className="px-3 py-1.5 bg-primary-500 text-white rounded-lg text-sm font-medium hover:bg-primary-600 transition-colors dark:bg-primary-600 dark:hover:bg-primary-700"
style={{ minHeight: '44px', minWidth: '44px' }}
>
Manage
</button>
</div>
{vehiclesLoading ? (
<div className="flex justify-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-500"></div>
</div>
) : !vehicles?.length ? (
<p className="text-sm text-slate-500 dark:text-titanio py-2">
No vehicles registered. Add your first vehicle to get started.
</p>
) : (
<div className="space-y-2">
{vehicles.map((vehicle) => (
<div
key={vehicle.id}
className="p-3 bg-slate-50 dark:bg-nero rounded-lg"
>
<p className="font-medium text-slate-800 dark:text-avus">
{vehicle.year} {vehicle.make} {vehicle.model}
</p>
{vehicle.nickname && (
<p className="text-sm text-slate-500 dark:text-titanio">{vehicle.nickname}</p>
)}
</div>
))}
</div>
)}
</div>
</GlassCard>
{/* Notifications Section */}
<GlassCard padding="md">
<div>
<h2 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">Notifications</h2>
<div className="space-y-3">
<ToggleSwitch
enabled={settings.notifications.email}
onChange={() => updateSetting('notifications', {
...settings.notifications,
email: !settings.notifications.email
})}
label="Email Notifications"
description="Receive updates via email"
/>
<ToggleSwitch
enabled={settings.notifications.push}
onChange={() => updateSetting('notifications', {
...settings.notifications,
push: !settings.notifications.push
})}
label="Push Notifications"
description="Receive mobile push notifications"
/>
<ToggleSwitch
enabled={settings.notifications.maintenance}
onChange={() => updateSetting('notifications', {
...settings.notifications,
maintenance: !settings.notifications.maintenance
})}
label="Maintenance Reminders"
description="Get reminded about vehicle maintenance"
/>
</div>
</div>
</GlassCard>
{/* Appearance & Units Section */}
<GlassCard padding="md">
<div>
<h2 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">Appearance & Units</h2>
<div className="space-y-4">
<ToggleSwitch
enabled={settings.darkMode}
onChange={() => updateSetting('darkMode', !settings.darkMode)}
label="Dark Mode"
description="Switch to dark theme"
/>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-slate-800 dark:text-avus">Unit System</p>
<p className="text-sm text-slate-500 dark:text-titanio">
Currently using {settings.unitSystem === 'imperial' ? 'Miles, Gallons, MPG, USD' : 'Km, Liters, L/100km, EUR'}
</p>
</div>
<button
onClick={() => updateSetting('unitSystem', settings.unitSystem === 'imperial' ? 'metric' : 'imperial')}
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>
</div>
</div>
</div>
</GlassCard>
{/* Data Management Section */}
<GlassCard padding="md">
<div>
<h2 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">Data Management</h2>
<div className="space-y-3">
<button
onClick={() => setShowDataExport(true)}
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>
<p className="text-sm text-slate-500 dark:text-titanio">
Download a copy of all your vehicle and fuel data
</p>
</div>
</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-primary-500 dark:text-primary-400 mb-4">Admin Console</h2>
<div className="space-y-3">
<button
onClick={() => navigateToScreen('AdminUsers')}
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-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-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-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-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-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-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-primary-500 mt-1 dark:text-primary-400">Create backups and restore data</div>
</button>
<button
onClick={() => navigateToScreen('AdminLogs')}
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">Audit Logs</div>
<div className="text-sm text-primary-500 mt-1 dark:text-primary-400">View system activity and audit logs</div>
</button>
</div>
</div>
</GlassCard>
)}
{/* Account Actions Section */}
<GlassCard padding="md">
<div>
<h2 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">Account Actions</h2>
<div className="space-y-3">
<button
onClick={handleLogout}
className="w-full py-3 px-4 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg text-left font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Sign Out
</button>
<button
onClick={() => setShowDeleteConfirm(true)}
className="w-full py-3 px-4 bg-red-50 text-red-600 rounded-lg text-left font-medium hover:bg-red-100 transition-colors"
>
Delete Account
</button>
</div>
</div>
</GlassCard>
{/* Data Export Modal */}
<Modal
isOpen={showDataExport}
onClose={() => setShowDataExport(false)}
title="Export Data"
>
<p className="text-slate-600 dark:text-titanio mb-4">
This will create a downloadable file containing all your vehicle data, fuel logs, and preferences.
</p>
<div className="flex space-x-3">
<button
onClick={() => setShowDataExport(false)}
className="flex-1 py-2 px-4 bg-gray-200 text-gray-700 rounded-lg font-medium"
>
Cancel
</button>
<button
onClick={handleExportData}
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"
>
{exportMutation.isPending ? 'Exporting...' : 'Export'}
</button>
</div>
</Modal>
{/* Delete Account Modal */}
<DeleteAccountModal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
/>
</div>
</MobileContainer>
);
};