Redesign Settings page with tab-based layout

Replace 895-line monolith with 5 focused tab components (Profile,
Appearance, Social, Security, Integrations) mirroring AdminPortal's
tab pattern. URL deep linking via ?tab= search param. Conditional
rendering prevents unmounted tabs from firing API calls.

Reviewed by senior-code-reviewer, senior-ui-designer, and
security-penetration-tester agents — all findings actioned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-11 14:58:28 +08:00
parent 6f8054c63d
commit f2050efe2d
8 changed files with 1015 additions and 887 deletions

View File

@ -30,7 +30,7 @@ export default function AdminPortal() {
</div> </div>
{/* Horizontal tab navigation — evenly spaced on mobile, left-aligned on desktop */} {/* Horizontal tab navigation — evenly spaced on mobile, left-aligned on desktop */}
<nav className="flex items-center justify-evenly md:justify-start flex-1 md:flex-none md:gap-1 h-full min-w-0 overflow-x-auto overflow-y-hidden"> <nav className="flex items-center justify-evenly md:justify-start flex-1 md:flex-none md:gap-1 h-full min-w-0 overflow-hidden">
{tabs.map(({ label, path, icon: Icon }) => { {tabs.map(({ label, path, icon: Icon }) => {
const isActive = location.pathname.startsWith(path); const isActive = location.pathname.startsWith(path);
return ( return (

View File

@ -0,0 +1,180 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { useQueryClient } from '@tanstack/react-query';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import type { Settings } from '@/types';
const accentColors = [
{ name: 'cyan', color: '#06b6d4' },
{ name: 'blue', color: '#3b82f6' },
{ name: 'purple', color: '#8b5cf6' },
{ name: 'orange', color: '#f97316' },
{ name: 'green', color: '#22c55e' },
{ name: 'red', color: '#ef4444' },
{ name: 'pink', color: '#ec4899' },
{ name: 'yellow', color: '#eab308' },
];
interface AppearanceTabProps {
settings: Settings | undefined;
updateSettings: (updates: Partial<Settings>) => Promise<Settings>;
isUpdating: boolean;
}
export default function AppearanceTab({ settings, updateSettings, isUpdating }: AppearanceTabProps) {
const queryClient = useQueryClient();
const [selectedColor, setSelectedColor] = useState(settings?.accent_color || 'cyan');
const [firstDayOfWeek, setFirstDayOfWeek] = useState(settings?.first_day_of_week ?? 0);
const [upcomingDays, setUpcomingDays] = useState(settings?.upcoming_days || 7);
useEffect(() => {
if (settings) {
setSelectedColor(settings.accent_color);
setFirstDayOfWeek(settings.first_day_of_week);
setUpcomingDays(settings.upcoming_days);
}
}, [settings?.id]);
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');
}
};
return (
<div className="space-y-6">
{/* Accent Color */}
<Card>
<CardContent className="pt-6">
<div className="space-y-3">
<Label>Accent Color</Label>
<div className="flex items-center gap-2.5">
{accentColors.map((c) => (
<button
key={c.name}
type="button"
onClick={() => handleColorChange(c.name)}
aria-pressed={selectedColor === c.name}
aria-label={c.name}
className="relative group p-1.5"
>
<div
className={cn(
'h-7 w-7 rounded-full transition-all duration-150',
selectedColor === c.name
? 'ring-2 ring-offset-2 ring-offset-background'
: 'hover:scale-110'
)}
style={{
backgroundColor: c.color,
...(selectedColor === c.name ? { '--tw-ring-color': c.color } as React.CSSProperties : {}),
}}
/>
</button>
))}
</div>
</div>
</CardContent>
</Card>
{/* Calendar & Dashboard */}
<Card>
<CardContent className="pt-6 space-y-5">
<div className="space-y-2">
<Label>First Day of Week</Label>
<div className="flex items-center rounded-md border border-border overflow-hidden w-fit">
<button
type="button"
onClick={() => handleFirstDayChange(0)}
className={cn(
'px-4 py-2 text-sm font-medium transition-colors duration-150',
firstDayOfWeek === 0
? 'text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
)}
style={{
backgroundColor: firstDayOfWeek === 0 ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: firstDayOfWeek === 0 ? 'hsl(var(--accent-color))' : undefined,
}}
>
Sunday
</button>
<button
type="button"
onClick={() => handleFirstDayChange(1)}
className={cn(
'px-4 py-2 text-sm font-medium transition-colors duration-150',
firstDayOfWeek === 1
? 'text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
)}
style={{
backgroundColor: firstDayOfWeek === 1 ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: firstDayOfWeek === 1 ? 'hsl(var(--accent-color))' : undefined,
}}
>
Monday
</button>
</div>
<p className="text-sm text-muted-foreground">
Sets which day the calendar week starts on
</p>
</div>
<div className="space-y-2">
<Label htmlFor="upcoming_days">Upcoming Days Range</Label>
<div className="flex gap-3 items-center">
<Input
id="upcoming_days"
type="number"
min="1"
max="30"
value={upcomingDays}
onChange={(e) => setUpcomingDays(parseInt(e.target.value))}
onBlur={handleUpcomingDaysSave}
onKeyDown={(e) => { if (e.key === 'Enter') handleUpcomingDaysSave(); }}
className="w-24"
disabled={isUpdating}
/>
<span className="text-sm text-muted-foreground">days</span>
</div>
<p className="text-sm text-muted-foreground">
How many days ahead to show in the upcoming items widget
</p>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,182 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { toast } from 'sonner';
import { useQueryClient } from '@tanstack/react-query';
import {
MapPin,
X,
Search,
Loader2,
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent } from '@/components/ui/card';
import api from '@/lib/api';
import NtfySettingsSection from './NtfySettingsSection';
import type { Settings, GeoLocation } from '@/types';
interface IntegrationsTabProps {
settings: Settings | undefined;
updateSettings: (
updates: Partial<Settings> & { preferred_name?: string | null; ntfy_auth_token?: string }
) => Promise<Settings>;
}
export default function IntegrationsTab({ settings, updateSettings }: IntegrationsTabProps) {
const queryClient = useQueryClient();
const [locationQuery, setLocationQuery] = useState('');
const [locationResults, setLocationResults] = useState<GeoLocation[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
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<GeoLocation[]>('/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');
}
};
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);
};
}, []);
return (
<div className="space-y-6">
{/* Weather Location */}
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<Label>Weather Location</Label>
{hasLocation ? (
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 py-1.5 text-sm text-foreground">
<MapPin className="h-3.5 w-3.5 text-accent" />
{settings?.weather_city || `${settings?.weather_lat}, ${settings?.weather_lon}`}
</span>
<button
type="button"
onClick={handleLocationClear}
className="inline-flex items-center justify-center rounded-md h-7 w-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Clear location"
aria-label="Clear weather location"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<div ref={searchRef} className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
type="text"
placeholder="Search for a city..."
value={locationQuery}
onChange={(e) => handleLocationInputChange(e.target.value)}
onFocus={() => { if (locationResults.length > 0) setShowDropdown(true); }}
className="pl-9 pr-9"
/>
{isSearching && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground animate-spin" />
)}
</div>
{showDropdown && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg overflow-hidden">
{locationResults.map((loc, i) => (
<button
key={`${loc.lat}-${loc.lon}-${i}`}
type="button"
onClick={() => handleLocationSelect(loc)}
className="flex items-center gap-2.5 w-full px-3 py-2.5 text-sm text-left hover:bg-accent/10 transition-colors"
>
<MapPin className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span>
<span className="text-foreground font-medium">{loc.name}</span>
{(loc.state || loc.country) && (
<span className="text-muted-foreground">
{loc.state ? `, ${loc.state}` : ''}{loc.country ? `, ${loc.country}` : ''}
</span>
)}
</span>
</button>
))}
</div>
)}
</div>
)}
<p className="text-sm text-muted-foreground">
Search and select your city for accurate weather data on the dashboard.
</p>
</div>
</CardContent>
</Card>
{/* ntfy Push Notifications */}
<Card>
<CardContent className="pt-6">
<NtfySettingsSection settings={settings} updateSettings={updateSettings} />
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,269 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { useQueryClient, useQuery } from '@tanstack/react-query';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { Card, CardContent } from '@/components/ui/card';
import api from '@/lib/api';
import type { Settings, UserProfile } from '@/types';
interface ProfileTabProps {
settings: Settings | undefined;
updateSettings: (updates: Partial<Settings> & { preferred_name?: string | null }) => Promise<Settings>;
}
export default function ProfileTab({ settings, updateSettings }: ProfileTabProps) {
const queryClient = useQueryClient();
// Profile fields (stored on User model via /auth/profile)
const profileQuery = useQuery({
queryKey: ['profile'],
queryFn: async () => {
const { data } = await api.get<UserProfile>('/auth/profile');
return data;
},
});
const [preferredName, setPreferredName] = useState(settings?.preferred_name ?? '');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [profileEmail, setProfileEmail] = useState('');
const [dateOfBirth, setDateOfBirth] = useState('');
const [emailError, setEmailError] = useState<string | null>(null);
// 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 ?? '');
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]);
useEffect(() => {
if (settings) {
setPreferredName(settings.preferred_name ?? '');
setSettingsPhone(settings.phone ?? '');
setSettingsMobile(settings.mobile ?? '');
setSettingsAddress(settings.address ?? '');
setSettingsCompany(settings.company ?? '');
setSettingsJobTitle(settings.job_title ?? '');
}
}, [settings?.id]);
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<string, string> = { 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;
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 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');
}
};
return (
<Card>
<CardContent className="pt-6 space-y-4">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">
Personal Information
</p>
<div className="space-y-2">
<Label htmlFor="preferred_name">Preferred Name</Label>
<Input
id="preferred_name"
type="text"
placeholder="Enter your name"
value={preferredName}
onChange={(e) => setPreferredName(e.target.value)}
onBlur={handleNameSave}
onKeyDown={(e) => { if (e.key === 'Enter') handleNameSave(); }}
maxLength={100}
/>
<p className="text-sm text-muted-foreground">
Used in the dashboard greeting, e.g. "Good morning, {preferredName || 'Kyle'}."
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="first_name">First Name</Label>
<Input
id="first_name"
type="text"
placeholder="First name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
onBlur={() => handleProfileSave('first_name')}
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('first_name'); }}
maxLength={100}
/>
</div>
<div className="space-y-2">
<Label htmlFor="last_name">Last Name</Label>
<Input
id="last_name"
type="text"
placeholder="Last name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
onBlur={() => handleProfileSave('last_name')}
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('last_name'); }}
maxLength={100}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="profile_email">Email</Label>
<Input
id="profile_email"
type="email"
placeholder="your@email.com"
value={profileEmail}
onChange={(e) => { 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 && (
<p className="text-xs text-red-400">{emailError}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="date_of_birth">Date of Birth</Label>
<DatePicker
variant="input"
id="date_of_birth"
value={dateOfBirth}
onChange={(v) => {
setDateOfBirth(v);
const orig = profileQuery.data?.date_of_birth ?? '';
if (v.trim() !== (orig || '')) {
api.put('/auth/profile', { date_of_birth: v.trim() || null })
.then(() => { queryClient.invalidateQueries({ queryKey: ['profile'] }); toast.success('Profile updated'); })
.catch(() => toast.error('Failed to update profile'));
}
}}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="settings_phone">Phone</Label>
<Input
id="settings_phone"
type="tel"
placeholder="Phone number"
value={settingsPhone}
onChange={(e) => setSettingsPhone(e.target.value)}
onBlur={() => handleSettingsFieldSave('phone', settingsPhone)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('phone', settingsPhone); }}
maxLength={50}
/>
</div>
<div className="space-y-2">
<Label htmlFor="settings_mobile">Mobile</Label>
<Input
id="settings_mobile"
type="tel"
placeholder="Mobile number"
value={settingsMobile}
onChange={(e) => setSettingsMobile(e.target.value)}
onBlur={() => handleSettingsFieldSave('mobile', settingsMobile)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('mobile', settingsMobile); }}
maxLength={50}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="settings_address">Address</Label>
<Input
id="settings_address"
type="text"
placeholder="Your address"
value={settingsAddress}
onChange={(e) => setSettingsAddress(e.target.value)}
onBlur={() => handleSettingsFieldSave('address', settingsAddress)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('address', settingsAddress); }}
maxLength={2000}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="settings_company">Company</Label>
<Input
id="settings_company"
type="text"
placeholder="Company name"
value={settingsCompany}
onChange={(e) => setSettingsCompany(e.target.value)}
onBlur={() => handleSettingsFieldSave('company', settingsCompany)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('company', settingsCompany); }}
maxLength={255}
/>
</div>
<div className="space-y-2">
<Label htmlFor="settings_job_title">Job Title</Label>
<Input
id="settings_job_title"
type="text"
placeholder="Your role"
value={settingsJobTitle}
onChange={(e) => setSettingsJobTitle(e.target.value)}
onBlur={() => handleSettingsFieldSave('job_title', settingsJobTitle)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('job_title', settingsJobTitle); }}
maxLength={255}
/>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,93 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import TotpSetupSection from './TotpSetupSection';
import type { Settings } from '@/types';
interface SecurityTabProps {
settings: Settings | undefined;
updateSettings: (updates: Partial<Settings>) => Promise<Settings>;
isUpdating: boolean;
}
export default function SecurityTab({ settings, updateSettings, isUpdating }: SecurityTabProps) {
const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false);
const [autoLockMinutes, setAutoLockMinutes] = useState<number | string>(settings?.auto_lock_minutes ?? 5);
useEffect(() => {
if (settings) {
setAutoLockEnabled(settings.auto_lock_enabled);
setAutoLockMinutes(settings.auto_lock_minutes ?? 5);
}
}, [settings?.id]);
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 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 (
<div className="space-y-6">
{/* Auto-lock */}
<Card>
<CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Auto-lock</Label>
<p className="text-sm text-muted-foreground">
Automatically lock the screen after idle time
</p>
</div>
<Switch
checked={autoLockEnabled}
onCheckedChange={handleAutoLockToggle}
/>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="auto_lock_minutes" className="shrink-0">Lock after</Label>
<Input
id="auto_lock_minutes"
type="number"
min="1"
max="60"
value={autoLockMinutes}
onChange={(e) => setAutoLockMinutes(e.target.value === '' ? '' : parseInt(e.target.value) || '')}
onBlur={handleAutoLockMinutesSave}
onKeyDown={(e) => { if (e.key === 'Enter') handleAutoLockMinutesSave(); }}
className="w-20"
disabled={!autoLockEnabled || isUpdating}
/>
<span className="text-sm text-muted-foreground shrink-0">minutes</span>
</div>
</CardContent>
</Card>
{/* Password + TOTP */}
<TotpSetupSection bare />
</div>
);
}

View File

@ -1,894 +1,102 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom';
import { toast } from 'sonner';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
Settings, Settings,
User, User,
Palette, Palette,
Cloud,
CalendarDays,
LayoutDashboard,
MapPin,
X,
Search,
Loader2,
Shield, Shield,
Blocks, Blocks,
Ghost, Ghost,
} from 'lucide-react'; } 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 { cn } from '@/lib/utils';
import api from '@/lib/api'; import { useSettings } from '@/hooks/useSettings';
import type { GeoLocation, UserProfile } from '@/types'; import ProfileTab from './ProfileTab';
import { Switch } from '@/components/ui/switch'; import AppearanceTab from './AppearanceTab';
import CopyableField from '@/components/shared/CopyableField'; import SocialTab from './SocialTab';
import TotpSetupSection from './TotpSetupSection'; import SecurityTab from './SecurityTab';
import NtfySettingsSection from './NtfySettingsSection'; import IntegrationsTab from './IntegrationsTab';
const accentColors = [ const tabs = [
{ name: 'cyan', label: 'Cyan', color: '#06b6d4' }, { id: 'profile', label: 'Profile', icon: User },
{ name: 'blue', label: 'Blue', color: '#3b82f6' }, { id: 'appearance', label: 'Appearance', icon: Palette },
{ name: 'purple', label: 'Purple', color: '#8b5cf6' }, { id: 'social', label: 'Social', icon: Ghost },
{ name: 'orange', label: 'Orange', color: '#f97316' }, { id: 'security', label: 'Security', icon: Shield },
{ name: 'green', label: 'Green', color: '#22c55e' }, { id: 'integrations', label: 'Integrations', icon: Blocks },
{ name: 'red', label: 'Red', color: '#ef4444' }, ] as const;
{ name: 'pink', label: 'Pink', color: '#ec4899' },
{ name: 'yellow', label: 'Yellow', color: '#eab308' }, type TabId = (typeof tabs)[number]['id'];
];
export default function SettingsPage() { export default function SettingsPage() {
const queryClient = useQueryClient(); const [searchParams, setSearchParams] = useSearchParams();
const { settings, updateSettings, isUpdating } = useSettings(); const { settings, updateSettings, isUpdating } = useSettings();
const [selectedColor, setSelectedColor] = useState(settings?.accent_color || 'cyan'); const rawTab = searchParams.get('tab');
const [upcomingDays, setUpcomingDays] = useState(settings?.upcoming_days || 7); const activeTab: TabId = tabs.some((t) => t.id === rawTab) ? (rawTab as TabId) : 'profile';
const [preferredName, setPreferredName] = useState(settings?.preferred_name ?? '');
const [locationQuery, setLocationQuery] = useState('');
const [locationResults, setLocationResults] = useState<GeoLocation[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const [firstDayOfWeek, setFirstDayOfWeek] = useState(settings?.first_day_of_week ?? 0);
const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false);
const [autoLockMinutes, setAutoLockMinutes] = useState<number | string>(settings?.auto_lock_minutes ?? 5);
// Profile extension fields (stored on Settings model) const handleTabChange = (tab: TabId) => {
const [settingsPhone, setSettingsPhone] = useState(settings?.phone ?? ''); setSearchParams({ tab }, { replace: true });
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 [shareFirstName, setShareFirstName] = useState(settings?.share_first_name ?? false);
const [shareLastName, setShareLastName] = useState(settings?.share_last_name ?? 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<UserProfile>('/auth/profile');
return data;
},
});
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [profileEmail, setProfileEmail] = useState('');
const [dateOfBirth, setDateOfBirth] = useState('');
const [emailError, setEmailError] = useState<string | null>(null);
const [umbralName, setUmbralName] = useState('');
const [umbralNameError, setUmbralNameError] = useState<string | null>(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 ?? '');
setUmbralName(profileQuery.data.umbral_name ?? '');
}
}, [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);
setShareFirstName(settings.share_first_name);
setShareLastName(settings.share_last_name);
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<GeoLocation[]>('/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' | 'umbral_name') => {
const values: Record<string, string> = { first_name: firstName, last_name: lastName, email: profileEmail, date_of_birth: dateOfBirth, umbral_name: umbralName };
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);
// Client-side umbral name validation
if (field === 'umbral_name') {
if (current.includes(' ')) {
setUmbralNameError('Must be a single word with no spaces');
return;
}
if (!current || !/^[a-zA-Z0-9_-]{3,50}$/.test(current)) {
setUmbralNameError('3-50 characters: letters, numbers, hyphens, underscores');
return;
}
setUmbralNameError(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 if (field === 'umbral_name' && detail) {
setUmbralNameError(typeof detail === 'string' ? detail : 'Failed to update umbral name');
} 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 ( return (
<div className="flex flex-col h-full animate-fade-in"> <div className="flex flex-col h-full animate-fade-in">
{/* Page header — matches Stage 4-5 pages */} {/* Header with tab navigation — mirrors AdminPortal pattern */}
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-3 shrink-0"> <div className="shrink-0 border-b bg-card overflow-hidden">
<Settings className="h-5 w-5 text-accent" aria-hidden="true" /> <div className="px-3 md:px-6 h-14 md:h-16 flex items-center gap-2 md:gap-4">
<h1 className="text-xl font-semibold font-heading">Settings</h1> <div className="flex items-center gap-2 shrink-0 md:mr-6">
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-5xl mx-auto">
<div className="grid gap-6 lg:grid-cols-2">
{/* ── Left column: Profile, Appearance, Calendar, Dashboard, Weather ── */}
<div className="space-y-6">
{/* Profile */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-accent/10"> <div className="p-1.5 rounded-md bg-accent/10">
<User className="h-4 w-4 text-accent" aria-hidden="true" /> <Settings className="h-5 w-5 text-accent" aria-hidden="true" />
</div> </div>
<div> <h1 className="font-heading text-base md:text-2xl font-bold tracking-tight">
<CardTitle>Profile</CardTitle> Settings
<CardDescription>Your profile and display preferences</CardDescription> </h1>
</div> </div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="preferred_name">Preferred Name</Label>
<Input
id="preferred_name"
type="text"
placeholder="Enter your name"
value={preferredName}
onChange={(e) => setPreferredName(e.target.value)}
onBlur={handleNameSave}
onKeyDown={(e) => { if (e.key === 'Enter') handleNameSave(); }}
maxLength={100}
/>
<p className="text-sm text-muted-foreground">
Used in the dashboard greeting, e.g. "Good morning, {preferredName || 'Kyle'}."
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="first_name">First Name</Label>
<Input
id="first_name"
type="text"
placeholder="First name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
onBlur={() => handleProfileSave('first_name')}
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('first_name'); }}
maxLength={100}
/>
</div>
<div className="space-y-2">
<Label htmlFor="last_name">Last Name</Label>
<Input
id="last_name"
type="text"
placeholder="Last name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
onBlur={() => handleProfileSave('last_name')}
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('last_name'); }}
maxLength={100}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="profile_email">Email</Label>
<Input
id="profile_email"
type="email"
placeholder="your@email.com"
value={profileEmail}
onChange={(e) => { 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 && (
<p className="text-xs text-red-400">{emailError}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="date_of_birth">Date of Birth</Label>
<DatePicker
variant="input"
id="date_of_birth"
value={dateOfBirth}
onChange={(v) => {
setDateOfBirth(v);
const orig = profileQuery.data?.date_of_birth ?? '';
if (v.trim() !== (orig || '')) {
api.put('/auth/profile', { date_of_birth: v.trim() || null })
.then(() => { queryClient.invalidateQueries({ queryKey: ['profile'] }); toast.success('Profile updated'); })
.catch(() => toast.error('Failed to update profile'));
}
}}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="settings_phone">Phone</Label>
<Input
id="settings_phone"
type="tel"
placeholder="Phone number"
value={settingsPhone}
onChange={(e) => setSettingsPhone(e.target.value)}
onBlur={() => handleSettingsFieldSave('phone', settingsPhone)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('phone', settingsPhone); }}
maxLength={50}
/>
</div>
<div className="space-y-2">
<Label htmlFor="settings_mobile">Mobile</Label>
<Input
id="settings_mobile"
type="tel"
placeholder="Mobile number"
value={settingsMobile}
onChange={(e) => setSettingsMobile(e.target.value)}
onBlur={() => handleSettingsFieldSave('mobile', settingsMobile)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('mobile', settingsMobile); }}
maxLength={50}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="settings_address">Address</Label>
<Input
id="settings_address"
type="text"
placeholder="Your address"
value={settingsAddress}
onChange={(e) => setSettingsAddress(e.target.value)}
onBlur={() => handleSettingsFieldSave('address', settingsAddress)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('address', settingsAddress); }}
maxLength={2000}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="settings_company">Company</Label>
<Input
id="settings_company"
type="text"
placeholder="Company name"
value={settingsCompany}
onChange={(e) => setSettingsCompany(e.target.value)}
onBlur={() => handleSettingsFieldSave('company', settingsCompany)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('company', settingsCompany); }}
maxLength={255}
/>
</div>
<div className="space-y-2">
<Label htmlFor="settings_job_title">Job Title</Label>
<Input
id="settings_job_title"
type="text"
placeholder="Your role"
value={settingsJobTitle}
onChange={(e) => setSettingsJobTitle(e.target.value)}
onBlur={() => handleSettingsFieldSave('job_title', settingsJobTitle)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('job_title', settingsJobTitle); }}
maxLength={255}
/>
</div>
</div>
</CardContent>
</Card>
{/* Appearance */} <nav role="tablist" className="flex items-center justify-evenly md:justify-start flex-1 md:flex-none md:gap-1 h-full min-w-0 overflow-hidden">
<Card> {tabs.map(({ id, label, icon: Icon }) => {
<CardHeader> const isActive = activeTab === id;
<div className="flex items-center gap-3"> return (
<div className="p-1.5 rounded-md bg-purple-500/10">
<Palette className="h-4 w-4 text-purple-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Appearance</CardTitle>
<CardDescription>Customize the look and feel of your application</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div>
<Label>Accent Color</Label>
<div className="grid grid-cols-4 gap-3 mt-3">
{accentColors.map((color) => (
<button <button
key={color.name} key={id}
type="button" type="button"
onClick={() => handleColorChange(color.name)} onClick={() => handleTabChange(id)}
aria-pressed={selectedColor === color.name} title={label}
aria-label={label}
aria-selected={isActive}
role="tab"
className={cn( className={cn(
'flex flex-col items-center gap-2 p-3 rounded-lg border transition-all duration-150', 'flex items-center justify-center md:justify-start gap-1.5 px-2.5 md:px-4 h-full text-sm font-medium transition-colors duration-150 border-b-2 -mb-px whitespace-nowrap',
selectedColor === color.name isActive
? 'border-accent/50 bg-accent/5' ? 'text-accent border-accent'
: 'border-border hover:border-border/80 hover:bg-card-elevated' : 'text-muted-foreground hover:text-foreground border-transparent'
)} )}
> >
<div <Icon className="h-4 w-4 shrink-0" />
className="h-8 w-8 rounded-full" <span className="hidden sm:inline">{label}</span>
style={{ backgroundColor: color.color }}
/>
<span className="text-[10px] tracking-wider uppercase text-muted-foreground">
{color.label}
</span>
</button> </button>
))} );
})}
</nav>
</div> </div>
</div> </div>
</CardContent>
</Card>
{/* Calendar */} {/* Tab content */}
<Card> <div className="flex-1 overflow-y-auto p-6">
<CardHeader> <div className="max-w-2xl mx-auto">
<div className="flex items-center gap-3"> {activeTab === 'profile' && (
<div className="p-1.5 rounded-md bg-blue-500/10"> <ProfileTab settings={settings} updateSettings={updateSettings} />
<CalendarDays className="h-4 w-4 text-blue-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Calendar</CardTitle>
<CardDescription>Configure your calendar preferences</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label>First Day of Week</Label>
<div className="flex items-center rounded-md border border-border overflow-hidden w-fit">
<button
type="button"
onClick={() => handleFirstDayChange(0)}
className={cn(
'px-4 py-2 text-sm font-medium transition-colors duration-150',
firstDayOfWeek === 0
? 'text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
)} )}
style={{ {activeTab === 'appearance' && (
backgroundColor: firstDayOfWeek === 0 ? 'hsl(var(--accent-color) / 0.15)' : undefined, <AppearanceTab settings={settings} updateSettings={updateSettings} isUpdating={isUpdating} />
color: firstDayOfWeek === 0 ? 'hsl(var(--accent-color))' : undefined,
}}
>
Sunday
</button>
<button
type="button"
onClick={() => handleFirstDayChange(1)}
className={cn(
'px-4 py-2 text-sm font-medium transition-colors duration-150',
firstDayOfWeek === 1
? 'text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
)} )}
style={{ {activeTab === 'social' && (
backgroundColor: firstDayOfWeek === 1 ? 'hsl(var(--accent-color) / 0.15)' : undefined, <SocialTab settings={settings} updateSettings={updateSettings} />
color: firstDayOfWeek === 1 ? 'hsl(var(--accent-color))' : undefined,
}}
>
Monday
</button>
</div>
<p className="text-sm text-muted-foreground">
Sets which day the calendar week starts on
</p>
</div>
</CardContent>
</Card>
{/* Dashboard */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-teal-500/10">
<LayoutDashboard className="h-4 w-4 text-teal-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Dashboard</CardTitle>
<CardDescription>Configure your dashboard preferences</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="upcoming_days">Upcoming Days Range</Label>
<div className="flex gap-3 items-center">
<Input
id="upcoming_days"
type="number"
min="1"
max="30"
value={upcomingDays}
onChange={(e) => setUpcomingDays(parseInt(e.target.value))}
onBlur={handleUpcomingDaysSave}
onKeyDown={(e) => { if (e.key === 'Enter') handleUpcomingDaysSave(); }}
className="w-24"
disabled={isUpdating}
/>
<span className="text-sm text-muted-foreground">days</span>
</div>
<p className="text-sm text-muted-foreground">
How many days ahead to show in the upcoming items widget
</p>
</div>
</CardContent>
</Card>
{/* Weather */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-amber-500/10">
<Cloud className="h-4 w-4 text-amber-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Weather</CardTitle>
<CardDescription>Configure the weather widget on your dashboard</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label>Location</Label>
{hasLocation ? (
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 py-1.5 text-sm text-foreground">
<MapPin className="h-3.5 w-3.5 text-accent" />
{settings?.weather_city || `${settings?.weather_lat}, ${settings?.weather_lon}`}
</span>
<button
type="button"
onClick={handleLocationClear}
className="inline-flex items-center justify-center rounded-md h-7 w-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Clear location"
aria-label="Clear weather location"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<div ref={searchRef} className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
type="text"
placeholder="Search for a city..."
value={locationQuery}
onChange={(e) => handleLocationInputChange(e.target.value)}
onFocus={() => { if (locationResults.length > 0) setShowDropdown(true); }}
className="pl-9 pr-9"
/>
{isSearching && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground animate-spin" />
)} )}
</div> {activeTab === 'security' && (
{showDropdown && ( <SecurityTab settings={settings} updateSettings={updateSettings} isUpdating={isUpdating} />
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg overflow-hidden">
{locationResults.map((loc, i) => (
<button
key={`${loc.lat}-${loc.lon}-${i}`}
type="button"
onClick={() => handleLocationSelect(loc)}
className="flex items-center gap-2.5 w-full px-3 py-2.5 text-sm text-left hover:bg-accent/10 transition-colors"
>
<MapPin className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span>
<span className="text-foreground font-medium">{loc.name}</span>
{(loc.state || loc.country) && (
<span className="text-muted-foreground">
{loc.state ? `, ${loc.state}` : ''}{loc.country ? `, ${loc.country}` : ''}
</span>
)} )}
</span> {activeTab === 'integrations' && (
</button> <IntegrationsTab settings={settings} updateSettings={updateSettings} />
))}
</div>
)} )}
</div> </div>
)}
<p className="text-sm text-muted-foreground">
Search and select your city for accurate weather data on the dashboard.
</p>
</div>
</CardContent>
</Card>
</div>
{/* ── Right column: Social, Security, Authentication, Integrations ── */}
<div className="space-y-6">
{/* Social */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-violet-500/10">
<Ghost className="h-4 w-4 text-violet-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Social</CardTitle>
<CardDescription>Manage your Umbra identity and connections</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="umbral_name">Umbral Name</Label>
<div className="flex items-center gap-3">
<Input
id="umbral_name"
value={umbralName}
onChange={(e) => { setUmbralName(e.target.value); setUmbralNameError(null); }}
onBlur={() => handleProfileSave('umbral_name')}
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('umbral_name'); }}
maxLength={50}
placeholder="Your discoverable name"
className={umbralNameError ? 'border-red-500/50' : ''}
/>
<CopyableField value={umbralName} label="Umbral name" />
</div>
{umbralNameError ? (
<p className="text-xs text-red-400">{umbralNameError}</p>
) : (
<p className="text-sm text-muted-foreground">
How other Umbra users find you
</p>
)}
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Accept Connections</Label>
<p className="text-sm text-muted-foreground">
Allow other users to find and connect with you
</p>
</div>
<Switch
checked={acceptConnections}
onCheckedChange={(checked) => handleSocialToggle('accept_connections', checked, setAcceptConnections)}
/>
</div>
<div className="border-t border-border pt-4 mt-4">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground mb-3">
Sharing Defaults
</p>
<div className="grid grid-cols-2 gap-3">
{[
{ field: 'share_first_name', label: 'First Name', state: shareFirstName, setter: setShareFirstName },
{ field: 'share_last_name', label: 'Last Name', state: shareLastName, setter: setShareLastName },
{ 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 }) => (
<div key={field} className="flex items-center justify-between">
<Label className="text-sm font-normal">{label}</Label>
<Switch
checked={state}
onCheckedChange={(checked) => handleSocialToggle(field, checked, setter)}
/>
</div>
))}
</div>
</div>
</CardContent>
</Card>
{/* Security (auto-lock) */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-emerald-500/10">
<Shield className="h-4 w-4 text-emerald-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Security</CardTitle>
<CardDescription>Configure screen lock behavior</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Auto-lock</Label>
<p className="text-sm text-muted-foreground">
Automatically lock the screen after idle time
</p>
</div>
<Switch
checked={autoLockEnabled}
onCheckedChange={handleAutoLockToggle}
/>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="auto_lock_minutes" className="shrink-0">Lock after</Label>
<Input
id="auto_lock_minutes"
type="number"
min="1"
max="60"
value={autoLockMinutes}
onChange={(e) => setAutoLockMinutes(e.target.value === '' ? '' : parseInt(e.target.value) || '')}
onBlur={handleAutoLockMinutesSave}
onKeyDown={(e) => { if (e.key === 'Enter') handleAutoLockMinutesSave(); }}
className="w-20"
disabled={!autoLockEnabled || isUpdating}
/>
<span className="text-sm text-muted-foreground shrink-0">minutes</span>
</div>
</CardContent>
</Card>
{/* Authentication (TOTP + password change) */}
<TotpSetupSection />
{/* Integrations */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-orange-500/10">
<Blocks className="h-4 w-4 text-orange-400" aria-hidden="true" />
</div>
<CardTitle>Integrations</CardTitle>
</div>
</CardHeader>
<CardContent>
<NtfySettingsSection settings={settings} updateSettings={updateSettings} />
</CardContent>
</Card>
</div>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,177 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { useQueryClient, useQuery } from '@tanstack/react-query';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import CopyableField from '@/components/shared/CopyableField';
import api from '@/lib/api';
import type { Settings, UserProfile } from '@/types';
interface SocialTabProps {
settings: Settings | undefined;
updateSettings: (updates: Partial<Settings>) => Promise<Settings>;
}
export default function SocialTab({ settings, updateSettings }: SocialTabProps) {
const queryClient = useQueryClient();
const profileQuery = useQuery({
queryKey: ['profile'],
queryFn: async () => {
const { data } = await api.get<UserProfile>('/auth/profile');
return data;
},
});
const [umbralName, setUmbralName] = useState('');
const [umbralNameError, setUmbralNameError] = useState<string | null>(null);
const [acceptConnections, setAcceptConnections] = useState(settings?.accept_connections ?? false);
const [shareFirstName, setShareFirstName] = useState(settings?.share_first_name ?? false);
const [shareLastName, setShareLastName] = useState(settings?.share_last_name ?? 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);
useEffect(() => {
if (profileQuery.data) {
setUmbralName(profileQuery.data.umbral_name ?? '');
}
}, [profileQuery.dataUpdatedAt]);
useEffect(() => {
if (settings) {
setAcceptConnections(settings.accept_connections);
setShareFirstName(settings.share_first_name);
setShareLastName(settings.share_last_name);
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]);
const handleUmbralNameSave = async () => {
const current = umbralName.trim();
const original = profileQuery.data?.umbral_name ?? '';
if (current === (original || '')) return;
if (current.includes(' ')) {
setUmbralNameError('Must be a single word with no spaces');
return;
}
if (!current || !/^[a-zA-Z0-9_-]{3,50}$/.test(current)) {
setUmbralNameError('3-50 characters: letters, numbers, hyphens, underscores');
return;
}
setUmbralNameError(null);
try {
await api.put('/auth/profile', { umbral_name: current });
queryClient.invalidateQueries({ queryKey: ['profile'] });
toast.success('Profile updated');
} catch (err: any) {
const detail = err?.response?.data?.detail;
setUmbralNameError(typeof detail === 'string' ? detail : 'Failed to update umbral name');
}
};
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 sharingToggles = [
{ field: 'share_first_name', label: 'First Name', state: shareFirstName, setter: setShareFirstName },
{ field: 'share_last_name', label: 'Last Name', state: shareLastName, setter: setShareLastName },
{ 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 },
];
return (
<div className="space-y-6">
{/* Umbral Name */}
<Card>
<CardContent className="pt-6 space-y-4">
<div className="space-y-2">
<Label htmlFor="umbral_name">Umbral Name</Label>
<div className="flex items-center gap-3">
<Input
id="umbral_name"
value={umbralName}
onChange={(e) => { setUmbralName(e.target.value); setUmbralNameError(null); }}
onBlur={handleUmbralNameSave}
onKeyDown={(e) => { if (e.key === 'Enter') handleUmbralNameSave(); }}
maxLength={50}
placeholder="Your discoverable name"
className={umbralNameError ? 'border-red-500/50' : ''}
/>
<CopyableField value={umbralName} label="Umbral name" />
</div>
{umbralNameError ? (
<p className="text-xs text-red-400">{umbralNameError}</p>
) : (
<p className="text-sm text-muted-foreground">
How other Umbra users find you
</p>
)}
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Accept Connections</Label>
<p className="text-sm text-muted-foreground">
Allow other users to find and connect with you
</p>
</div>
<Switch
checked={acceptConnections}
onCheckedChange={(checked) => handleSocialToggle('accept_connections', checked, setAcceptConnections)}
/>
</div>
</CardContent>
</Card>
{/* Sharing Defaults */}
<Card>
<CardContent className="pt-6">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground mb-3">
Sharing Defaults
</p>
<div className="grid grid-cols-2 gap-x-3 gap-y-4">
{sharingToggles.map(({ field, label, state, setter }) => (
<div key={field} className="flex items-center justify-between">
<Label className="text-sm font-normal">{label}</Label>
<Switch
checked={state}
onCheckedChange={(checked) => handleSocialToggle(field, checked, setter)}
/>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -26,7 +26,11 @@ import type { TotpSetupResponse } from '@/types';
type TotpSetupState = 'idle' | 'setup' | 'confirm' | 'backup_codes' | 'enabled'; type TotpSetupState = 'idle' | 'setup' | 'confirm' | 'backup_codes' | 'enabled';
export default function TotpSetupSection() { interface TotpSetupSectionProps {
bare?: boolean;
}
export default function TotpSetupSection({ bare = false }: TotpSetupSectionProps) {
// ── Password change state ── // ── Password change state ──
const [passwordForm, setPasswordForm] = useState({ const [passwordForm, setPasswordForm] = useState({
oldPassword: '', oldPassword: '',
@ -205,21 +209,8 @@ export default function TotpSetupSection() {
setDialogCode(''); setDialogCode('');
}; };
return ( const content = (
<> <div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-red-500/10">
<ShieldCheck className="h-4 w-4 text-red-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Authentication</CardTitle>
<CardDescription>Manage your password and two-factor authentication</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Subsection A: Change Password */} {/* Subsection A: Change Password */}
<div className="space-y-4"> <div className="space-y-4">
@ -243,6 +234,7 @@ export default function TotpSetupSection() {
value={passwordForm.newPassword} value={passwordForm.newPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })} onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
autoComplete="new-password" autoComplete="new-password"
minLength={12}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -424,8 +416,35 @@ export default function TotpSetupSection() {
</div> </div>
)} )}
</div> </div>
</div>
);
return (
<>
{bare ? (
<Card>
<CardContent className="pt-6">
{content}
</CardContent> </CardContent>
</Card> </Card>
) : (
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-red-500/10">
<ShieldCheck className="h-4 w-4 text-red-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Authentication</CardTitle>
<CardDescription>Manage your password and two-factor authentication</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{content}
</CardContent>
</Card>
)}
{/* Disable TOTP Dialog */} {/* Disable TOTP Dialog */}
<Dialog open={disableDialogOpen} onOpenChange={closeDialog}> <Dialog open={disableDialogOpen} onOpenChange={closeDialog}>