Files
smoothschedule/frontend/src/pages/ProfileSettings.tsx
poduck 2e111364a2 Initial commit: SmoothSchedule multi-tenant scheduling platform
This commit includes:
- Django backend with multi-tenancy (django-tenants)
- React + TypeScript frontend with Vite
- Platform administration API with role-based access control
- Authentication system with token-based auth
- Quick login dev tools for testing different user roles
- CORS and CSRF configuration for local development
- Docker development environment setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 01:43:20 -05:00

1140 lines
49 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
User,
Mail,
Lock,
Shield,
Bell,
Monitor,
Clock,
CheckCircle,
AlertCircle,
ChevronRight,
Eye,
EyeOff,
Phone,
MapPin,
Plus,
Trash2,
Star,
} from 'lucide-react';
import PhoneInput, { type Country, formatPhoneNumber } from 'react-phone-number-input';
import 'react-phone-number-input/style.css';
import { useCurrentUser } from '../hooks/useAuth';
import {
useProfile,
useUpdateProfile,
useSendVerificationEmail,
useChangePassword,
useSessions,
useRevokeOtherSessions,
useSendPhoneVerification,
useVerifyPhoneCode,
useUserEmails,
useAddUserEmail,
useDeleteUserEmail,
useSendUserEmailVerification,
useSetPrimaryEmail,
} from '../hooks/useProfile';
import type { UserEmail } from '../api/profile';
import TwoFactorSetup from '../components/profile/TwoFactorSetup';
import { useUserNotifications } from '../hooks/useUserNotifications';
interface SettingsSectionProps {
title: string;
description: string;
icon: React.ReactNode;
children: React.ReactNode;
}
const SettingsSection: React.FC<SettingsSectionProps> = ({ title, description, icon, children }) => (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg text-brand-600 dark:text-brand-400">
{icon}
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{title}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">{description}</p>
</div>
</div>
</div>
<div className="p-6">{children}</div>
</div>
);
// Toast notification component
const Toast: React.FC<{ message: string; type: 'success' | 'error'; onClose: () => void }> = ({
message,
type,
onClose,
}) => {
useEffect(() => {
const timer = setTimeout(onClose, 3000);
return () => clearTimeout(timer);
}, [onClose]);
return (
<div
className={`fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg flex items-center gap-2 z-50 ${
type === 'success'
? 'bg-green-500 text-white'
: 'bg-red-500 text-white'
}`}
>
{type === 'success' ? <CheckCircle size={18} /> : <AlertCircle size={18} />}
{message}
</div>
);
};
const ProfileSettings: React.FC = () => {
const { data: currentUser, isLoading: userLoading } = useCurrentUser();
const { data: profile, isLoading: profileLoading, refetch: refetchProfile } = useProfile();
const updateProfile = useUpdateProfile();
const sendVerification = useSendVerificationEmail();
const sendPhoneVerification = useSendPhoneVerification();
const verifyPhoneCode = useVerifyPhoneCode();
const changePassword = useChangePassword();
const { data: sessions, isLoading: sessionsLoading } = useSessions();
const revokeOtherSessions = useRevokeOtherSessions();
// Multiple email hooks
const { data: userEmails, isLoading: emailsLoading } = useUserEmails();
const addUserEmail = useAddUserEmail();
const deleteUserEmail = useDeleteUserEmail();
const sendUserEmailVerification = useSendUserEmailVerification();
const setPrimaryEmail = useSetPrimaryEmail();
const [activeTab, setActiveTab] = useState<'profile' | 'security' | 'notifications'>('profile');
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
// WebSocket for real-time notifications (e.g., email verified)
useUserNotifications({
onEmailVerified: (_emailId, email) => {
setToast({ message: `Email "${email}" has been verified!`, type: 'success' });
},
});
const [show2FAModal, setShow2FAModal] = useState(false);
// Form states
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [timezone, setTimezone] = useState('UTC');
const [locale, setLocale] = useState('en-US');
// Phone verification
const [phoneVerificationCode, setPhoneVerificationCode] = useState('');
const [showPhoneVerification, setShowPhoneVerification] = useState(false);
// Address fields
const [addressLine1, setAddressLine1] = useState('');
const [addressLine2, setAddressLine2] = useState('');
const [city, setCity] = useState('');
const [state, setState] = useState('');
const [postalCode, setPostalCode] = useState('');
const [country, setCountry] = useState('US');
// Password form
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
// Email management
const [newEmail, setNewEmail] = useState('');
const [showAddEmail, setShowAddEmail] = useState(false);
// Notification preferences
const [notificationPrefs, setNotificationPrefs] = useState({
email: true,
sms: false,
in_app: true,
appointment_reminders: true,
marketing: false,
});
// Initialize form data when profile loads
useEffect(() => {
if (profile) {
setName(profile.name || '');
setPhone(profile.phone || '');
setTimezone(profile.timezone || 'UTC');
setLocale(profile.locale || 'en-US');
setAddressLine1(profile.address_line1 || '');
setAddressLine2(profile.address_line2 || '');
setCity(profile.city || '');
setState(profile.state || '');
setPostalCode(profile.postal_code || '');
setCountry(profile.country || 'US');
if (profile.notification_preferences) {
setNotificationPrefs(profile.notification_preferences);
}
}
}, [profile]);
const showToast = (message: string, type: 'success' | 'error') => {
setToast({ message, type });
};
const handleSaveProfile = async () => {
try {
await updateProfile.mutateAsync({ name, phone });
showToast('Profile updated successfully', 'success');
} catch (err: any) {
showToast(err.response?.data?.detail || 'Failed to update profile', 'error');
}
};
const handleSendPhoneVerification = async () => {
if (!phone) {
showToast('Please enter a phone number first', 'error');
return;
}
try {
await sendPhoneVerification.mutateAsync(phone);
setShowPhoneVerification(true);
showToast('Verification code sent! Check your console for the code.', 'success');
} catch (err: any) {
showToast(err.response?.data?.detail || 'Failed to send verification code', 'error');
}
};
const handleVerifyPhoneCode = async () => {
if (!phoneVerificationCode || phoneVerificationCode.length !== 6) {
showToast('Please enter a 6-digit code', 'error');
return;
}
try {
await verifyPhoneCode.mutateAsync(phoneVerificationCode);
setShowPhoneVerification(false);
setPhoneVerificationCode('');
showToast('Phone number verified successfully', 'success');
} catch (err: any) {
showToast(err.response?.data?.detail || 'Invalid verification code', 'error');
}
};
const handleSaveAddress = async () => {
try {
await updateProfile.mutateAsync({
address_line1: addressLine1,
address_line2: addressLine2,
city,
state,
postal_code: postalCode,
country,
});
showToast('Address updated successfully', 'success');
} catch (err: any) {
showToast(err.response?.data?.detail || 'Failed to update address', 'error');
}
};
const handleSavePreferences = async () => {
try {
await updateProfile.mutateAsync({ timezone, locale });
showToast('Preferences updated successfully', 'success');
} catch (err: any) {
showToast(err.response?.data?.detail || 'Failed to update preferences', 'error');
}
};
const handleSaveNotifications = async () => {
try {
await updateProfile.mutateAsync({ notification_preferences: notificationPrefs });
showToast('Notification preferences updated', 'success');
} catch (err: any) {
showToast(err.response?.data?.detail || 'Failed to update notifications', 'error');
}
};
const handleSendVerification = async () => {
try {
await sendVerification.mutateAsync();
showToast('Verification email sent', 'success');
} catch (err: any) {
showToast(err.response?.data?.detail || 'Failed to send verification email', 'error');
}
};
// Email management handlers
const handleAddEmail = async () => {
if (!newEmail || !newEmail.includes('@')) {
showToast('Please enter a valid email address', 'error');
return;
}
try {
await addUserEmail.mutateAsync(newEmail);
setNewEmail('');
setShowAddEmail(false);
showToast('Email added. Verification email sent.', 'success');
} catch (err: any) {
showToast(err.response?.data?.detail || err.response?.data?.email?.[0] || 'Failed to add email', 'error');
}
};
const handleDeleteEmail = async (emailId: number) => {
try {
await deleteUserEmail.mutateAsync(emailId);
showToast('Email removed', 'success');
} catch (err: any) {
showToast(err.response?.data?.detail || 'Failed to remove email', 'error');
}
};
const handleSendEmailVerification = async (emailId: number) => {
try {
await sendUserEmailVerification.mutateAsync(emailId);
showToast('Verification email sent', 'success');
} catch (err: any) {
showToast(err.response?.data?.detail || 'Failed to send verification email', 'error');
}
};
const handleSetPrimaryEmail = async (emailId: number) => {
try {
await setPrimaryEmail.mutateAsync(emailId);
showToast('Primary email updated', 'success');
} catch (err: any) {
showToast(err.response?.data?.detail || 'Failed to set primary email', 'error');
}
};
const handleChangePassword = async () => {
if (newPassword !== confirmPassword) {
showToast('Passwords do not match', 'error');
return;
}
if (newPassword.length < 8) {
showToast('Password must be at least 8 characters', 'error');
return;
}
try {
await changePassword.mutateAsync({ currentPassword, newPassword });
showToast('Password changed successfully', 'success');
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} catch (err: any) {
showToast(err.response?.data?.detail || 'Failed to change password', 'error');
}
};
const handleRevokeOtherSessions = async () => {
try {
await revokeOtherSessions.mutateAsync();
showToast('All other sessions have been signed out', 'success');
} catch (err: any) {
showToast(err.response?.data?.detail || 'Failed to revoke sessions', 'error');
}
};
const handle2FASuccess = () => {
refetchProfile();
showToast('Two-factor authentication updated', 'success');
};
if (userLoading || profileLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500" />
</div>
);
}
const user = profile || currentUser;
if (!user) {
return (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">Unable to load user profile.</p>
</div>
);
}
const tabs = [
{ id: 'profile' as const, label: 'Profile', icon: <User size={18} /> },
{ id: 'security' as const, label: 'Security', icon: <Shield size={18} /> },
{ id: 'notifications' as const, label: 'Notifications', icon: <Bell size={18} /> },
];
return (
<div className="max-w-4xl mx-auto">
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
{show2FAModal && (
<TwoFactorSetup
isEnabled={profile?.two_factor_enabled || false}
phoneVerified={profile?.phone_verified || false}
hasPhone={!!phone}
onClose={() => setShow2FAModal(false)}
onSuccess={handle2FASuccess}
onVerifyPhone={() => {
setActiveTab('profile');
// Trigger phone verification after a short delay to allow tab switch
setTimeout(() => {
if (phone) {
handleSendPhoneVerification();
}
}, 100);
}}
/>
)}
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Profile Settings</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Manage your account settings and preferences
</p>
</div>
{/* Tab Navigation */}
<div className="flex gap-1 mb-6 bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === tab.id
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{/* Profile Tab */}
{activeTab === 'profile' && (
<div className="space-y-6">
{/* Personal Information */}
<SettingsSection
title="Personal Information"
description="Update your personal details"
icon={<User size={20} />}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Full Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
</div>
<div className="flex justify-end">
<button
onClick={handleSaveProfile}
disabled={updateProfile.isPending}
className="px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors disabled:opacity-50"
>
{updateProfile.isPending ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</SettingsSection>
{/* Phone Verification */}
<SettingsSection
title="Phone Number"
description="Manage your phone number and verification status"
icon={<Phone size={20} />}
>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="flex items-center gap-3">
<Phone size={20} className="text-gray-400" />
<div>
<p className="font-medium text-gray-900 dark:text-white">
{phone ? formatPhoneNumber(phone) : 'No phone number set'}
</p>
{phone && (
<div className="flex items-center gap-1 mt-1">
{profile?.phone_verified ? (
<>
<CheckCircle size={14} className="text-green-500" />
<span className="text-xs text-green-600 dark:text-green-400">Verified</span>
</>
) : (
<>
<AlertCircle size={14} className="text-amber-500" />
<span className="text-xs text-amber-600 dark:text-amber-400">Not verified</span>
</>
)}
</div>
)}
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Phone Number
</label>
<PhoneInput
defaultCountry={(country as Country) || 'US'}
countrySelectComponent={() => null}
value={phone}
onChange={(value) => setPhone(value || '')}
className="phone-input-wrapper"
/>
<style>{`
.phone-input-wrapper {
display: flex;
align-items: center;
border: 1px solid rgb(209 213 219);
border-radius: 0.5rem;
background: white;
padding: 0.5rem 0.75rem;
}
.dark .phone-input-wrapper {
border-color: rgb(75 85 99);
background: rgb(55 65 81);
}
.phone-input-wrapper:focus-within {
border-color: transparent;
box-shadow: 0 0 0 2px rgb(59 130 246);
}
.phone-input-wrapper .PhoneInputInput {
flex: 1;
border: none;
outline: none;
background: transparent;
font-size: 1rem;
color: rgb(17 24 39);
}
.dark .phone-input-wrapper .PhoneInputInput {
color: white;
}
.phone-input-wrapper .PhoneInputInput::placeholder {
color: rgb(156 163 175);
}
`}</style>
</div>
{showPhoneVerification ? (
<div className="space-y-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-700 dark:text-blue-300">
Enter the 6-digit code sent to your phone. (Check Django console for code)
</p>
<input
type="text"
value={phoneVerificationCode}
onChange={(e) => setPhoneVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
maxLength={6}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent text-center text-xl tracking-widest"
/>
<div className="flex gap-2">
<button
onClick={handleVerifyPhoneCode}
disabled={verifyPhoneCode.isPending || phoneVerificationCode.length !== 6}
className="flex-1 px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors disabled:opacity-50"
>
{verifyPhoneCode.isPending ? 'Verifying...' : 'Verify Code'}
</button>
<button
onClick={() => {
setShowPhoneVerification(false);
setPhoneVerificationCode('');
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
</div>
</div>
) : (
<div className="flex gap-2">
<button
onClick={handleSaveProfile}
disabled={updateProfile.isPending}
className="px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors disabled:opacity-50"
>
{updateProfile.isPending ? 'Saving...' : 'Save Phone'}
</button>
{phone && !profile?.phone_verified && (
<button
onClick={handleSendPhoneVerification}
disabled={sendPhoneVerification.isPending}
className="px-4 py-2 border border-brand-500 text-brand-600 dark:text-brand-400 rounded-lg hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors disabled:opacity-50"
>
{sendPhoneVerification.isPending ? 'Sending...' : 'Send Verification Code'}
</button>
)}
</div>
)}
</div>
</SettingsSection>
{/* Address - Show for non-customer roles or when address is needed */}
{profile?.role !== 'customer' && (
<SettingsSection
title="Address"
description="Your mailing address"
icon={<MapPin size={20} />}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Address Line 1
</label>
<input
type="text"
value={addressLine1}
onChange={(e) => setAddressLine1(e.target.value)}
placeholder="Street address"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Address Line 2
</label>
<input
type="text"
value={addressLine2}
onChange={(e) => setAddressLine2(e.target.value)}
placeholder="Apartment, suite, etc. (optional)"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
City
</label>
<input
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
State / Province
</label>
<input
type="text"
value={state}
onChange={(e) => setState(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Postal Code
</label>
<input
type="text"
value={postalCode}
onChange={(e) => setPostalCode(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Country
</label>
<select
value={country}
onChange={(e) => setCountry(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent"
>
<option value="US">United States</option>
<option value="CA">Canada</option>
<option value="GB">United Kingdom</option>
<option value="AU">Australia</option>
<option value="DE">Germany</option>
<option value="FR">France</option>
<option value="ES">Spain</option>
<option value="IT">Italy</option>
<option value="JP">Japan</option>
<option value="BR">Brazil</option>
<option value="MX">Mexico</option>
</select>
</div>
</div>
<div className="flex justify-end">
<button
onClick={handleSaveAddress}
disabled={updateProfile.isPending}
className="px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors disabled:opacity-50"
>
{updateProfile.isPending ? 'Saving...' : 'Save Address'}
</button>
</div>
</div>
</SettingsSection>
)}
{/* Email Settings */}
<SettingsSection
title="Email Addresses"
description="Manage your email addresses"
icon={<Mail size={20} />}
>
<div className="space-y-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
Any verified email address can be used to sign in to your account. Your primary email is used for account notifications.
</p>
{/* Email List */}
{emailsLoading ? (
<div className="text-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-brand-500 mx-auto" />
</div>
) : userEmails && userEmails.length > 0 ? (
<div className="space-y-2">
{userEmails.map((email) => (
<div
key={email.id}
className={`flex items-center justify-between p-4 rounded-lg ${
email.is_primary
? 'bg-brand-50 dark:bg-brand-900/20 border border-brand-200 dark:border-brand-800'
: 'bg-gray-50 dark:bg-gray-700/50'
}`}
>
<div className="flex items-center gap-3">
<Mail size={20} className={email.is_primary ? 'text-brand-500' : 'text-gray-400'} />
<div>
<div className="flex items-center gap-2">
<p className="font-medium text-gray-900 dark:text-white">{email.email}</p>
{email.is_primary && (
<span className="text-xs bg-brand-100 dark:bg-brand-900/50 text-brand-700 dark:text-brand-300 px-2 py-0.5 rounded-full flex items-center gap-1">
<Star size={10} /> Primary
</span>
)}
</div>
<div className="flex items-center gap-1 mt-1">
{email.verified ? (
<>
<CheckCircle size={14} className="text-green-500" />
<span className="text-xs text-green-600 dark:text-green-400">Verified</span>
</>
) : (
<>
<AlertCircle size={14} className="text-amber-500" />
<span className="text-xs text-amber-600 dark:text-amber-400">Not verified</span>
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{!email.verified && (
<button
onClick={() => handleSendEmailVerification(email.id)}
disabled={sendUserEmailVerification.isPending}
className="text-xs text-brand-600 dark:text-brand-400 hover:underline"
>
Verify
</button>
)}
{!email.is_primary && email.verified && (
<button
onClick={() => handleSetPrimaryEmail(email.id)}
disabled={setPrimaryEmail.isPending}
className="text-xs text-brand-600 dark:text-brand-400 hover:underline"
>
Make Primary
</button>
)}
{!email.is_primary && (
<button
onClick={() => handleDeleteEmail(email.id)}
disabled={deleteUserEmail.isPending}
className="p-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
title="Remove email"
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-gray-500 dark:text-gray-400 text-sm">No email addresses found.</p>
)}
{/* Add Email Form */}
{showAddEmail ? (
<div className="space-y-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<input
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder="Enter email address"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
<div className="flex gap-2">
<button
onClick={handleAddEmail}
disabled={addUserEmail.isPending}
className="flex-1 px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors disabled:opacity-50"
>
{addUserEmail.isPending ? 'Adding...' : 'Add Email'}
</button>
<button
onClick={() => {
setShowAddEmail(false);
setNewEmail('');
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => setShowAddEmail(true)}
className="flex items-center gap-2 px-4 py-2 border border-dashed border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors w-full justify-center"
>
<Plus size={18} />
Add Email Address
</button>
)}
</div>
</SettingsSection>
{/* Preferences */}
<SettingsSection
title="Preferences"
description="Customize your experience"
icon={<Clock size={20} />}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Timezone
</label>
<select
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent"
>
<option value="UTC">UTC</option>
<option value="America/New_York">Eastern Time (US & Canada)</option>
<option value="America/Chicago">Central Time (US & Canada)</option>
<option value="America/Denver">Mountain Time (US & Canada)</option>
<option value="America/Los_Angeles">Pacific Time (US & Canada)</option>
<option value="Europe/London">London</option>
<option value="Europe/Paris">Paris</option>
<option value="Asia/Tokyo">Tokyo</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Language
</label>
<select
value={locale}
onChange={(e) => setLocale(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent"
>
<option value="en-US">English (US)</option>
<option value="en-GB">English (UK)</option>
<option value="es">Spanish</option>
<option value="fr">French</option>
<option value="de">German</option>
</select>
</div>
<div className="flex justify-end">
<button
onClick={handleSavePreferences}
disabled={updateProfile.isPending}
className="px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors disabled:opacity-50"
>
{updateProfile.isPending ? 'Saving...' : 'Save Preferences'}
</button>
</div>
</div>
</SettingsSection>
</div>
)}
{/* Security Tab */}
{activeTab === 'security' && (
<div className="space-y-6">
{/* Password */}
<SettingsSection
title="Password"
description="Change your password"
icon={<Lock size={20} />}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Current Password
</label>
<div className="relative">
<input
type={showCurrentPassword ? 'text' : 'password'}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
<button
type="button"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showCurrentPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
New Password
</label>
<div className="relative">
<input
type={showNewPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showNewPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Confirm New Password
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
</div>
<div className="flex justify-end">
<button
onClick={handleChangePassword}
disabled={changePassword.isPending || !currentPassword || !newPassword || !confirmPassword}
className="px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors disabled:opacity-50"
>
{changePassword.isPending ? 'Updating...' : 'Update Password'}
</button>
</div>
</div>
</SettingsSection>
{/* Two-Factor Authentication */}
<SettingsSection
title="Two-Factor Authentication"
description="Add an extra layer of security to your account"
icon={<Shield size={20} />}
>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="flex items-center gap-3">
<div
className={`p-2 rounded-full ${
profile?.two_factor_enabled
? 'bg-green-100 dark:bg-green-900/30'
: 'bg-gray-100 dark:bg-gray-700'
}`}
>
<Shield
size={20}
className={
profile?.two_factor_enabled
? 'text-green-600 dark:text-green-400'
: 'text-gray-400'
}
/>
</div>
<div>
<p className="font-medium text-gray-900 dark:text-white">Authenticator App</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{profile?.two_factor_enabled ? 'Enabled' : 'Not configured'}
</p>
</div>
</div>
<button
onClick={() => setShow2FAModal(true)}
className="flex items-center gap-1 text-sm text-brand-600 dark:text-brand-400 hover:underline"
>
{profile?.two_factor_enabled ? 'Manage' : 'Setup'}
<ChevronRight size={16} />
</button>
</div>
{profile?.two_factor_enabled && (
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="flex items-center gap-3">
<div className="p-2 rounded-full bg-gray-100 dark:bg-gray-700">
<Lock size={20} className="text-gray-400" />
</div>
<div>
<p className="font-medium text-gray-900 dark:text-white">Recovery Codes</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
Use these if you lose access to your authenticator
</p>
</div>
</div>
<button
onClick={() => setShow2FAModal(true)}
className="flex items-center gap-1 text-sm text-brand-600 dark:text-brand-400 hover:underline"
>
View Codes
<ChevronRight size={16} />
</button>
</div>
)}
</div>
</SettingsSection>
{/* Active Sessions */}
<SettingsSection
title="Active Sessions"
description="Manage your logged in devices"
icon={<Monitor size={20} />}
>
<div className="space-y-4">
{sessionsLoading ? (
<div className="text-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-brand-500 mx-auto" />
</div>
) : sessions && sessions.length > 0 ? (
<>
{sessions.map((session) => (
<div
key={session.id}
className={`flex items-center justify-between p-4 rounded-lg ${
session.is_current
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
: 'bg-gray-50 dark:bg-gray-700/50'
}`}
>
<div className="flex items-center gap-3">
<Monitor
size={20}
className={
session.is_current
? 'text-green-600 dark:text-green-400'
: 'text-gray-400'
}
/>
<div>
<p className="font-medium text-gray-900 dark:text-white">
{session.is_current ? 'Current Session' : session.device_info || 'Unknown Device'}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{session.location || 'Unknown Location'} · Last active{' '}
{session.is_current ? 'now' : new Date(session.last_activity).toLocaleDateString()}
</p>
</div>
</div>
{session.is_current && (
<span className="text-xs bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300 px-2 py-1 rounded-full">
Active
</span>
)}
</div>
))}
</>
) : (
<div className="flex items-center justify-between p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<div className="flex items-center gap-3">
<Monitor size={20} className="text-green-600 dark:text-green-400" />
<div>
<p className="font-medium text-gray-900 dark:text-white">Current Session</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
This device · Last active now
</p>
</div>
</div>
<span className="text-xs bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300 px-2 py-1 rounded-full">
Active
</span>
</div>
)}
<button
onClick={handleRevokeOtherSessions}
disabled={revokeOtherSessions.isPending}
className="w-full px-4 py-2 border border-red-300 dark:border-red-700 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50"
>
{revokeOtherSessions.isPending ? 'Signing out...' : 'Sign Out All Other Sessions'}
</button>
</div>
</SettingsSection>
</div>
)}
{/* Notifications Tab */}
{activeTab === 'notifications' && (
<div className="space-y-6">
<SettingsSection
title="Notification Preferences"
description="Choose how you want to be notified"
icon={<Bell size={20} />}
>
<div className="space-y-4">
{[
{ id: 'email' as const, label: 'Email Notifications', description: 'Receive updates via email' },
{ id: 'sms' as const, label: 'SMS Notifications', description: 'Receive text message alerts' },
{ id: 'in_app' as const, label: 'In-App Notifications', description: 'Show notifications in the app' },
{
id: 'appointment_reminders' as const,
label: 'Appointment Reminders',
description: 'Get reminded before appointments',
},
{ id: 'marketing' as const, label: 'Marketing Emails', description: 'Receive promotional content' },
].map((pref) => (
<div
key={pref.id}
className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700 last:border-0"
>
<div>
<p className="font-medium text-gray-900 dark:text-white">{pref.label}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{pref.description}</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={notificationPrefs[pref.id]}
onChange={(e) =>
setNotificationPrefs((prev) => ({ ...prev, [pref.id]: e.target.checked }))
}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-300 dark:peer-focus:ring-brand-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-brand-500"></div>
</label>
</div>
))}
<div className="flex justify-end pt-4">
<button
onClick={handleSaveNotifications}
disabled={updateProfile.isPending}
className="px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors disabled:opacity-50"
>
{updateProfile.isPending ? 'Saving...' : 'Save Preferences'}
</button>
</div>
</div>
</SettingsSection>
</div>
)}
</div>
);
};
export default ProfileSettings;