2026-07-04 22:53:51 -05:00
import { useEffect , useState , useCallback , type ComponentType , type ReactNode } 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' ;
2026-07-04 22:53:51 -05:00
import { errMessage } from '@/lib/utils' ;
import { useAuth , type User as AuthUser } 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
2026-07-04 22:53:51 -05:00
type Settings = Record < string , unknown > ;
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 ;
2026-05-03 19:51:57 -05:00
}
2026-07-04 22:53:51 -05:00
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 {
2026-06-07 01:17:49 -05:00
return profile . display_name || profile . displayName || profile . name || '' ;
}
2026-07-04 22:53:51 -05:00
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 ;
2026-05-03 19:51:57 -05:00
}
2026-07-04 22:53:51 -05:00
function formatDateTime ( value : unknown ) : string {
2026-05-03 19:51:57 -05:00
if ( ! value ) return 'Not recorded' ;
2026-07-04 22:53:51 -05:00
const d = new Date ( value as string | number ) ;
2026-05-03 19:51:57 -05:00
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-07-04 22:53:51 -05:00
function SectionCard ( { title , icon : Icon , subtitle , action , children } : {
title : ReactNode ;
icon : ComponentType < { className? : string } > ;
subtitle? : ReactNode ;
action? : ReactNode ;
children : ReactNode ;
} ) {
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 >
) ;
}
2026-07-04 22:53:51 -05:00
function FieldRow ( { label , value } : { label : ReactNode ; value? : ReactNode } ) {
2026-05-03 19:51:57 -05:00
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 >
) ;
}
2026-07-04 22:53:51 -05:00
function CheckRow ( { id , label , checked , onChange , disabled } : {
id : string ;
label : ReactNode ;
checked? : boolean ;
onChange ? : ( v : boolean ) = > void ;
disabled? : boolean ;
} ) {
2026-05-03 19:51:57 -05:00
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-07-04 22:53:51 -05:00
function parseUserAgent ( ua? : string | null ) : { browser : string ; os : string ; mobile : boolean } {
2026-05-15 01:36:56 -05:00
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-07-04 22:53:51 -05:00
function deviceLabel ( type ? : string ) : string {
2026-05-15 22:45:38 -05:00
if ( type === 'mobile' ) return 'Mobile' ;
if ( type === 'tablet' ) return 'Tablet' ;
if ( type === 'api' ) return 'API client' ;
return 'Desktop' ;
}
2026-07-04 22:53:51 -05:00
function LoginHistoryModal ( { history : providedHistory , open , onClose , onLoaded } : {
history? : LoginEntry [ ] ;
open : boolean ;
onClose : ( ) = > void ;
onLoaded ? : ( rows : LoginEntry [ ] ) = > void ;
} ) {
const [ history , setHistory ] = useState < LoginEntry [ ] > ( [ ] ) ;
2026-05-15 01:36:56 -05:00
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-07-04 22:53:51 -05:00
. then ( raw = > {
const rows = ( raw as { history? : LoginEntry [ ] } ) . history ? ? [ ] ;
2026-05-15 22:45:38 -05:00
setHistory ( rows ) ;
onLoaded ? . ( rows ) ;
} )
2026-05-29 01:06:20 -05:00
. catch ( err = > {
setHistory ( [ ] ) ;
2026-07-04 22:53:51 -05:00
toast . error ( errMessage ( err , 'Failed to load login history.' ) ) ;
2026-05-29 01:06:20 -05:00
} )
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-07-04 22:53:51 -05:00
function LoginSummaryCard ( { latestLogin , loading , onOpen } : {
latestLogin : LoginEntry | null ;
loading : boolean ;
onOpen : ( ) = > void ;
} ) {
2026-05-15 22:45:38 -05:00
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-07-04 22:53:51 -05:00
function ProfileSummary ( { profile , loading } : { profile : Profile ; loading : boolean } ) {
2026-05-15 01:36:56 -05:00
const [ historyOpen , setHistoryOpen ] = useState ( false ) ;
2026-07-04 22:53:51 -05:00
const [ loginHistory , setLoginHistory ] = useState < LoginEntry [ ] > ( [ ] ) ;
2026-05-15 22:45:38 -05:00
const [ historyLoading , setHistoryLoading ] = useState ( false ) ;
useEffect ( ( ) = > {
if ( loading ) return ;
setHistoryLoading ( true ) ;
api . loginHistory ( )
2026-07-04 22:53:51 -05:00
. then ( raw = > setLoginHistory ( ( raw as { history? : LoginEntry [ ] } ) . history ? ? [ ] ) )
2026-05-29 01:06:20 -05:00
. catch ( err = > {
setLoginHistory ( [ ] ) ;
2026-07-04 22:53:51 -05:00
toast . error ( errMessage ( err , 'Failed to load login history.' ) ) ;
2026-05-29 01:06:20 -05:00
} )
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
) ;
}
2026-07-04 22:53:51 -05:00
function EditProfile ( { profile , onSaved } : { profile : Profile ; onSaved : ( p : Profile ) = > void } ) {
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 ) ) ;
2026-07-03 19:47:14 -05:00
} , [ profile ] ) ;
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 ) {
2026-07-04 22:53:51 -05:00
toast . error ( errMessage ( err , 'Failed to save profile.' ) ) ;
2026-05-03 19:51:57 -05:00
} 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-07-04 22:53:51 -05:00
function buildNotificationPayload ( form : Settings ) {
2026-05-03 19:51:57 -05:00
const payload = {
2026-07-04 22:53:51 -05:00
email : String ( 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-07-04 22:53:51 -05:00
enabled : false ,
notify_3d : false ,
notify_1d : false ,
notify_day_of : false ,
notify_daily_overdue : false ,
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-07-04 22:53:51 -05:00
export function NotificationPreferences ( { settings } : { settings : Settings } ) {
const [ form , setForm ] = useState < Settings > ( settings ) ;
2026-06-12 02:08:42 -05:00
// 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-07-04 22:53:51 -05:00
toast . error ( errMessage ( err , 'Failed to save notification preferences.' ) ) ;
2026-06-12 02:08:42 -05:00
throw err ;
} ) ,
) ;
2026-07-04 22:53:51 -05:00
const set = ( k : string , v : unknown , delay? : number ) = > setForm ( prev = > {
2026-06-12 02:08:42 -05:00
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-07-04 22:53:51 -05:00
interface PushChannel {
value : string ;
label : string ;
urlLabel : string ;
urlHint : string ;
tokenLabel : string | null ;
chatIdLabel? : string ;
}
const PUSH_CHANNELS : PushChannel [ ] = [
2026-06-03 21:43:54 -05:00
{ 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).
2026-07-04 22:53:51 -05:00
export function PushNotifications ( { settings } : { settings : Settings ; onSaved ? : ( settings? : Settings ) = > void } ) {
2026-06-03 21:43:54 -05:00
const [ enabled , setEnabled ] = useState ( ! ! settings . notify_push_enabled ) ;
2026-07-04 22:53:51 -05:00
const [ channel , setChannel ] = useState ( String ( settings . push_channel || 'ntfy' ) ) ;
const [ url , setUrl ] = useState ( String ( settings . push_url || '' ) ) ;
2026-06-03 21:43:54 -05:00
const [ token , setToken ] = useState ( '' ) ; // never pre-filled for security
2026-07-04 22:53:51 -05:00
const [ chatId , setChatId ] = useState ( String ( settings . push_chat_id || '' ) ) ;
2026-06-03 21:43:54 -05:00
const [ tokenSet , setTokenSet ] = useState ( ! ! settings . push_token_set ) ;
const [ saving , setSaving ] = useState ( false ) ;
const [ testing , setTesting ] = useState ( false ) ;
2026-07-04 22:53:51 -05:00
const ch = PUSH_CHANNELS . find ( c = > c . value === channel ) || PUSH_CHANNELS [ 0 ] ! ;
2026-06-03 21:43:54 -05:00
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.
2026-07-04 22:53:51 -05:00
const buildPatch = ( over : Partial < { enabled : boolean ; channel : string ; url : string ; chatId : string } > = { } ) = > {
2026-06-12 02:08:42 -05:00
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 ) = > {
2026-07-04 22:53:51 -05:00
toast . error ( errMessage ( err , 'Failed to save push settings.' ) ) ;
2026-06-12 02:08:42 -05:00
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-07-04 22:53:51 -05:00
toast . error ( errMessage ( err , '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 ) {
2026-07-04 22:53:51 -05:00
toast . error ( errMessage ( err , 'Test failed. Check your channel settings.' ) ) ;
2026-06-03 21:43:54 -05:00
} 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 ( '' ) ;
} ;
2026-07-04 22:53:51 -05:00
const submit = async ( e : React.FormEvent ) = > {
2026-05-03 19:51:57 -05:00
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 ) {
2026-07-04 22:53:51 -05:00
toast . error ( errMessage ( err , 'Failed to change password.' ) ) ;
2026-05-03 19:51:57 -05:00
} 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
< / >
) ;
}
2026-07-04 22:53:51 -05:00
function CopyButton ( { text } : { text : string } ) {
2026-06-04 04:10:14 -05:00
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 ( ) ;
2026-07-04 22:53:51 -05:00
const [ enabled , setEnabled ] = useState < boolean | null > ( 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 ) ;
2026-06-04 04:10:14 -05:00
const [ code , setCode ] = useState ( '' ) ;
2026-07-04 22:53:51 -05:00
const [ recoveryCodes , setRecoveryCodes ] = useState < string [ ] > ( [ ] ) ;
2026-06-04 04:10:14 -05:00
const [ saving , setSaving ] = useState ( false ) ;
const load = useCallback ( ( ) = > {
if ( singleUserMode ) return ;
api . totpStatus ( )
2026-07-04 22:53:51 -05:00
. then ( raw = > setEnabled ( ! ! ( raw as { enabled? : boolean } ) . enabled ) )
2026-06-04 04:10:14 -05:00
. catch ( ( ) = > setEnabled ( false ) ) ;
} , [ singleUserMode ] ) ;
useEffect ( ( ) = > { load ( ) ; } , [ load ] ) ;
if ( singleUserMode ) return null ;
if ( enabled === null ) return null ;
const startSetup = async ( ) = > {
setSaving ( true ) ;
try {
2026-07-04 22:53:51 -05:00
const d = await api . totpSetup ( ) as { secret? : string ; qr_data_url? : string } ;
2026-06-04 04:10:14 -05:00
setSetupData ( d ) ;
setCode ( '' ) ;
setStep ( 'setup' ) ;
} catch ( err ) {
2026-07-04 22:53:51 -05:00
toast . error ( errMessage ( err , 'Failed to generate setup data.' ) ) ;
2026-06-04 04:10:14 -05:00
} finally {
setSaving ( false ) ;
}
} ;
2026-07-04 22:53:51 -05:00
const confirmEnable = async ( e : React.FormEvent ) = > {
2026-06-04 04:10:14 -05:00
e . preventDefault ( ) ;
2026-07-04 22:53:51 -05:00
if ( ! setupData ) return ;
2026-06-04 04:10:14 -05:00
setSaving ( true ) ;
try {
2026-07-04 22:53:51 -05:00
const d = await api . totpEnable ( { secret : setupData.secret , code } ) as { recovery_codes? : string [ ] } ;
setRecoveryCodes ( d . recovery_codes ? ? [ ] ) ;
2026-06-04 04:10:14 -05:00
setEnabled ( true ) ;
setStep ( 'recovery' ) ;
} catch ( err ) {
2026-07-04 22:53:51 -05:00
toast . error ( errMessage ( err , 'Invalid code. Try again.' ) ) ;
2026-06-04 04:10:14 -05:00
} finally {
setSaving ( false ) ;
}
} ;
2026-07-04 22:53:51 -05:00
const confirmDisable = async ( e : React.FormEvent ) = > {
2026-06-04 04:10:14 -05:00
e . preventDefault ( ) ;
setSaving ( true ) ;
try {
await api . totpDisable ( { code } ) ;
setEnabled ( false ) ;
setStep ( 'idle' ) ;
setCode ( '' ) ;
toast . success ( 'Authenticator app removed.' ) ;
} catch ( err ) {
2026-07-04 22:53:51 -05:00
toast . error ( errMessage ( err , 'Invalid code.' ) ) ;
2026-06-04 04:10:14 -05:00
} 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 }
2026-07-04 22:53:51 -05:00
< CopyButton text = { setupData . secret || '' } / >
2026-06-04 04:10:14 -05:00
< / 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-07-04 22:53:51 -05:00
function PrivacySettings ( { settings , onSaved } : { settings : Settings ; onSaved : ( settings : Settings ) = > void } ) {
2026-06-07 21:18:02 -05:00
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 ) {
2026-07-04 22:53:51 -05:00
toast . error ( errMessage ( err , 'Failed to save privacy settings.' ) ) ;
2026-06-07 21:18:02 -05:00
} 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() {
2026-07-04 22:53:51 -05:00
const items : [ string , string ] [ ] = [
2026-05-03 19:51:57 -05:00
[ '#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 ( ) ;
2026-07-04 22:53:51 -05:00
const [ profile , setProfile ] = useState < Profile > ( { } ) ;
const [ settings , setSettings ] = useState < Settings > ( { } ) ;
2026-05-03 19:51:57 -05:00
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 ) ) ;
} )
2026-07-04 22:53:51 -05:00
. catch ( err = > toast . error ( errMessage ( err , 'Failed to load profile.' ) ) )
. finally ( ( ) = > { if ( mounted ) setLoading ( false ) ; } ) ;
2026-05-03 19:51:57 -05:00
return ( ) = > { mounted = false ; } ;
} , [ ] ) ;
2026-07-04 22:53:51 -05:00
const handleProfileSaved = ( nextProfile : Profile ) = > {
2026-05-03 19:51:57 -05:00
setProfile ( prev = > ( { . . . prev , . . . nextProfile } ) ) ;
2026-07-04 22:53:51 -05:00
setUser ( prev = > prev ? { . . . prev , . . . nextProfile } as AuthUser : prev ) ;
2026-05-03 19:51:57 -05:00
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 >
) ;
}