import { useEffect, useState, useCallback, type ComponentType, type ReactNode } from 'react'; import { toast } from 'sonner'; import { User, Mail, KeyRound, ShieldCheck, Loader2, History, Monitor, Smartphone, ChevronRight, Bell, SendHorizontal, ScanLine, TriangleAlert, Copy, Check, Lock, } from 'lucide-react'; import { api } from '@/api'; import { errMessage } from '@/lib/utils'; import { useAuth, type User as AuthUser } from '@/hooks/useAuth'; import { useAutoSave } from '@/hooks/useAutoSave'; import { SaveStatus } from '@/components/ui/save-status'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from '@/components/ui/dialog'; type Settings = Record; interface Profile { username?: string; display_name?: string; displayName?: string; name?: string; role?: string; last_password_change_at?: string; password_changed_at?: string; [key: string]: unknown; } interface LoginEntry { id: number | string; success?: boolean; user_agent?: string; browser?: string; os?: string; device_type?: string; logged_in_at?: string; is_current_session?: boolean; ip_address?: string; location_city?: string; location_region?: string; location_country?: string; location_isp?: string; device_fingerprint?: string; } function asProfile(data: unknown): Profile { const d = data as { profile?: Profile; user?: Profile } | Profile | null | undefined; return ((d as { profile?: Profile })?.profile || (d as { user?: Profile })?.user || (d as Profile) || {}) as Profile; } function displayNameOf(profile: Profile): string { return profile.display_name || profile.displayName || profile.name || ''; } export function asSettings(data: unknown): Settings { const d = data as { settings?: Settings; notifications?: Settings } | Settings | null | undefined; return ((d as { settings?: Settings })?.settings || (d as { notifications?: Settings })?.notifications || (d as Settings) || {}) as Settings; } function formatDateTime(value: unknown): string { if (!value) return 'Not recorded'; const d = new Date(value as string | number); if (Number.isNaN(d.getTime())) return String(value); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } function SectionCard({ title, icon: Icon, subtitle, action, children }: { title: ReactNode; icon: ComponentType<{ className?: string }>; subtitle?: ReactNode; action?: ReactNode; children: ReactNode; }) { return (

{title}

{subtitle &&

{subtitle}

}
{action &&
{action}
}
{children}
); } function FieldRow({ label, value }: { label: ReactNode; value?: ReactNode }) { return (

{label}

{value || 'Not set'}

); } function CheckRow({ id, label, checked, onChange, disabled }: { id: string; label: ReactNode; checked?: boolean; onChange?: (v: boolean) => void; disabled?: boolean; }) { return ( ); } function parseUserAgent(ua?: string | null): { browser: string; os: string; mobile: boolean } { if (!ua) return { browser: 'Unknown', os: 'Unknown', mobile: false }; const s = ua; const mobile = /iPhone|iPad|Android|Mobile/i.test(s); const browser = /Edg\//i.test(s) ? 'Edge' : /OPR\//i.test(s) ? 'Opera' : /Chrome\//i.test(s) ? 'Chrome' : /Firefox\//i.test(s) ? 'Firefox' : /Safari\//i.test(s) ? 'Safari' : /curl\//i.test(s) ? 'curl' : 'Unknown'; const os = /iPhone|iPad/i.test(s) ? 'iOS' : /Android/i.test(s) ? 'Android' : /Windows/i.test(s) ? 'Windows' : /Macintosh/i.test(s) ? 'macOS' : /Linux/i.test(s) ? 'Linux' : 'Unknown'; return { browser, os, mobile }; } function deviceLabel(type?: string): string { if (type === 'mobile') return 'Mobile'; if (type === 'tablet') return 'Tablet'; if (type === 'api') return 'API client'; return 'Desktop'; } function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded }: { history?: LoginEntry[]; open: boolean; onClose: () => void; onLoaded?: (rows: LoginEntry[]) => void; }) { const [history, setHistory] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { if (!open) return; if (providedHistory?.length) { setHistory(providedHistory); return; } setLoading(true); api.loginHistory() .then(raw => { const rows = (raw as { history?: LoginEntry[] }).history ?? []; setHistory(rows); onLoaded?.(rows); }) .catch(err => { setHistory([]); toast.error(errMessage(err, 'Failed to load login history.')); }) .finally(() => setLoading(false)); }, [open, providedHistory, onLoaded]); return ( { if (!v) onClose(); }}> Login History Your last 10 sign-in events
{loading ? (
Loading…
) : history.length === 0 ? (

No login history recorded.

) : history.map((entry, i) => { const isFailed = entry.success === false; const parsed = parseUserAgent(entry.user_agent); const browser = entry.browser || parsed.browser; const os = entry.os || parsed.os; const deviceType = entry.device_type || (parsed.mobile ? 'mobile' : 'desktop'); const DeviceIcon = deviceType === 'mobile' || deviceType === 'tablet' ? Smartphone : Monitor; const isFirstSuccess = !isFailed && history.slice(0, i).every(e => e.success === false); return (

{formatDateTime(entry.logged_in_at)} {isFailed && ( Failed attempt )} {entry.is_current_session && ( This session )} {!entry.is_current_session && isFirstSuccess && ( Most recent )}

{deviceLabel(deviceType)} · {browser} on {os} {entry.ip_address && ( {entry.ip_address} )} {(entry.location_city || entry.location_country) && ( — {[entry.location_city, entry.location_region, entry.location_country].filter(Boolean).join(', ')} )}

{entry.location_isp && (

{entry.location_isp}

)} {entry.device_fingerprint && (

Device ID {entry.device_fingerprint}

)}
); })}

Showing up to 10 most recent events including failed attempts. Device ID is a short privacy-preserving identifier.

This information is shown only to you and is encrypted at rest. It is not shared with admins.

); } function LoginSummaryCard({ latestLogin, loading, onOpen }: { latestLogin: LoginEntry | null; loading: boolean; onOpen: () => void; }) { const parsed = parseUserAgent(latestLogin?.user_agent); const browser = latestLogin?.browser || parsed.browser; const os = latestLogin?.os || parsed.os; const deviceType = latestLogin?.device_type || (parsed.mobile ? 'mobile' : 'desktop'); const DeviceIcon = deviceType === 'mobile' || deviceType === 'tablet' ? Smartphone : Monitor; return ( ); } function ProfileSummary({ profile, loading }: { profile: Profile; loading: boolean }) { const [historyOpen, setHistoryOpen] = useState(false); const [loginHistory, setLoginHistory] = useState([]); const [historyLoading, setHistoryLoading] = useState(false); useEffect(() => { if (loading) return; setHistoryLoading(true); api.loginHistory() .then(raw => setLoginHistory((raw as { history?: LoginEntry[] }).history ?? [])) .catch(err => { setLoginHistory([]); toast.error(errMessage(err, 'Failed to load login history.')); }) .finally(() => setHistoryLoading(false)); }, [loading]); if (loading) { return (
Loading profile…
); } // Show the most recent SUCCESSFUL login in the summary card (not a failed attempt) const latestLogin = loginHistory.find(l => l.success !== false) || null; return ( <>
setHistoryOpen(true)} />
setHistoryOpen(false)} onLoaded={setLoginHistory} /> ); } function EditProfile({ profile, onSaved }: { profile: Profile; onSaved: (p: Profile) => void }) { const [displayName, setDisplayName] = useState(displayNameOf(profile)); const [saving, setSaving] = useState(false); useEffect(() => { setDisplayName(displayNameOf(profile)); }, [profile]); const save = async () => { setSaving(true); try { const data = await api.updateProfile({ display_name: displayName.trim() || null }); toast.success('Profile saved.'); onSaved(asProfile(data)); } catch (err) { toast.error(errMessage(err, 'Failed to save profile.')); } finally { setSaving(false); } }; return (
setDisplayName(e.target.value)} placeholder="Display name" />
); } // Exported: rendered on the Settings page ("Notifications" section). Lives here // because it shares asSettings/CheckRow/SectionCard with the rest of this file. function buildNotificationPayload(form: Settings) { const payload = { email: String(form.email || form.notification_email || ''), notifications_enabled: !!(form.notifications_enabled ?? form.enabled), notify_3_day: !!(form.notify_3_day ?? form.notify_3d), notify_1_day: !!(form.notify_1_day ?? form.notify_1d), notify_due: !!(form.notify_due ?? form.notify_day_of), notify_overdue: !!(form.notify_overdue ?? form.notify_daily_overdue), notify_amount_change: !!(form.notify_amount_change ?? true), enabled: false, notify_3d: false, notify_1d: false, notify_day_of: false, notify_daily_overdue: false, }; payload.enabled = payload.notifications_enabled; payload.notify_3d = payload.notify_3_day; payload.notify_1d = payload.notify_1_day; payload.notify_day_of = payload.notify_due; payload.notify_daily_overdue = payload.notify_overdue; return payload; } export function NotificationPreferences({ settings }: { settings: Settings }) { const [form, setForm] = useState(settings); // Auto-save: toggles persist almost instantly, the email field debounces so // we never save a half-typed address. Local form stays the source of truth — // no parent refresh that could clobber in-flight edits. const { status, schedule, flush } = useAutoSave( (payload) => api.updateProfileSettings(payload).catch((err) => { toast.error(errMessage(err, 'Failed to save notification preferences.')); throw err; }), ); const set = (k: string, v: unknown, delay?: number) => setForm(prev => { const next = { ...prev, [k]: v }; schedule(buildNotificationPayload(next), delay); return next; }); const payload = buildNotificationPayload(form); return ( } >
set('email', e.target.value, 900)} onBlur={flush} placeholder="you@example.com" />
set('notifications_enabled', v)} /> set('notify_3_day', v)} disabled={!payload.notifications_enabled} /> set('notify_1_day', v)} disabled={!payload.notifications_enabled} /> set('notify_due', v)} disabled={!payload.notifications_enabled} /> set('notify_overdue', v)} disabled={!payload.notifications_enabled} /> set('notify_amount_change', v)} disabled={!payload.notifications_enabled} />
); } interface PushChannel { value: string; label: string; urlLabel: string; urlHint: string; tokenLabel: string | null; chatIdLabel?: string; } const PUSH_CHANNELS: PushChannel[] = [ { value: 'ntfy', label: 'ntfy', urlLabel: 'Topic URL', urlHint: 'https://pulse.scheller.ltd/bills', tokenLabel: 'Access token (optional)' }, { value: 'gotify', label: 'Gotify', urlLabel: 'Server URL', urlHint: 'http://192.168.1.11:8077', tokenLabel: 'App token' }, { value: 'discord', label: 'Discord', urlLabel: 'Webhook URL', urlHint: 'https://discord.com/api/webhooks/…', tokenLabel: null }, { value: 'telegram', label: 'Telegram', urlLabel: 'Server URL / n/a', urlHint: 'Leave blank for api.telegram.org', tokenLabel: 'Bot token', chatIdLabel: 'Chat ID' }, ]; // Exported: rendered on the Settings page ("Notifications" section). export function PushNotifications({ settings }: { settings: Settings; onSaved?: (settings?: Settings) => void }) { const [enabled, setEnabled] = useState(!!settings.notify_push_enabled); const [channel, setChannel] = useState(String(settings.push_channel || 'ntfy')); const [url, setUrl] = useState(String(settings.push_url || '')); const [token, setToken] = useState(''); // never pre-filled for security const [chatId, setChatId] = useState(String(settings.push_chat_id || '')); const [tokenSet, setTokenSet] = useState(!!settings.push_token_set); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); const ch = PUSH_CHANNELS.find(c => c.value === channel) || PUSH_CHANNELS[0]!; // Auto-save. Toggle/channel persist immediately; URL and chat ID debounce. // The token is deliberately NOT auto-saved while typing — a half-typed token // must never overwrite a working one. It saves on blur, when complete. const buildPatch = (over: Partial<{ enabled: boolean; channel: string; url: string; chatId: string }> = {}) => { const s = { enabled, channel, url, chatId, ...over }; return { notify_push_enabled: s.enabled, push_channel: s.channel, push_url: (s.url || '').trim() || null, push_chat_id: (s.chatId || '').trim() || null, }; }; const { status, schedule, flush } = useAutoSave( (patch) => api.updateProfileSettings(patch).catch((err) => { toast.error(errMessage(err, 'Failed to save push settings.')); throw err; }), ); const saveToken = async () => { const t = token.trim(); if (!t) return; setSaving(true); try { await api.updateProfileSettings({ ...buildPatch(), push_token: t }); setTokenSet(true); setToken(''); toast.success('Token saved.'); } catch (err) { toast.error(errMessage(err, 'Failed to save token.')); } finally { setSaving(false); } }; const test = async () => { setTesting(true); try { await api.testPushNotification(); toast.success('Test notification sent — check your device.'); } catch (err) { toast.error(errMessage(err, 'Test failed. Check your channel settings.')); } finally { setTesting(false); } }; return ( } >
{/* Master toggle — same CheckRow pattern as the email section */} { setEnabled(v); schedule(buildPatch({ enabled: v }), 150); }} /> {enabled && (

Sent at 6 AM alongside email reminders. Use the test button below to verify immediately.

)} {enabled && ( <> {/* Channel picker */}
{PUSH_CHANNELS.map(c => ( ))}
{/* Channel-specific inputs */}
{ setUrl(e.target.value); schedule(buildPatch({ url: e.target.value }), 900); }} onBlur={flush} placeholder={ch.urlHint} autoComplete="off" />
{ch.tokenLabel && (
setToken(e.target.value)} onBlur={saveToken} placeholder={tokenSet ? '(leave blank to keep saved token)' : 'Enter token — saves when you click away'} type="password" autoComplete="off" />
)} {ch.chatIdLabel && (
{ setChatId(e.target.value); schedule(buildPatch({ chatId: e.target.value }), 900); }} onBlur={flush} placeholder="e.g. 123456789" autoComplete="off" />

Send /start to your bot, then visit{' '} api.telegram.org/bot{''}/getUpdates {' '}to find your chat ID.

)}
{/* Channel hints */} {channel === 'ntfy' && (

Your ntfy server is running at{' '} pulse.scheller.ltd. Set the topic URL to{' '} https://pulse.scheller.ltd/your-topic.

)} {channel === 'gotify' && (

Your Gotify server is at{' '} notify.originalsinners.org. Create an app in Gotify and paste the app token above.

)} )}

Settings save as you change them.

); } function ChangePassword() { const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [saving, setSaving] = useState(false); const reset = () => { setCurrentPassword(''); setNewPassword(''); setConfirmPassword(''); }; const submit = async (e: React.FormEvent) => { e.preventDefault(); if (!currentPassword || !newPassword || !confirmPassword) { toast.error('All password fields are required.'); return; } if (newPassword !== confirmPassword) { toast.error('New passwords do not match.'); return; } setSaving(true); try { await api.changeProfilePassword({ current_password: currentPassword, new_password: newPassword, confirm_new_password: confirmPassword, }); reset(); toast.success('Password changed.'); } catch (err) { toast.error(errMessage(err, 'Failed to change password.')); } finally { setSaving(false); } }; return ( <>
setCurrentPassword(e.target.value)} />
setNewPassword(e.target.value)} />
setConfirmPassword(e.target.value)} />
); } function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); const copy = () => { navigator.clipboard.writeText(text).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); }; return ( ); } function TotpSection() { const { singleUserMode } = useAuth(); const [enabled, setEnabled] = useState(null); // null = loading const [step, setStep] = useState<'idle' | 'setup' | 'confirm' | 'recovery' | 'disable'>('idle'); const [setupData, setSetupData] = useState<{ secret?: string; qr_data_url?: string } | null>(null); const [code, setCode] = useState(''); const [recoveryCodes, setRecoveryCodes] = useState([]); const [saving, setSaving] = useState(false); const load = useCallback(() => { if (singleUserMode) return; api.totpStatus() .then(raw => setEnabled(!!(raw as { enabled?: boolean }).enabled)) .catch(() => setEnabled(false)); }, [singleUserMode]); useEffect(() => { load(); }, [load]); if (singleUserMode) return null; if (enabled === null) return null; const startSetup = async () => { setSaving(true); try { const d = await api.totpSetup() as { secret?: string; qr_data_url?: string }; setSetupData(d); setCode(''); setStep('setup'); } catch (err) { toast.error(errMessage(err, 'Failed to generate setup data.')); } finally { setSaving(false); } }; const confirmEnable = async (e: React.FormEvent) => { e.preventDefault(); if (!setupData) return; setSaving(true); try { const d = await api.totpEnable({ secret: setupData.secret, code }) as { recovery_codes?: string[] }; setRecoveryCodes(d.recovery_codes ?? []); setEnabled(true); setStep('recovery'); } catch (err) { toast.error(errMessage(err, 'Invalid code. Try again.')); } finally { setSaving(false); } }; const confirmDisable = async (e: React.FormEvent) => { e.preventDefault(); setSaving(true); try { await api.totpDisable({ code }); setEnabled(false); setStep('idle'); setCode(''); toast.success('Authenticator app removed.'); } catch (err) { toast.error(errMessage(err, 'Invalid code.')); } finally { setSaving(false); } }; return (
{/* Idle — enabled status */} {step === 'idle' && (
{enabled ? 'Authenticator app is active' : 'Not configured'}
{enabled ? : }
)} {/* Setup — show QR code */} {step === 'setup' && setupData && (

Scan the QR code with Google Authenticator, Authy, 1Password, Bitwarden, or any TOTP app. Then enter the 6-digit code to confirm.

TOTP QR code

Can't scan? Enter this key manually:

{setupData.secret}
setCode(e.target.value)} placeholder="000 000" autoComplete="one-time-code" maxLength={7} className="text-center tracking-widest font-mono text-lg max-w-[140px]" autoFocus required />
)} {/* Recovery codes — shown once after enabling */} {step === 'recovery' && (

Save these recovery codes somewhere safe. Each code works once. If you lose your phone, use one of these to sign in.

{recoveryCodes.map(c => (
{c}
))}
)} {/* Disable — requires TOTP code */} {step === 'disable' && (

Enter the current code from your authenticator app to remove 2FA.

setCode(e.target.value)} placeholder="000 000" autoComplete="one-time-code" maxLength={7} className="text-center tracking-widest font-mono text-lg max-w-[140px]" autoFocus required />
)}
); } function PrivacySettings({ settings, onSaved }: { settings: Settings; onSaved: (settings: Settings) => void }) { const [geoEnabled, setGeoEnabled] = useState(!!settings.geolocation_enabled); const [saving, setSaving] = useState(false); useEffect(() => { setGeoEnabled(!!settings.geolocation_enabled); }, [settings.geolocation_enabled]); const save = async () => { setSaving(true); try { await api.updateProfileSettings({ geolocation_enabled: geoEnabled }); toast.success('Privacy settings saved.'); onSaved({ ...settings, geolocation_enabled: geoEnabled }); } catch (err) { toast.error(errMessage(err, 'Failed to save privacy settings.')); } finally { setSaving(false); } }; const changed = geoEnabled !== !!settings.geolocation_enabled; return (

When on, your login IP is resolved to a city/region via{' '} ip-api.com over plain HTTP. Location data is encrypted at rest and visible only to you. Turn off to keep all login data on-device.

); } function ProfileNav() { const items: [string, string][] = [ ['#account', 'Account'], ['#security', 'Security'], ['#privacy', 'Privacy'], ]; return (
{items.map(([href, label]) => ( {label} ))}
); } export default function ProfilePage() { const { setUser, refresh } = useAuth(); const [profile, setProfile] = useState({}); const [settings, setSettings] = useState({}); const [loading, setLoading] = useState(true); useEffect(() => { let mounted = true; Promise.all([ api.profile(), api.profileSettings(), ]) .then(([profileData, settingsData]) => { if (!mounted) return; setProfile(asProfile(profileData)); setSettings(asSettings(settingsData)); }) .catch(err => toast.error(errMessage(err, 'Failed to load profile.'))) .finally(() => { if (mounted) setLoading(false); }); return () => { mounted = false; }; }, []); const handleProfileSaved = (nextProfile: Profile) => { setProfile(prev => ({ ...prev, ...nextProfile })); setUser(prev => prev ? { ...prev, ...nextProfile } as AuthUser : prev); refresh(); }; return (

Profile

Manage your account, security, and privacy. Notification preferences live in Settings.

User-owned data only
{!loading && }
{/* Notification preferences moved to Settings → Notifications */}
{!loading && }
); }