267 lines
14 KiB
TypeScript
267 lines
14 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 { LoadingSpinner } from './LoadingSpinner';
|
|
import { XMarkIcon } from './icons/XMarkIcon';
|
|
|
|
interface ProfileManagerProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
session: Session;
|
|
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('');
|
|
|
|
// 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(() => {
|
|
if (isOpen) {
|
|
// Reset state when modal opens
|
|
setFullName(profile.full_name || '');
|
|
setAvatarUrl(profile.avatar_url || '');
|
|
setActiveTab('profile');
|
|
setIsConfirmingDelete(false);
|
|
setPasswordForDelete('');
|
|
setDeleteError('');
|
|
setPasswordError('');
|
|
setPasswordMessage('');
|
|
}
|
|
}, [isOpen, profile]);
|
|
|
|
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);
|
|
setProfileMessage('Profile updated successfully!');
|
|
} catch (error: any) {
|
|
setProfileMessage(error.message);
|
|
} finally {
|
|
setProfileLoading(false);
|
|
setTimeout(() => setProfileMessage(''), 3000);
|
|
}
|
|
};
|
|
|
|
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);
|
|
setPasswordMessage("Password updated successfully!");
|
|
setPassword('');
|
|
setConfirmPassword('');
|
|
} catch (error: any) {
|
|
setPasswordError(error.message);
|
|
} finally {
|
|
setPasswordLoading(false);
|
|
setTimeout(() => {
|
|
setPasswordMessage('');
|
|
setPasswordError('');
|
|
}, 4000);
|
|
}
|
|
};
|
|
|
|
const handleExportData = async () => {
|
|
setExportLoading(true);
|
|
try {
|
|
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: any) {
|
|
console.error("Failed to export data:", error);
|
|
alert(`Error exporting data: ${error.message}`);
|
|
} finally {
|
|
setExportLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteAccount = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setDeleteLoading(true);
|
|
setDeleteError('');
|
|
try {
|
|
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();
|
|
} catch (error: any) {
|
|
setDeleteError(error.message);
|
|
} 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"
|
|
>
|
|
<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>
|
|
<input id="newPassword" type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="••••••••" 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>
|
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Confirm New Password</label>
|
|
<input id="confirmPassword" type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} placeholder="••••••••" 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={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>
|
|
</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 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={handleDeleteAccount} 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>
|
|
<input
|
|
id="delete-password"
|
|
type="password"
|
|
value={passwordForDelete}
|
|
onChange={e => setPasswordForDelete(e.target.value)}
|
|
required
|
|
placeholder="Enter your password"
|
|
className="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>
|
|
{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>
|
|
);
|
|
}; |