feat: delete users - not tested
This commit is contained in:
@@ -43,6 +43,17 @@ const AdminEmailTemplatesMobileScreen = lazy(() => import('./features/admin/mobi
|
||||
// Admin Community Stations (lazy-loaded)
|
||||
const AdminCommunityStationsPage = lazy(() => import('./features/admin/pages/AdminCommunityStationsPage').then(m => ({ default: m.AdminCommunityStationsPage })));
|
||||
const AdminCommunityStationsMobileScreen = lazy(() => import('./features/admin/mobile/AdminCommunityStationsMobileScreen').then(m => ({ default: m.AdminCommunityStationsMobileScreen })));
|
||||
|
||||
// Auth pages (lazy-loaded)
|
||||
const SignupPage = lazy(() => import('./features/auth/pages/SignupPage').then(m => ({ default: m.SignupPage })));
|
||||
const VerifyEmailPage = lazy(() => import('./features/auth/pages/VerifyEmailPage').then(m => ({ default: m.VerifyEmailPage })));
|
||||
const SignupMobileScreen = lazy(() => import('./features/auth/mobile/SignupMobileScreen').then(m => ({ default: m.SignupMobileScreen })));
|
||||
const VerifyEmailMobileScreen = lazy(() => import('./features/auth/mobile/VerifyEmailMobileScreen').then(m => ({ default: m.VerifyEmailMobileScreen })));
|
||||
|
||||
// Onboarding pages (lazy-loaded)
|
||||
const OnboardingPage = lazy(() => import('./features/onboarding/pages/OnboardingPage').then(m => ({ default: m.OnboardingPage })));
|
||||
const OnboardingMobileScreen = lazy(() => import('./features/onboarding/mobile/OnboardingMobileScreen').then(m => ({ default: m.OnboardingMobileScreen })));
|
||||
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { BottomNavigation } from './shared-minimal/components/mobile/BottomNavigation';
|
||||
import { QuickAction } from './shared-minimal/components/mobile/quickActions';
|
||||
@@ -399,7 +410,11 @@ function App() {
|
||||
|
||||
const isGarageRoute = location.pathname === '/garage' || location.pathname.startsWith('/garage/');
|
||||
const isCallbackRoute = location.pathname === '/callback';
|
||||
const shouldShowHomePage = !isGarageRoute && !isCallbackRoute;
|
||||
const isSignupRoute = location.pathname === '/signup';
|
||||
const isVerifyEmailRoute = location.pathname === '/verify-email';
|
||||
const isOnboardingRoute = location.pathname === '/onboarding';
|
||||
const isAuthRoute = isSignupRoute || isVerifyEmailRoute || isOnboardingRoute;
|
||||
const shouldShowHomePage = !isGarageRoute && !isCallbackRoute && !isAuthRoute;
|
||||
|
||||
// Enhanced navigation handlers for mobile
|
||||
const handleVehicleSelect = useCallback((vehicle: Vehicle) => {
|
||||
@@ -475,10 +490,60 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
// Signup route is public - no authentication required
|
||||
if (isSignupRoute) {
|
||||
return (
|
||||
<ThemeProvider theme={md3Theme}>
|
||||
<CssBaseline />
|
||||
<React.Suspense fallback={
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-lg">Loading...</div>
|
||||
</div>
|
||||
}>
|
||||
{mobileMode ? <SignupMobileScreen /> : <SignupPage />}
|
||||
</React.Suspense>
|
||||
<DebugInfo />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
// Verify email and onboarding routes require authentication but not full initialization
|
||||
if (isVerifyEmailRoute) {
|
||||
return (
|
||||
<ThemeProvider theme={md3Theme}>
|
||||
<CssBaseline />
|
||||
<React.Suspense fallback={
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-lg">Loading...</div>
|
||||
</div>
|
||||
}>
|
||||
{mobileMode ? <VerifyEmailMobileScreen /> : <VerifyEmailPage />}
|
||||
</React.Suspense>
|
||||
<DebugInfo />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (isOnboardingRoute) {
|
||||
return (
|
||||
<ThemeProvider theme={md3Theme}>
|
||||
<CssBaseline />
|
||||
<React.Suspense fallback={
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-lg">Loading...</div>
|
||||
</div>
|
||||
}>
|
||||
{mobileMode ? <OnboardingMobileScreen /> : <OnboardingPage />}
|
||||
</React.Suspense>
|
||||
<DebugInfo />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for auth gate to be ready before rendering protected routes
|
||||
// This prevents a race condition where the page renders before the auth token is ready
|
||||
if (!isAuthGateReady) {
|
||||
|
||||
@@ -359,5 +359,13 @@ export const adminApi = {
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
hardDelete: async (auth0Sub: string, reason?: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.delete<{ message: string }>(
|
||||
`/admin/users/${encodeURIComponent(auth0Sub)}`,
|
||||
{ params: reason ? { reason } : undefined }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -178,3 +178,26 @@ export const usePromoteToAdmin = () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to hard delete a user (GDPR permanent deletion)
|
||||
*/
|
||||
export const useHardDeleteUser = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ auth0Sub, reason }: { auth0Sub: string; reason?: string }) =>
|
||||
adminApi.users.hardDelete(auth0Sub, reason),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
|
||||
toast.success('User permanently deleted');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
'Failed to delete user'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useReactivateUser,
|
||||
useUpdateUserProfile,
|
||||
usePromoteToAdmin,
|
||||
useHardDeleteUser,
|
||||
} from '../hooks/useUsers';
|
||||
import {
|
||||
ManagedUser,
|
||||
@@ -103,6 +104,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
|
||||
const reactivateMutation = useReactivateUser();
|
||||
const updateProfileMutation = useUpdateUserProfile();
|
||||
const promoteToAdminMutation = usePromoteToAdmin();
|
||||
const hardDeleteMutation = useHardDeleteUser();
|
||||
|
||||
// Selected user for actions
|
||||
const [selectedUser, setSelectedUser] = useState<ManagedUser | null>(null);
|
||||
@@ -115,6 +117,9 @@ export const AdminUsersMobileScreen: React.FC = () => {
|
||||
const [editDisplayName, setEditDisplayName] = useState('');
|
||||
const [showPromoteModal, setShowPromoteModal] = useState(false);
|
||||
const [promoteRole, setPromoteRole] = useState<'admin' | 'super_admin'>('admin');
|
||||
const [showHardDeleteModal, setShowHardDeleteModal] = useState(false);
|
||||
const [hardDeleteReason, setHardDeleteReason] = useState('');
|
||||
const [hardDeleteConfirmText, setHardDeleteConfirmText] = useState('');
|
||||
|
||||
// Handlers
|
||||
const handleSearch = useCallback(() => {
|
||||
@@ -256,6 +261,34 @@ export const AdminUsersMobileScreen: React.FC = () => {
|
||||
setSelectedUser(null);
|
||||
}, []);
|
||||
|
||||
const handleHardDeleteClick = useCallback(() => {
|
||||
setShowUserActions(false);
|
||||
setShowHardDeleteModal(true);
|
||||
}, []);
|
||||
|
||||
const handleHardDeleteConfirm = useCallback(() => {
|
||||
if (selectedUser && hardDeleteConfirmText === 'DELETE') {
|
||||
hardDeleteMutation.mutate(
|
||||
{ auth0Sub: selectedUser.auth0Sub, reason: hardDeleteReason || undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowHardDeleteModal(false);
|
||||
setHardDeleteReason('');
|
||||
setHardDeleteConfirmText('');
|
||||
setSelectedUser(null);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [selectedUser, hardDeleteReason, hardDeleteConfirmText, hardDeleteMutation]);
|
||||
|
||||
const handleHardDeleteCancel = useCallback(() => {
|
||||
setShowHardDeleteModal(false);
|
||||
setHardDeleteReason('');
|
||||
setHardDeleteConfirmText('');
|
||||
setSelectedUser(null);
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
setParams(prev => ({ ...prev, page: (prev.page || 1) + 1 }));
|
||||
}, []);
|
||||
@@ -527,6 +560,15 @@ export const AdminUsersMobileScreen: React.FC = () => {
|
||||
Deactivate User
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!selectedUser.isAdmin && (
|
||||
<button
|
||||
onClick={handleHardDeleteClick}
|
||||
className="w-full py-3 text-left text-red-600 font-medium min-h-[44px]"
|
||||
>
|
||||
Delete Permanently
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -716,6 +758,82 @@ export const AdminUsersMobileScreen: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Hard Delete Confirmation Modal */}
|
||||
<Modal
|
||||
isOpen={showHardDeleteModal}
|
||||
onClose={() => !hardDeleteMutation.isPending && handleHardDeleteCancel()}
|
||||
title="Permanently Delete User"
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
onClick={handleHardDeleteCancel}
|
||||
disabled={hardDeleteMutation.isPending}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleHardDeleteConfirm}
|
||||
disabled={hardDeleteMutation.isPending || hardDeleteConfirmText !== 'DELETE'}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg font-medium min-h-[44px] disabled:opacity-50"
|
||||
>
|
||||
{hardDeleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<p className="text-sm font-semibold text-red-800">
|
||||
Warning: This action cannot be undone!
|
||||
</p>
|
||||
<p className="text-sm text-red-700 mt-1">
|
||||
All user data will be permanently deleted, including vehicles, fuel logs,
|
||||
maintenance records, and documents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-slate-600">
|
||||
Are you sure you want to permanently delete{' '}
|
||||
<strong>{selectedUser?.email}</strong>?
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 block mb-1">
|
||||
Reason for deletion
|
||||
</label>
|
||||
<textarea
|
||||
value={hardDeleteReason}
|
||||
onChange={(e) => setHardDeleteReason(e.target.value)}
|
||||
placeholder="GDPR request, user request, etc..."
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 resize-none min-h-[60px]"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 block mb-1">
|
||||
Type <strong>DELETE</strong> to confirm
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={hardDeleteConfirmText}
|
||||
onChange={(e) => setHardDeleteConfirmText(e.target.value.toUpperCase())}
|
||||
placeholder="Type DELETE"
|
||||
className={`w-full px-3 py-2 rounded-lg border min-h-[44px] ${
|
||||
hardDeleteConfirmText && hardDeleteConfirmText !== 'DELETE'
|
||||
? 'border-red-500'
|
||||
: 'border-slate-200'
|
||||
}`}
|
||||
style={{ fontSize: '16px' }}
|
||||
/>
|
||||
{hardDeleteConfirmText && hardDeleteConfirmText !== 'DELETE' && (
|
||||
<p className="text-sm text-red-500 mt-1">Please type DELETE exactly</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
|
||||
40
frontend/src/features/auth/api/auth.api.ts
Normal file
40
frontend/src/features/auth/api/auth.api.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @ai-summary API client for auth feature (signup, verification)
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
import {
|
||||
SignupRequest,
|
||||
SignupResponse,
|
||||
VerifyStatusResponse,
|
||||
ResendVerificationResponse,
|
||||
} from '../types/auth.types';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
|
||||
// Create unauthenticated client for public signup endpoint
|
||||
const unauthenticatedClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
export const authApi = {
|
||||
signup: async (data: SignupRequest): Promise<SignupResponse> => {
|
||||
const response = await unauthenticatedClient.post('/auth/signup', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getVerifyStatus: async (): Promise<VerifyStatusResponse> => {
|
||||
const response = await apiClient.get('/auth/verify-status');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
resendVerification: async (): Promise<ResendVerificationResponse> => {
|
||||
const response = await apiClient.post('/auth/resend-verification');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
148
frontend/src/features/auth/components/SignupForm.tsx
Normal file
148
frontend/src/features/auth/components/SignupForm.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @ai-summary Signup form component with password validation and show/hide toggle
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '../../../shared-minimal/components/Button';
|
||||
import { SignupRequest } from '../types/auth.types';
|
||||
|
||||
const signupSchema = z
|
||||
.object({
|
||||
email: z.string().email('Please enter a valid email address'),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain at least one number'),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
interface SignupFormProps {
|
||||
onSubmit: (data: SignupRequest) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const SignupForm: React.FC<SignupFormProps> = ({ onSubmit, loading }) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<SignupRequest & { confirmPassword: string }>({
|
||||
resolver: zodResolver(signupSchema),
|
||||
});
|
||||
|
||||
const handleFormSubmit = (data: SignupRequest & { confirmPassword: string }) => {
|
||||
const { email, password } = data;
|
||||
onSubmit({ email, password });
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email Address <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...register('email')}
|
||||
type="email"
|
||||
inputMode="email"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-[44px]"
|
||||
placeholder="your.email@example.com"
|
||||
style={{ fontSize: '16px' }}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
{...register('password')}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-[44px]"
|
||||
placeholder="At least 8 characters"
|
||||
style={{ fontSize: '16px' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700 focus:outline-none min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
Must be at least 8 characters with one uppercase letter and one number
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Confirm Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
{...register('confirmPassword')}
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-[44px]"
|
||||
placeholder="Re-enter your password"
|
||||
style={{ fontSize: '16px' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700 focus:outline-none min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||
aria-label={showConfirmPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<Button type="submit" loading={loading} className="w-full min-h-[44px]">
|
||||
Create Account
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
32
frontend/src/features/auth/hooks/useSignup.ts
Normal file
32
frontend/src/features/auth/hooks/useSignup.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @ai-summary React Query hook for user signup
|
||||
*/
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import { authApi } from '../api/auth.api';
|
||||
import { SignupRequest } from '../types/auth.types';
|
||||
|
||||
interface ApiError {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
status?: number;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const useSignup = () => {
|
||||
return useMutation({
|
||||
mutationFn: (data: SignupRequest) => authApi.signup(data),
|
||||
onSuccess: () => {
|
||||
toast.success('Account created! Please check your email to verify your account.');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
const errorMessage = error.response?.data?.error || error.response?.data?.message || error.message || 'Failed to create account';
|
||||
toast.error(errorMessage);
|
||||
},
|
||||
});
|
||||
};
|
||||
54
frontend/src/features/auth/hooks/useVerifyStatus.ts
Normal file
54
frontend/src/features/auth/hooks/useVerifyStatus.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @ai-summary React Query hook for email verification status with polling
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { authApi } from '../api/auth.api';
|
||||
|
||||
interface ApiError {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const useVerifyStatus = (options?: { enablePolling?: boolean; onVerified?: () => void }) => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['verifyStatus'],
|
||||
queryFn: authApi.getVerifyStatus,
|
||||
enabled: isAuthenticated && !isLoading,
|
||||
refetchInterval: options?.enablePolling ? 5000 : false, // Poll every 5 seconds if enabled
|
||||
refetchIntervalInBackground: false,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
// Call onVerified callback when verification completes
|
||||
if (query.data?.emailVerified && options?.onVerified) {
|
||||
options.onVerified();
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
export const useResendVerification = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => authApi.resendVerification(),
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message || 'Verification email sent. Please check your inbox.');
|
||||
queryClient.invalidateQueries({ queryKey: ['verifyStatus'] });
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
const errorMessage = error.response?.data?.error || error.response?.data?.message || error.message || 'Failed to resend verification email';
|
||||
toast.error(errorMessage);
|
||||
},
|
||||
});
|
||||
};
|
||||
24
frontend/src/features/auth/index.ts
Normal file
24
frontend/src/features/auth/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @ai-summary Auth feature module exports
|
||||
*/
|
||||
|
||||
// Types
|
||||
export * from './types/auth.types';
|
||||
|
||||
// API
|
||||
export { authApi } from './api/auth.api';
|
||||
|
||||
// Hooks
|
||||
export { useSignup } from './hooks/useSignup';
|
||||
export { useVerifyStatus, useResendVerification } from './hooks/useVerifyStatus';
|
||||
|
||||
// Components
|
||||
export { SignupForm } from './components/SignupForm';
|
||||
|
||||
// Pages
|
||||
export { SignupPage } from './pages/SignupPage';
|
||||
export { VerifyEmailPage } from './pages/VerifyEmailPage';
|
||||
|
||||
// Mobile Screens
|
||||
export { SignupMobileScreen } from './mobile/SignupMobileScreen';
|
||||
export { VerifyEmailMobileScreen } from './mobile/VerifyEmailMobileScreen';
|
||||
54
frontend/src/features/auth/mobile/SignupMobileScreen.tsx
Normal file
54
frontend/src/features/auth/mobile/SignupMobileScreen.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @ai-summary Mobile signup screen with glass card styling
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { SignupForm } from '../components/SignupForm';
|
||||
import { useSignup } from '../hooks/useSignup';
|
||||
import { SignupRequest } from '../types/auth.types';
|
||||
|
||||
export const SignupMobileScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { mutate: signup, isPending } = useSignup();
|
||||
|
||||
const handleSubmit = (data: SignupRequest) => {
|
||||
signup(data, {
|
||||
onSuccess: () => {
|
||||
navigate('/verify-email');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
<div className="text-center pt-8 pb-4">
|
||||
<h1 className="text-3xl font-bold text-primary-600 mb-2">MotoVaultPro</h1>
|
||||
<h2 className="text-xl font-semibold text-slate-800">Create Your Account</h2>
|
||||
<p className="text-sm text-slate-600 mt-2">
|
||||
Start tracking your vehicle maintenance and fuel logs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<GlassCard>
|
||||
<SignupForm onSubmit={handleSubmit} loading={isPending} />
|
||||
</GlassCard>
|
||||
|
||||
<div className="text-center text-sm text-slate-600 pb-8">
|
||||
Already have an account?{' '}
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="text-primary-600 hover:text-primary-700 font-medium focus:outline-none focus:underline min-h-[44px] inline-flex items-center"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignupMobileScreen;
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @ai-summary Mobile email verification screen with polling and resend
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { Button } from '../../../shared-minimal/components/Button';
|
||||
import { useVerifyStatus, useResendVerification } from '../hooks/useVerifyStatus';
|
||||
|
||||
export const VerifyEmailMobileScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { data: verifyStatus, isLoading } = useVerifyStatus({
|
||||
enablePolling: true,
|
||||
});
|
||||
const { mutate: resendVerification, isPending: isResending } = useResendVerification();
|
||||
|
||||
useEffect(() => {
|
||||
if (verifyStatus?.emailVerified) {
|
||||
navigate('/onboarding');
|
||||
}
|
||||
}, [verifyStatus, navigate]);
|
||||
|
||||
const handleResend = () => {
|
||||
resendVerification();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-lg text-slate-600">Loading...</div>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
<div className="text-center pt-8 pb-4">
|
||||
<div className="mx-auto w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-primary-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-800 mb-2">Check Your Email</h1>
|
||||
<p className="text-slate-600">
|
||||
We've sent a verification link to
|
||||
</p>
|
||||
<p className="text-primary-600 font-medium mt-1 break-words px-4">
|
||||
{verifyStatus?.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<GlassCard>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-sm text-slate-700">
|
||||
<p className="mb-2">Click the link in the email to verify your account.</p>
|
||||
<p>Once verified, you'll be automatically redirected to complete your profile.</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-slate-600 mb-3">Didn't receive the email?</p>
|
||||
<Button
|
||||
onClick={handleResend}
|
||||
loading={isResending}
|
||||
variant="secondary"
|
||||
className="w-full min-h-[44px]"
|
||||
>
|
||||
Resend Verification Email
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<div className="text-center text-sm text-slate-500 pb-8 px-4">
|
||||
<p>Check your spam folder if you don't see the email in your inbox.</p>
|
||||
</div>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerifyEmailMobileScreen;
|
||||
50
frontend/src/features/auth/pages/SignupPage.tsx
Normal file
50
frontend/src/features/auth/pages/SignupPage.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @ai-summary Desktop signup page with centered card layout
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { SignupForm } from '../components/SignupForm';
|
||||
import { useSignup } from '../hooks/useSignup';
|
||||
import { SignupRequest } from '../types/auth.types';
|
||||
|
||||
export const SignupPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { mutate: signup, isPending } = useSignup();
|
||||
|
||||
const handleSubmit = (data: SignupRequest) => {
|
||||
signup(data, {
|
||||
onSuccess: () => {
|
||||
navigate('/verify-email');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-rose-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-primary-600 mb-2">MotoVaultPro</h1>
|
||||
<h2 className="text-xl font-semibold text-gray-800">Create Your Account</h2>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
Start tracking your vehicle maintenance and fuel logs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SignupForm onSubmit={handleSubmit} loading={isPending} />
|
||||
|
||||
<div className="mt-6 text-center text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="text-primary-600 hover:text-primary-700 font-medium focus:outline-none focus:underline"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
90
frontend/src/features/auth/pages/VerifyEmailPage.tsx
Normal file
90
frontend/src/features/auth/pages/VerifyEmailPage.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @ai-summary Desktop email verification page with polling and resend functionality
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useVerifyStatus, useResendVerification } from '../hooks/useVerifyStatus';
|
||||
import { Button } from '../../../shared-minimal/components/Button';
|
||||
|
||||
export const VerifyEmailPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { data: verifyStatus, isLoading } = useVerifyStatus({
|
||||
enablePolling: true,
|
||||
});
|
||||
const { mutate: resendVerification, isPending: isResending } = useResendVerification();
|
||||
|
||||
useEffect(() => {
|
||||
if (verifyStatus?.emailVerified) {
|
||||
navigate('/onboarding');
|
||||
}
|
||||
}, [verifyStatus, navigate]);
|
||||
|
||||
const handleResend = () => {
|
||||
resendVerification();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-rose-50 flex items-center justify-center p-4">
|
||||
<div className="text-lg text-gray-600">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-rose-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="mx-auto w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-primary-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-2">Check Your Email</h1>
|
||||
<p className="text-gray-600">
|
||||
We've sent a verification link to
|
||||
</p>
|
||||
<p className="text-primary-600 font-medium mt-1">
|
||||
{verifyStatus?.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-sm text-gray-700">
|
||||
<p className="mb-2">Click the link in the email to verify your account.</p>
|
||||
<p>Once verified, you'll be automatically redirected to complete your profile.</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600 mb-3">Didn't receive the email?</p>
|
||||
<Button
|
||||
onClick={handleResend}
|
||||
loading={isResending}
|
||||
variant="secondary"
|
||||
className="w-full min-h-[44px]"
|
||||
>
|
||||
Resend Verification Email
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-gray-500">
|
||||
<p>Check your spam folder if you don't see the email in your inbox.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
23
frontend/src/features/auth/types/auth.types.ts
Normal file
23
frontend/src/features/auth/types/auth.types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @ai-summary TypeScript types for auth feature
|
||||
*/
|
||||
|
||||
export interface SignupRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface SignupResponse {
|
||||
userId: string;
|
||||
email: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface VerifyStatusResponse {
|
||||
emailVerified: boolean;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface ResendVerificationResponse {
|
||||
message: string;
|
||||
}
|
||||
23
frontend/src/features/onboarding/api/onboarding.api.ts
Normal file
23
frontend/src/features/onboarding/api/onboarding.api.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @ai-summary API client for onboarding endpoints
|
||||
*/
|
||||
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
import { OnboardingPreferences, OnboardingStatus } from '../types/onboarding.types';
|
||||
|
||||
export const onboardingApi = {
|
||||
savePreferences: async (data: OnboardingPreferences) => {
|
||||
const response = await apiClient.post('/onboarding/preferences', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
completeOnboarding: async () => {
|
||||
const response = await apiClient.post('/onboarding/complete');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getStatus: async () => {
|
||||
const response = await apiClient.get<OnboardingStatus>('/onboarding/status');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
114
frontend/src/features/onboarding/components/AddVehicleStep.tsx
Normal file
114
frontend/src/features/onboarding/components/AddVehicleStep.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @ai-summary Step 2 of onboarding - Optionally add first vehicle
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '../../../shared-minimal/components/Button';
|
||||
import { VehicleForm } from '../../vehicles/components/VehicleForm';
|
||||
import { CreateVehicleRequest } from '../../vehicles/types/vehicles.types';
|
||||
|
||||
interface AddVehicleStepProps {
|
||||
onNext: () => void;
|
||||
onAddVehicle: (data: CreateVehicleRequest) => void;
|
||||
onBack: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const AddVehicleStep: React.FC<AddVehicleStepProps> = ({
|
||||
onNext,
|
||||
onAddVehicle,
|
||||
onBack,
|
||||
loading,
|
||||
}) => {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
const handleSkip = () => {
|
||||
onNext();
|
||||
};
|
||||
|
||||
const handleAddVehicle = (data: CreateVehicleRequest) => {
|
||||
onAddVehicle(data);
|
||||
};
|
||||
|
||||
if (!showForm) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto w-20 h-20 bg-primary-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg
|
||||
className="w-10 h-10 text-primary-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-slate-800 mb-2">Add Your First Vehicle</h2>
|
||||
<p className="text-slate-600 mb-6">
|
||||
Add a vehicle now or skip this step and add it later from your garage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="w-full min-h-[44px]"
|
||||
>
|
||||
Add Vehicle
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleSkip}
|
||||
className="w-full min-h-[44px]"
|
||||
>
|
||||
Skip for Now
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
className="min-h-[44px]"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-800 mb-2">Add Your First Vehicle</h2>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Fill in the details below. You can always edit this later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<VehicleForm
|
||||
onSubmit={handleAddVehicle}
|
||||
onCancel={() => setShowForm(false)}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
className="min-h-[44px]"
|
||||
disabled={loading}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
70
frontend/src/features/onboarding/components/CompleteStep.tsx
Normal file
70
frontend/src/features/onboarding/components/CompleteStep.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @ai-summary Step 3 of onboarding - Success screen
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '../../../shared-minimal/components/Button';
|
||||
|
||||
interface CompleteStepProps {
|
||||
onComplete: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const CompleteStep: React.FC<CompleteStepProps> = ({ onComplete, loading }) => {
|
||||
return (
|
||||
<div className="space-y-6 text-center py-8">
|
||||
<div className="mx-auto w-24 h-24 bg-green-100 rounded-full flex items-center justify-center animate-bounce">
|
||||
<svg
|
||||
className="w-12 h-12 text-green-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-800 mb-2">You're All Set!</h2>
|
||||
<p className="text-slate-600 max-w-md mx-auto">
|
||||
Welcome to MotoVault Pro. Your account is ready and you can now start tracking your vehicles.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-primary-50 rounded-lg p-6 max-w-md mx-auto">
|
||||
<h3 className="font-semibold text-primary-900 mb-2">What's Next?</h3>
|
||||
<ul className="text-left space-y-2 text-sm text-primary-800">
|
||||
<li className="flex items-start">
|
||||
<svg className="w-5 h-5 text-primary-600 mr-2 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Add or manage your vehicles in the garage</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<svg className="w-5 h-5 text-primary-600 mr-2 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Track fuel logs and maintenance records</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<svg className="w-5 h-5 text-primary-600 mr-2 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Upload important vehicle documents</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="pt-6">
|
||||
<Button onClick={onComplete} loading={loading} className="min-h-[44px] px-8">
|
||||
Go to My Garage
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
141
frontend/src/features/onboarding/components/PreferencesStep.tsx
Normal file
141
frontend/src/features/onboarding/components/PreferencesStep.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @ai-summary Step 1 of onboarding - Set user preferences
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '../../../shared-minimal/components/Button';
|
||||
import { OnboardingPreferences } from '../types/onboarding.types';
|
||||
|
||||
const preferencesSchema = z.object({
|
||||
unitSystem: z.enum(['imperial', 'metric']),
|
||||
currencyCode: z.string().length(3),
|
||||
timeZone: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
interface PreferencesStepProps {
|
||||
onNext: (data: OnboardingPreferences) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const PreferencesStep: React.FC<PreferencesStepProps> = ({ onNext, loading }) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<OnboardingPreferences>({
|
||||
resolver: zodResolver(preferencesSchema),
|
||||
defaultValues: {
|
||||
unitSystem: 'imperial',
|
||||
currencyCode: 'USD',
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
});
|
||||
|
||||
const unitSystem = watch('unitSystem');
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onNext)} className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-800 mb-4">Set Your Preferences</h2>
|
||||
<p className="text-slate-600 mb-6">
|
||||
Choose your preferred units and settings to personalize your experience.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Unit System Toggle */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">
|
||||
Unit System
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue('unitSystem', 'imperial')}
|
||||
className={`min-h-[44px] py-3 px-4 rounded-lg border-2 font-medium transition-all ${
|
||||
unitSystem === 'imperial'
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-semibold">Imperial</div>
|
||||
<div className="text-xs mt-1">Miles & Gallons</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue('unitSystem', 'metric')}
|
||||
className={`min-h-[44px] py-3 px-4 rounded-lg border-2 font-medium transition-all ${
|
||||
unitSystem === 'metric'
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-semibold">Metric</div>
|
||||
<div className="text-xs mt-1">Kilometers & Liters</div>
|
||||
</button>
|
||||
</div>
|
||||
{errors.unitSystem && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.unitSystem.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Currency Dropdown */}
|
||||
<div>
|
||||
<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>
|
||||
{errors.currencyCode && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.currencyCode.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timezone Dropdown */}
|
||||
<div>
|
||||
<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>
|
||||
{errors.timeZone && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.timeZone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button type="submit" loading={loading} className="min-h-[44px]">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
63
frontend/src/features/onboarding/hooks/useOnboarding.ts
Normal file
63
frontend/src/features/onboarding/hooks/useOnboarding.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @ai-summary React Query hooks for onboarding flow
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { onboardingApi } from '../api/onboarding.api';
|
||||
import { OnboardingPreferences } from '../types/onboarding.types';
|
||||
|
||||
interface ApiError {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
status?: number;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const useOnboardingStatus = () => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['onboarding-status'],
|
||||
queryFn: onboardingApi.getStatus,
|
||||
enabled: isAuthenticated && !isLoading,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
retry: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const useSavePreferences = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: OnboardingPreferences) => onboardingApi.savePreferences(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
|
||||
toast.success('Preferences saved successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
const errorMessage = error.response?.data?.error || error.message || 'Failed to save preferences';
|
||||
toast.error(errorMessage);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCompleteOnboarding = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: onboardingApi.completeOnboarding,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
const errorMessage = error.response?.data?.error || error.message || 'Failed to complete onboarding';
|
||||
toast.error(errorMessage);
|
||||
},
|
||||
});
|
||||
};
|
||||
28
frontend/src/features/onboarding/index.ts
Normal file
28
frontend/src/features/onboarding/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @ai-summary Public API exports for onboarding feature
|
||||
*/
|
||||
|
||||
// Pages
|
||||
export { OnboardingPage } from './pages/OnboardingPage';
|
||||
|
||||
// Mobile
|
||||
export { OnboardingMobileScreen } from './mobile/OnboardingMobileScreen';
|
||||
|
||||
// Components
|
||||
export { PreferencesStep } from './components/PreferencesStep';
|
||||
export { AddVehicleStep } from './components/AddVehicleStep';
|
||||
export { CompleteStep } from './components/CompleteStep';
|
||||
|
||||
// Hooks
|
||||
export {
|
||||
useOnboardingStatus,
|
||||
useSavePreferences,
|
||||
useCompleteOnboarding,
|
||||
} from './hooks/useOnboarding';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
OnboardingPreferences,
|
||||
OnboardingStatus,
|
||||
OnboardingStep,
|
||||
} from './types/onboarding.types';
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @ai-summary Mobile onboarding screen with multi-step wizard
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { useSavePreferences, useCompleteOnboarding } from '../hooks/useOnboarding';
|
||||
import { PreferencesStep } from '../components/PreferencesStep';
|
||||
import { AddVehicleStep } from '../components/AddVehicleStep';
|
||||
import { CompleteStep } from '../components/CompleteStep';
|
||||
import { OnboardingStep, OnboardingPreferences } from '../types/onboarding.types';
|
||||
import { CreateVehicleRequest } from '../../vehicles/types/vehicles.types';
|
||||
import { vehiclesApi } from '../../vehicles/api/vehicles.api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export const OnboardingMobileScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState<OnboardingStep>('preferences');
|
||||
const savePreferences = useSavePreferences();
|
||||
const completeOnboarding = useCompleteOnboarding();
|
||||
const [isAddingVehicle, setIsAddingVehicle] = useState(false);
|
||||
|
||||
const stepNumbers: Record<OnboardingStep, number> = {
|
||||
preferences: 1,
|
||||
vehicle: 2,
|
||||
complete: 3,
|
||||
};
|
||||
|
||||
const handleSavePreferences = async (data: OnboardingPreferences) => {
|
||||
try {
|
||||
await savePreferences.mutateAsync(data);
|
||||
setCurrentStep('vehicle');
|
||||
} catch (error) {
|
||||
// Error is handled by the mutation hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddVehicle = async (data: CreateVehicleRequest) => {
|
||||
setIsAddingVehicle(true);
|
||||
try {
|
||||
await vehiclesApi.create(data);
|
||||
toast.success('Vehicle added successfully');
|
||||
setCurrentStep('complete');
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Failed to add vehicle');
|
||||
} finally {
|
||||
setIsAddingVehicle(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipVehicle = () => {
|
||||
setCurrentStep('complete');
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
try {
|
||||
await completeOnboarding.mutateAsync();
|
||||
navigate('/vehicles');
|
||||
} catch (error) {
|
||||
// Error is handled by the mutation hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep === 'vehicle') {
|
||||
setCurrentStep('preferences');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center pt-4">
|
||||
<h1 className="text-2xl font-bold text-slate-800 mb-2">Welcome to MotoVault Pro</h1>
|
||||
<p className="text-slate-600 text-sm">Let's set up your account</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Indicator */}
|
||||
<div className="flex items-center justify-between px-4">
|
||||
{(['preferences', 'vehicle', 'complete'] as OnboardingStep[]).map((step, index) => (
|
||||
<React.Fragment key={step}>
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold text-sm transition-all ${
|
||||
stepNumbers[currentStep] >= stepNumbers[step]
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{stepNumbers[step]}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs mt-1 font-medium ${
|
||||
stepNumbers[currentStep] >= stepNumbers[step]
|
||||
? 'text-primary-600'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{step === 'preferences' && 'Setup'}
|
||||
{step === 'vehicle' && 'Vehicle'}
|
||||
{step === 'complete' && 'Done'}
|
||||
</span>
|
||||
</div>
|
||||
{index < 2 && (
|
||||
<div
|
||||
className={`flex-1 h-1 mx-2 rounded transition-all ${
|
||||
stepNumbers[currentStep] > stepNumbers[step]
|
||||
? 'bg-primary-600'
|
||||
: 'bg-gray-200'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<GlassCard padding="md">
|
||||
{currentStep === 'preferences' && (
|
||||
<PreferencesStep
|
||||
onNext={handleSavePreferences}
|
||||
loading={savePreferences.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'vehicle' && (
|
||||
<AddVehicleStep
|
||||
onNext={handleSkipVehicle}
|
||||
onAddVehicle={handleAddVehicle}
|
||||
onBack={handleBack}
|
||||
loading={isAddingVehicle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'complete' && (
|
||||
<CompleteStep
|
||||
onComplete={handleComplete}
|
||||
loading={completeOnboarding.isPending}
|
||||
/>
|
||||
)}
|
||||
</GlassCard>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingMobileScreen;
|
||||
147
frontend/src/features/onboarding/pages/OnboardingPage.tsx
Normal file
147
frontend/src/features/onboarding/pages/OnboardingPage.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @ai-summary Desktop onboarding page with multi-step wizard
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSavePreferences, useCompleteOnboarding } from '../hooks/useOnboarding';
|
||||
import { PreferencesStep } from '../components/PreferencesStep';
|
||||
import { AddVehicleStep } from '../components/AddVehicleStep';
|
||||
import { CompleteStep } from '../components/CompleteStep';
|
||||
import { OnboardingStep, OnboardingPreferences } from '../types/onboarding.types';
|
||||
import { CreateVehicleRequest } from '../../vehicles/types/vehicles.types';
|
||||
import { vehiclesApi } from '../../vehicles/api/vehicles.api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export const OnboardingPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState<OnboardingStep>('preferences');
|
||||
const savePreferences = useSavePreferences();
|
||||
const completeOnboarding = useCompleteOnboarding();
|
||||
const [isAddingVehicle, setIsAddingVehicle] = useState(false);
|
||||
|
||||
const stepNumbers: Record<OnboardingStep, number> = {
|
||||
preferences: 1,
|
||||
vehicle: 2,
|
||||
complete: 3,
|
||||
};
|
||||
|
||||
const handleSavePreferences = async (data: OnboardingPreferences) => {
|
||||
try {
|
||||
await savePreferences.mutateAsync(data);
|
||||
setCurrentStep('vehicle');
|
||||
} catch (error) {
|
||||
// Error is handled by the mutation hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddVehicle = async (data: CreateVehicleRequest) => {
|
||||
setIsAddingVehicle(true);
|
||||
try {
|
||||
await vehiclesApi.create(data);
|
||||
toast.success('Vehicle added successfully');
|
||||
setCurrentStep('complete');
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Failed to add vehicle');
|
||||
} finally {
|
||||
setIsAddingVehicle(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipVehicle = () => {
|
||||
setCurrentStep('complete');
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
try {
|
||||
await completeOnboarding.mutateAsync();
|
||||
navigate('/vehicles');
|
||||
} catch (error) {
|
||||
// Error is handled by the mutation hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep === 'vehicle') {
|
||||
setCurrentStep('preferences');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-rose-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-2xl">
|
||||
{/* Progress Indicator */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{(['preferences', 'vehicle', 'complete'] as OnboardingStep[]).map((step, index) => (
|
||||
<React.Fragment key={step}>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold transition-all ${
|
||||
stepNumbers[currentStep] >= stepNumbers[step]
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{stepNumbers[step]}
|
||||
</div>
|
||||
<span
|
||||
className={`ml-2 text-sm font-medium hidden sm:inline ${
|
||||
stepNumbers[currentStep] >= stepNumbers[step]
|
||||
? 'text-primary-600'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{step === 'preferences' && 'Preferences'}
|
||||
{step === 'vehicle' && 'Add Vehicle'}
|
||||
{step === 'complete' && 'Complete'}
|
||||
</span>
|
||||
</div>
|
||||
{index < 2 && (
|
||||
<div
|
||||
className={`flex-1 h-1 mx-2 rounded transition-all ${
|
||||
stepNumbers[currentStep] > stepNumbers[step]
|
||||
? 'bg-primary-600'
|
||||
: 'bg-gray-200'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 text-center mt-4">
|
||||
Step {stepNumbers[currentStep]} of 3
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="bg-white rounded-2xl shadow-xl border border-slate-200 p-6 md:p-8">
|
||||
{currentStep === 'preferences' && (
|
||||
<PreferencesStep
|
||||
onNext={handleSavePreferences}
|
||||
loading={savePreferences.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'vehicle' && (
|
||||
<AddVehicleStep
|
||||
onNext={handleSkipVehicle}
|
||||
onAddVehicle={handleAddVehicle}
|
||||
onBack={handleBack}
|
||||
loading={isAddingVehicle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'complete' && (
|
||||
<CompleteStep
|
||||
onComplete={handleComplete}
|
||||
loading={completeOnboarding.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingPage;
|
||||
17
frontend/src/features/onboarding/types/onboarding.types.ts
Normal file
17
frontend/src/features/onboarding/types/onboarding.types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @ai-summary TypeScript types for onboarding feature
|
||||
*/
|
||||
|
||||
export interface OnboardingPreferences {
|
||||
unitSystem: 'imperial' | 'metric';
|
||||
currencyCode: string;
|
||||
timeZone: string;
|
||||
}
|
||||
|
||||
export interface OnboardingStatus {
|
||||
preferencesSet: boolean;
|
||||
onboardingCompleted: boolean;
|
||||
onboardingCompletedAt: string | null;
|
||||
}
|
||||
|
||||
export type OnboardingStep = 'preferences' | 'vehicle' | 'complete';
|
||||
@@ -3,9 +3,19 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
import { UserProfile, UpdateProfileRequest } from '../types/profile.types';
|
||||
import {
|
||||
UserProfile,
|
||||
UpdateProfileRequest,
|
||||
DeletionStatus,
|
||||
RequestDeletionRequest,
|
||||
RequestDeletionResponse,
|
||||
CancelDeletionResponse,
|
||||
} from '../types/profile.types';
|
||||
|
||||
export const profileApi = {
|
||||
getProfile: () => apiClient.get<UserProfile>('/user/profile'),
|
||||
updateProfile: (data: UpdateProfileRequest) => apiClient.put<UserProfile>('/user/profile', data),
|
||||
requestDeletion: (data: RequestDeletionRequest) => apiClient.post<RequestDeletionResponse>('/user/delete', data),
|
||||
cancelDeletion: () => apiClient.post<CancelDeletionResponse>('/user/cancel-deletion'),
|
||||
getDeletionStatus: () => apiClient.get<DeletionStatus>('/user/deletion-status'),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @ai-summary Desktop dialog for requesting account deletion with 30-day grace period
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Typography,
|
||||
Box,
|
||||
} from '@mui/material';
|
||||
import { useRequestDeletion } from '../hooks/useDeletion';
|
||||
|
||||
interface DeleteAccountDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DeleteAccountDialog: React.FC<DeleteAccountDialogProps> = ({ open, onClose }) => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmationText, setConfirmationText] = useState('');
|
||||
const requestDeletionMutation = useRequestDeletion();
|
||||
|
||||
// Clear form when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPassword('');
|
||||
setConfirmationText('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!password || confirmationText !== 'DELETE') {
|
||||
return;
|
||||
}
|
||||
|
||||
await requestDeletionMutation.mutateAsync({ password, confirmationText });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const isValid = password.length > 0 && confirmationText === 'DELETE';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Delete Account</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
30-Day Grace Period
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Your account will be scheduled for deletion in 30 days. You can cancel this request at any time during
|
||||
the grace period by logging back in.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
fullWidth
|
||||
required
|
||||
autoComplete="current-password"
|
||||
helperText="Enter your password to confirm"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Type DELETE to confirm"
|
||||
value={confirmationText}
|
||||
onChange={(e) => setConfirmationText(e.target.value)}
|
||||
fullWidth
|
||||
required
|
||||
helperText='Type the word "DELETE" (all caps) to confirm'
|
||||
error={confirmationText.length > 0 && confirmationText !== 'DELETE'}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={onClose} disabled={requestDeletionMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={!isValid || requestDeletionMutation.isPending}
|
||||
startIcon={requestDeletionMutation.isPending ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{requestDeletionMutation.isPending ? 'Deleting...' : 'Delete Account'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @ai-summary Desktop banner showing pending account deletion with cancel option
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Alert, AlertTitle, Button, CircularProgress, Box, Typography } from '@mui/material';
|
||||
import { useDeletionStatus, useCancelDeletion } from '../hooks/useDeletion';
|
||||
|
||||
export const PendingDeletionBanner: React.FC = () => {
|
||||
const { data: deletionStatus, isLoading } = useDeletionStatus();
|
||||
const cancelDeletionMutation = useCancelDeletion();
|
||||
|
||||
// Don't show banner if not loading and not pending deletion
|
||||
if (isLoading || !deletionStatus?.isPendingDeletion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleCancelDeletion = async () => {
|
||||
await cancelDeletionMutation.mutateAsync();
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert
|
||||
severity="warning"
|
||||
sx={{ mb: 3 }}
|
||||
action={
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={handleCancelDeletion}
|
||||
disabled={cancelDeletionMutation.isPending}
|
||||
startIcon={cancelDeletionMutation.isPending ? <CircularProgress size={16} /> : null}
|
||||
>
|
||||
{cancelDeletionMutation.isPending ? 'Cancelling...' : 'Cancel Deletion'}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<AlertTitle>Account Deletion Pending</AlertTitle>
|
||||
<Box>
|
||||
<Typography variant="body2">
|
||||
Your account is scheduled for deletion in{' '}
|
||||
<strong>{deletionStatus.daysRemaining} {deletionStatus.daysRemaining === 1 ? 'day' : 'days'}</strong>.
|
||||
</Typography>
|
||||
{deletionStatus.deletionScheduledFor && (
|
||||
<Typography variant="body2" sx={{ mt: 0.5 }}>
|
||||
Scheduled for: {new Date(deletionStatus.deletionScheduledFor).toLocaleDateString()}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
74
frontend/src/features/settings/hooks/useDeletion.ts
Normal file
74
frontend/src/features/settings/hooks/useDeletion.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @ai-summary React hooks for account deletion functionality
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { profileApi } from '../api/profile.api';
|
||||
import { RequestDeletionRequest } from '../types/profile.types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface ApiError {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const useDeletionStatus = () => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['user-deletion-status'],
|
||||
queryFn: async () => {
|
||||
const response = await profileApi.getDeletionStatus();
|
||||
return response.data;
|
||||
},
|
||||
enabled: isAuthenticated && !isLoading,
|
||||
staleTime: 1 * 60 * 1000, // 1 minute
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes cache time
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const useRequestDeletion = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { logout } = useAuth0();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: RequestDeletionRequest) => profileApi.requestDeletion(data),
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user-deletion-status'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['user-profile'] });
|
||||
toast.success(response.data.message || 'Account deletion scheduled');
|
||||
|
||||
// Logout after 2 seconds
|
||||
setTimeout(() => {
|
||||
logout({ logoutParams: { returnTo: window.location.origin } });
|
||||
}, 2000);
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to request account deletion');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCancelDeletion = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => profileApi.cancelDeletion(),
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user-deletion-status'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['user-profile'] });
|
||||
queryClient.setQueryData(['user-profile'], response.data.profile);
|
||||
toast.success('Welcome back! Account deletion cancelled');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to cancel account deletion');
|
||||
},
|
||||
});
|
||||
};
|
||||
116
frontend/src/features/settings/mobile/DeleteAccountModal.tsx
Normal file
116
frontend/src/features/settings/mobile/DeleteAccountModal.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @ai-summary Mobile modal for requesting account deletion with 30-day grace period
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRequestDeletion } from '../hooks/useDeletion';
|
||||
|
||||
interface DeleteAccountModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({ isOpen, onClose }) => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmationText, setConfirmationText] = useState('');
|
||||
const requestDeletionMutation = useRequestDeletion();
|
||||
|
||||
// Clear form when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setPassword('');
|
||||
setConfirmationText('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!password || confirmationText !== 'DELETE') {
|
||||
return;
|
||||
}
|
||||
|
||||
await requestDeletionMutation.mutateAsync({ password, confirmationText });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const isValid = password.length > 0 && confirmationText === 'DELETE';
|
||||
|
||||
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-lg p-6 max-w-md w-full">
|
||||
<h3 className="text-xl font-semibold text-slate-800 mb-4">Delete Account</h3>
|
||||
|
||||
{/* Warning Alert */}
|
||||
<div className="mb-4 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<p className="font-semibold text-amber-900 mb-2">30-Day Grace Period</p>
|
||||
<p className="text-sm text-amber-800">
|
||||
Your account will be scheduled for deletion in 30 days. You can cancel this request at any time during
|
||||
the grace period by logging back in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Password Input */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||
style={{ fontSize: '16px', minHeight: '44px' }}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Enter your password to confirm</p>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Input */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Type DELETE to confirm
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmationText}
|
||||
onChange={(e) => setConfirmationText(e.target.value)}
|
||||
placeholder="DELETE"
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 ${
|
||||
confirmationText.length > 0 && confirmationText !== 'DELETE'
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: 'border-slate-300 focus:ring-red-500 focus:border-red-500'
|
||||
}`}
|
||||
style={{ fontSize: '16px', minHeight: '44px' }}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Type the word "DELETE" (all caps) to confirm</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={requestDeletionMutation.isPending}
|
||||
className="flex-1 py-2.5 px-4 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors disabled:opacity-50"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid || requestDeletionMutation.isPending}
|
||||
className="flex-1 py-2.5 px-4 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center justify-center"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
{requestDeletionMutation.isPending ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||
) : (
|
||||
'Delete Account'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,8 @@ import { useSettings } from '../hooks/useSettings';
|
||||
import { useProfile, useUpdateProfile } from '../hooks/useProfile';
|
||||
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
||||
import { useNavigationStore } from '../../../core/store';
|
||||
import { DeleteAccountModal } from './DeleteAccountModal';
|
||||
import { PendingDeletionBanner } from './PendingDeletionBanner';
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
enabled: boolean;
|
||||
@@ -105,11 +107,6 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
setShowDataExport(false);
|
||||
};
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
// TODO: Implement account deletion
|
||||
console.log('Deleting account...');
|
||||
setShowDeleteConfirm(false);
|
||||
};
|
||||
|
||||
const handleEditProfile = () => {
|
||||
setIsEditingProfile(true);
|
||||
@@ -190,6 +187,9 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
<p className="text-slate-500 mt-2">Manage your account and preferences</p>
|
||||
</div>
|
||||
|
||||
{/* Pending Deletion Banner */}
|
||||
<PendingDeletionBanner />
|
||||
|
||||
{/* Profile Section */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
@@ -488,30 +488,11 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Account Confirmation */}
|
||||
<Modal
|
||||
{/* Delete Account Modal */}
|
||||
<DeleteAccountModal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title="Delete Account"
|
||||
>
|
||||
<p className="text-slate-600 mb-4">
|
||||
This action cannot be undone. All your data will be permanently deleted.
|
||||
</p>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="flex-1 py-2 px-4 bg-gray-200 text-gray-700 rounded-lg font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteAccount}
|
||||
className="flex-1 py-2 px-4 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
/>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @ai-summary Mobile banner showing pending account deletion with cancel option
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { useDeletionStatus, useCancelDeletion } from '../hooks/useDeletion';
|
||||
|
||||
export const PendingDeletionBanner: React.FC = () => {
|
||||
const { data: deletionStatus, isLoading } = useDeletionStatus();
|
||||
const cancelDeletionMutation = useCancelDeletion();
|
||||
|
||||
// Don't show banner if not loading and not pending deletion
|
||||
if (isLoading || !deletionStatus?.isPendingDeletion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleCancelDeletion = async () => {
|
||||
await cancelDeletionMutation.mutateAsync();
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassCard padding="md" className="bg-amber-50/80 border-amber-200/70">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-amber-900 mb-1">Account Deletion Pending</h3>
|
||||
<p className="text-sm text-amber-800">
|
||||
Your account is scheduled for deletion in{' '}
|
||||
<strong>{deletionStatus.daysRemaining} {deletionStatus.daysRemaining === 1 ? 'day' : 'days'}</strong>.
|
||||
</p>
|
||||
{deletionStatus.deletionScheduledFor && (
|
||||
<p className="text-xs text-amber-700 mt-1">
|
||||
Scheduled for: {new Date(deletionStatus.deletionScheduledFor).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCancelDeletion}
|
||||
disabled={cancelDeletionMutation.isPending}
|
||||
className="w-full py-2.5 px-4 bg-amber-600 text-white rounded-lg font-medium hover:bg-amber-700 transition-colors disabled:opacity-50 flex items-center justify-center"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
{cancelDeletionMutation.isPending ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||
) : (
|
||||
'Cancel Deletion'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
@@ -16,3 +16,26 @@ export interface UpdateProfileRequest {
|
||||
displayName?: string;
|
||||
notificationEmail?: string;
|
||||
}
|
||||
|
||||
export interface DeletionStatus {
|
||||
isPendingDeletion: boolean;
|
||||
deletionRequestedAt: string | null;
|
||||
deletionScheduledFor: string | null;
|
||||
daysRemaining: number | null;
|
||||
}
|
||||
|
||||
export interface RequestDeletionRequest {
|
||||
password: string;
|
||||
confirmationText: string;
|
||||
}
|
||||
|
||||
export interface RequestDeletionResponse {
|
||||
message: string;
|
||||
deletionScheduledFor: string;
|
||||
daysRemaining: number;
|
||||
}
|
||||
|
||||
export interface CancelDeletionResponse {
|
||||
message: string;
|
||||
profile: UserProfile;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ export const HomePage = () => {
|
||||
loginWithRedirect({ appState: { returnTo: '/garage' } });
|
||||
};
|
||||
|
||||
const handleSignup = () => {
|
||||
navigate('/signup');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Navigation Bar */}
|
||||
@@ -44,6 +48,12 @@ export const HomePage = () => {
|
||||
<a href="#about" className="text-gray-700 hover:text-primary-500 transition-colors">
|
||||
About
|
||||
</a>
|
||||
<button
|
||||
onClick={handleSignup}
|
||||
className="border-2 border-primary-500 text-primary-500 hover:bg-primary-50 font-semibold py-2 px-6 rounded-lg transition-colors duration-300"
|
||||
>
|
||||
Sign Up
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAuthAction}
|
||||
className="bg-primary-500 hover:bg-primary-700 text-white font-semibold py-2 px-6 rounded-lg transition-colors duration-300"
|
||||
@@ -103,6 +113,12 @@ export const HomePage = () => {
|
||||
>
|
||||
About
|
||||
</a>
|
||||
<button
|
||||
onClick={handleSignup}
|
||||
className="w-full border-2 border-primary-500 text-primary-500 hover:bg-primary-50 font-semibold py-2 px-6 rounded-lg transition-colors duration-300"
|
||||
>
|
||||
Sign Up
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAuthAction}
|
||||
className="w-full bg-primary-500 hover:bg-primary-700 text-white font-semibold py-2 px-6 rounded-lg transition-colors duration-300"
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useUnits } from '../core/units/UnitsContext';
|
||||
import { useAdminAccess } from '../core/auth/useAdminAccess';
|
||||
import { useProfile, useUpdateProfile } from '../features/settings/hooks/useProfile';
|
||||
import { DeleteAccountDialog } from '../features/settings/components/DeleteAccountDialog';
|
||||
import { PendingDeletionBanner } from '../features/settings/components/PendingDeletionBanner';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
@@ -52,6 +54,7 @@ export const SettingsPage: React.FC = () => {
|
||||
const [isEditingProfile, setIsEditingProfile] = useState(false);
|
||||
const [editedDisplayName, setEditedDisplayName] = useState('');
|
||||
const [editedNotificationEmail, setEditedNotificationEmail] = useState('');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
// Initialize edit form when profile loads or edit mode starts
|
||||
React.useEffect(() => {
|
||||
@@ -105,6 +108,8 @@ export const SettingsPage: React.FC = () => {
|
||||
Settings
|
||||
</Typography>
|
||||
|
||||
<PendingDeletionBanner />
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Profile Section */}
|
||||
<Card>
|
||||
@@ -477,9 +482,10 @@ export const SettingsPage: React.FC = () => {
|
||||
>
|
||||
Sign Out
|
||||
</MuiButton>
|
||||
<MuiButton
|
||||
variant="outlined"
|
||||
<MuiButton
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
sx={{ borderRadius: '999px' }}
|
||||
>
|
||||
Delete Account
|
||||
@@ -487,6 +493,8 @@ export const SettingsPage: React.FC = () => {
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<DeleteAccountDialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
PersonAdd,
|
||||
Edit,
|
||||
Security,
|
||||
DeleteForever,
|
||||
} from '@mui/icons-material';
|
||||
import { useAdminAccess } from '../../core/auth/useAdminAccess';
|
||||
import {
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
useReactivateUser,
|
||||
useUpdateUserProfile,
|
||||
usePromoteToAdmin,
|
||||
useHardDeleteUser,
|
||||
} from '../../features/admin/hooks/useUsers';
|
||||
import {
|
||||
ManagedUser,
|
||||
@@ -84,6 +86,7 @@ export const AdminUsersPage: React.FC = () => {
|
||||
const reactivateMutation = useReactivateUser();
|
||||
const updateProfileMutation = useUpdateUserProfile();
|
||||
const promoteToAdminMutation = usePromoteToAdmin();
|
||||
const hardDeleteMutation = useHardDeleteUser();
|
||||
|
||||
// Action menu state
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
@@ -102,6 +105,11 @@ export const AdminUsersPage: React.FC = () => {
|
||||
const [promoteDialogOpen, setPromoteDialogOpen] = useState(false);
|
||||
const [promoteRole, setPromoteRole] = useState<'admin' | 'super_admin'>('admin');
|
||||
|
||||
// Hard delete dialog state
|
||||
const [hardDeleteDialogOpen, setHardDeleteDialogOpen] = useState(false);
|
||||
const [hardDeleteReason, setHardDeleteReason] = useState('');
|
||||
const [hardDeleteConfirmText, setHardDeleteConfirmText] = useState('');
|
||||
|
||||
// Handlers
|
||||
const handleSearch = useCallback(() => {
|
||||
setParams(prev => ({ ...prev, search: searchInput || undefined, page: 1 }));
|
||||
@@ -262,6 +270,34 @@ export const AdminUsersPage: React.FC = () => {
|
||||
setSelectedUser(null);
|
||||
}, []);
|
||||
|
||||
const handleHardDeleteClick = useCallback(() => {
|
||||
setHardDeleteDialogOpen(true);
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const handleHardDeleteConfirm = useCallback(() => {
|
||||
if (selectedUser && hardDeleteConfirmText === 'DELETE') {
|
||||
hardDeleteMutation.mutate(
|
||||
{ auth0Sub: selectedUser.auth0Sub, reason: hardDeleteReason || undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setHardDeleteDialogOpen(false);
|
||||
setHardDeleteReason('');
|
||||
setHardDeleteConfirmText('');
|
||||
setSelectedUser(null);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [selectedUser, hardDeleteReason, hardDeleteConfirmText, hardDeleteMutation]);
|
||||
|
||||
const handleHardDeleteCancel = useCallback(() => {
|
||||
setHardDeleteDialogOpen(false);
|
||||
setHardDeleteReason('');
|
||||
setHardDeleteConfirmText('');
|
||||
setSelectedUser(null);
|
||||
}, []);
|
||||
|
||||
// Loading state
|
||||
if (adminLoading) {
|
||||
return (
|
||||
@@ -485,6 +521,12 @@ export const AdminUsersPage: React.FC = () => {
|
||||
Deactivate User
|
||||
</MenuItem>
|
||||
)}
|
||||
{!selectedUser?.isAdmin && (
|
||||
<MenuItem onClick={handleHardDeleteClick} sx={{ color: 'error.main' }}>
|
||||
<DeleteForever sx={{ mr: 1 }} fontSize="small" />
|
||||
Delete Permanently
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
|
||||
{/* Deactivate Confirmation Dialog */}
|
||||
@@ -624,6 +666,81 @@ export const AdminUsersPage: React.FC = () => {
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Hard Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={hardDeleteDialogOpen}
|
||||
onClose={() => !hardDeleteMutation.isPending && handleHardDeleteCancel()}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle sx={{ color: 'error.main' }}>
|
||||
Permanently Delete User
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ bgcolor: 'error.light', color: 'error.contrastText', p: 2, borderRadius: 1, mb: 3 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
Warning: This action cannot be undone!
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
All user data will be permanently deleted, including vehicles, fuel logs,
|
||||
maintenance records, and documents. The user's Auth0 account will also be deleted.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography sx={{ mb: 2 }}>
|
||||
Are you sure you want to permanently delete{' '}
|
||||
<strong>{selectedUser?.email}</strong>?
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label="Reason for deletion"
|
||||
value={hardDeleteReason}
|
||||
onChange={(e) => setHardDeleteReason(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
placeholder="GDPR request, user request, etc..."
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Type <strong>DELETE</strong> to confirm:
|
||||
</Typography>
|
||||
<TextField
|
||||
value={hardDeleteConfirmText}
|
||||
onChange={(e) => setHardDeleteConfirmText(e.target.value.toUpperCase())}
|
||||
fullWidth
|
||||
placeholder="Type DELETE"
|
||||
error={hardDeleteConfirmText.length > 0 && hardDeleteConfirmText !== 'DELETE'}
|
||||
helperText={
|
||||
hardDeleteConfirmText.length > 0 && hardDeleteConfirmText !== 'DELETE'
|
||||
? 'Please type DELETE exactly'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={handleHardDeleteCancel}
|
||||
disabled={hardDeleteMutation.isPending}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleHardDeleteConfirm}
|
||||
disabled={hardDeleteMutation.isPending || hardDeleteConfirmText !== 'DELETE'}
|
||||
color="error"
|
||||
variant="contained"
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
{hardDeleteMutation.isPending ? <CircularProgress size={20} /> : 'Delete Permanently'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user