feat: delete users - not tested

This commit is contained in:
Eric Gullickson
2025-12-22 18:20:25 -06:00
parent 91b4534e76
commit 4897f0a52c
73 changed files with 4923 additions and 62 deletions

View File

@@ -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;
},
},
};

View File

@@ -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'
);
},
});
};

View File

@@ -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>
);
};

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

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

View 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);
},
});
};

View 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);
},
});
};

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

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

View File

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

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

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

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

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

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

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

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

View 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);
},
});
};

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

View File

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

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

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

View File

@@ -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'),
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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');
},
});
};

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

View File

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

View File

@@ -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>
);
};

View File

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