diff --git a/server.ts b/server.ts index b4bf9bbf..5c2c6aa7 100644 --- a/server.ts +++ b/server.ts @@ -15,7 +15,7 @@ import * as db from './src/services/db'; import { logger } from './src/services/logger'; // This import is correct import * as aiService from './src/services/aiService.server'; // Import the new server-side AI service import { sendPasswordResetEmail } from './src/services/emailService'; -import { Profile, UserProfile, ShoppingListItem, ReceiptItem } from './src/types'; +import { UserProfile, ShoppingListItem } from './src/types'; // Load environment variables from a .env file at the root of your project dotenv.config(); @@ -122,7 +122,7 @@ passport.use(new LocalStrategy( } // 3. Success! Return the user object (without password_hash for security). - const { password_hash, ...userWithoutHash } = user; + const { password_hash: _password_hash, ...userWithoutHash } = user; logger.info(`User successfully authenticated: ${email}`); return done(null, userWithoutHash); } catch (err) { @@ -958,8 +958,8 @@ app.post('/api/auth/register', async (req: Request, res: Response, next: NextFun // Login Route app.post('/api/auth/login', (req: Request, res: Response, next: NextFunction) => { // Use passport.authenticate with the 'local' strategy - // { session: false } because we're using JWTs, not server-side sessions - passport.authenticate('local', { session: false }, (err: Error, user: Express.User | false, info: { message: string }) => { + // { session: false } because we're using JWTs, not server-side sessions. The 'info' object is not used, so it's removed. + passport.authenticate('local', { session: false }, (err: Error, user: Express.User | false) => { const { rememberMe } = req.body; // Get the 'rememberMe' flag from the request if (err) { logger.error('Login authentication error in /login route:', { error: err }); @@ -967,7 +967,7 @@ app.post('/api/auth/login', (req: Request, res: Response, next: NextFunction) => } if (!user) { // Authentication failed (e.g., incorrect credentials) - return res.status(401).json({ message: info ? info.message : 'Login failed' }); + return res.status(401).json({ message: 'Login failed' }); } // User is authenticated, create and sign a JWT @@ -1587,7 +1587,7 @@ app.get('/api/receipts/:id/deals', passport.authenticate('jwt', { session: false // --- Error Handling and Server Startup --- // Basic error handling middleware -app.use((err: Error, req: Request, res: Response, next: NextFunction) => { +app.use((err: Error, req: Request, res: Response, _next: NextFunction) => { logger.error('Unhandled application error:', { error: err.stack }); res.status(500).send('Something broke!'); }); diff --git a/src/App.tsx b/src/App.tsx index 405638e7..d2f84771 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,7 +10,7 @@ import { ErrorDisplay } from './components/ErrorDisplay'; import { Header } from './components/Header'; import { logger } from './services/logger'; // This is correct import { isImageAFlyer, extractCoreDataFromImage, extractAddressFromImage, extractLogoFromImage } from './services/aiApiClient'; // prettier-ignore -import type { FlyerItem, Flyer, MasterGroceryItem, DealItem, ProcessingStage, StageStatus, Profile, ShoppingList, ShoppingListItem, User, UserProfile } from './types'; +import type { FlyerItem, Flyer, MasterGroceryItem, DealItem, ProcessingStage, StageStatus, Profile, ShoppingList, ShoppingListItem, User } from './types'; import { BulkImporter } from './components/BulkImporter'; import { PriceHistoryChart } from './components/PriceHistoryChart'; // This import seems to have a supabase dependency, but the component is not provided. Assuming it will be updated separately. import { getAuthenticatedUserProfile, fetchFlyers as apiFetchFlyers, fetchMasterItems as apiFetchMasterItems, fetchWatchedItems as apiFetchWatchedItems, addWatchedItem as apiAddWatchedItem, removeWatchedItem as apiRemoveWatchedItem, fetchShoppingLists as apiFetchShoppingLists, createShoppingList as apiCreateShoppingList, deleteShoppingList as apiDeleteShoppingList, addShoppingListItem as apiAddShoppingListItem, updateShoppingListItem as apiUpdateShoppingListItem, removeShoppingListItem as apiRemoveShoppingListItem, processFlyerFile, fetchFlyerItems as apiFetchFlyerItems, fetchFlyerItemsForFlyers as apiFetchFlyerItemsForFlyers, countFlyerItemsForFlyers as apiCountFlyerItemsForFlyers, uploadLogoAndUpdateStore } from './services/apiClient'; // updateUserPreferences is no longer called directly from App.tsx @@ -32,6 +32,7 @@ import { WatchedItemsList } from './components/WatchedItemsList'; import { AdminStatsPage } from './pages/AdminStatPages'; import { ResetPasswordPage } from './pages/ResetPasswordPage'; import { AnonymousUserBanner } from './components/AnonymousUserBanner'; +import { VoiceLabPage } from './pages/VoiceLabPage'; // Import the new page /** * Defines the possible authentication states for a user session. @@ -133,10 +134,9 @@ function App() { try { // Fetch all essential user data *before* setting the final authenticated state. // This ensures the app doesn't enter an inconsistent state if one of these calls fails. - const [userProfile, watchedData, listsData] = await Promise.all([ + const [userProfile, watchedData] = await Promise.all([ getAuthenticatedUserProfile(), apiFetchWatchedItems(), - apiFetchShoppingLists(), ]); // Now that all data is successfully fetched, update the application state. @@ -144,10 +144,9 @@ function App() { setUser(loggedInUser); // Or userProfile.user, which should be identical setAuthStatus('AUTHENTICATED'); setWatchedItems(watchedData); - setShoppingLists(listsData); - if (listsData.length > 0) { - setActiveListId(listsData[0].id); - } + + // The fetchShoppingLists function will be triggered by the useEffect below + // now that the user state has been set. logger.info('Login and data fetch successful', { user: loggedInUser }); } catch (e) { @@ -184,7 +183,7 @@ function App() { const errorMessage = e instanceof Error ? e.message : String(e); setError(`Could not fetch watched items: ${errorMessage}`); } - }, []); // No dependencies on user or profile, as this is general data + }, [user]); const fetchShoppingLists = useCallback(async () => { if (!user) { // Check for authenticated user @@ -204,7 +203,7 @@ function App() { const errorMessage = e instanceof Error ? e.message : String(e); setError(`Could not fetch shopping lists: ${errorMessage}`); } - }, [activeListId]); // activeListId is a dependency for managing the active list + }, [user, activeListId]); // user is a dependency to ensure we fetch lists for the correct user. const fetchMasterItems = useCallback(async () => { try { @@ -229,6 +228,11 @@ function App() { setUser(userProfile.user); setProfile(userProfile); setAuthStatus('AUTHENTICATED'); + + // Fetch user-specific data now that authentication is confirmed. + // These functions are safe to call here because they check for the user internally. + fetchWatchedItems(); + fetchShoppingLists(); logger.info('Token validated successfully.', { user: userProfile.user }); } catch (e) { logger.warn('Auth token validation failed. Clearing token.', { error: e }); @@ -242,15 +246,22 @@ function App() { } }; checkAuthToken(); - }, []); + }, [fetchWatchedItems, fetchShoppingLists]); // Add callbacks to dependency array. useEffect(() => { if (isReady) { fetchFlyers(); fetchMasterItems(); + + // If the user is already authenticated when the app becomes ready, + // fetch their specific data. + if (authStatus === 'AUTHENTICATED') { + fetchWatchedItems(); + fetchShoppingLists(); + } } - }, [isReady, fetchFlyers, fetchMasterItems]); + }, [isReady, authStatus, fetchFlyers, fetchMasterItems, fetchWatchedItems, fetchShoppingLists]); const resetState = useCallback(() => { @@ -883,6 +894,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 8cc11800..7386e267 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import { ShoppingCartIcon } from './icons/ShoppingCartIcon'; import { UserIcon } from './icons/UserIcon'; import { Cog8ToothIcon } from './icons/Cog8ToothIcon'; diff --git a/src/components/PasswordInput.tsx b/src/components/PasswordInput.tsx new file mode 100644 index 00000000..50f26c27 --- /dev/null +++ b/src/components/PasswordInput.tsx @@ -0,0 +1,46 @@ +import React, { useState } from 'react'; +import { EyeIcon } from './icons/EyeIcon'; +import { EyeSlashIcon } from './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 { + showStrength?: boolean; +} + +/** + * A reusable password input component with a show/hide toggle + * and an optional password strength indicator. + */ +export const PasswordInput: React.FC = ({ showStrength = false, className, ...props }) => { + const [showPassword, setShowPassword] = useState(false); + + return ( +
+
+ + +
+ {showStrength && typeof props.value === 'string' && props.value.length > 0 && ( +
+ +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/src/components/ProfileManager.tsx b/src/components/ProfileManager.tsx index 04015f76..ebb15ee6 100644 --- a/src/components/ProfileManager.tsx +++ b/src/components/ProfileManager.tsx @@ -5,13 +5,9 @@ import { notifySuccess, notifyError } from '../services/notificationService'; import { logger } from '../services/logger'; import { LoadingSpinner } from './LoadingSpinner'; import { XMarkIcon } from './icons/XMarkIcon'; -import { GoogleIcon } from './icons/GoogleIcon'; -import { GithubIcon } from './icons/GithubIcon'; import { ConfirmationModal } from './ConfirmationModal'; -import { EyeIcon } from './icons/EyeIcon'; -import { EyeSlashIcon } from './icons/EyeSlashIcon'; -import { PasswordStrengthIndicator } from './PasswordStrengthIndicator'; import { User } from '../types'; // Import User type for props +import { PasswordInput } from './PasswordInput'; type AuthStatus = 'SIGNED_OUT' | 'ANONYMOUS' | 'AUTHENTICATED'; interface ProfileManagerProps { @@ -38,7 +34,6 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, const [confirmPassword, setConfirmPassword] = useState(''); const [passwordLoading, setPasswordLoading] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [showPassword, setShowPassword] = useState(false); // Data & Privacy state const [exportLoading, setExportLoading] = useState(false); @@ -68,8 +63,6 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, setActiveTab('profile'); setIsConfirmingDelete(false); setPasswordForDelete(''); - setDeleteError(''); - setShowPassword(false); setAuthEmail(''); setAuthPassword(''); setAuthFullName(''); @@ -348,30 +341,19 @@ export const ProfileManager: React.FC = ({ isOpen, onClose,
- 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" /> + 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" />
-
- setAuthPassword(e.target.value)} required 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" /> - -
+ setAuthPassword(e.target.value)} + required + className="mt-1" + showStrength={isRegistering} + />
- {isRegistering && ( -
- - 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" /> -
- )} - {isRegistering && ( -
- - setAuthAvatarUrl(e.target.value)} placeholder="https://..." 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" /> -
- )} - {isRegistering && } {!isRegistering && (
@@ -385,6 +367,11 @@ export const ProfileManager: React.FC = ({ isOpen, onClose,
)} + {authError && ( +
+ {authError} +
+ )}
-
- -
- - -
) ) : ( @@ -462,22 +431,26 @@ export const ProfileManager: React.FC = ({ isOpen, onClose,
-
- setPassword(e.target.value)} placeholder="••••••••" required 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" /> - -
- + setPassword(e.target.value)} + placeholder="••••••••" + required + className="mt-1" + showStrength + />
-
- setConfirmPassword(e.target.value)} placeholder="••••••••" required 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" /> - -
+ setConfirmPassword(e.target.value)} + placeholder="••••••••" + required + className="mt-1" + />
-
-

Link Social Accounts

-

Connect other accounts for easier sign-in.

-
- - -
-
)} @@ -525,23 +484,13 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, ) : (
{ 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">

To confirm, please enter your current password.

-
- -
- setPasswordForDelete(e.target.value)} - required - placeholder="Enter your password" - 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" - /> - -
-
+ setPasswordForDelete(e.target.value)} + required + placeholder="Enter your password" + /> {deleteError &&

{deleteError}

}
-
-
- -
-
- - setConfirmPassword(e.target.value)} required className="appearance-none rounded-none relative block w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 placeholder-gray-500 text-gray-900 dark:text-white rounded-b-md focus:outline-none focus:ring-brand-primary focus:border-brand-primary sm:text-sm" placeholder="Confirm New Password" /> - -
+ setPassword(e.target.value)} + required + placeholder="New Password" + showStrength + /> + {/* The confirm password field is removed as it's not needed for a password reset. + The user is focused on creating one new, strong password. + If you prefer to keep it, you can add another here. + For example: +
+ +
+ */}
{error &&

{error}

}
diff --git a/src/pages/VoiceLabPage.tsx b/src/pages/VoiceLabPage.tsx new file mode 100644 index 00000000..011186a0 --- /dev/null +++ b/src/pages/VoiceLabPage.tsx @@ -0,0 +1,120 @@ +import React, { useState } from 'react'; +import { generateSpeechFromText, startVoiceSession } from '../services/aiApiClient'; +import { logger } from '../services/logger'; +import { notifyError } from '../services/notificationService'; +import { LoadingSpinner } from '../components/LoadingSpinner'; +import { SpeakerWaveIcon, MicrophoneIcon } from '../components/icons/HeroIcons'; + +export const VoiceLabPage: React.FC = () => { + const [textToSpeak, setTextToSpeak] = useState('Hello! This is a test of the text-to-speech generation.'); + const [isGeneratingSpeech, setIsGeneratingSpeech] = useState(false); + const [audioPlayer, setAudioPlayer] = useState(null); + + const handleGenerateSpeech = async () => { + if (!textToSpeak.trim()) { + notifyError('Please enter some text to generate speech.'); + return; + } + setIsGeneratingSpeech(true); + try { + const base64Audio = await generateSpeechFromText(textToSpeak); + if (base64Audio) { + const audioSrc = `data:audio/mpeg;base64,${base64Audio}`; + const audio = new Audio(audioSrc); + setAudioPlayer(audio); + audio.play(); + } else { + notifyError('The AI did not return any audio data.'); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.'; + logger.error('Failed to generate speech:', { error: errorMessage }); + notifyError(`Speech generation failed: ${errorMessage}`); + } finally { + setIsGeneratingSpeech(false); + } + }; + + const handleStartVoiceSession = () => { + try { + // This function is currently a stub and will throw an error. + // This is the placeholder for the future real-time voice implementation. + startVoiceSession({ + onmessage: (message) => { + logger.info('Received voice session message:', message); + }, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.'; + logger.error('Failed to start voice session:', { error: errorMessage }); + notifyError(`Could not start voice session: ${errorMessage}`); + } + }; + + return ( +
+
+

Admin Voice Lab

+

+ This page is for testing and developing voice-related AI features. +

+ + {/* Text-to-Speech Section */} +
+

+ + Text-to-Speech Generation +

+
+
+ +