2026-06-04 04:10:14 -05:00
import React , { useEffect , useState , useCallback } from 'react' ;
2026-05-03 19:51:57 -05:00
import { toast } from 'sonner' ;
import {
2026-05-15 22:45:38 -05:00
User , Mail , KeyRound , ShieldCheck , Loader2 , History , Monitor , Smartphone , ChevronRight ,
2026-06-07 21:18:02 -05:00
Bell , SendHorizontal , ScanLine , TriangleAlert , Copy , Check , Lock ,
2026-05-03 19:51:57 -05:00
} from 'lucide-react' ;
import { api } from '@/api' ;
import { useAuth } from '@/hooks/useAuth' ;
2026-06-12 02:08:42 -05:00
import { useAutoSave } from '@/hooks/useAutoSave' ;
import { SaveStatus } from '@/components/ui/save-status' ;
2026-05-03 19:51:57 -05:00
import { Button } from '@/components/ui/button' ;
import { Input } from '@/components/ui/input' ;
import { Switch } from '@/components/ui/switch' ;
2026-05-15 01:36:56 -05:00
import {
Dialog , DialogContent , DialogHeader , DialogTitle , DialogDescription ,
} from '@/components/ui/dialog' ;
2026-05-03 19:51:57 -05:00
function asProfile ( data ) {
return data ? . profile || data ? . user || data || { } ;
}
2026-06-07 01:17:49 -05:00
function displayNameOf ( profile ) {
return profile . display _name || profile . displayName || profile . name || '' ;
}
2026-06-12 01:52:48 -05:00
export function asSettings ( data ) {
2026-05-03 19:51:57 -05:00
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' } ) ;
}
2026-06-12 02:08:42 -05:00
function SectionCard ( { title , icon : Icon , subtitle , action , children } ) {
2026-05-03 19:51:57 -05:00
return (
2026-05-04 23:34:24 -05:00
< section className = "overflow-hidden rounded-xl border border-border/70 bg-card/90 shadow-sm" >
2026-05-03 19:51:57 -05:00
< div className = "px-6 py-4 border-b border-border/50 flex items-center gap-3" >
2026-05-04 23:34:24 -05:00
< div className = "h-9 w-9 rounded-lg border border-primary/15 bg-primary/10 flex items-center justify-center shrink-0" >
2026-05-03 19:51:57 -05:00
< Icon className = "h-4 w-4 text-primary" / >
< / div >
< div className = "min-w-0" >
< h2 className = "text-xs font-bold uppercase tracking-widest text-muted-foreground" > { title } < / h2 >
{ subtitle && < p className = "text-sm text-muted-foreground mt-0.5" > { subtitle } < / p > }
< / div >
2026-06-12 02:08:42 -05:00
{ action && < div className = "ml-auto shrink-0" > { action } < / div > }
2026-05-03 19:51:57 -05:00
< / div >
< div > { children } < / div >
< / section >
) ;
}
function FieldRow ( { label , value } ) {
return (
< div className = "rounded-lg border border-border/60 bg-muted/25 px-4 py-3" >
< p className = "text-[10px] font-bold uppercase tracking-widest text-muted-foreground" > { label } < / p >
< p className = "mt-1 text-sm font-medium text-foreground truncate" > { value || 'Not set' } < / p >
< / div >
) ;
}
function CheckRow ( { id , label , checked , onChange , disabled } ) {
return (
< label htmlFor = { id } className = "flex items-center justify-between gap-4 rounded-lg border border-border/60 bg-muted/20 px-4 py-3" >
< span className = "text-sm font-medium" > { label } < / span >
< Switch id = { id } checked = { ! ! checked } onCheckedChange = { onChange } disabled = { disabled } / >
< / label >
) ;
}
2026-05-15 01:36:56 -05:00
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 } ;
}
2026-05-15 22:45:38 -05:00
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 } ) {
2026-05-15 01:36:56 -05:00
const [ history , setHistory ] = useState ( [ ] ) ;
const [ loading , setLoading ] = useState ( false ) ;
useEffect ( ( ) => {
if ( ! open ) return ;
2026-05-15 22:45:38 -05:00
if ( providedHistory ? . length ) {
setHistory ( providedHistory ) ;
return ;
}
2026-05-15 01:36:56 -05:00
setLoading ( true ) ;
api . loginHistory ( )
2026-05-15 22:45:38 -05:00
. then ( d => {
const rows = d . history ? ? [ ] ;
setHistory ( rows ) ;
onLoaded ? . ( rows ) ;
} )
2026-05-29 01:06:20 -05:00
. catch ( err => {
setHistory ( [ ] ) ;
toast . error ( err . message || 'Failed to load login history.' ) ;
} )
2026-05-15 01:36:56 -05:00
. finally ( ( ) => setLoading ( false ) ) ;
2026-05-15 22:45:38 -05:00
} , [ open , providedHistory , onLoaded ] ) ;
2026-05-15 01:36:56 -05:00
return (
< Dialog open = { open } onOpenChange = { v => { if ( ! v ) onClose ( ) ; } } >
< DialogContent className = "sm:max-w-md border-border/60 bg-card/95 backdrop-blur-xl" >
< DialogHeader >
< DialogTitle className = "flex items-center gap-2 text-base" >
< History className = "h-4 w-4 text-primary" / >
Login History
< / DialogTitle >
< DialogDescription className = "sr-only" >
2026-06-04 03:38:32 -05:00
Your last 10 sign - in events
2026-05-15 01:36:56 -05:00
< / DialogDescription >
< / DialogHeader >
< div className = "mt-1 space-y-1" >
{ loading ? (
< div className = "flex items-center justify-center py-8 gap-2 text-muted-foreground text-sm" >
< Loader2 className = "h-4 w-4 animate-spin" / > Loading …
< / div >
) : history . length === 0 ? (
< p className = "text-sm text-muted-foreground text-center py-8" > No login history recorded . < / p >
) : history . map ( ( entry , i ) => {
2026-06-04 03:38:32 -05:00
const isFailed = entry . success === false ;
2026-05-15 22:45:38 -05:00
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 ;
2026-06-04 03:38:32 -05:00
const isFirstSuccess = ! isFailed && history . slice ( 0 , i ) . every ( e => e . success === false ) ;
2026-05-15 01:36:56 -05:00
return (
< div key = { entry . id }
2026-06-04 03:38:32 -05:00
className = { ` flex items-start gap-3 rounded-lg border px-4 py-3 ${
isFailed ? 'border-destructive/30 bg-destructive/5' : 'border-border/50 bg-muted/20'
} ` }>
< DeviceIcon className = { ` h-4 w-4 mt-0.5 shrink-0 ${ isFailed ? 'text-destructive/70' : 'text-muted-foreground' } ` } / >
2026-05-15 01:36:56 -05:00
< div className = "min-w-0" >
2026-06-04 03:38:32 -05:00
< p className = "text-sm font-medium flex items-center flex-wrap gap-1.5" >
2026-05-15 01:36:56 -05:00
{ formatDateTime ( entry . logged _in _at ) }
2026-06-04 03:38:32 -05:00
{ isFailed && (
< span className = "text-[10px] font-semibold uppercase tracking-wide text-destructive bg-destructive/10 px-1.5 py-0.5 rounded" >
Failed attempt
< / span >
) }
{ entry . is _current _session && (
< span className = "text-[10px] font-semibold uppercase tracking-wide text-emerald-500 bg-emerald-500/10 px-1.5 py-0.5 rounded" >
This session
< / span >
) }
{ ! entry . is _current _session && isFirstSuccess && (
< span className = "text-[10px] font-semibold uppercase tracking-wide text-blue-500" >
Most recent
2026-05-15 01:36:56 -05:00
< / span >
) }
< / p >
< p className = "text-xs text-muted-foreground mt-0.5" >
2026-05-15 22:45:38 -05:00
{ deviceLabel ( deviceType ) } · { browser } on { os }
2026-05-15 01:36:56 -05:00
{ entry . ip _address && (
< span className = "ml-2 font-mono" > { entry . ip _address } < / span >
) }
2026-06-04 03:38:32 -05:00
{ ( entry . location _city || entry . location _country ) && (
< span className = "ml-2 text-muted-foreground/80" >
— { [ entry . location _city , entry . location _region , entry . location _country ] . filter ( Boolean ) . join ( ', ' ) }
< / span >
) }
2026-05-15 01:36:56 -05:00
< / p >
2026-06-04 03:38:32 -05:00
{ entry . location _isp && (
< p className = "text-[10px] text-muted-foreground/60 mt-0.5" >
{ entry . location _isp }
< / p >
) }
2026-05-15 22:45:38 -05:00
{ entry . device _fingerprint && (
< p className = "text-[10px] text-muted-foreground/70 mt-1 font-mono" >
Device ID { entry . device _fingerprint }
< / p >
) }
2026-05-15 01:36:56 -05:00
< / div >
< / div >
) ;
} ) }
< / div >
< p className = "text-[10px] text-muted-foreground/60 text-center pt-1" >
2026-06-04 03:38:32 -05:00
Showing up to 10 most recent events including failed attempts . Device ID is a short privacy - preserving identifier .
2026-05-15 22:45:38 -05:00
< / p >
< p className = "text-[10px] text-muted-foreground/60 text-center" >
2026-06-04 03:38:32 -05:00
This information is shown only to you and is encrypted at rest . It is not shared with admins .
2026-05-15 01:36:56 -05:00
< / p >
< / DialogContent >
< / Dialog >
) ;
}
2026-05-15 22:45:38 -05:00
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 (
< button
type = "button"
onClick = { onOpen }
className = "group rounded-lg border border-border/60 bg-muted/25 px-4 py-3 text-left transition-colors hover:border-primary/35 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
< div className = "flex items-start gap-3" >
< div className = "mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-background/60 text-muted-foreground group-hover:text-primary" >
{ loading ? < Loader2 className = "h-4 w-4 animate-spin" / > : < DeviceIcon className = "h-4 w-4" / > }
< / div >
< div className = "min-w-0 flex-1" >
< div className = "flex items-center justify-between gap-3" >
< p className = "text-[10px] font-bold uppercase tracking-widest text-muted-foreground" > Last Login < / p >
< ChevronRight className = "h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-primary" / >
< / div >
{ latestLogin ? (
< >
< p className = "mt-1 truncate text-sm font-semibold text-foreground" >
{ formatDateTime ( latestLogin . logged _in _at ) }
< / p >
< p className = "mt-0.5 truncate text-xs text-muted-foreground" >
{ deviceLabel ( deviceType ) } · { browser } on { os }
< / p >
2026-06-04 03:38:32 -05:00
{ ( latestLogin . location _city || latestLogin . location _country ) && (
< p className = "mt-0.5 truncate text-xs text-muted-foreground/70" >
{ [ latestLogin . location _city , latestLogin . location _region , latestLogin . location _country ] . filter ( Boolean ) . join ( ', ' ) }
< / p >
) }
2026-05-15 22:45:38 -05:00
{ latestLogin . ip _address && (
2026-06-04 03:38:32 -05:00
< p className = "mt-0.5 truncate font-mono text-[11px] text-muted-foreground" >
2026-05-15 22:45:38 -05:00
{ latestLogin . ip _address }
< / p >
) }
< / >
) : (
< >
< p className = "mt-1 text-sm font-semibold text-foreground" >
{ loading ? 'Checking login history...' : 'No login recorded yet' }
< / p >
< p className = "mt-0.5 text-xs text-muted-foreground" >
Open history to view device and IP details .
< / p >
< / >
) }
< / div >
< / div >
< / button >
) ;
}
2026-05-03 19:51:57 -05:00
function ProfileSummary ( { profile , loading } ) {
2026-05-15 01:36:56 -05:00
const [ historyOpen , setHistoryOpen ] = useState ( false ) ;
2026-05-15 22:45:38 -05:00
const [ loginHistory , setLoginHistory ] = useState ( [ ] ) ;
const [ historyLoading , setHistoryLoading ] = useState ( false ) ;
useEffect ( ( ) => {
if ( loading ) return ;
setHistoryLoading ( true ) ;
api . loginHistory ( )
. then ( d => setLoginHistory ( d . history ? ? [ ] ) )
2026-05-29 01:06:20 -05:00
. catch ( err => {
setLoginHistory ( [ ] ) ;
toast . error ( err . message || 'Failed to load login history.' ) ;
} )
2026-05-15 22:45:38 -05:00
. finally ( ( ) => setHistoryLoading ( false ) ) ;
} , [ loading ] ) ;
2026-05-15 01:36:56 -05:00
2026-05-03 19:51:57 -05:00
if ( loading ) {
return (
< SectionCard title = "Profile Summary" icon = { User } >
< div className = "px-6 py-6 text-sm text-muted-foreground" > Loading profile … < / div >
< / SectionCard >
) ;
}
2026-06-04 03:38:32 -05:00
// Show the most recent SUCCESSFUL login in the summary card (not a failed attempt)
const latestLogin = loginHistory . find ( l => l . success !== false ) || null ;
2026-05-15 01:36:56 -05:00
2026-05-03 19:51:57 -05:00
return (
2026-05-15 01:36:56 -05:00
< >
< SectionCard title = "Profile Summary" icon = { User } subtitle = "Your signed-in account details." >
< div className = "px-6 py-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-3" >
< FieldRow label = "Username" value = { profile . username } / >
2026-06-07 01:17:49 -05:00
< FieldRow label = "Display Name" value = { displayNameOf ( profile ) } / >
2026-05-15 01:36:56 -05:00
< FieldRow label = "Role" value = { profile . role } / >
2026-05-15 22:45:38 -05:00
< LoginSummaryCard
latestLogin = { latestLogin }
loading = { historyLoading }
onOpen = { ( ) => setHistoryOpen ( true ) }
/ >
2026-05-15 01:36:56 -05:00
< FieldRow label = "Password Changed" value = { formatDateTime ( profile . last _password _change _at || profile . password _changed _at ) } / >
< / div >
< / SectionCard >
< LoginHistoryModal
2026-05-15 22:45:38 -05:00
history = { loginHistory }
2026-05-15 01:36:56 -05:00
open = { historyOpen }
onClose = { ( ) => setHistoryOpen ( false ) }
2026-05-15 22:45:38 -05:00
onLoaded = { setLoginHistory }
2026-05-15 01:36:56 -05:00
/ >
< / >
2026-05-03 19:51:57 -05:00
) ;
}
function EditProfile ( { profile , onSaved } ) {
2026-06-07 01:17:49 -05:00
const [ displayName , setDisplayName ] = useState ( displayNameOf ( profile ) ) ;
2026-05-03 19:51:57 -05:00
const [ saving , setSaving ] = useState ( false ) ;
useEffect ( ( ) => {
2026-06-07 01:17:49 -05:00
setDisplayName ( displayNameOf ( profile ) ) ;
} , [ profile . display _name , profile . displayName , profile . name ] ) ;
2026-05-03 19:51:57 -05:00
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 (
< SectionCard title = "Edit Profile" icon = { User } subtitle = "Choose how your name appears inside the app." >
< div className = "px-6 py-5 flex flex-col gap-3 sm:flex-row sm:items-end" >
< div className = "space-y-1.5 flex-1 max-w-md" >
< label htmlFor = "display-name" className = "text-xs font-medium text-muted-foreground" > Display name < / label >
< Input id = "display-name" value = { displayName } onChange = { e => setDisplayName ( e . target . value ) } placeholder = "Display name" / >
< / div >
< Button onClick = { save } disabled = { saving } className = "sm:mb-0" >
{ saving ? < > < Loader2 className = "h-4 w-4 mr-2 animate-spin" / > Saving … < / > : 'Save Profile' }
< / Button >
< / div >
< / SectionCard >
) ;
}
2026-06-12 01:52:48 -05:00
// Exported: rendered on the Settings page ("Notifications" section). Lives here
// because it shares asSettings/CheckRow/SectionCard with the rest of this file.
2026-06-12 02:08:42 -05:00
function buildNotificationPayload ( form ) {
2026-05-03 19:51:57 -05:00
const payload = {
2026-05-04 23:34:24 -05:00
email : form . email || form . notification _email || '' ,
2026-05-03 19:51:57 -05:00
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 ) ,
2026-05-30 14:33:55 -05:00
notify _amount _change : ! ! ( form . notify _amount _change ? ? true ) ,
2026-05-03 19:51:57 -05:00
} ;
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 ;
2026-06-12 02:08:42 -05:00
return payload ;
}
2026-05-03 19:51:57 -05:00
2026-06-12 02:08:42 -05:00
export function NotificationPreferences ( { 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 ) => {
2026-05-03 19:51:57 -05:00
toast . error ( err . message || 'Failed to save notification preferences.' ) ;
2026-06-12 02:08:42 -05:00
throw err ;
} ) ,
) ;
const set = ( k , v , delay ) => setForm ( prev => {
const next = { ... prev , [ k ] : v } ;
schedule ( buildNotificationPayload ( next ) , delay ) ;
return next ;
} ) ;
const payload = buildNotificationPayload ( form ) ;
2026-05-03 19:51:57 -05:00
return (
2026-06-12 02:08:42 -05:00
< SectionCard
title = "Notification Preferences"
icon = { Mail }
subtitle = "Manage email reminders for your bills. Changes save automatically."
action = { < SaveStatus status = { status } / > }
>
2026-05-03 19:51:57 -05:00
< div className = "px-6 py-5 space-y-4" >
< div className = "space-y-1.5 max-w-md" >
< label htmlFor = "profile-email" className = "text-xs font-medium text-muted-foreground" > Email < / label >
2026-06-12 02:08:42 -05:00
< Input
id = "profile-email"
type = "email"
value = { payload . email }
onChange = { e => set ( 'email' , e . target . value , 900 ) }
onBlur = { flush }
placeholder = "you@example.com"
/ >
2026-05-03 19:51:57 -05:00
< / div >
< div className = "grid gap-3 sm:grid-cols-2 xl:grid-cols-3" >
< CheckRow id = "n-enabled" label = "Notifications enabled" checked = { payload . notifications _enabled } onChange = { v => set ( 'notifications_enabled' , v ) } / >
< CheckRow id = "n-3" label = "Notify 3 days before" checked = { payload . notify _3 _day } onChange = { v => set ( 'notify_3_day' , v ) } disabled = { ! payload . notifications _enabled } / >
< CheckRow id = "n-1" label = "Notify 1 day before" checked = { payload . notify _1 _day } onChange = { v => set ( 'notify_1_day' , v ) } disabled = { ! payload . notifications _enabled } / >
< CheckRow id = "n-due" label = "Notify due date" checked = { payload . notify _due } onChange = { v => set ( 'notify_due' , v ) } disabled = { ! payload . notifications _enabled } / >
< CheckRow id = "n-overdue" label = "Notify overdue" checked = { payload . notify _overdue } onChange = { v => set ( 'notify_overdue' , v ) } disabled = { ! payload . notifications _enabled } / >
2026-05-30 14:33:55 -05:00
< CheckRow id = "n-amount" label = "Notify on price changes" checked = { payload . notify _amount _change } onChange = { v => set ( 'notify_amount_change' , v ) } disabled = { ! payload . notifications _enabled } / >
2026-05-03 19:51:57 -05:00
< / div >
< / div >
< / SectionCard >
) ;
}
2026-06-03 21:43:54 -05:00
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' } ,
] ;
2026-06-12 01:52:48 -05:00
// Exported: rendered on the Settings page ("Notifications" section).
export function PushNotifications ( { settings , onSaved } ) {
2026-06-03 21:43:54 -05:00
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 ] ;
2026-06-12 02:08:42 -05:00
// 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 = { } ) => {
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 ( err . message || 'Failed to save push settings.' ) ;
throw err ;
} ) ,
) ;
const saveToken = async ( ) => {
const t = token . trim ( ) ;
if ( ! t ) return ;
2026-06-03 21:43:54 -05:00
setSaving ( true ) ;
try {
2026-06-12 02:08:42 -05:00
await api . updateProfileSettings ( { ... buildPatch ( ) , push _token : t } ) ;
setTokenSet ( true ) ;
2026-06-03 21:43:54 -05:00
setToken ( '' ) ;
2026-06-12 02:08:42 -05:00
toast . success ( 'Token saved.' ) ;
2026-06-03 21:43:54 -05:00
} catch ( err ) {
2026-06-12 02:08:42 -05:00
toast . error ( err . message || 'Failed to save token.' ) ;
2026-06-03 21:43:54 -05:00
} 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 (
2026-06-12 02:08:42 -05:00
< SectionCard
title = "Push Notifications"
icon = { Bell }
subtitle = "Get bill reminders on your phone via ntfy, Gotify, Discord, or Telegram. Changes save automatically."
action = { < SaveStatus status = { status } / > }
>
2026-06-03 21:43:54 -05:00
< div className = "px-6 py-5 space-y-5" >
{ /* Master toggle — same CheckRow pattern as the email section */ }
< CheckRow
id = "push-enabled"
label = "Enable push notifications"
checked = { enabled }
2026-06-12 02:08:42 -05:00
onChange = { ( v ) => { setEnabled ( v ) ; schedule ( buildPatch ( { enabled : v } ) , 150 ) ; } }
2026-06-03 21:43:54 -05:00
/ >
{ enabled && (
< p className = "text-xs text-muted-foreground -mt-3 pl-1" >
Sent at 6 AM alongside email reminders . Use the test button below to verify immediately .
< / p >
) }
{ enabled && (
< >
{ /* Channel picker */ }
< div className = "space-y-1.5" >
< label className = "text-xs font-medium text-muted-foreground" > Channel < / label >
< div className = "flex flex-wrap gap-2" >
{ PUSH _CHANNELS . map ( c => (
< button
key = { c . value }
type = "button"
2026-06-12 02:08:42 -05:00
onClick = { ( ) => { setChannel ( c . value ) ; schedule ( buildPatch ( { channel : c . value } ) , 150 ) ; } }
2026-06-03 21:43:54 -05:00
className = { ` rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors ${
channel === c . value
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background hover:bg-muted text-foreground'
} ` }
>
{ c . label }
< / button >
) ) }
< / div >
< / div >
{ /* Channel-specific inputs */ }
< div className = "space-y-3 max-w-md" >
< div className = "space-y-1.5" >
< label className = "text-xs font-medium text-muted-foreground" > { ch . urlLabel } < / label >
< Input
value = { url }
2026-06-12 02:08:42 -05:00
onChange = { e => { setUrl ( e . target . value ) ; schedule ( buildPatch ( { url : e . target . value } ) , 900 ) ; } }
onBlur = { flush }
2026-06-03 21:43:54 -05:00
placeholder = { ch . urlHint }
autoComplete = "off"
/ >
< / div >
{ ch . tokenLabel && (
< div className = "space-y-1.5" >
< label className = "text-xs font-medium text-muted-foreground" >
{ ch . tokenLabel }
{ tokenSet && ! token && (
< span className = "ml-2 text-emerald-600 dark:text-emerald-400" > ✓ saved < / span >
) }
< / label >
< Input
value = { token }
onChange = { e => setToken ( e . target . value ) }
2026-06-12 02:08:42 -05:00
onBlur = { saveToken }
placeholder = { tokenSet ? '(leave blank to keep saved token)' : 'Enter token — saves when you click away' }
2026-06-03 21:43:54 -05:00
type = "password"
autoComplete = "off"
/ >
< / div >
) }
{ ch . chatIdLabel && (
< div className = "space-y-1.5" >
< label className = "text-xs font-medium text-muted-foreground" > { ch . chatIdLabel } < / label >
< Input
value = { chatId }
2026-06-12 02:08:42 -05:00
onChange = { e => { setChatId ( e . target . value ) ; schedule ( buildPatch ( { chatId : e . target . value } ) , 900 ) ; } }
onBlur = { flush }
2026-06-03 21:43:54 -05:00
placeholder = "e.g. 123456789"
autoComplete = "off"
/ >
< p className = "text-[11px] text-muted-foreground" >
Send / start to your bot , then visit { ' ' }
< code className = "rounded bg-muted px-1 py-0.5" > api . telegram . org / bot { '<token>' } / getUpdates < / code >
{ ' ' } to find your chat ID .
< / p >
< / div >
) }
< / div >
{ /* Channel hints */ }
{ channel === 'ntfy' && (
< p className = "text-[11px] text-muted-foreground" >
Your ntfy server is running at { ' ' }
< code className = "rounded bg-muted px-1 py-0.5" > pulse . scheller . ltd < / code > .
Set the topic URL to { ' ' }
< code className = "rounded bg-muted px-1 py-0.5" > https : //pulse.scheller.ltd/your-topic</code>.
< / p >
) }
{ channel === 'gotify' && (
< p className = "text-[11px] text-muted-foreground" >
Your Gotify server is at { ' ' }
< code className = "rounded bg-muted px-1 py-0.5" > notify . originalsinners . org < / code > .
Create an app in Gotify and paste the app token above .
< / p >
) }
< / >
) }
< / div >
2026-06-12 02:08:42 -05:00
< div className = "px-6 py-4 border-t border-border/50 flex items-center gap-3" >
2026-06-03 21:43:54 -05:00
< Button
type = "button"
variant = "outline"
size = "sm"
onClick = { test }
2026-06-12 02:08:42 -05:00
disabled = { testing || saving || ! enabled || ! url . trim ( ) }
2026-06-03 21:43:54 -05:00
className = "gap-1.5"
>
{ testing
? < > < Loader2 className = "h-3.5 w-3.5 animate-spin" / > Sending … < / >
: < > < SendHorizontal className = "h-3.5 w-3.5" / > Send test < / > }
< / Button >
2026-06-12 02:08:42 -05:00
< p className = "text-[11px] text-muted-foreground" > Settings save as you change them . < / p >
2026-06-03 21:43:54 -05:00
< / div >
< / SectionCard >
) ;
}
2026-05-03 19:51:57 -05:00
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 (
2026-06-04 04:10:14 -05:00
< >
< TotpSection / >
2026-05-03 19:51:57 -05:00
< SectionCard title = "Change Password" icon = { KeyRound } subtitle = "Update your password without exposing it in logs or page state beyond this form." >
2026-05-04 23:34:24 -05:00
< form onSubmit = { submit } className = "px-6 py-5 grid gap-4 lg:grid-cols-3" >
2026-05-03 19:51:57 -05:00
< div className = "space-y-1.5" >
< label htmlFor = "current-password" className = "text-xs font-medium text-muted-foreground" > Current password < / label >
< Input id = "current-password" type = "password" autoComplete = "current-password" value = { currentPassword } onChange = { e => setCurrentPassword ( e . target . value ) } / >
< / div >
< div className = "space-y-1.5" >
< label htmlFor = "new-password" className = "text-xs font-medium text-muted-foreground" > New password < / label >
< Input id = "new-password" type = "password" autoComplete = "new-password" value = { newPassword } onChange = { e => setNewPassword ( e . target . value ) } / >
< / div >
< div className = "space-y-1.5" >
< label htmlFor = "confirm-password" className = "text-xs font-medium text-muted-foreground" > Confirm new password < / label >
< Input id = "confirm-password" type = "password" autoComplete = "new-password" value = { confirmPassword } onChange = { e => setConfirmPassword ( e . target . value ) } / >
< / div >
2026-05-04 23:34:24 -05:00
< Button type = "submit" disabled = { saving } className = "lg:col-start-3" >
2026-05-03 19:51:57 -05:00
{ saving ? < > < Loader2 className = "h-4 w-4 mr-2 animate-spin" / > Saving … < / > : 'Change Password' }
< / Button >
< / form >
< / SectionCard >
2026-06-04 04:10:14 -05:00
< / >
) ;
}
function CopyButton ( { text } ) {
const [ copied , setCopied ] = useState ( false ) ;
const copy = ( ) => {
navigator . clipboard . writeText ( text ) . then ( ( ) => {
setCopied ( true ) ;
setTimeout ( ( ) => setCopied ( false ) , 2000 ) ;
} ) ;
} ;
return (
< button type = "button" onClick = { copy } className = "ml-1.5 inline-flex items-center text-muted-foreground hover:text-foreground transition-colors" >
{ copied ? < Check className = "h-3.5 w-3.5 text-emerald-500" / > : < Copy className = "h-3.5 w-3.5" / > }
< / button >
) ;
}
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 (
< SectionCard title = "Two-Factor Authentication" icon = { ScanLine } subtitle = "Require an authenticator app code on every sign-in." >
< div className = "px-6 py-5 space-y-5" >
{ /* Idle — enabled status */ }
{ step === 'idle' && (
< div className = "flex items-center justify-between gap-4" >
< div className = "flex items-center gap-3" >
< div className = { ` h-2.5 w-2.5 rounded-full ${ enabled ? 'bg-emerald-500' : 'bg-muted-foreground/40' } ` } / >
< span className = "text-sm" > { enabled ? 'Authenticator app is active' : 'Not configured' } < / span >
< / div >
{ enabled
? < Button variant = "outline" size = "sm" onClick = { ( ) => { setCode ( '' ) ; setStep ( 'disable' ) ; } } > Remove < / Button >
: < Button size = "sm" onClick = { startSetup } disabled = { saving } > { saving ? 'Loading…' : 'Set up' } < / Button >
}
< / div >
) }
{ /* Setup — show QR code */ }
{ step === 'setup' && setupData && (
< div className = "space-y-5" >
< p className = "text-sm text-muted-foreground" >
Scan the QR code with Google Authenticator , Authy , 1 Password , Bitwarden , or any TOTP app . Then enter the 6 - digit code to confirm .
< / p >
< div className = "flex flex-col sm:flex-row gap-6 items-start" >
< img src = { setupData . qr _data _url } alt = "TOTP QR code" className = "rounded-lg border border-border/60 w-40 h-40 shrink-0" / >
< div className = "space-y-3 min-w-0" >
< p className = "text-xs text-muted-foreground" > Can ' t scan ? Enter this key manually : < / p >
< div className = "flex items-center gap-1 font-mono text-sm bg-muted/30 border border-border/60 rounded px-3 py-2 break-all" >
{ setupData . secret }
< CopyButton text = { setupData . secret } / >
< / div >
< form onSubmit = { confirmEnable } className = "space-y-3 pt-1" >
< Input
value = { code }
onChange = { e => 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
/ >
< div className = "flex gap-2" >
< Button type = "submit" size = "sm" disabled = { saving || ! code . trim ( ) } > { saving ? 'Verifying…' : 'Confirm & Enable' } < / Button >
< Button type = "button" variant = "ghost" size = "sm" onClick = { ( ) => { setStep ( 'idle' ) ; setSetupData ( null ) ; } } > Cancel < / Button >
< / div >
< / form >
< / div >
< / div >
< / div >
) }
{ /* Recovery codes — shown once after enabling */ }
{ step === 'recovery' && (
< div className = "space-y-4" >
< div className = "flex items-start gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3" >
< TriangleAlert className = "h-4 w-4 text-amber-500 shrink-0 mt-0.5" / >
< p className = "text-sm text-amber-600 dark:text-amber-400" >
Save these recovery codes somewhere safe . Each code works once . If you lose your phone , use one of these to sign in .
< / p >
< / div >
< div className = "grid grid-cols-2 sm:grid-cols-4 gap-2" >
{ recoveryCodes . map ( c => (
< div key = { c } className = "flex items-center justify-between gap-1 font-mono text-xs bg-muted/30 border border-border/60 rounded px-2.5 py-1.5" >
{ c } < CopyButton text = { c } / >
< / div >
) ) }
< / div >
< Button size = "sm" onClick = { ( ) => { setStep ( 'idle' ) ; setRecoveryCodes ( [ ] ) ; } } > Done — I ' ve saved these < / Button >
< / div >
) }
{ /* Disable — requires TOTP code */ }
{ step === 'disable' && (
< div className = "space-y-4" >
< p className = "text-sm text-muted-foreground" > Enter the current code from your authenticator app to remove 2 FA . < / p >
< form onSubmit = { confirmDisable } className = "flex items-end gap-3" >
< div className = "space-y-1.5" >
< label className = "text-xs font-medium text-muted-foreground" > Authenticator code < / label >
< Input
value = { code }
onChange = { e => 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
/ >
< / div >
< Button type = "submit" variant = "destructive" size = "sm" disabled = { saving || ! code . trim ( ) } > { saving ? 'Removing…' : 'Remove 2FA' } < / Button >
< Button type = "button" variant = "ghost" size = "sm" onClick = { ( ) => { setStep ( 'idle' ) ; setCode ( '' ) ; } } > Cancel < / Button >
< / form >
< / div >
) }
< / div >
< / SectionCard >
2026-05-03 19:51:57 -05:00
) ;
}
2026-06-07 21:18:02 -05:00
function PrivacySettings ( { settings , onSaved } ) {
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 ( err . message || 'Failed to save privacy settings.' ) ;
} finally {
setSaving ( false ) ;
}
} ;
const changed = geoEnabled !== ! ! settings . geolocation _enabled ;
return (
< SectionCard title = "Privacy" icon = { Lock } subtitle = "Control what login data is collected about you." >
< div className = "px-6 py-5 space-y-4" >
< CheckRow
id = "geo-enabled"
label = "Login geolocation"
checked = { geoEnabled }
onChange = { setGeoEnabled }
/ >
< p className = "text-xs text-muted-foreground" >
When on , your login IP is resolved to a city / region via { ' ' }
< span className = "font-mono text-[11px]" > ip - api . com < / span > over plain HTTP .
Location data is encrypted at rest and visible only to you . Turn off to keep
all login data on - device .
< / p >
< / div >
< div className = "px-6 py-4 border-t border-border/50 flex justify-end" >
< Button onClick = { save } disabled = { saving || ! changed } >
{ saving ? < > < Loader2 className = "h-4 w-4 mr-2 animate-spin" / > Saving … < / > : 'Save Privacy Settings' }
< / Button >
< / div >
< / SectionCard >
) ;
}
2026-05-03 19:51:57 -05:00
function ProfileNav ( ) {
const items = [
[ '#account' , 'Account' ] ,
[ '#security' , 'Security' ] ,
2026-06-07 21:18:02 -05:00
[ '#privacy' , 'Privacy' ] ,
2026-05-03 19:51:57 -05:00
] ;
return (
< div className = "mb-6 flex flex-wrap gap-2" >
{ items . map ( ( [ href , label ] ) => (
< a
key = { href }
href = { href }
className = "rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
>
{ label }
< / a >
) ) }
< / div >
) ;
}
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 (
2026-05-04 23:34:24 -05:00
< div className = "mx-auto w-full max-w-5xl" >
< div className = "mb-6 flex items-start justify-between gap-4" >
2026-05-03 19:51:57 -05:00
< div >
< h1 className = "text-2xl font-bold tracking-tight" > Profile < / h1 >
2026-06-12 01:52:48 -05:00
< p className = "text-sm text-muted-foreground mt-0.5" > Manage your account , security , and privacy . Notification preferences live in Settings . < / p >
2026-05-03 19:51:57 -05:00
< / div >
< div className = "hidden sm:flex items-center gap-2 rounded-full border border-border bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground" >
< ShieldCheck className = "h-3.5 w-3.5 text-emerald-500" / >
User - owned data only
< / div >
< / div >
< ProfileNav / >
2026-05-04 23:34:24 -05:00
< div className = "space-y-5" >
2026-05-03 19:51:57 -05:00
< div id = "account" className = "scroll-mt-6 space-y-6" >
< ProfileSummary profile = { profile } loading = { loading } / >
{ ! loading && < EditProfile profile = { profile } onSaved = { handleProfileSaved } / > }
< / div >
< div id = "security" className = "scroll-mt-6" >
< ChangePassword / >
< / div >
2026-06-12 01:52:48 -05:00
{ /* Notification preferences moved to Settings → Notifications */ }
2026-06-07 21:18:02 -05:00
< div id = "privacy" className = "scroll-mt-6" >
{ ! loading && < PrivacySettings settings = { settings } onSaved = { setSettings } / > }
< / div >
2026-05-03 19:51:57 -05:00
< / div >
< / div >
) ;
}