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>
1140 lines
49 KiB
TypeScript
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;
|