Files
flyer-crawler.projectium.com/components/ProfileManager.tsx
Torben Sorensen ecdd642fe8
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 13s
typescript fixin
2025-11-12 00:36:00 -08:00

359 lines
21 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { Session } from '@supabase/supabase-js';
import type { Profile } from '../types';
import { supabase, updateUserProfile, updateUserPassword, exportUserData, deleteUserAccount } from '../services/supabaseClient';
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';
type AuthStatus = 'SIGNED_OUT' | 'ANONYMOUS' | 'AUTHENTICATED';
interface ProfileManagerProps {
isOpen: boolean;
onClose: () => void;
session: Session;
authStatus: AuthStatus;
profile: Profile;
onProfileUpdate: (updatedProfile: Profile) => void;
}
export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose, session, profile, onProfileUpdate }) => {
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);
const [profileMessage, setProfileMessage] = useState('');
// Password state
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordLoading, setPasswordLoading] = useState(false);
const [passwordError, setPasswordError] = useState('');
const [passwordMessage, setPasswordMessage] = useState('');
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// Data & Privacy state
const [exportLoading, setExportLoading] = useState(false);
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
const [passwordForDelete, setPasswordForDelete] = useState('');
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteError, setDeleteError] = useState('');
useEffect(() => {
// Only reset state when the modal is opened.
// Do not reset on profile changes, which can happen during sign-out.
if (isOpen) {
setFullName(profile?.full_name || '');
setAvatarUrl(profile?.avatar_url || '');
setActiveTab('profile');
setIsConfirmingDelete(false);
setPasswordForDelete('');
setDeleteError('');
setPasswordError('');
setPasswordMessage('');
setShowPassword(false);
}
}, [isOpen]); // Only depend on isOpen
const handleProfileSave = async (e: React.FormEvent) => {
e.preventDefault();
setProfileLoading(true);
setProfileMessage('');
try {
const updatedProfile = await updateUserProfile(session.user.id, {
full_name: fullName,
avatar_url: avatarUrl
});
onProfileUpdate(updatedProfile);
logger.info('User profile updated successfully.', { userId: session.user.id, fullName, avatarUrl });
setProfileMessage('Profile updated successfully!');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
logger.error('Failed to update user profile.', { userId: session.user.id, error: errorMessage });
setProfileMessage(errorMessage);
} finally {
setProfileLoading(false);
setTimeout(() => setProfileMessage(''), 3000);
}
};
const handleOAuthLink = async (provider: 'google' | 'github') => {
// This will redirect the user to the OAuth provider to link the account.
// After successful linking, they will be redirected back to the app.
const { error } = await supabase.auth.linkIdentity({
provider,
options: {
redirectTo: window.location.href,
}
});
if (error) {
// This error will be shown if the user cancels or if there's a config issue.
logger.error(`Could not link ${provider} account.`, { userId: session.user.id, error: error.message });
setPasswordError(`Could not link ${provider} account: ${error.message}`);
}
};
const handlePasswordUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
setPasswordError("Passwords do not match.");
return;
}
if (password.length < 6) {
setPasswordError("Password must be at least 6 characters long.");
return;
}
setPasswordLoading(true);
setPasswordError('');
setPasswordMessage('');
try {
await updateUserPassword(password);
logger.info('User password updated successfully.', { userId: session.user.id });
setPasswordMessage("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: session.user.id, error: errorMessage });
setPasswordError(errorMessage);
} finally {
setPasswordLoading(false);
setTimeout(() => {
setPasswordMessage('');
setPasswordError('');
}, 4000);
}
};
const handleExportData = async () => {
setExportLoading(true);
try {
logger.info('User initiated data export.', { userId: session.user.id });
const userData = await exportUserData(session.user.id);
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: session.user.id, error: errorMessage });
alert(`Error exporting data: ${errorMessage}`);
} finally {
setExportLoading(false);
}
};
const handleDeleteAccount = async (e: React.FormEvent) => {
e.preventDefault();
setIsDeleteModalOpen(false); // Close the confirmation modal
setDeleteError('');
// CRITICAL: Prevent anonymous users from attempting to delete their account.
if (session.user.is_anonymous) {
setDeleteError("Cannot delete an anonymous guest account. Please sign up for a full account first.");
setDeleteLoading(false);
return;
}
setDeleteLoading(true);
try {
logger.warn('ProfileManager: handleDeleteAccount function has been called.');
logger.warn('User initiated account deletion.', { userId: session.user.id });
await deleteUserAccount(passwordForDelete);
alert("Your account and all associated data have been permanently deleted.");
// The onAuthStateChange listener in App.tsx will handle the UI update
await supabase.auth.signOut();
onClose();
logger.warn('User account deleted successfully.', { userId: session.user.id });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
logger.error('ProfileManager: Account deletion failed.', { error: errorMessage, stack: (error as Error).stack });
setDeleteError(errorMessage);
} finally {
setDeleteLoading(false);
}
};
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}
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>
<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>
</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>
{profileMessage && <p className="text-sm text-green-600 dark:text-green-400 text-center mt-2">{profileMessage}</p>}
</div>
</form>
)}
{activeTab === 'security' && (
<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>
<div className="relative mt-1">
<input id="newPassword" type={showPassword ? 'text' : 'password'} value={password} onChange={e => 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" />
<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>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Confirm New Password</label>
<div className="relative mt-1">
<input id="confirmPassword" type={showPassword ? 'text' : 'password'} value={confirmPassword} onChange={e => 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" />
<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>
</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>
{passwordError && <p className="text-sm text-red-600 dark:text-red-400 text-center mt-2">{passwordError}</p>}
{passwordMessage && <p className="text-sm text-green-600 dark:text-green-400 text-center mt-2">{passwordMessage}</p>}
</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 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>
<div>
<label htmlFor="delete-password" className="sr-only">Current Password</label>
<div className="relative">
<input
id="delete-password"
type={showPassword ? 'text' : 'password'}
value={passwordForDelete}
onChange={e => 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"
/>
<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>
</div>
{deleteError && <p className="text-xs text-red-600 dark:text-red-400 whitespace-pre-wrap font-mono">{deleteError}</p>}
<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>
)}
</div>
</div>
</div>
);
};