some moves - wheee breaking stuff again
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 1m3s
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 1m3s
This commit is contained in:
@@ -1,600 +0,0 @@
|
|||||||
// src/components/ProfileManager.tsx
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import type { Profile } from '../../types';
|
|
||||||
import { updateUserPreferences, updateUserPassword, deleteUserAccount, loginUser, registerUser, requestPasswordReset, updateUserProfile, exportUserData } from '../../services/apiClient';
|
|
||||||
import { notifySuccess, notifyError } from '../../services/notificationService';
|
|
||||||
import { logger } from '../../services/logger';
|
|
||||||
import { LoadingSpinner } from '../../components/LoadingSpinner';
|
|
||||||
import { XMarkIcon } from '../../components/icons/XMarkIcon';
|
|
||||||
import { GoogleIcon } from '../../components/icons/GoogleIcon';
|
|
||||||
import { GithubIcon } from '../../components/icons/GithubIcon';
|
|
||||||
import { ConfirmationModal } from '../../components/ConfirmationModal';
|
|
||||||
import { User } from '../../types';
|
|
||||||
import { PasswordInput } from './components/PasswordInput';
|
|
||||||
|
|
||||||
type AuthStatus = 'SIGNED_OUT' | 'ANONYMOUS' | 'AUTHENTICATED';
|
|
||||||
interface ProfileManagerProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
user: User | null; // Can be null for login/register
|
|
||||||
authStatus: AuthStatus;
|
|
||||||
profile: Profile | null; // Can be null for login/register
|
|
||||||
onProfileUpdate: (updatedProfile: Profile) => void;
|
|
||||||
onSignOut: () => void;
|
|
||||||
onLoginSuccess: (user: User, token: string, rememberMe: boolean) => void; // Add login handler
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose, user, authStatus, profile, onProfileUpdate, onSignOut, onLoginSuccess }) => {
|
|
||||||
const [activeTab, setActiveTab] = useState('profile');
|
|
||||||
|
|
||||||
// Profile state
|
|
||||||
const [fullName, setFullName] = useState(profile?.full_name || '');
|
|
||||||
const [avatarUrl, setAvatarUrl] = useState(profile?.avatar_url || '');
|
|
||||||
const [profileLoading, setProfileLoading] = useState(false);
|
|
||||||
|
|
||||||
// Password state
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
|
||||||
const [passwordLoading, setPasswordLoading] = useState(false);
|
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
|
||||||
|
|
||||||
// Data & Privacy state
|
|
||||||
const [exportLoading, setExportLoading] = useState(false);
|
|
||||||
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
|
|
||||||
const [passwordForDelete, setPasswordForDelete] = useState('');
|
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
|
||||||
|
|
||||||
// Login/Register State
|
|
||||||
const [isRegistering, setIsRegistering] = useState(false);
|
|
||||||
const [authEmail, setAuthEmail] = useState('');
|
|
||||||
const [authPassword, setAuthPassword] = useState('');
|
|
||||||
const [authFullName, setAuthFullName] = useState(''); // State for full name
|
|
||||||
const [authAvatarUrl, setAuthAvatarUrl] = useState(''); // State for avatar URL
|
|
||||||
const [authLoading, setAuthLoading] = useState(false);
|
|
||||||
const [isForgotPassword, setIsForgotPassword] = useState(false);
|
|
||||||
const [rememberMe, setRememberMe] = useState(false);
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Only reset state when the modal is opened.
|
|
||||||
// Do not reset on profile changes, which can happen during sign-out.
|
|
||||||
if (isOpen && profile) { // Ensure profile exists before setting state
|
|
||||||
setFullName(profile?.full_name || '');
|
|
||||||
setAvatarUrl(profile?.avatar_url || '');
|
|
||||||
setActiveTab('profile');
|
|
||||||
setIsConfirmingDelete(false);
|
|
||||||
setPasswordForDelete('');
|
|
||||||
setAuthEmail('');
|
|
||||||
setAuthPassword('');
|
|
||||||
setAuthFullName('');
|
|
||||||
setAuthAvatarUrl('');
|
|
||||||
setIsRegistering(false);
|
|
||||||
setIsForgotPassword(false);
|
|
||||||
setRememberMe(false); // Reset on open
|
|
||||||
}
|
|
||||||
}, [isOpen, profile]); // Depend on isOpen and profile
|
|
||||||
|
|
||||||
const handleProfileSave = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setProfileLoading(true);
|
|
||||||
if (!user) {
|
|
||||||
notifyError("Cannot save profile, no user is logged in.");
|
|
||||||
setProfileLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const updatedProfile = await updateUserProfile({ // Use the new apiClient function
|
|
||||||
full_name: fullName,
|
|
||||||
avatar_url: avatarUrl
|
|
||||||
});
|
|
||||||
onProfileUpdate(updatedProfile);
|
|
||||||
logger.info('User profile updated successfully.', { userId: user.user_id, fullName, avatarUrl });
|
|
||||||
notifySuccess('Profile updated successfully!');
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
|
||||||
logger.error('Failed to update user profile.', { userId: user.user_id, error: errorMessage });
|
|
||||||
notifyError(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setProfileLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOAuthLink = async (provider: 'google' | 'github') => {
|
|
||||||
// This will redirect the user to the OAuth provider to link the account.
|
|
||||||
// TODO: This is a placeholder. Implement OAuth account linking via the Passport.js backend.
|
|
||||||
if (!user) {
|
|
||||||
return; // Should not be possible to see this button if not logged in
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorMessage = `Account linking with ${provider} is not yet implemented.`;
|
|
||||||
logger.warn(errorMessage, { userId: user.user_id });
|
|
||||||
notifyError(errorMessage);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePasswordUpdate = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
notifyError("Passwords do not match.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (password.length < 6) {
|
|
||||||
notifyError("Password must be at least 6 characters long.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPasswordLoading(true);
|
|
||||||
if (!user) {
|
|
||||||
notifyError("Cannot update password, no user is logged in.");
|
|
||||||
setPasswordLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await updateUserPassword(password); // This now uses the new apiClient function
|
|
||||||
logger.info('User password updated successfully.', { userId: user.user_id });
|
|
||||||
notifySuccess("Password updated successfully!");
|
|
||||||
setPassword('');
|
|
||||||
setConfirmPassword('');
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
|
||||||
logger.error('Failed to update user password.', { userId: user.user_id, error: errorMessage });
|
|
||||||
notifyError(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setPasswordLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExportData = async () => {
|
|
||||||
setExportLoading(true);
|
|
||||||
if (!user) {
|
|
||||||
notifyError("Cannot export data, no user is logged in.");
|
|
||||||
setExportLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
logger.info('User initiated data export.', { userId: user.user_id });
|
|
||||||
const userData = await exportUserData(); // Call the new apiClient function
|
|
||||||
const jsonString = `data:text/json;charset=utf-8,${encodeURIComponent(JSON.stringify(userData, null, 2))}`;
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = jsonString;
|
|
||||||
link.download = `flyer-crawler-data-export-${new Date().toISOString().split('T')[0]}.json`;
|
|
||||||
link.click();
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
|
||||||
logger.error("Failed to export user data:", { userId: user.user_id, error: errorMessage });
|
|
||||||
notifyError(`Error exporting data: ${errorMessage}`);
|
|
||||||
} finally {
|
|
||||||
setExportLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteAccount = async () => {
|
|
||||||
setIsDeleteModalOpen(false); // Close the confirmation modal
|
|
||||||
setDeleteLoading(true);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
notifyError("Cannot delete account, no user is logged in.");
|
|
||||||
setDeleteLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
logger.warn('User initiated account deletion.', { userId: user.user_id });
|
|
||||||
await deleteUserAccount(passwordForDelete);
|
|
||||||
logger.warn('User account deleted successfully.', { userId: user.user_id });
|
|
||||||
|
|
||||||
// Set a success message and then sign out after a short delay
|
|
||||||
notifySuccess("Account deleted successfully. You will be logged out shortly.");
|
|
||||||
setTimeout(() => {
|
|
||||||
onClose();
|
|
||||||
onSignOut();
|
|
||||||
}, 3000); // 3-second delay for user to read the message
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
|
||||||
logger.error('Account deletion failed for user:', { userId: user.user_id, error: errorMessage }); // This was a duplicate log, fixed.
|
|
||||||
notifyError(errorMessage);
|
|
||||||
setDeleteLoading(false); // Stop loading on failure
|
|
||||||
} finally {
|
|
||||||
// Loading state will persist on success until the component unmounts.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleDarkMode = async (newMode: boolean) => {
|
|
||||||
if (!user) {
|
|
||||||
notifyError("Cannot update preferences, no user is logged in.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// Call the API client function to update preferences
|
|
||||||
const updatedProfile = await updateUserPreferences({ darkMode: newMode });
|
|
||||||
// Notify parent component (App.tsx) to update its profile state
|
|
||||||
onProfileUpdate(updatedProfile);
|
|
||||||
logger.info('Dark mode preference updated.', { userId: user.user_id, darkMode: newMode });
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
||||||
logger.error('Failed to update dark mode preference:', { userId: user.user_id, error: errorMessage });
|
|
||||||
notifyError(`Failed to update dark mode: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleUnitSystem = async (newSystem: 'metric' | 'imperial') => {
|
|
||||||
if (!user) {
|
|
||||||
notifyError("Cannot update preferences, no user is logged in.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// Call the API client function to update preferences
|
|
||||||
const updatedProfile = await updateUserPreferences({ unitSystem: newSystem });
|
|
||||||
// Notify parent component (App.tsx) to update its profile state
|
|
||||||
onProfileUpdate(updatedProfile);
|
|
||||||
logger.info('Unit system preference updated.', { userId: user.user_id, unitSystem: newSystem });
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
||||||
logger.error('Failed to update unit system preference:', { userId: user.user_id, error: errorMessage });
|
|
||||||
notifyError(`Failed to update unit system: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAuthSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setAuthLoading(true);
|
|
||||||
|
|
||||||
// Use a timeout to allow the UI to update to the loading state before the async operation begins.
|
|
||||||
// This is crucial for testing the loading state correctly.
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
let response;
|
|
||||||
if (isRegistering) {
|
|
||||||
response = await registerUser(authEmail, authPassword, authFullName, authAvatarUrl);
|
|
||||||
logger.info('New user registration successful.', { email: authEmail });
|
|
||||||
} else {
|
|
||||||
response = await loginUser(authEmail, authPassword, rememberMe);
|
|
||||||
}
|
|
||||||
onLoginSuccess(response.user, response.token, rememberMe);
|
|
||||||
onClose(); // Close modal on success
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
|
||||||
notifyError(errorMessage); // Use toast notification for consistency
|
|
||||||
} finally {
|
|
||||||
setAuthLoading(false);
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePasswordResetRequest = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setAuthLoading(true);
|
|
||||||
try {
|
|
||||||
const { message } = await requestPasswordReset(authEmail);
|
|
||||||
notifySuccess(message);
|
|
||||||
logger.info('Password reset email sent successfully.', { email: authEmail });
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
|
||||||
notifyError(errorMessage);
|
|
||||||
logger.error('Password reset request failed.', { email: authEmail, error: errorMessage });
|
|
||||||
} finally {
|
|
||||||
setAuthLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOAuthSignIn = (provider: 'google' | 'github') => {
|
|
||||||
// Redirect to the backend OAuth initiation route
|
|
||||||
// The backend will then handle the redirect to the OAuth provider
|
|
||||||
// and eventually redirect back to the frontend with a token.
|
|
||||||
window.location.href = '/api/auth/' + provider;
|
|
||||||
// Set authLoading to true to show a spinner while redirecting
|
|
||||||
setAuthLoading(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-black bg-opacity-60 z-50 flex justify-center items-center p-4"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-modal="true"
|
|
||||||
role="dialog"
|
|
||||||
>
|
|
||||||
<ConfirmationModal
|
|
||||||
isOpen={isDeleteModalOpen}
|
|
||||||
onClose={() => setIsDeleteModalOpen(false)}
|
|
||||||
onConfirm={handleDeleteAccount} // Pass the handler directly
|
|
||||||
title="Confirm Account Deletion"
|
|
||||||
message={
|
|
||||||
<>
|
|
||||||
Are you absolutely sure you want to delete your account?
|
|
||||||
<strong className="block mt-2">This action is permanent and cannot be undone.</strong>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
confirmButtonText="Yes, Delete My Account"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg relative"
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
|
||||||
aria-label="Close profile manager"
|
|
||||||
>
|
|
||||||
<XMarkIcon className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{authStatus === 'SIGNED_OUT' ? (
|
|
||||||
isForgotPassword ? (
|
|
||||||
<div className="p-8">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white mb-1">Reset Password</h2>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">Enter your email to receive a password reset link.</p> <form data-testid="reset-password-form" onSubmit={handlePasswordResetRequest} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="resetEmail" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Email Address</label>
|
|
||||||
<input id="resetEmail" type="email" value={authEmail} onChange={e => setAuthEmail(e.target.value)} required className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" />
|
|
||||||
</div>
|
|
||||||
<div className="pt-2">
|
|
||||||
<button type="submit" disabled={authLoading} className="w-full bg-brand-secondary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center">
|
|
||||||
{authLoading ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Send Reset Link'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div className="text-center mt-4">
|
|
||||||
<button onClick={() => { setIsForgotPassword(false); }} className="text-sm font-medium text-brand-primary hover:underline">
|
|
||||||
Back to Sign In
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="p-8">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white mb-1">{isRegistering ? 'Create an Account' : 'Sign In'}</h2>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">{isRegistering ? 'to get started.' : 'to access your account.'}</p>
|
|
||||||
{/* When registering, show optional fields for full name and avatar URL */}
|
|
||||||
{isRegistering && (
|
|
||||||
<div className="space-y-4 mb-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="authFullName" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Full Name</label>
|
|
||||||
<input id="authFullName" type="text" value={authFullName} onChange={e => setAuthFullName(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" placeholder="Optional" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="authAvatarUrl" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Avatar URL</label>
|
|
||||||
<input id="authAvatarUrl" type="url" value={authAvatarUrl} onChange={e => setAuthAvatarUrl(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" placeholder="Optional, e.g., http://example.com/avatar.png" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)} <form data-testid="auth-form" onSubmit={handleAuthSubmit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="authEmail" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Email Address</label>
|
|
||||||
<input id="authEmail" type="email" value={authEmail} onChange={e => setAuthEmail(e.target.value)} required className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary sm:text-sm" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="authPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Password</label>
|
|
||||||
<PasswordInput
|
|
||||||
id="authPassword"
|
|
||||||
value={authPassword}
|
|
||||||
onChange={e => setAuthPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
className="mt-1"
|
|
||||||
showStrength={isRegistering}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{!isRegistering && (
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<input id="remember-me" name="remember-me" type="checkbox" checked={rememberMe} onChange={(e) => setRememberMe(e.target.checked)} className="h-4 w-4 text-brand-primary border-gray-300 rounded focus:ring-brand-secondary" />
|
|
||||||
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
|
|
||||||
Remember me
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button type="button" onClick={() => setIsForgotPassword(true)} className="font-medium text-brand-primary hover:underline">
|
|
||||||
Forgot password?
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="pt-2">
|
|
||||||
<button type="submit" disabled={authLoading} className="w-full bg-brand-secondary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center">
|
|
||||||
{authLoading ? <div className="w-5 h-5"><LoadingSpinner /></div> : (isRegistering ? 'Register' : 'Sign In')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div className="text-center mt-4">
|
|
||||||
<button onClick={() => { setIsRegistering(!isRegistering); }} className="text-sm font-medium text-brand-primary hover:underline">
|
|
||||||
{isRegistering ? 'Already have an account? Sign In' : "Don't have an account? Register"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="relative my-6">
|
|
||||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
|
||||||
<div className="w-full border-t border-gray-300 dark:border-gray-600" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center">
|
|
||||||
<span className="bg-white dark:bg-gray-800 px-2 text-sm text-gray-500 dark:text-gray-400">Or continue with</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<button type="button" onClick={() => handleOAuthSignIn('google')} disabled={authLoading} className="w-full flex items-center justify-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
||||||
{authLoading ? <div className="w-5 h-5 mr-3"><LoadingSpinner /></div> : <GoogleIcon className="w-5 h-5 mr-3" />}
|
|
||||||
Sign In with Google
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => handleOAuthSignIn('github')} disabled={authLoading} className="w-full flex items-center justify-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
||||||
{authLoading ? <div className="w-5 h-5 mr-3"><LoadingSpinner /></div> : <GithubIcon className="w-5 h-5 mr-3" />}
|
|
||||||
Sign In with GitHub
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
// Wrap the authenticated view in a React Fragment to return a single element
|
|
||||||
<>
|
|
||||||
<div className="p-8">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white mb-1">My Account</h2>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">Manage your profile, preferences, and security.</p>
|
|
||||||
|
|
||||||
<div className="border-b border-gray-200 dark:border-gray-700 mb-6">
|
|
||||||
<nav className="-mb-px flex space-x-6" aria-label="Tabs">
|
|
||||||
<button onClick={() => setActiveTab('profile')} className={`whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm ${activeTab === 'profile' ? 'border-brand-primary text-brand-primary' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
|
|
||||||
Profile
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setActiveTab('security')} className={`whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm ${activeTab === 'security' ? 'border-brand-primary text-brand-primary' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
|
|
||||||
Security
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setActiveTab('data')} className={`whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm ${activeTab === 'data' ? 'border-brand-primary text-brand-primary' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
|
|
||||||
Data & Privacy
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setActiveTab('preferences')} className={`whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm ${activeTab === 'preferences' ? 'border-brand-primary text-brand-primary' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
|
|
||||||
Preferences
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === 'profile' && (
|
|
||||||
<form onSubmit={handleProfileSave} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="fullName" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Full Name</label>
|
|
||||||
<input id="fullName" type="text" value={fullName} onChange={e => setFullName(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="avatarUrl" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Avatar URL</label>
|
|
||||||
<input id="avatarUrl" type="url" value={avatarUrl} onChange={e => setAvatarUrl(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" />
|
|
||||||
</div>
|
|
||||||
<div className="pt-2">
|
|
||||||
<button type="submit" disabled={profileLoading} className="w-full bg-brand-secondary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center">
|
|
||||||
{profileLoading ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Save Profile'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'security' && (
|
|
||||||
<form data-testid="update-password-form" onSubmit={handlePasswordUpdate} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300">New Password</label>
|
|
||||||
<PasswordInput
|
|
||||||
id="newPassword"
|
|
||||||
value={password}
|
|
||||||
onChange={e => setPassword(e.target.value)}
|
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
|
||||||
className="mt-1"
|
|
||||||
showStrength
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Confirm New Password</label>
|
|
||||||
<PasswordInput
|
|
||||||
id="confirmPassword"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={e => setConfirmPassword(e.target.value)}
|
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="pt-2">
|
|
||||||
<button type="submit" disabled={passwordLoading} className="w-full bg-brand-secondary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center">
|
|
||||||
{passwordLoading ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Update Password'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
|
||||||
<h3 className="text-md font-semibold text-gray-800 dark:text-white">Link Social Accounts</h3>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-3">Connect other accounts for easier sign-in.</p>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<button type="button" onClick={() => handleOAuthLink('google')} className="w-full flex items-center justify-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
||||||
<GoogleIcon className="w-5 h-5 mr-3" />
|
|
||||||
Link Google Account
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => handleOAuthLink('github')} className="w-full flex items-center justify-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
||||||
<GithubIcon className="w-5 h-5 mr-3" />
|
|
||||||
Link GitHub Account
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'data' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-md font-semibold text-gray-800 dark:text-white">Export Your Data</h3>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Download a JSON file of your profile, watched items, and shopping lists.</p>
|
|
||||||
<button onClick={handleExportData} disabled={exportLoading} className="mt-3 w-full sm:w-auto bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-bold py-2 px-4 rounded-lg flex items-center justify-center">
|
|
||||||
{exportLoading ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Export My Data'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-gray-200 dark:border-gray-700"></div>
|
|
||||||
|
|
||||||
<div className="p-4 border border-red-500/50 dark:border-red-400/50 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
|
||||||
<h3 className="text-md font-semibold text-red-800 dark:text-red-300">Danger Zone</h3>
|
|
||||||
<p className="text-sm text-red-700 dark:text-red-400 mt-1">This action is permanent and cannot be undone. All your data will be erased.</p>
|
|
||||||
|
|
||||||
{!isConfirmingDelete ? (
|
|
||||||
<button type="button" onClick={() => setIsConfirmingDelete(true)} className="mt-3 w-full sm:w-auto bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg">
|
|
||||||
Delete My Account
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<form data-testid="delete-account-form" onSubmit={(e) => { e.preventDefault(); setIsDeleteModalOpen(true); }} className="mt-4 space-y-3 bg-white dark:bg-gray-800 p-4 rounded-md border border-red-500/50">
|
|
||||||
<p className="text-sm font-medium text-gray-800 dark:text-white">To confirm, please enter your current password.</p>
|
|
||||||
<PasswordInput
|
|
||||||
id="delete-password"
|
|
||||||
value={passwordForDelete}
|
|
||||||
onChange={e => setPasswordForDelete(e.target.value)}
|
|
||||||
required
|
|
||||||
placeholder="Enter your password"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:space-x-2 space-y-2 sm:space-y-0">
|
|
||||||
<button type="button" onClick={() => setIsConfirmingDelete(false)} className="flex-1 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-800 dark:text-white font-bold py-2 px-4 rounded-lg">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" disabled={deleteLoading || !passwordForDelete} className="flex-1 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white font-bold py-2 px-4 rounded-lg flex justify-center">
|
|
||||||
{deleteLoading ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Delete Account Permanently'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'preferences' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-md font-semibold text-gray-800 dark:text-white">Theme</h3>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Choose your preferred visual theme.</p>
|
|
||||||
<div className="mt-3 flex items-center">
|
|
||||||
<label htmlFor="darkModeToggle" className="flex items-center cursor-pointer">
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="darkModeToggle"
|
|
||||||
className="sr-only"
|
|
||||||
checked={profile?.preferences?.darkMode ?? false}
|
|
||||||
onChange={(e) => handleToggleDarkMode(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<div className="block bg-gray-300 dark:bg-gray-600 w-14 h-8 rounded-full"></div>
|
|
||||||
<div className="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"></div>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3 text-gray-700 dark:text-gray-300 font-medium">
|
|
||||||
Dark Mode
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
|
||||||
<h3 className="text-md font-semibold text-gray-800 dark:text-white">Unit System</h3>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Select your preferred system of measurement.</p>
|
|
||||||
<div className="mt-3 flex space-x-4">
|
|
||||||
<label className="inline-flex items-center">
|
|
||||||
<input type="radio" className="form-radio text-brand-primary" name="unitSystem" value="imperial" checked={profile?.preferences?.unitSystem === 'imperial'} onChange={() => handleToggleUnitSystem('imperial')} />
|
|
||||||
<span className="ml-2 text-gray-700 dark:text-gray-300">Imperial (e.g., lbs, oz)</span>
|
|
||||||
</label>
|
|
||||||
<label className="inline-flex items-center">
|
|
||||||
<input type="radio" className="form-radio text-brand-primary" name="unitSystem" value="metric" checked={profile?.preferences?.unitSystem === 'metric'} onChange={() => handleToggleUnitSystem('metric')} />
|
|
||||||
<span className="ml-2 text-gray-700 dark:text-gray-300">Metric (e.g., kg, g)</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
// src/components/AnonymousUserBanner.tsx
|
|
||||||
import React from 'react';
|
|
||||||
import { InformationCircleIcon } from '../../../components/icons/InformationCircleIcon';
|
|
||||||
|
|
||||||
interface AnonymousUserBannerProps {
|
|
||||||
/**
|
|
||||||
* A callback function to open the login/signup modal.
|
|
||||||
*/
|
|
||||||
onOpenProfile: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A banner displayed to anonymous users to encourage them to sign up or log in.
|
|
||||||
*/
|
|
||||||
export const AnonymousUserBanner: React.FC<AnonymousUserBannerProps> = ({ onOpenProfile }) => {
|
|
||||||
return (
|
|
||||||
<div className="bg-blue-100 dark:bg-blue-900/30 border-l-4 border-blue-500 text-blue-700 dark:text-blue-300 p-4 rounded-r-lg" role="alert">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="py-1">
|
|
||||||
<InformationCircleIcon className="h-6 w-6 text-blue-500 mr-4" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-bold">You're viewing as a guest.</p>
|
|
||||||
<p className="text-sm">To save your flyers, create a watchlist, and access more features, please <button onClick={onOpenProfile} className="font-bold underline hover:text-blue-600 dark:hover:text-blue-200">sign up or log in</button>.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
// src/components/PasswordInput.tsx
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { EyeIcon } from '../../../components/icons/EyeIcon';
|
|
||||||
import { EyeSlashIcon } from '../../../components/icons/EyeSlashIcon';
|
|
||||||
import { PasswordStrengthIndicator } from './PasswordStrengthIndicator';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for the PasswordInput component.
|
|
||||||
* It extends standard HTML input attributes and adds a custom prop to show a strength indicator.
|
|
||||||
*/
|
|
||||||
interface PasswordInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
||||||
showStrength?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A reusable password input component with a show/hide toggle
|
|
||||||
* and an optional password strength indicator.
|
|
||||||
*/
|
|
||||||
export const PasswordInput: React.FC<PasswordInputProps> = ({ showStrength = false, className, ...props }) => {
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
{...props}
|
|
||||||
type={showPassword ? 'text' : 'password'}
|
|
||||||
// Combine passed classNames with default styling
|
|
||||||
className={`block w-full px-3 py-2 pr-10 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary sm:text-sm ${className || ''}`}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
|
||||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeSlashIcon className="h-5 w-5" /> : <EyeIcon className="h-5 w-5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{showStrength && typeof props.value === 'string' && props.value.length > 0 && (
|
|
||||||
<div className="pt-2">
|
|
||||||
<PasswordStrengthIndicator password={props.value} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
// src/components/PasswordStrengthIndicator.tsx
|
|
||||||
import React from 'react';
|
|
||||||
import zxcvbn from 'zxcvbn';
|
|
||||||
|
|
||||||
interface PasswordStrengthIndicatorProps {
|
|
||||||
password?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A component that visually indicates the strength of a password using zxcvbn.
|
|
||||||
* It displays a colored bar and provides feedback to the user.
|
|
||||||
*/
|
|
||||||
export const PasswordStrengthIndicator: React.FC<PasswordStrengthIndicatorProps> = ({ password = '' }) => {
|
|
||||||
const result = zxcvbn(password);
|
|
||||||
const score = result.score; // Score from 0 (worst) to 4 (best)
|
|
||||||
|
|
||||||
// Function to determine the color of each segment of the strength bar
|
|
||||||
const getBarColor = (index: number) => {
|
|
||||||
if (password.length === 0) return 'bg-gray-200 dark:bg-gray-600';
|
|
||||||
if (index > score) return 'bg-gray-200 dark:bg-gray-600';
|
|
||||||
switch (score) {
|
|
||||||
case 0: return 'bg-red-500';
|
|
||||||
case 1: return 'bg-red-500';
|
|
||||||
case 2: return 'bg-orange-500';
|
|
||||||
case 3: return 'bg-yellow-500';
|
|
||||||
case 4: return 'bg-green-500';
|
|
||||||
default: return 'bg-gray-200 dark:bg-gray-600';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to get a human-readable strength label
|
|
||||||
const getStrengthLabel = () => {
|
|
||||||
switch (score) {
|
|
||||||
case 0: return 'Very Weak';
|
|
||||||
case 1: return 'Weak';
|
|
||||||
case 2: return 'Fair';
|
|
||||||
case 3: return 'Good';
|
|
||||||
case 4: return 'Strong';
|
|
||||||
default: return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-2 space-y-1">
|
|
||||||
<div className="flex space-x-1">
|
|
||||||
{/* Create 5 segments for the strength bar */}
|
|
||||||
{Array.from(Array(5).keys()).map(index => (
|
|
||||||
<div key={index} className={`h-1.5 flex-1 rounded-full ${getBarColor(index)} transition-colors`}></div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{password.length > 0 && (
|
|
||||||
<div className="flex justify-between items-center text-xs">
|
|
||||||
<span className={`font-bold ${
|
|
||||||
score < 2 ? 'text-red-500' : score < 3 ? 'text-orange-500' : 'text-green-500'
|
|
||||||
}`}>
|
|
||||||
{getStrengthLabel()}
|
|
||||||
</span>
|
|
||||||
{/* Display feedback from zxcvbn if available */}
|
|
||||||
{(result.feedback.warning || result.feedback.suggestions.length > 0) && (
|
|
||||||
<span className="text-gray-500 dark:text-gray-400 text-right">
|
|
||||||
{result.feedback.warning && (
|
|
||||||
<span>{result.feedback.warning} </span>
|
|
||||||
)}
|
|
||||||
{result.feedback.suggestions.length > 0 && (
|
|
||||||
<span>{result.feedback.suggestions[0]}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// src/pages/ResetPasswordPage.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// src/pages/ResetPasswordPage.tsx
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import { resetPassword } from '../services/apiClient';
|
import { resetPassword } from '../services/apiClient';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// src/pages/VoiceLabPage.tsx
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { generateSpeechFromText, startVoiceSession } from '../services/aiApiClient';
|
import { generateSpeechFromText, startVoiceSession } from '../services/aiApiClient';
|
||||||
import { logger } from '../services/logger';
|
import { logger } from '../services/logger';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/components/ActivityLog.test.tsx
|
// src/pages/admin/ActivityLog.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/components/ActivityLog.tsx
|
// src/pages/admin/ActivityLog.tsx
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { fetchActivityLog } from '../../services/apiClient';
|
import { fetchActivityLog } from '../../services/apiClient';
|
||||||
import { ActivityLogItem } from '../../types';
|
import { ActivityLogItem } from '../../types';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/pages/AdminPage.test.tsx
|
// src/pages/admin/AdminPage.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// src/pages/admin/AdminPage.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { SystemCheck } from './components/SystemCheck';
|
import { SystemCheck } from './components/SystemCheck';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/pages/AdminStatsPage.test.tsx
|
// src/pages/admin/AdminStatsPage.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/pages/CorrectionsPage.test.tsx
|
// src/pages/admin/CorrectionsPage.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// src/pages/admin/CorrectionsPage.tsx
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { getSuggestedCorrections, fetchMasterItems, fetchCategories } from '../../services/apiClient'; // Using apiClient for all data fetching
|
import { getSuggestedCorrections, fetchMasterItems, fetchCategories } from '../../services/apiClient'; // Using apiClient for all data fetching
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/components/ShoppingList.test.tsx
|
// src/pages/admin/ShoppingList.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/components/ShoppingList.tsx
|
// src/pages/admin/ShoppingList.tsx
|
||||||
import React, { useState, useMemo, useCallback } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import type { ShoppingList, ShoppingListItem, User } from '../../types';
|
import type { ShoppingList, ShoppingListItem, User } from '../../types';
|
||||||
import { UserIcon } from '../../components/icons/UserIcon';
|
import { UserIcon } from '../../components/icons/UserIcon';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/components/WatchedItemsList.test.tsx
|
// src/pages/admin/WatchedItemsList.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/components/WatchedItemsList.tsx
|
// src/pages/admin/WatchedItemsList.tsx
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import type { MasterGroceryItem, User } from '../../types';
|
import type { MasterGroceryItem, User } from '../../types';
|
||||||
import { EyeIcon } from '../../components/icons/EyeIcon';
|
import { EyeIcon } from '../../components/icons/EyeIcon';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/components/AdminBrandManager.test.tsx
|
// src/pages/admin/components/AdminBrandManager.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/components/AdminBrandManager.tsx
|
// src/pages/admin/components/AdminBrandManager.tsx
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { fetchAllBrands, uploadBrandLogo } from '../../../services/apiClient';
|
import { fetchAllBrands, uploadBrandLogo } from '../../../services/apiClient';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/components/AnonymousUserBanner.tsx
|
// src/pages/admin/components/AnonymousUserBanner.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { InformationCircleIcon } from '../../../components/icons/InformationCircleIcon';
|
import { InformationCircleIcon } from '../../../components/icons/InformationCircleIcon';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/components/CorrectionRow.test.tsx
|
// src/pages/admin/components/CorrectionRow.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/components/CorrectionRow.tsx
|
// src/pages/admin/components/CorrectionRow.tsx
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import type { SuggestedCorrection, MasterGroceryItem, Category } from '../../../types';
|
import type { SuggestedCorrection, MasterGroceryItem, Category } from '../../../types';
|
||||||
import { approveCorrection, rejectCorrection, updateSuggestedCorrection } from '../../../services/apiClient'; // Ensure we are using apiClient
|
import { approveCorrection, rejectCorrection, updateSuggestedCorrection } from '../../../services/apiClient'; // Ensure we are using apiClient
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/components/PasswordInput.tsx
|
// src/pages/admin/components/PasswordInput.tsx
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { EyeIcon } from '../../../components/icons/EyeIcon';
|
import { EyeIcon } from '../../../components/icons/EyeIcon';
|
||||||
import { EyeSlashIcon } from '../../../components/icons/EyeSlashIcon';
|
import { EyeSlashIcon } from '../../../components/icons/EyeSlashIcon';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||||
import { PasswordStrengthIndicator } from './PasswordStrengthIndicator';
|
import { PasswordStrengthIndicator } from '../../../features/auth/components/PasswordStrengthIndicator';
|
||||||
import zxcvbn from 'zxcvbn';
|
import zxcvbn from 'zxcvbn';
|
||||||
|
|
||||||
// Mock the zxcvbn library to control its output for testing
|
// Mock the zxcvbn library to control its output for testing
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/components/PasswordStrengthIndicator.tsx
|
// src/pages/admin/components/PasswordStrengthIndicator.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import zxcvbn from 'zxcvbn';
|
import zxcvbn from 'zxcvbn';
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
import { ProfileManager } from './ProfileManager';
|
import { ProfileManager } from '../../../features/auth/ProfileManager';
|
||||||
import * as apiClient from '../../services/apiClient'; // Import the entire module to mock functions
|
import * as apiClient from '../../../services/apiClient'; // Import the entire module to mock functions
|
||||||
import { notifySuccess, notifyError } from '../../services/notificationService'; // Import the notification service to check calls
|
import { notifySuccess, notifyError } from '../../../services/notificationService'; // Import the notification service to check calls
|
||||||
|
|
||||||
// Mock the apiClient functions
|
// Mock the apiClient functions
|
||||||
vi.mock('../services/apiClient', () => ({
|
vi.mock('../services/apiClient', () => ({
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/components/ProfileManager.tsx
|
// src/pages/admin/components/ProfileManager.tsx
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import type { Profile } from '../types';
|
import type { Profile } from '../types';
|
||||||
import { updateUserPreferences, updateUserPassword, deleteUserAccount, loginUser, registerUser, requestPasswordReset, updateUserProfile, exportUserData } from '../services/apiClient';
|
import { updateUserPreferences, updateUserPassword, deleteUserAccount, loginUser, registerUser, requestPasswordReset, updateUserProfile, exportUserData } from '../services/apiClient';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/components/SystemCheck.test.tsx
|
// src/pages/admin/components/SystemCheck.test.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/components/SystemCheck.tsx
|
// src/pages/admin/components/SystemCheck.tsx
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { loginUser, pingBackend, checkDbSchema, checkStorage, checkDbPoolHealth, checkPm2Status } from '../../../services/apiClient';
|
import { loginUser, pingBackend, checkDbSchema, checkStorage, checkDbPoolHealth, checkPm2Status } from '../../../services/apiClient';
|
||||||
import { ShieldCheckIcon } from '../../../components/icons/ShieldCheckIcon';
|
import { ShieldCheckIcon } from '../../../components/icons/ShieldCheckIcon';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/components/auth.integration.test.ts
|
// src/tests/integration/auth.integration.test.ts
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { loginUser } from '../../services/apiClient';
|
import { loginUser } from '../../services/apiClient';
|
||||||
import { getPool } from '../../services/db/connection';
|
import { getPool } from '../../services/db/connection';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/services/db.integration.test.ts
|
// src/tests/integration/db.integration.test.ts
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import * as db from '../../services/db';
|
import * as db from '../../services/db';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/services/flyer-processing.integration.test.ts
|
// src/tests/integration/flyer-processing.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/services/shopping-list.integration.test.ts
|
// src/tests/integration/shopping-list.integration.test.ts
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import * as db from '../../services/db'; // This was missing
|
import * as db from '../../services/db'; // This was missing
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/routes/user.integration.test.ts
|
// src/tests/integration/user.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import * as apiClient from '../../services/apiClient';
|
import * as apiClient from '../../services/apiClient';
|
||||||
import { logger } from '../../services/logger.server';
|
import { logger } from '../../services/logger.server';
|
||||||
|
|||||||
Reference in New Issue
Block a user