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, LogOut, } 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'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-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); const [confirmSignOut, setConfirmSignOut] = useState(false); const [signingOut, setSigningOut] = useState(false); const fetchHistory = useCallback(() => { setLoading(true); return 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)); }, [onLoaded]); useEffect(() => { if (!open) return; if (providedHistory?.length) { setHistory(providedHistory); return; } fetchHistory(); }, [open, providedHistory, fetchHistory]); async function handleSignOutOthers() { setSigningOut(true); try { const res = await api.logoutOthers(); const n = res?.count ?? 0; toast.success( n > 0 ? `Signed out of ${n} other device${n === 1 ? '' : 's'}.` : 'No other devices were signed in.', ); setConfirmSignOut(false); await fetchHistory(); // collapse the list to just this device } catch (err) { toast.error(errMessage(err, 'Failed to sign out other devices.')); } finally { setSigningOut(false); } } 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.

Sign out of other devices? This ends every other active session — anyone signed in on another browser or device will be logged out. This device stays signed in. Cancel { e.preventDefault(); handleSignOutOthers(); }} disabled={signingOut} > {signingOut ? 'Signing out…' : 'Sign out others'}
); } 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 && }
); }