2026-05-09 13:03:36 -05:00
import React , { useEffect , useState } 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-05-03 19:51:57 -05:00
} 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' ;
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 || { } ;
}
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 (
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 >
< / 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-15 01:36:56 -05:00
. catch ( ( ) => setHistory ( [ ] ) )
. 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" >
Your last 3 sign - in events
< / 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-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-05-15 01:36:56 -05:00
return (
< div key = { entry . id }
className = "flex items-start gap-3 rounded-lg border border-border/50 bg-muted/20 px-4 py-3" >
< DeviceIcon className = "h-4 w-4 mt-0.5 text-muted-foreground shrink-0" / >
< div className = "min-w-0" >
< p className = "text-sm font-medium" >
{ formatDateTime ( entry . logged _in _at ) }
{ i === 0 && (
< span className = "ml-2 text-[10px] font-semibold uppercase tracking-wide text-emerald-500" >
most recent
< / 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 >
) }
< / 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-05-15 22:45:38 -05:00
Showing up to 3 most recent sign - ins . Device ID is a short privacy - preserving identifier .
< / p >
< p className = "text-[10px] text-muted-foreground/60 text-center" >
This information is shown only to you here . It is not shared with admins in the app UI .
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 >
{ latestLogin . ip _address && (
< p className = "mt-1 truncate font-mono text-[11px] text-muted-foreground" >
{ 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 ? ? [ ] ) )
. catch ( ( ) => setLoginHistory ( [ ] ) )
. 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-05-15 22:45:38 -05:00
const latestLogin = loginHistory [ 0 ] || 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 } / >
< FieldRow label = "Display Name" value = { profile . display _name || profile . displayName } / >
< 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 } ) {
const [ displayName , setDisplayName ] = useState ( profile . display _name || profile . displayName || '' ) ;
const [ saving , setSaving ] = useState ( false ) ;
useEffect ( ( ) => {
setDisplayName ( profile . display _name || profile . displayName || '' ) ;
} , [ profile . display _name , profile . displayName ] ) ;
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 >
) ;
}
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 = {
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 ) ,
} ;
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 (
< SectionCard title = "Notification Preferences" icon = { Mail } subtitle = "Manage email reminders for your bills." >
< 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 >
< Input id = "profile-email" type = "email" value = { payload . email } onChange = { e => set ( 'email' , e . target . value ) } placeholder = "you@example.com" / >
< / 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 } / >
< / div >
< / div >
< div className = "px-6 py-4 border-t border-border/50 flex justify-end" >
< Button onClick = { save } disabled = { saving } >
{ saving ? < > < Loader2 className = "h-4 w-4 mr-2 animate-spin" / > Saving … < / > : 'Save Preferences' }
< / Button >
< / div >
< / SectionCard >
) ;
}
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 (
< 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 >
) ;
}
function ProfileNav ( ) {
const items = [
[ '#account' , 'Account' ] ,
[ '#security' , 'Security' ] ,
[ '#notifications' , 'Notifications' ] ,
] ;
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-05-04 23:34:24 -05:00
< p className = "text-sm text-muted-foreground mt-0.5" > Manage your account , notification preferences , and password . < / 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 >
< div id = "notifications" className = "scroll-mt-6" >
{ ! loading && < NotificationPreferences settings = { settings } onSaved = { setSettings } / > }
< / div >
< / div >
< / div >
) ;
}