import React, { useEffect, useState, useCallback } from 'react'; import { toast } from 'sonner'; import { User, Mail, KeyRound, ShieldCheck, Loader2, History, Monitor, Smartphone, ChevronRight, Bell, SendHorizontal, ScanLine, TriangleAlert, Copy, Check, } from 'lucide-react'; import { api } from '@/api'; import { useAuth } from '@/hooks/useAuth'; 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'; function asProfile(data) { return data?.profile || data?.user || data || {}; } function displayNameOf(profile) { return profile.display_name || profile.displayName || profile.name || ''; } function asSettings(data) { return data?.settings || data?.notifications || data || {}; } function formatDateTime(value) { if (!value) return 'Not recorded'; const d = new Date(value); 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, children }) { return (

{title}

{subtitle &&

{subtitle}

}
{children}
); } function FieldRow({ label, value }) { return (

{label}

{value || 'Not set'}

); } function CheckRow({ id, label, checked, onChange, disabled }) { return ( ); } function parseUserAgent(ua) { 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) { 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 }) { const [history, setHistory] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { if (!open) return; if (providedHistory?.length) { setHistory(providedHistory); return; } setLoading(true); api.loginHistory() .then(d => { const rows = d.history ?? []; setHistory(rows); onLoaded?.(rows); }) .catch(err => { setHistory([]); toast.error(err.message || '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 }) { 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 }) { const [historyOpen, setHistoryOpen] = useState(false); const [loginHistory, setLoginHistory] = useState([]); const [historyLoading, setHistoryLoading] = useState(false); useEffect(() => { if (loading) return; setHistoryLoading(true); api.loginHistory() .then(d => setLoginHistory(d.history ?? [])) .catch(err => { setLoginHistory([]); toast.error(err.message || '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 }) { const [displayName, setDisplayName] = useState(displayNameOf(profile)); const [saving, setSaving] = useState(false); useEffect(() => { setDisplayName(displayNameOf(profile)); }, [profile.display_name, profile.displayName, profile.name]); 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(err.message || 'Failed to save profile.'); } finally { setSaving(false); } }; return (
setDisplayName(e.target.value)} placeholder="Display name" />
); } function NotificationPreferences({ settings, onSaved }) { const [form, setForm] = useState(settings); const [saving, setSaving] = useState(false); useEffect(() => setForm(settings), [settings]); const set = (k, v) => setForm(prev => ({ ...prev, [k]: v })); const payload = { email: 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), }; 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; const save = async () => { setSaving(true); try { const data = await api.updateProfileSettings(payload); toast.success('Notification preferences saved.'); onSaved(asSettings(data)); } catch (err) { toast.error(err.message || 'Failed to save notification preferences.'); } finally { setSaving(false); } }; return (
set('email', e.target.value)} 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} />
); } const PUSH_CHANNELS = [ { 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' }, ]; function PushNotifications({ settings, onSaved }) { const [enabled, setEnabled] = useState(!!settings.notify_push_enabled); const [channel, setChannel] = useState(settings.push_channel || 'ntfy'); const [url, setUrl] = useState(settings.push_url || ''); const [token, setToken] = useState(''); // never pre-filled for security const [chatId, setChatId] = useState(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]; const save = async () => { setSaving(true); try { const patch = { notify_push_enabled: enabled, push_channel: channel, push_url: url.trim() || null, push_chat_id: chatId.trim() || null, }; if (token.trim()) patch.push_token = token.trim(); await api.updateProfileSettings(patch); setTokenSet(!!token.trim() || tokenSet); setToken(''); toast.success('Push notification settings saved.'); onSaved?.(); } catch (err) { toast.error(err.message || 'Failed to save push settings.'); } finally { setSaving(false); } }; const test = async () => { setTesting(true); try { await api.testPushNotification(); toast.success('Test notification sent — check your device.'); } catch (err) { toast.error(err.message || 'Test failed. Check your channel settings.'); } finally { setTesting(false); } }; return (
{/* Master toggle — same CheckRow pattern as the email section */} {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)} placeholder={ch.urlHint} autoComplete="off" />
{ch.tokenLabel && (
setToken(e.target.value)} placeholder={tokenSet ? '(leave blank to keep saved token)' : 'Enter token…'} type="password" autoComplete="off" />
)} {ch.chatIdLabel && (
setChatId(e.target.value)} 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.

)} )}
); } 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) => { 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(err.message || 'Failed to change password.'); } finally { setSaving(false); } }; return ( <>
setCurrentPassword(e.target.value)} />
setNewPassword(e.target.value)} />
setConfirmPassword(e.target.value)} />
); } function CopyButton({ text }) { 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'); // idle | setup | confirm | recovery | disable const [setupData, setSetupData] = useState(null); // { secret, qr_data_url } const [code, setCode] = useState(''); const [recoveryCodes, setRecoveryCodes] = useState([]); const [saving, setSaving] = useState(false); const load = useCallback(() => { if (singleUserMode) return; api.totpStatus() .then(d => setEnabled(d.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(); setSetupData(d); setCode(''); setStep('setup'); } catch (err) { toast.error(err.message || 'Failed to generate setup data.'); } finally { setSaving(false); } }; const confirmEnable = async (e) => { e.preventDefault(); setSaving(true); try { const d = await api.totpEnable({ secret: setupData.secret, code }); setRecoveryCodes(d.recovery_codes); setEnabled(true); setStep('recovery'); } catch (err) { toast.error(err.message || 'Invalid code. Try again.'); } finally { setSaving(false); } }; const confirmDisable = async (e) => { e.preventDefault(); setSaving(true); try { await api.totpDisable({ code }); setEnabled(false); setStep('idle'); setCode(''); toast.success('Authenticator app removed.'); } catch (err) { toast.error(err.message || '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 ProfileNav() { const items = [ ['#account', 'Account'], ['#security', 'Security'], ['#notifications', 'Notifications'], ]; 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(err.message || 'Failed to load profile.')) .finally(() => mounted && setLoading(false)); return () => { mounted = false; }; }, []); const handleProfileSaved = (nextProfile) => { setProfile(prev => ({ ...prev, ...nextProfile })); setUser(prev => prev ? { ...prev, ...nextProfile } : prev); refresh(); }; return (

Profile

Manage your account, notification preferences, and password.

User-owned data only
{!loading && }
{!loading && } {!loading && api.profileSettings().then(setSettings).catch(() => {})} />}
); }