import { useState, useEffect, useRef, useCallback } from 'react'; import { toast } from 'sonner'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Settings, User, Palette, Cloud, CalendarDays, LayoutDashboard, MapPin, X, Search, Loader2, Shield, Blocks, Ghost, } from 'lucide-react'; import { useSettings } from '@/hooks/useSettings'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { DatePicker } from '@/components/ui/date-picker'; import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; import api from '@/lib/api'; import type { GeoLocation, UserProfile } from '@/types'; import { Switch } from '@/components/ui/switch'; import CopyableField from '@/components/shared/CopyableField'; import TotpSetupSection from './TotpSetupSection'; import NtfySettingsSection from './NtfySettingsSection'; const accentColors = [ { name: 'cyan', label: 'Cyan', color: '#06b6d4' }, { name: 'blue', label: 'Blue', color: '#3b82f6' }, { name: 'purple', label: 'Purple', color: '#8b5cf6' }, { name: 'orange', label: 'Orange', color: '#f97316' }, { name: 'green', label: 'Green', color: '#22c55e' }, { name: 'red', label: 'Red', color: '#ef4444' }, { name: 'pink', label: 'Pink', color: '#ec4899' }, { name: 'yellow', label: 'Yellow', color: '#eab308' }, ]; export default function SettingsPage() { const queryClient = useQueryClient(); const { settings, updateSettings, isUpdating } = useSettings(); const [selectedColor, setSelectedColor] = useState(settings?.accent_color || 'cyan'); const [upcomingDays, setUpcomingDays] = useState(settings?.upcoming_days || 7); const [preferredName, setPreferredName] = useState(settings?.preferred_name ?? ''); const [locationQuery, setLocationQuery] = useState(''); const [locationResults, setLocationResults] = useState([]); const [isSearching, setIsSearching] = useState(false); const [showDropdown, setShowDropdown] = useState(false); const searchRef = useRef(null); const debounceRef = useRef>(); const [firstDayOfWeek, setFirstDayOfWeek] = useState(settings?.first_day_of_week ?? 0); const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false); const [autoLockMinutes, setAutoLockMinutes] = useState(settings?.auto_lock_minutes ?? 5); // Profile extension fields (stored on Settings model) const [settingsPhone, setSettingsPhone] = useState(settings?.phone ?? ''); const [settingsMobile, setSettingsMobile] = useState(settings?.mobile ?? ''); const [settingsAddress, setSettingsAddress] = useState(settings?.address ?? ''); const [settingsCompany, setSettingsCompany] = useState(settings?.company ?? ''); const [settingsJobTitle, setSettingsJobTitle] = useState(settings?.job_title ?? ''); // Social settings const [acceptConnections, setAcceptConnections] = useState(settings?.accept_connections ?? false); const [sharePreferredName, setSharePreferredName] = useState(settings?.share_preferred_name ?? true); const [shareEmail, setShareEmail] = useState(settings?.share_email ?? false); const [sharePhone, setSharePhone] = useState(settings?.share_phone ?? false); const [shareMobile, setShareMobile] = useState(settings?.share_mobile ?? false); const [shareBirthday, setShareBirthday] = useState(settings?.share_birthday ?? false); const [shareAddress, setShareAddress] = useState(settings?.share_address ?? false); const [shareCompany, setShareCompany] = useState(settings?.share_company ?? false); const [shareJobTitle, setShareJobTitle] = useState(settings?.share_job_title ?? false); // Profile fields (stored on User model, fetched from /auth/profile) const profileQuery = useQuery({ queryKey: ['profile'], queryFn: async () => { const { data } = await api.get('/auth/profile'); return data; }, }); const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [profileEmail, setProfileEmail] = useState(''); const [dateOfBirth, setDateOfBirth] = useState(''); const [emailError, setEmailError] = useState(null); useEffect(() => { if (profileQuery.data) { setFirstName(profileQuery.data.first_name ?? ''); setLastName(profileQuery.data.last_name ?? ''); setProfileEmail(profileQuery.data.email ?? ''); setDateOfBirth(profileQuery.data.date_of_birth ?? ''); } }, [profileQuery.dataUpdatedAt]); // Sync state when settings load useEffect(() => { if (settings) { setSelectedColor(settings.accent_color); setUpcomingDays(settings.upcoming_days); setPreferredName(settings.preferred_name ?? ''); setFirstDayOfWeek(settings.first_day_of_week); setAutoLockEnabled(settings.auto_lock_enabled); setAutoLockMinutes(settings.auto_lock_minutes ?? 5); setSettingsPhone(settings.phone ?? ''); setSettingsMobile(settings.mobile ?? ''); setSettingsAddress(settings.address ?? ''); setSettingsCompany(settings.company ?? ''); setSettingsJobTitle(settings.job_title ?? ''); setAcceptConnections(settings.accept_connections); setSharePreferredName(settings.share_preferred_name); setShareEmail(settings.share_email); setSharePhone(settings.share_phone); setShareMobile(settings.share_mobile); setShareBirthday(settings.share_birthday); setShareAddress(settings.share_address); setShareCompany(settings.share_company); setShareJobTitle(settings.share_job_title); } }, [settings?.id]); // only re-sync on initial load (settings.id won't change) const hasLocation = settings?.weather_lat != null && settings?.weather_lon != null; const searchLocations = useCallback(async (query: string) => { if (query.length < 2) { setLocationResults([]); setShowDropdown(false); return; } setIsSearching(true); try { const { data } = await api.get('/weather/search', { params: { q: query } }); setLocationResults(data); setShowDropdown(data.length > 0); } catch { setLocationResults([]); setShowDropdown(false); } finally { setIsSearching(false); } }, []); const handleLocationInputChange = (value: string) => { setLocationQuery(value); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => searchLocations(value), 300); }; const handleLocationSelect = async (loc: GeoLocation) => { const displayName = [loc.name, loc.state, loc.country].filter(Boolean).join(', '); setShowDropdown(false); setLocationQuery(''); setLocationResults([]); try { await updateSettings({ weather_city: displayName, weather_lat: loc.lat, weather_lon: loc.lon, }); queryClient.invalidateQueries({ queryKey: ['weather'] }); toast.success(`Weather location set to ${displayName}`); } catch { toast.error('Failed to update weather location'); } }; const handleLocationClear = async () => { try { await updateSettings({ weather_city: null, weather_lat: null, weather_lon: null }); queryClient.invalidateQueries({ queryKey: ['weather'] }); toast.success('Weather location cleared'); } catch { toast.error('Failed to clear weather location'); } }; // Close dropdown on outside click useEffect(() => { const handler = (e: MouseEvent) => { if (searchRef.current && !searchRef.current.contains(e.target as Node)) { setShowDropdown(false); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, []); useEffect(() => { return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, []); const handleNameSave = async () => { const trimmed = preferredName.trim(); if (trimmed === (settings?.preferred_name || '')) return; try { await updateSettings({ preferred_name: trimmed || null }); toast.success('Name updated'); } catch { toast.error('Failed to update name'); } }; const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email' | 'date_of_birth') => { const values: Record = { first_name: firstName, last_name: lastName, email: profileEmail, date_of_birth: dateOfBirth }; const current = values[field].trim(); const original = profileQuery.data?.[field] ?? ''; if (current === (original || '')) return; // Client-side email validation if (field === 'email' && current) { if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(current)) { setEmailError('Invalid email format'); return; } } setEmailError(null); try { await api.put('/auth/profile', { [field]: current || null }); queryClient.invalidateQueries({ queryKey: ['profile'] }); toast.success('Profile updated'); } catch (err: any) { const detail = err?.response?.data?.detail; if (field === 'email' && detail) { setEmailError(typeof detail === 'string' ? detail : 'Failed to update email'); } else { toast.error(typeof detail === 'string' ? detail : 'Failed to update profile'); } } }; const handleColorChange = async (color: string) => { setSelectedColor(color); try { await updateSettings({ accent_color: color }); toast.success('Accent color updated'); } catch { toast.error('Failed to update accent color'); } }; const handleFirstDayChange = async (value: number) => { const previous = firstDayOfWeek; setFirstDayOfWeek(value); try { await updateSettings({ first_day_of_week: value }); queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); toast.success(value === 0 ? 'Week starts on Sunday' : 'Week starts on Monday'); } catch { setFirstDayOfWeek(previous); toast.error('Failed to update first day of week'); } }; const handleUpcomingDaysSave = async () => { if (isNaN(upcomingDays) || upcomingDays < 1 || upcomingDays > 30) return; if (upcomingDays === settings?.upcoming_days) return; try { await updateSettings({ upcoming_days: upcomingDays }); toast.success('Settings updated'); } catch { toast.error('Failed to update settings'); } }; const handleAutoLockToggle = async (checked: boolean) => { const previous = autoLockEnabled; setAutoLockEnabled(checked); try { await updateSettings({ auto_lock_enabled: checked }); toast.success(checked ? 'Auto-lock enabled' : 'Auto-lock disabled'); } catch { setAutoLockEnabled(previous); toast.error('Failed to update auto-lock setting'); } }; const handleSettingsFieldSave = async (field: string, value: string) => { const trimmed = value.trim(); const currentVal = (settings as any)?.[field] || ''; if (trimmed === (currentVal || '')) return; try { await updateSettings({ [field]: trimmed || null } as any); toast.success('Profile updated'); } catch { toast.error('Failed to update profile'); } }; const handleSocialToggle = async (field: string, checked: boolean, setter: (v: boolean) => void) => { const previous = (settings as any)?.[field]; setter(checked); try { await updateSettings({ [field]: checked } as any); } catch { setter(previous); toast.error('Failed to update setting'); } }; const handleAutoLockMinutesSave = async () => { const raw = typeof autoLockMinutes === 'string' ? parseInt(autoLockMinutes) : autoLockMinutes; const clamped = Math.max(1, Math.min(60, isNaN(raw) ? 5 : raw)); setAutoLockMinutes(clamped); if (clamped === settings?.auto_lock_minutes) return; try { await updateSettings({ auto_lock_minutes: clamped }); toast.success(`Auto-lock timeout set to ${clamped} minutes`); } catch { setAutoLockMinutes(settings?.auto_lock_minutes ?? 5); toast.error('Failed to update auto-lock timeout'); } }; return (
{/* Page header — matches Stage 4-5 pages */}
{/* ── Left column: Profile, Appearance, Calendar, Dashboard, Weather ── */}
{/* Profile */}
Profile Your profile and display preferences
setPreferredName(e.target.value)} onBlur={handleNameSave} onKeyDown={(e) => { if (e.key === 'Enter') handleNameSave(); }} maxLength={100} />

Used in the dashboard greeting, e.g. "Good morning, {preferredName || 'Kyle'}."

setFirstName(e.target.value)} onBlur={() => handleProfileSave('first_name')} onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('first_name'); }} maxLength={100} />
setLastName(e.target.value)} onBlur={() => handleProfileSave('last_name')} onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('last_name'); }} maxLength={100} />
{ setProfileEmail(e.target.value); setEmailError(null); }} onBlur={() => handleProfileSave('email')} onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('email'); }} maxLength={254} className={emailError ? 'border-red-500/50' : ''} /> {emailError && (

{emailError}

)}
setDateOfBirth(v)} onBlur={() => handleProfileSave('date_of_birth')} onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('date_of_birth'); }} />
setSettingsPhone(e.target.value)} onBlur={() => handleSettingsFieldSave('phone', settingsPhone)} onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('phone', settingsPhone); }} maxLength={50} />
setSettingsMobile(e.target.value)} onBlur={() => handleSettingsFieldSave('mobile', settingsMobile)} onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('mobile', settingsMobile); }} maxLength={50} />
setSettingsAddress(e.target.value)} onBlur={() => handleSettingsFieldSave('address', settingsAddress)} onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('address', settingsAddress); }} maxLength={2000} />
setSettingsCompany(e.target.value)} onBlur={() => handleSettingsFieldSave('company', settingsCompany)} onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('company', settingsCompany); }} maxLength={255} />
setSettingsJobTitle(e.target.value)} onBlur={() => handleSettingsFieldSave('job_title', settingsJobTitle)} onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('job_title', settingsJobTitle); }} maxLength={255} />
{/* Appearance */}
Appearance Customize the look and feel of your application
{accentColors.map((color) => ( ))}
{/* Calendar */}
Calendar Configure your calendar preferences

Sets which day the calendar week starts on

{/* Dashboard */}
Dashboard Configure your dashboard preferences
setUpcomingDays(parseInt(e.target.value))} onBlur={handleUpcomingDaysSave} onKeyDown={(e) => { if (e.key === 'Enter') handleUpcomingDaysSave(); }} className="w-24" disabled={isUpdating} /> days

How many days ahead to show in the upcoming items widget

{/* Weather */}
Weather Configure the weather widget on your dashboard
{hasLocation ? (
{settings?.weather_city || `${settings?.weather_lat}, ${settings?.weather_lon}`}
) : (
handleLocationInputChange(e.target.value)} onFocus={() => { if (locationResults.length > 0) setShowDropdown(true); }} className="pl-9 pr-9" /> {isSearching && ( )}
{showDropdown && (
{locationResults.map((loc, i) => ( ))}
)}
)}

Search and select your city for accurate weather data on the dashboard.

{/* ── Right column: Social, Security, Authentication, Integrations ── */}
{/* Social */}
Social Manage your Umbra identity and connections

How other Umbra users find you

Allow other users to find and connect with you

handleSocialToggle('accept_connections', checked, setAcceptConnections)} />

Sharing Defaults

{[ { field: 'share_preferred_name', label: 'Preferred Name', state: sharePreferredName, setter: setSharePreferredName }, { field: 'share_email', label: 'Email', state: shareEmail, setter: setShareEmail }, { field: 'share_phone', label: 'Phone', state: sharePhone, setter: setSharePhone }, { field: 'share_mobile', label: 'Mobile', state: shareMobile, setter: setShareMobile }, { field: 'share_birthday', label: 'Birthday', state: shareBirthday, setter: setShareBirthday }, { field: 'share_address', label: 'Address', state: shareAddress, setter: setShareAddress }, { field: 'share_company', label: 'Company', state: shareCompany, setter: setShareCompany }, { field: 'share_job_title', label: 'Job Title', state: shareJobTitle, setter: setShareJobTitle }, ].map(({ field, label, state, setter }) => (
handleSocialToggle(field, checked, setter)} />
))}
{/* Security (auto-lock) */}
Security Configure screen lock behavior

Automatically lock the screen after idle time

setAutoLockMinutes(e.target.value === '' ? '' : parseInt(e.target.value) || '')} onBlur={handleAutoLockMinutesSave} onKeyDown={(e) => { if (e.key === 'Enter') handleAutoLockMinutesSave(); }} className="w-20" disabled={!autoLockEnabled || isUpdating} /> minutes
{/* Authentication (TOTP + password change) */} {/* Integrations */}
Integrations
); }