import { useState, useRef, useEffect } from 'react'; import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X, Calendar, Plus, Trash2, Copy, Eye, EyeOff, Shield, ShieldCheck, ShieldOff } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; import { useTheme } from '../contexts/ThemeContext'; import { useLanguage } from '../contexts/LanguageContext'; import { themes, getThemeGroups } from '../themes'; import api from '../services/api'; import toast from 'react-hot-toast'; export default function Settings() { const { user, updateUser } = useAuth(); const { theme, setTheme } = useTheme(); const { t, language, setLanguage } = useLanguage(); const [profile, setProfile] = useState({ name: user?.name || '', display_name: user?.display_name || '', email: user?.email || '', }); const [passwords, setPasswords] = useState({ currentPassword: '', newPassword: '', confirmPassword: '', }); const [savingProfile, setSavingProfile] = useState(false); const [savingPassword, setSavingPassword] = useState(false); const handleLanguageChange = async (lang) => { setLanguage(lang); try { const res = await api.put('/auth/profile', { language: lang }); updateUser(res.data.user); } catch { // Language is still saved locally even if API fails } }; const [activeSection, setActiveSection] = useState('profile'); const [uploadingAvatar, setUploadingAvatar] = useState(false); const fileInputRef = useRef(null); // CalDAV token state const [caldavTokens, setCaldavTokens] = useState([]); const [caldavLoading, setCaldavLoading] = useState(false); const [newTokenName, setNewTokenName] = useState(''); const [creatingToken, setCreatingToken] = useState(false); const [newlyCreatedToken, setNewlyCreatedToken] = useState(null); const [tokenVisible, setTokenVisible] = useState(false); // 2FA state const [twoFaEnabled, setTwoFaEnabled] = useState(!!user?.totp_enabled); const [twoFaLoading, setTwoFaLoading] = useState(false); const [twoFaSetupData, setTwoFaSetupData] = useState(null); // { secret, uri, qrDataUrl } const [twoFaCode, setTwoFaCode] = useState(''); const [twoFaEnabling, setTwoFaEnabling] = useState(false); const [twoFaDisablePassword, setTwoFaDisablePassword] = useState(''); const [twoFaDisableCode, setTwoFaDisableCode] = useState(''); const [twoFaDisabling, setTwoFaDisabling] = useState(false); const [showDisableForm, setShowDisableForm] = useState(false); useEffect(() => { if (activeSection === 'caldav') { setCaldavLoading(true); api.get('/calendar/caldav-tokens') .then(r => setCaldavTokens(r.data.tokens || [])) .catch(() => {}) .finally(() => setCaldavLoading(false)); } if (activeSection === 'security') { setTwoFaLoading(true); api.get('/auth/2fa/status') .then(r => setTwoFaEnabled(r.data.enabled)) .catch(() => {}) .finally(() => setTwoFaLoading(false)); } }, [activeSection]); const handleCreateToken = async (e) => { e.preventDefault(); if (!newTokenName.trim()) return; setCreatingToken(true); try { const res = await api.post('/calendar/caldav-tokens', { name: newTokenName.trim() }); setNewlyCreatedToken(res.data.plainToken); setTokenVisible(false); setNewTokenName(''); const r = await api.get('/calendar/caldav-tokens'); setCaldavTokens(r.data.tokens || []); } catch (err) { toast.error(err.response?.data?.error || t('settings.caldav.createFailed')); } finally { setCreatingToken(false); } }; const handleRevokeToken = async (id) => { if (!confirm(t('settings.caldav.revokeConfirm'))) return; try { await api.delete(`/calendar/caldav-tokens/${id}`); setCaldavTokens(prev => prev.filter(tk => tk.id !== id)); toast.success(t('settings.caldav.revoked')); } catch { toast.error(t('settings.caldav.revokeFailed')); } }; // 2FA handlers const handleSetup2FA = async () => { setTwoFaLoading(true); try { const res = await api.post('/auth/2fa/setup'); // Generate QR code data URL client-side const QRCode = (await import('qrcode')).default; const qrDataUrl = await QRCode.toDataURL(res.data.uri, { width: 200, margin: 2, color: { dark: '#000000', light: '#ffffff' } }); setTwoFaSetupData({ secret: res.data.secret, uri: res.data.uri, qrDataUrl }); } catch (err) { toast.error(err.response?.data?.error || t('settings.security.setupFailed')); } finally { setTwoFaLoading(false); } }; const handleEnable2FA = async (e) => { e.preventDefault(); setTwoFaEnabling(true); try { await api.post('/auth/2fa/enable', { code: twoFaCode }); setTwoFaEnabled(true); setTwoFaSetupData(null); setTwoFaCode(''); toast.success(t('settings.security.enabled')); } catch (err) { toast.error(err.response?.data?.error || t('settings.security.enableFailed')); setTwoFaCode(''); } finally { setTwoFaEnabling(false); } }; const handleDisable2FA = async (e) => { e.preventDefault(); setTwoFaDisabling(true); try { await api.post('/auth/2fa/disable', { password: twoFaDisablePassword, code: twoFaDisableCode }); setTwoFaEnabled(false); setShowDisableForm(false); setTwoFaDisablePassword(''); setTwoFaDisableCode(''); toast.success(t('settings.security.disabled')); } catch (err) { toast.error(err.response?.data?.error || t('settings.security.disableFailed')); } finally { setTwoFaDisabling(false); } }; const groups = getThemeGroups(); const avatarColors = [ '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', '#f43f5e', '#ef4444', '#f97316', '#eab308', '#22c55e', '#14b8a6', '#06b6d4', '#3b82f6', '#2563eb', '#7c3aed', '#64748b', ]; const handleProfileSave = async (e) => { e.preventDefault(); setSavingProfile(true); try { const res = await api.put('/auth/profile', { name: profile.name, display_name: profile.display_name, email: profile.email, theme, language, avatar_color: user?.avatar_color, }); updateUser(res.data.user); toast.success(t('settings.profileSaved')); } catch (err) { toast.error(err.response?.data?.error || t('settings.profileSaveFailed')); } finally { setSavingProfile(false); } }; const handlePasswordSave = async (e) => { e.preventDefault(); if (passwords.newPassword !== passwords.confirmPassword) { toast.error(t('settings.passwordMismatch')); return; } setSavingPassword(true); try { await api.put('/auth/password', { currentPassword: passwords.currentPassword, newPassword: passwords.newPassword, }); setPasswords({ currentPassword: '', newPassword: '', confirmPassword: '' }); toast.success(t('settings.passwordChanged')); } catch (err) { toast.error(err.response?.data?.error || t('settings.passwordChangeFailed')); } finally { setSavingPassword(false); } }; const handleAvatarColor = async (color) => { try { const res = await api.put('/auth/profile', { avatar_color: color }); updateUser(res.data.user); } catch { // Ignore } }; const handleAvatarUpload = async (e) => { const file = e.target.files?.[0]; if (!file) return; if (!file.type.startsWith('image/')) { toast.error(t('settings.avatarInvalidType')); return; } if (file.size > 2 * 1024 * 1024) { toast.error(t('settings.avatarTooLarge')); return; } setUploadingAvatar(true); try { const res = await api.post('/auth/avatar', file, { headers: { 'Content-Type': file.type }, }); updateUser(res.data.user); toast.success(t('settings.avatarUploaded')); } catch (err) { toast.error(err.response?.data?.error || t('settings.avatarUploadFailed')); } finally { setUploadingAvatar(false); if (fileInputRef.current) fileInputRef.current.value = ''; } }; const handleAvatarRemove = async () => { try { const res = await api.delete('/auth/avatar'); updateUser(res.data.user); toast.success(t('settings.avatarRemoved')); } catch { toast.error(t('settings.avatarRemoveFailed')); } }; const sections = [ { id: 'profile', label: t('settings.profile'), icon: User }, { id: 'password', label: t('settings.password'), icon: Lock }, { id: 'security', label: t('settings.security.title'), icon: Shield }, { id: 'language', label: t('settings.language'), icon: Globe }, { id: 'themes', label: t('settings.themes'), icon: Palette }, { id: 'caldav', label: t('settings.caldav.title'), icon: Calendar }, ]; return (
{t('settings.subtitle')}
{t('settings.avatarHint')}
{t('settings.avatarColorHint')}
{t('settings.security.subtitle')}
{twoFaLoading ? ({t('settings.security.statusEnabled')}
{t('settings.security.statusEnabledDesc')}
{t('settings.security.scanQR')}
{t('settings.security.manualKey')}
{twoFaSetupData.secret}
{t('settings.security.statusDisabled')}
{t('settings.security.statusDisabledDesc')}
{t('settings.subtitle')}
{t('settings.caldav.subtitle')}
{t('settings.caldav.serverUrl')}
{`${window.location.origin}/caldav/`}
{t('settings.caldav.username')}
{user?.email}
{t('settings.caldav.hint')}
{t('settings.caldav.newTokenCreated')}
{t('settings.caldav.newTokenHint')}
{tokenVisible ? newlyCreatedToken : 'โข'.repeat(48)}
{t('settings.caldav.noTokens')}
) : ({tk.name}
{t('settings.caldav.created')}: {new Date(tk.created_at).toLocaleDateString()} {tk.last_used_at && ` ยท ${t('settings.caldav.lastUsed')}: ${new Date(tk.last_used_at).toLocaleDateString()}`}