← back to Watches
src/components/ProfilePage.jsx
350 lines
/**
* User Profile Page Component
* View and edit profile, change password, manage sessions, delete account
*/
import React, { useState, useEffect } from 'react';
const ProfilePage = ({ user, onLogout }) => {
const [editing, setEditing] = useState(false);
const [name, setName] = useState(user?.name || '');
const [sessions, setSessions] = useState([]);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showChangePassword, setShowChangePassword] = useState(false);
const [passwordData, setPasswordData] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
useEffect(() => {
fetchSessions();
}, []);
const fetchSessions = async () => {
try {
const response = await fetch('/api/auth/sessions', { credentials: 'include' });
const data = await response.json();
if (data.success) {
setSessions(data.sessions);
}
} catch (error) {
console.error('Failed to fetch sessions:', error);
}
};
const handleUpdateName = async () => {
setLoading(true);
try {
const response = await fetch('/api/user/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name })
});
const data = await response.json();
if (data.success) {
setMessage({ type: 'success', text: 'Name updated successfully' });
setEditing(false);
} else {
setMessage({ type: 'error', text: data.error });
}
} catch (error) {
setMessage({ type: 'error', text: 'Failed to update name' });
} finally {
setLoading(false);
}
};
const handleChangePassword = async (e) => {
e.preventDefault();
if (passwordData.newPassword !== passwordData.confirmPassword) {
setMessage({ type: 'error', text: 'Passwords do not match' });
return;
}
setLoading(true);
try {
const response = await fetch('/api/auth/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
currentPassword: passwordData.currentPassword,
newPassword: passwordData.newPassword
})
});
const data = await response.json();
if (data.success) {
setMessage({ type: 'success', text: 'Password changed successfully' });
setShowChangePassword(false);
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
} else {
setMessage({ type: 'error', text: data.error });
}
} catch (error) {
setMessage({ type: 'error', text: 'Failed to change password' });
} finally {
setLoading(false);
}
};
const handleRevokeSession = async (sessionId) => {
try {
const response = await fetch(`/api/auth/sessions/${sessionId}`, {
method: 'DELETE',
credentials: 'include'
});
const data = await response.json();
if (data.success) {
setSessions(sessions.filter(s => s.id !== sessionId));
setMessage({ type: 'success', text: 'Session revoked' });
}
} catch (error) {
setMessage({ type: 'error', text: 'Failed to revoke session' });
}
};
const handleDeleteAccount = async () => {
setLoading(true);
try {
const response = await fetch('/api/user/profile', {
method: 'DELETE',
credentials: 'include'
});
const data = await response.json();
if (data.success) {
onLogout?.();
window.location.href = '/?deleted=true';
} else {
setMessage({ type: 'error', text: data.error });
}
} catch (error) {
setMessage({ type: 'error', text: 'Failed to delete account' });
} finally {
setLoading(false);
}
};
if (!user) {
return (
<div className="max-w-2xl mx-auto p-6">
<p className="text-gray-600">Please log in to view your profile.</p>
</div>
);
}
return (
<div className="max-w-2xl mx-auto p-6 space-y-8">
<h1 className="text-2xl font-bold text-gray-900">Profile</h1>
{message && (
<div className={`p-4 rounded-lg ${message.type === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>
{message.text}
</div>
)}
{/* Profile Info */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4">Profile Information</h2>
<div className="flex items-center space-x-4 mb-6">
{user.avatar_url ? (
<img src={user.avatar_url} alt={user.name} className="w-16 h-16 rounded-full" />
) : (
<div className="w-16 h-16 rounded-full bg-gray-300 flex items-center justify-center">
<span className="text-2xl text-gray-600">{user.name?.[0] || user.email[0]}</span>
</div>
)}
<div>
{editing ? (
<div className="flex items-center space-x-2">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="px-3 py-1 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleUpdateName}
disabled={loading}
className="px-3 py-1 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Save
</button>
<button
onClick={() => { setEditing(false); setName(user.name || ''); }}
className="px-3 py-1 text-gray-600 hover:text-gray-800"
>
Cancel
</button>
</div>
) : (
<div className="flex items-center space-x-2">
<span className="font-medium text-lg">{user.name || 'No name set'}</span>
<button onClick={() => setEditing(true)} className="text-blue-600 text-sm hover:underline">
Edit
</button>
</div>
)}
<p className="text-gray-600">{user.email}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500">Provider:</span>
<span className="ml-2 capitalize">{user.provider}</span>
</div>
<div>
<span className="text-gray-500">Email verified:</span>
<span className="ml-2">{user.email_verified ? 'Yes' : 'No'}</span>
</div>
<div>
<span className="text-gray-500">Member since:</span>
<span className="ml-2">{new Date(user.created_at).toLocaleDateString()}</span>
</div>
</div>
</section>
{/* Change Password (email auth only) */}
{user.provider === 'email' && (
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4">Security</h2>
{showChangePassword ? (
<form onSubmit={handleChangePassword} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Current Password</label>
<input
type="password"
value={passwordData.currentPassword}
onChange={(e) => setPasswordData({ ...passwordData, currentPassword: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">New Password</label>
<input
type="password"
value={passwordData.newPassword}
onChange={(e) => setPasswordData({ ...passwordData, newPassword: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Confirm New Password</label>
<input
type="password"
value={passwordData.confirmPassword}
onChange={(e) => setPasswordData({ ...passwordData, confirmPassword: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
required
/>
</div>
<div className="flex space-x-2">
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Change Password
</button>
<button
type="button"
onClick={() => setShowChangePassword(false)}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Cancel
</button>
</div>
</form>
) : (
<button
onClick={() => setShowChangePassword(true)}
className="text-blue-600 hover:underline"
>
Change password
</button>
)}
</section>
)}
{/* Active Sessions */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4">Active Sessions</h2>
<div className="space-y-3">
{sessions.map(session => (
<div key={session.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div>
<div className="flex items-center space-x-2">
<span className="font-medium capitalize">{session.device}</span>
{session.isCurrent && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full">
Current
</span>
)}
</div>
<p className="text-sm text-gray-500">
Last active: {new Date(session.lastActive).toLocaleString()}
</p>
</div>
{!session.isCurrent && (
<button
onClick={() => handleRevokeSession(session.id)}
className="text-red-600 text-sm hover:underline"
>
Revoke
</button>
)}
</div>
))}
</div>
</section>
{/* Delete Account */}
<section className="bg-white rounded-lg shadow p-6 border-2 border-red-200">
<h2 className="text-lg font-semibold text-red-600 mb-4">Danger Zone</h2>
{showDeleteConfirm ? (
<div className="space-y-4">
<p className="text-gray-600">
Are you sure you want to delete your account? This action cannot be undone.
All your data will be permanently removed.
</p>
<div className="flex space-x-2">
<button
onClick={handleDeleteAccount}
disabled={loading}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
{loading ? 'Deleting...' : 'Yes, delete my account'}
</button>
<button
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => setShowDeleteConfirm(true)}
className="text-red-600 hover:underline"
>
Delete my account
</button>
)}
</section>
</div>
);
};
export default ProfilePage;