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-07-06 14:23:53 -05:00
User ,
Mail ,
KeyRound ,
ShieldCheck ,
Loader2 ,
History ,
Monitor ,
Smartphone ,
ChevronRight ,
Bell ,
SendHorizontal ,
ScanLine ,
TriangleAlert ,
Copy ,
Check ,
Lock ,
LogOut ,
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 {
2026-07-06 14:23:53 -05:00
Dialog ,
DialogContent ,
DialogHeader ,
DialogTitle ,
DialogDescription ,
2026-05-15 01:36:56 -05:00
} from '@/components/ui/dialog' ;
2026-07-05 16:05:59 -05:00
import {
2026-07-06 14:23:53 -05:00
AlertDialog ,
AlertDialogAction ,
AlertDialogCancel ,
AlertDialogContent ,
AlertDialogDescription ,
AlertDialogFooter ,
AlertDialogHeader ,
AlertDialogTitle ,
2026-07-05 16:05:59 -05:00
} from '@/components/ui/alert-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 ;
2026-07-06 14:23:53 -05:00
return ( ( d as { profile? : Profile } ) ? . profile ||
( d as { user? : Profile } ) ? . user ||
( d as Profile ) ||
{ } ) as Profile ;
2026-07-04 22:53:51 -05:00
}
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 ;
2026-07-06 14:23:53 -05:00
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 ) ;
2026-07-06 14:23:53 -05:00
return (
d . toLocaleDateString ( undefined , { month : 'short' , day : 'numeric' , year : 'numeric' } ) +
' ' +
d . toLocaleTimeString ( [ ] , { hour : '2-digit' , minute : '2-digit' } )
) ;
2026-05-03 19:51:57 -05:00
}
2026-07-06 14:23:53 -05:00
function SectionCard ( {
title ,
icon : Icon ,
subtitle ,
action ,
children ,
} : {
2026-07-04 22:53:51 -05:00
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" >
2026-07-06 14:23:53 -05:00
< h2 className = "text-xs font-bold uppercase tracking-widest text-muted-foreground" >
{ title }
< / h2 >
2026-05-03 19:51:57 -05:00
{ 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" >
2026-07-06 14:23:53 -05:00
< p className = "text-[10px] font-bold uppercase tracking-widest text-muted-foreground" >
{ label }
< / p >
2026-05-03 19:51:57 -05:00
< p className = "mt-1 text-sm font-medium text-foreground truncate" > { value || 'Not set' } < / p >
< / div >
) ;
}
2026-07-06 14:23:53 -05:00
function CheckRow ( {
id ,
label ,
checked ,
onChange ,
disabled ,
} : {
2026-07-04 22:53:51 -05:00
id : string ;
label : ReactNode ;
checked? : boolean ;
onChange ? : ( v : boolean ) = > void ;
disabled? : boolean ;
} ) {
2026-05-03 19:51:57 -05:00
return (
2026-07-06 14:23:53 -05:00
< label
htmlFor = { id }
className = "flex items-center justify-between gap-4 rounded-lg border border-border/60 bg-muted/20 px-4 py-3"
>
2026-05-03 19:51:57 -05:00
< 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 ) ;
2026-07-06 14:23:53 -05:00
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' ;
2026-05-15 01:36:56 -05:00
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-06 14:23:53 -05:00
function LoginHistoryModal ( {
history : providedHistory ,
open ,
onClose ,
onLoaded ,
} : {
2026-07-04 22:53:51 -05:00
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 ) ;
2026-07-05 16:05:59 -05:00
const [ confirmSignOut , setConfirmSignOut ] = useState ( false ) ;
const [ signingOut , setSigningOut ] = useState ( false ) ;
2026-05-15 01:36:56 -05:00
2026-07-05 16:05:59 -05:00
const fetchHistory = useCallback ( ( ) = > {
2026-05-15 01:36:56 -05:00
setLoading ( true ) ;
2026-07-06 14:23:53 -05:00
return api
. loginHistory ( )
. then ( ( raw ) = > {
2026-07-04 22:53:51 -05:00
const rows = ( raw as { history? : LoginEntry [ ] } ) . history ? ? [ ] ;
2026-05-15 22:45:38 -05:00
setHistory ( rows ) ;
onLoaded ? . ( rows ) ;
} )
2026-07-06 14:23:53 -05:00
. catch ( ( err ) = > {
2026-05-29 01:06:20 -05:00
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-07-05 16:05:59 -05:00
} , [ onLoaded ] ) ;
useEffect ( ( ) = > {
if ( ! open ) return ;
if ( providedHistory ? . length ) {
setHistory ( providedHistory ) ;
return ;
}
fetchHistory ( ) ;
} , [ open , providedHistory , fetchHistory ] ) ;
async function handleSignOutOthers() {
setSigningOut ( true ) ;
try {
const res = await api . logoutOthers ( ) ;
const n = res ? . count ? ? 0 ;
2026-07-06 14:23:53 -05:00
toast . success (
n > 0
? ` Signed out of ${ n } other device ${ n === 1 ? '' : 's' } . `
: 'No other devices were signed in.' ,
) ;
2026-07-05 16:05:59 -05:00
setConfirmSignOut ( false ) ;
await fetchHistory ( ) ; // collapse the list to just this device
} catch ( err ) {
toast . error ( errMessage ( err , 'Failed to sign out other devices.' ) ) ;
} finally {
setSigningOut ( false ) ;
}
}
2026-05-15 01:36:56 -05:00
return (
2026-07-06 14:23:53 -05:00
< Dialog
open = { open }
onOpenChange = { ( v ) = > {
if ( ! v ) onClose ( ) ;
} }
>
2026-05-15 01:36:56 -05:00
< 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 >
2026-07-06 14:23:53 -05:00
< DialogDescription className = "sr-only" > Your last 10 sign - in events < / DialogDescription >
2026-05-15 01:36:56 -05:00
< / 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 ? (
2026-07-06 14:23:53 -05:00
< p className = "text-sm text-muted-foreground text-center py-8" >
No login history recorded .
< / p >
) : (
history . map ( ( entry , i ) = > {
const isFailed = entry . success === false ;
const parsed = parseUserAgent ( entry . user_agent ) ;
const browser = entry . browser || parsed . browser ;
const os = entry . os || parsed . os ;
const deviceType = entry . device_type || ( parsed . mobile ? 'mobile' : 'desktop' ) ;
const DeviceIcon =
deviceType === 'mobile' || deviceType === 'tablet' ? Smartphone : Monitor ;
const isFirstSuccess =
! isFailed && history . slice ( 0 , i ) . every ( ( e ) = > e . success === false ) ;
return (
< div
key = { entry . id }
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' } ` }
/ >
< div className = "min-w-0" >
< p className = "text-sm font-medium flex items-center flex-wrap gap-1.5" >
{ formatDateTime ( entry . logged_in_at ) }
{ 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
< / span >
) }
2026-06-04 03:38:32 -05:00
< / p >
2026-07-06 14:23:53 -05:00
< p className = "text-xs text-muted-foreground mt-0.5" >
{ deviceLabel ( deviceType ) } · { browser } on { os }
{ entry . ip_address && (
< span className = "ml-2 font-mono" > { entry . ip_address } < / span >
) }
{ ( 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 22:45:38 -05:00
< / p >
2026-07-06 14:23:53 -05:00
{ entry . location_isp && (
< p className = "text-[10px] text-muted-foreground/60 mt-0.5" >
{ entry . location_isp }
< / p >
) }
{ entry . device_fingerprint && (
< p className = "text-[10px] text-muted-foreground/70 mt-1 font-mono" >
Device ID { entry . device_fingerprint }
< / p >
) }
< / div >
2026-05-15 01:36:56 -05:00
< / div >
2026-07-06 14:23:53 -05:00
) ;
} )
) }
2026-05-15 01:36:56 -05:00
< / div >
2026-07-05 16:05:59 -05:00
< div className = "pt-2" >
< Button
variant = "outline"
size = "sm"
className = "w-full gap-1.5 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick = { ( ) = > setConfirmSignOut ( true ) }
>
< LogOut className = "h-3.5 w-3.5" / >
Sign out of other devices
< / Button >
< / div >
2026-05-15 01:36:56 -05:00
< p className = "text-[10px] text-muted-foreground/60 text-center pt-1" >
2026-07-06 14:23:53 -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-07-06 14:23:53 -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 >
2026-07-05 16:05:59 -05:00
< AlertDialog open = { confirmSignOut } onOpenChange = { setConfirmSignOut } >
< AlertDialogContent >
< AlertDialogHeader >
< AlertDialogTitle > Sign out of other devices ? < / AlertDialogTitle >
< AlertDialogDescription >
This ends every other active session — anyone signed in on another browser or device
will be logged out . This device stays signed in .
< / AlertDialogDescription >
< / AlertDialogHeader >
< AlertDialogFooter >
< AlertDialogCancel disabled = { signingOut } > Cancel < / AlertDialogCancel >
< AlertDialogAction
2026-07-06 14:23:53 -05:00
onClick = { ( e ) = > {
e . preventDefault ( ) ;
handleSignOutOthers ( ) ;
} }
2026-07-05 16:05:59 -05:00
disabled = { signingOut }
>
{ signingOut ? 'Signing out…' : 'Sign out others' }
< / AlertDialogAction >
< / AlertDialogFooter >
< / AlertDialogContent >
< / AlertDialog >
2026-05-15 01:36:56 -05:00
< / DialogContent >
< / Dialog >
) ;
}
2026-07-06 14:23:53 -05:00
function LoginSummaryCard ( {
latestLogin ,
loading ,
onOpen ,
} : {
2026-07-04 22:53:51 -05:00
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" >
2026-07-06 14:23:53 -05:00
{ loading ? (
< Loader2 className = "h-4 w-4 animate-spin" / >
) : (
< DeviceIcon className = "h-4 w-4" / >
) }
2026-05-15 22:45:38 -05:00
< / div >
< div className = "min-w-0 flex-1" >
< div className = "flex items-center justify-between gap-3" >
2026-07-06 14:23:53 -05:00
< p className = "text-[10px] font-bold uppercase tracking-widest text-muted-foreground" >
Last Login
< / p >
2026-05-15 22:45:38 -05:00
< 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" >
2026-07-06 14:23:53 -05:00
{ [
latestLogin . location_city ,
latestLogin . location_region ,
latestLogin . location_country ,
]
. filter ( Boolean )
. join ( ', ' ) }
2026-06-04 03:38:32 -05:00
< / 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 ) ;
2026-07-06 14:23:53 -05:00
api
. loginHistory ( )
. then ( ( raw ) = > setLoginHistory ( ( raw as { history? : LoginEntry [ ] } ) . history ? ? [ ] ) )
. catch ( ( err ) = > {
2026-05-29 01:06:20 -05:00
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)
2026-07-06 14:23:53 -05:00
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
2026-07-06 14:23:53 -05:00
< FieldRow
label = "Password Changed"
value = { formatDateTime ( profile . last_password_change_at || profile . password_changed_at ) }
/ >
2026-05-15 01:36:56 -05:00
< / 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 (
2026-07-06 14:23:53 -05:00
< SectionCard
title = "Edit Profile"
icon = { User }
subtitle = "Choose how your name appears inside the app."
>
2026-05-03 19:51:57 -05:00
< 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" >
2026-07-06 14:23:53 -05:00
< 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"
/ >
2026-05-03 19:51:57 -05:00
< / div >
< Button onClick = { save } disabled = { saving } className = "sm:mb-0" >
2026-07-06 14:23:53 -05:00
{ saving ? (
< >
< Loader2 className = "h-4 w-4 mr-2 animate-spin" / >
Saving …
< / >
) : (
'Save Profile'
) }
2026-05-03 19:51:57 -05:00
< / 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.
2026-07-06 14:23:53 -05:00
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-06 14:23:53 -05:00
const set = ( k : string , v : unknown , delay? : number ) = >
setForm ( ( prev ) = > {
const next = { . . . prev , [ k ] : v } ;
schedule ( buildNotificationPayload ( next ) , delay ) ;
return next ;
} ) ;
2026-06-12 02:08:42 -05:00
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" >
2026-07-06 14:23:53 -05:00
< 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 }
2026-07-06 14:23:53 -05:00
onChange = { ( e ) = > set ( 'email' , e . target . value , 900 ) }
2026-06-12 02:08:42 -05:00
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" >
2026-07-06 14:23:53 -05:00
< 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 }
/ >
< 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-07-06 14:23:53 -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-03 21:43:54 -05:00
] ;
2026-06-12 01:52:48 -05:00
// Exported: rendered on the Settings page ("Notifications" section).
2026-07-06 14:23:53 -05:00
export function PushNotifications ( {
settings ,
} : {
settings : Settings ;
onSaved ? : ( settings? : Settings ) = > void ;
} ) {
const [ enabled , setEnabled ] = useState ( ! ! settings . notify_push_enabled ) ;
const [ channel , setChannel ] = useState ( String ( settings . push_channel || 'ntfy' ) ) ;
const [ url , setUrl ] = useState ( String ( settings . push_url || '' ) ) ;
const [ token , setToken ] = useState ( '' ) ; // never pre-filled for security
const [ chatId , setChatId ] = useState ( String ( settings . push_chat_id || '' ) ) ;
2026-06-03 21:43:54 -05:00
const [ tokenSet , setTokenSet ] = useState ( ! ! settings . push_token_set ) ;
2026-07-06 14:23:53 -05:00
const [ saving , setSaving ] = useState ( false ) ;
const [ testing , setTesting ] = useState ( false ) ;
2026-06-03 21:43:54 -05:00
2026-07-06 14:23:53 -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-06 14:23:53 -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 ,
} ;
} ;
2026-07-06 14:23:53 -05:00
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-07-06 14:23:53 -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" >
2026-07-06 14:23:53 -05:00
{ PUSH_CHANNELS . map ( ( c ) = > (
2026-06-03 21:43:54 -05:00
< button
key = { c . value }
type = "button"
2026-07-06 14:23:53 -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-07-06 14:23:53 -05:00
onChange = { ( e ) = > {
setUrl ( e . target . value ) ;
schedule ( buildPatch ( { url : e.target.value } ) , 900 ) ;
} }
2026-06-12 02:08:42 -05:00
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 }
2026-07-06 14:23:53 -05:00
onChange = { ( e ) = > setToken ( e . target . value ) }
2026-06-12 02:08:42 -05:00
onBlur = { saveToken }
2026-07-06 14:23:53 -05:00
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" >
2026-07-06 14:23:53 -05:00
< label className = "text-xs font-medium text-muted-foreground" >
{ ch . chatIdLabel }
< / label >
2026-06-03 21:43:54 -05:00
< Input
value = { chatId }
2026-07-06 14:23:53 -05:00
onChange = { ( e ) = > {
setChatId ( e . target . value ) ;
schedule ( buildPatch ( { chatId : e.target.value } ) , 900 ) ;
} }
2026-06-12 02:08:42 -05:00
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 { ' ' }
2026-07-06 14:23:53 -05:00
< code className = "rounded bg-muted px-1 py-0.5" >
api . telegram . org / bot { '<token>' } / getUpdates
< / code > { ' ' }
to find your chat ID .
2026-06-03 21:43:54 -05:00
< / p >
< / div >
) }
< / div >
{ /* Channel hints */ }
{ channel === 'ntfy' && (
< p className = "text-[11px] text-muted-foreground" >
Your ntfy server is running at { ' ' }
2026-07-06 14:23:53 -05:00
< 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 >
.
2026-06-03 21:43:54 -05:00
< / 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"
>
2026-07-06 14:23:53 -05:00
{ testing ? (
< >
< Loader2 className = "h-3.5 w-3.5 animate-spin" / >
Sending …
< / >
) : (
< >
< SendHorizontal className = "h-3.5 w-3.5" / >
Send test
< / >
) }
2026-06-03 21:43:54 -05:00
< / 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
< >
2026-07-06 14:23:53 -05:00
< TotpSection / >
2026-07-10 17:30:14 -05:00
< PasskeySection / >
2026-07-06 14:23:53 -05:00
< SectionCard
title = "Change Password"
icon = { KeyRound }
subtitle = "Update your password without exposing it in logs or page state beyond this form."
>
< form onSubmit = { submit } className = "px-6 py-5 grid gap-4 lg:grid-cols-3" >
< 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 >
< Button type = "submit" disabled = { saving } className = "lg:col-start-3" >
{ 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 (
2026-07-06 14:23:53 -05:00
< 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" / >
) }
2026-06-04 04:10:14 -05:00
< / 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' ) ;
2026-07-06 14:23:53 -05:00
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 ;
2026-07-06 14:23:53 -05:00
api
. totpStatus ( )
. then ( ( raw ) = > setEnabled ( ! ! ( raw as { enabled? : boolean } ) . enabled ) )
2026-06-04 04:10:14 -05:00
. catch ( ( ) = > setEnabled ( false ) ) ;
} , [ singleUserMode ] ) ;
2026-07-06 14:23:53 -05:00
useEffect ( ( ) = > {
load ( ) ;
} , [ load ] ) ;
2026-06-04 04:10:14 -05:00
if ( singleUserMode ) return null ;
if ( enabled === null ) return null ;
const startSetup = async ( ) = > {
setSaving ( true ) ;
try {
2026-07-06 14:23:53 -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-06 14:23:53 -05:00
const d = ( await api . totpEnable ( { secret : setupData.secret , code } ) ) as {
recovery_codes? : string [ ] ;
} ;
2026-07-04 22:53:51 -05:00
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 (
2026-07-06 14:23:53 -05:00
< SectionCard
title = "Two-Factor Authentication"
icon = { ScanLine }
subtitle = "Require an authenticator app code on every sign-in."
>
2026-06-04 04:10:14 -05:00
< 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" >
2026-07-06 14:23:53 -05:00
< 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 >
2026-06-04 04:10:14 -05:00
< / div >
2026-07-06 14:23:53 -05:00
{ enabled ? (
< Button
variant = "outline"
size = "sm"
onClick = { ( ) = > {
setCode ( '' ) ;
setStep ( 'disable' ) ;
} }
>
Remove
< / Button >
) : (
< Button size = "sm" onClick = { startSetup } disabled = { saving } >
{ saving ? 'Loading…' : 'Set up' }
< / Button >
) }
2026-06-04 04:10:14 -05:00
< / div >
) }
{ /* Setup — show QR code */ }
{ step === 'setup' && setupData && (
< div className = "space-y-5" >
< p className = "text-sm text-muted-foreground" >
2026-07-06 14:23:53 -05:00
Scan the QR code with Google Authenticator , Authy , 1 Password , Bitwarden , or any TOTP
app . Then enter the 6 - digit code to confirm .
2026-06-04 04:10:14 -05:00
< / p >
< div className = "flex flex-col sm:flex-row gap-6 items-start" >
2026-07-06 14:23:53 -05:00
< img
src = { setupData . qr_data_url }
alt = "TOTP QR code"
className = "rounded-lg border border-border/60 w-40 h-40 shrink-0"
/ >
2026-06-04 04:10:14 -05:00
< div className = "space-y-3 min-w-0" >
2026-07-06 14:23:53 -05:00
< p className = "text-xs text-muted-foreground" >
Can ' t scan ? Enter this key manually :
< / p >
2026-06-04 04:10:14 -05:00
< 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 }
2026-07-06 14:23:53 -05:00
onChange = { ( e ) = > setCode ( e . target . value ) }
2026-06-04 04:10:14 -05:00
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" >
2026-07-06 14:23:53 -05:00
< 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 >
2026-06-04 04:10:14 -05:00
< / 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" >
2026-07-06 14:23:53 -05:00
Save these recovery codes somewhere safe . Each code works once . If you lose your
phone , use one of these to sign in .
2026-06-04 04:10:14 -05:00
< / p >
< / div >
< div className = "grid grid-cols-2 sm:grid-cols-4 gap-2" >
2026-07-06 14:23:53 -05:00
{ 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"
>
2026-06-04 04:10:14 -05:00
{ c } < CopyButton text = { c } / >
< / div >
) ) }
< / div >
2026-07-06 14:23:53 -05:00
< Button
size = "sm"
onClick = { ( ) = > {
setStep ( 'idle' ) ;
setRecoveryCodes ( [ ] ) ;
} }
>
Done — I ' ve saved these
< / Button >
2026-06-04 04:10:14 -05:00
< / div >
) }
{ /* Disable — requires TOTP code */ }
{ step === 'disable' && (
< div className = "space-y-4" >
2026-07-06 14:23:53 -05:00
< p className = "text-sm text-muted-foreground" >
Enter the current code from your authenticator app to remove 2 FA .
< / p >
2026-06-04 04:10:14 -05:00
< form onSubmit = { confirmDisable } className = "flex items-end gap-3" >
< div className = "space-y-1.5" >
2026-07-06 14:23:53 -05:00
< label className = "text-xs font-medium text-muted-foreground" >
Authenticator code
< / label >
2026-06-04 04:10:14 -05:00
< Input
value = { code }
2026-07-06 14:23:53 -05:00
onChange = { ( e ) = > setCode ( e . target . value ) }
2026-06-04 04:10:14 -05:00
placeholder = "000 000"
autoComplete = "one-time-code"
maxLength = { 7 }
className = "text-center tracking-widest font-mono text-lg max-w-[140px]"
autoFocus
required
/ >
< / div >
2026-07-06 14:23:53 -05:00
< 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 >
2026-06-04 04:10:14 -05:00
< / form >
< / div >
) }
< / div >
< / SectionCard >
2026-05-03 19:51:57 -05:00
) ;
}
2026-07-10 17:30:14 -05:00
interface PasskeyCredential {
id : number ;
credential_id : string ;
credential_name : string ;
created_at? : string ;
}
function PasskeySection() {
const { singleUserMode } = useAuth ( ) ;
const [ enabled , setEnabled ] = useState < boolean | null > ( null ) ; // null = loading
const [ credentials , setCredentials ] = useState < PasskeyCredential [ ] > ( [ ] ) ;
const [ keyName , setKeyName ] = useState ( '' ) ;
const [ naming , setNaming ] = useState ( false ) ; // show the name-your-key form
const [ password , setPassword ] = useState ( '' ) ;
const [ removeTarget , setRemoveTarget ] = useState < 'all' | string | null > ( null ) ; // credential_id or 'all'
const [ saving , setSaving ] = useState ( false ) ;
const load = useCallback ( ( ) = > {
if ( singleUserMode ) return ;
api
. webauthnStatus ( )
. then ( ( raw ) = > setEnabled ( ! ! ( raw as { enabled? : boolean } ) . enabled ) )
. catch ( ( ) = > setEnabled ( false ) ) ;
api
. webauthnCredentials ( )
. then ( ( raw ) = >
setCredentials ( ( raw as { credentials? : PasskeyCredential [ ] } ) . credentials ? ? [ ] ) ,
)
. catch ( ( ) = > setCredentials ( [ ] ) ) ;
} , [ singleUserMode ] ) ;
useEffect ( ( ) = > {
load ( ) ;
} , [ load ] ) ;
if ( singleUserMode ) return null ;
if ( enabled === null ) return null ;
const enroll = async ( e : React.FormEvent ) = > {
e . preventDefault ( ) ;
setSaving ( true ) ;
try {
const { startRegistration } = await import ( '@simplewebauthn/browser' ) ;
const setup = ( await api . webauthnSetup ( ) ) as { options? : unknown ; challengeId? : string } ;
const response = await startRegistration ( {
optionsJSON : setup.options as Parameters < typeof startRegistration > [ 0 ] [ 'optionsJSON' ] ,
} ) ;
await api . webauthnEnable ( {
challengeId : setup.challengeId ,
response ,
credential_name : keyName.trim ( ) || 'Security Key' ,
} ) ;
toast . success ( 'Security key added.' ) ;
setNaming ( false ) ;
setKeyName ( '' ) ;
load ( ) ;
} catch ( err ) {
const cancelled = ( err as Error ) ? . name === 'NotAllowedError' ;
toast . error (
cancelled
? 'Security key prompt was cancelled.'
: errMessage ( err , 'Failed to add security key.' ) ,
) ;
} finally {
setSaving ( false ) ;
}
} ;
const confirmRemove = async ( e : React.FormEvent ) = > {
e . preventDefault ( ) ;
if ( ! removeTarget ) return ;
setSaving ( true ) ;
try {
if ( removeTarget === 'all' ) {
await api . webauthnDisable ( { password } ) ;
toast . success ( 'Security keys removed.' ) ;
} else {
await api . webauthnDeleteCred ( removeTarget , { password } ) ;
toast . success ( 'Security key removed.' ) ;
}
setRemoveTarget ( null ) ;
setPassword ( '' ) ;
load ( ) ;
} catch ( err ) {
toast . error ( errMessage ( err , 'Failed to remove security key.' ) ) ;
} finally {
setSaving ( false ) ;
}
} ;
return (
< SectionCard
title = "Passkeys & Security Keys"
icon = { KeyRound }
subtitle = "Phishing-resistant sign-in with a passkey, fingerprint, or hardware key. If an authenticator app is also enabled, the app code is used at sign-in."
>
< div className = "px-6 py-5 space-y-5" >
< 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
? ` ${ credentials . length } security ${ credentials . length === 1 ? 'key' : 'keys' } registered `
: 'Not configured' }
< / span >
< / div >
< div className = "flex gap-2" >
{ enabled && credentials . length > 0 && (
< Button
variant = "outline"
size = "sm"
onClick = { ( ) = > {
setPassword ( '' ) ;
setRemoveTarget ( 'all' ) ;
} }
>
Remove all
< / Button >
) }
< Button
size = "sm"
onClick = { ( ) = > {
setKeyName ( '' ) ;
setNaming ( true ) ;
} }
disabled = { saving }
>
Add key
< / Button >
< / div >
< / div >
{ credentials . length > 0 && (
< ul className = "divide-y divide-border rounded-md border border-border" >
{ credentials . map ( ( cred ) = > (
< li
key = { cred . credential_id }
className = "flex items-center justify-between px-4 py-2.5"
>
< div >
< div className = "text-sm" > { cred . credential_name || 'Security Key' } < / div >
{ cred . created_at && (
< div className = "text-xs text-muted-foreground" >
Added { new Date ( cred . created_at ) . toLocaleDateString ( ) }
< / div >
) }
< / div >
< Button
variant = "ghost"
size = "sm"
onClick = { ( ) = > {
setPassword ( '' ) ;
setRemoveTarget ( cred . credential_id ) ;
} }
>
Remove
< / Button >
< / li >
) ) }
< / ul >
) }
{ naming && (
< form onSubmit = { enroll } className = "flex flex-wrap items-end gap-3" >
< div className = "space-y-1.5" >
< label htmlFor = "passkey-name" className = "text-xs font-medium text-muted-foreground" >
Key name
< / label >
< Input
id = "passkey-name"
value = { keyName }
onChange = { ( e ) = > setKeyName ( e . target . value ) }
placeholder = "e.g. YubiKey, This laptop"
maxLength = { 60 }
autoFocus
/ >
< / div >
< Button type = "submit" size = "sm" disabled = { saving } >
{ saving ? 'Waiting for your key…' : 'Register key' }
< / Button >
< Button type = "button" variant = "ghost" size = "sm" onClick = { ( ) = > setNaming ( false ) } >
Cancel
< / Button >
< / form >
) }
{ removeTarget !== null && (
< form onSubmit = { confirmRemove } className = "flex flex-wrap items-end gap-3" >
< div className = "space-y-1.5" >
< label
htmlFor = "passkey-remove-password"
className = "text-xs font-medium text-muted-foreground"
>
Confirm with your password to { ' ' }
{ removeTarget === 'all' ? 'remove all keys' : 'remove this key' }
< / label >
< Input
id = "passkey-remove-password"
type = "password"
autoComplete = "current-password"
value = { password }
onChange = { ( e ) = > setPassword ( e . target . value ) }
required
autoFocus
/ >
< / div >
< Button type = "submit" variant = "destructive" size = "sm" disabled = { saving || ! password } >
{ saving ? 'Removing…' : 'Remove' }
< / Button >
< Button
type = "button"
variant = "ghost"
size = "sm"
onClick = { ( ) = > {
setRemoveTarget ( null ) ;
setPassword ( '' ) ;
} }
>
Cancel
< / Button >
< / form >
) }
< / div >
< / SectionCard >
) ;
}
2026-07-06 14:23:53 -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 ) ;
2026-07-06 14:23:53 -05:00
useEffect ( ( ) = > {
setGeoEnabled ( ! ! settings . geolocation_enabled ) ;
} , [ settings . geolocation_enabled ] ) ;
2026-06-07 21:18:02 -05:00
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 (
2026-07-06 14:23:53 -05:00
< SectionCard
title = "Privacy"
icon = { Lock }
subtitle = "Control what login data is collected about you."
>
2026-06-07 21:18:02 -05:00
< 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 { ' ' }
2026-07-06 14:23:53 -05:00
< 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 .
2026-06-07 21:18:02 -05:00
< / p >
< / div >
< div className = "px-6 py-4 border-t border-border/50 flex justify-end" >
< Button onClick = { save } disabled = { saving || ! changed } >
2026-07-06 14:23:53 -05:00
{ saving ? (
< >
< Loader2 className = "h-4 w-4 mr-2 animate-spin" / >
Saving …
< / >
) : (
'Save Privacy Settings'
) }
2026-06-07 21:18:02 -05:00
< / 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 ;
2026-07-06 14:23:53 -05:00
Promise . all ( [ api . profile ( ) , api . profileSettings ( ) ] )
2026-05-03 19:51:57 -05:00
. then ( ( [ profileData , settingsData ] ) = > {
if ( ! mounted ) return ;
setProfile ( asProfile ( profileData ) ) ;
setSettings ( asSettings ( settingsData ) ) ;
} )
2026-07-06 14:23:53 -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
2026-07-06 14:23:53 -05:00
return ( ) = > {
mounted = false ;
} ;
2026-05-03 19:51:57 -05:00
} , [ ] ) ;
2026-07-04 22:53:51 -05:00
const handleProfileSaved = ( nextProfile : Profile ) = > {
2026-07-06 14:23:53 -05:00
setProfile ( ( prev ) = > ( { . . . prev , . . . nextProfile } ) ) ;
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-07-06 14:23:53 -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 >
) ;
}