Files
Budget_Tool_Martinhal/budget-app/frontend/src/pages/Profile.jsx
T

225 lines
9.6 KiB
React
Raw Normal View History

2026-10-29 20:40:02 +00:00
import { useEffect, useRef, useState } from 'react'
import { api } from '../api.js'
import { THEMES, useAuth } from '../auth.js'
import { dateTime } from '../format.js'
import { useT } from '../i18n.js'
import { Avatar, ErrorNote, Field, Modal } from '../components/ui.jsx'
export default function Profile() {
const t = useT()
const { user, setUser, setLang, theme, setTheme } = useAuth()
const [name, setName] = useState(user.name)
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [error, setError] = useState(null)
const [done, setDone] = useState(false)
const submit = async (e) => {
e.preventDefault()
try {
const body = { name }
if (next) Object.assign(body, { current_password: current, new_password: next })
setUser(await api.put('/api/auth/me', body))
setCurrent('')
setNext('')
setError(null)
setDone(true)
} catch (err) { setError(err); setDone(false) }
}
return (
<div className="card narrow">
<h1>{t('profile_title')}</h1>
<AvatarPicker />
<form onSubmit={submit}>
<Field label={t('name')}><input value={name} onChange={(e) => setName(e.target.value)} /></Field>
<Field label={t('email')}><input value={user.email} disabled /></Field>
<Field label={t('role')}><input value={t(`role_${user.role}`)} disabled /></Field>
<Field label={t('language')}>
<select value={user.language} onChange={(e) => { setLang(e.target.value); setUser({ ...user, language: e.target.value }) }}>
<option value="pt">Português</option><option value="en">English</option>
</select>
</Field>
<Field label={t('theme')}>
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
{THEMES.map((th) => <option key={th} value={th}>{t(`theme_${th}`)}</option>)}
</select>
</Field>
<h2 className="section-title">{t('change_password')}</h2>
<Field label={t('current_password')}><input type="password" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} /></Field>
<Field label={t('new_password')} hint={t('password_optional')}><input type="password" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} /></Field>
<ErrorNote error={error} />
{done && <div className="notice">{t('saved')}</div>}
<button className="primary">{t('save')}</button>
</form>
<MfaSection />
</div>
)
}
function AvatarPicker() {
const t = useT()
const { user, setUser } = useAuth()
const input = useRef(null)
const [error, setError] = useState(null)
const upload = async (e) => {
const file = e.target.files[0]
e.target.value = ''
if (!file) return
const form = new FormData()
form.append('file', file)
try { setUser(await api.put('/api/auth/me/avatar', form)); setError(null) } catch (err) { setError(err) }
}
const remove = async () => {
try { setUser(await api.del('/api/auth/me/avatar')); setError(null) } catch (err) { setError(err) }
}
return (
<div>
<div className="avatar-row">
<Avatar user={user} large />
<div>
<div className="actions">
<button type="button" onClick={() => input.current.click()}>{t('avatar_upload')}</button>
{user.avatar_url && <button type="button" className="link" onClick={remove}>{t('avatar_remove')}</button>}
</div>
<div className="hint small muted" style={{ marginTop: 6 }}>{t('avatar_hint')}</div>
</div>
<input ref={input} type="file" accept="image/png,image/jpeg,image/gif,image/webp" hidden onChange={upload} />
</div>
<ErrorNote error={error} />
</div>
)
}
function MfaSection() {
const t = useT()
const { user, lang } = useAuth()
const [dialog, setDialog] = useState(null) // setup | disable | codes
return (
<div>
<h2 className="section-title">{t('mfa')} <span className={'badge' + (user.mfa_enabled ? ' mfa-on' : '')}>{user.mfa_enabled ? t('mfa_on') : t('mfa_off')}</span></h2>
{user.mfa_enabled ? <>
<p className="muted small">{t('mfa_on_since', { when: dateTime(user.mfa_enabled_at, lang), n: user.mfa_recovery_left })}</p>
<div className="actions" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button type="button" onClick={() => setDialog('codes')}>{t('mfa_new_codes')}</button>
<button type="button" className="danger" onClick={() => setDialog('disable')}>{t('mfa_disable')}</button>
</div>
</> : <>
<p className="muted small">{t('mfa_off_text')}</p>
<button type="button" className="primary" onClick={() => setDialog('setup')}>{t('mfa_setup')}</button>
</>}
{dialog === 'setup' && <MfaSetup onClose={() => setDialog(null)} />}
{dialog === 'disable' && <MfaDisable onClose={() => setDialog(null)} />}
{dialog === 'codes' && <MfaNewCodes onClose={() => setDialog(null)} />}
</div>
)
}
function RecoveryCodes({ codes, onDone }) {
const t = useT()
const [copied, setCopied] = useState(false)
const copy = () => navigator.clipboard?.writeText(codes.join('\n')).then(() => setCopied(true)).catch(() => {})
return <>
<p>{t('mfa_codes_text')}</p>
<div className="codes">{codes.map((c) => <span key={c}>{c}</span>)}</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" onClick={copy}>{copied ? '✓ ' : ''}{t('mfa_codes_copy')}</button>
<button type="button" className="primary" onClick={onDone}>{t('mfa_codes_done')}</button>
</div>
</>
}
function MfaSetup({ onClose }) {
const t = useT()
const { setUser } = useAuth()
const [setup, setSetup] = useState(null)
const [code, setCode] = useState('')
const [codes, setCodes] = useState(null)
const [error, setError] = useState(null)
useEffect(() => { api.post('/api/auth/mfa/setup').then(setSetup).catch(setError) }, [])
const confirm = async (e) => {
e.preventDefault()
try {
const r = await api.post('/api/auth/mfa/enable', { code })
setCodes(r.recovery_codes)
setUser(r.user)
} catch (err) { setError(err); setCode('') }
}
if (codes) return <Modal title={t('mfa_codes_title')} onClose={onClose}><RecoveryCodes codes={codes} onDone={onClose} /></Modal>
return (
<Modal title={t('mfa_setup')} onClose={onClose}>
{setup && <form onSubmit={confirm}>
<p>{t('mfa_step1')}</p>
<div className="qr" dangerouslySetInnerHTML={{ __html: setup.qr_svg }} />
<p className="muted small">{t('mfa_manual')}</p>
<code className="secret">{setup.secret.match(/.{1,4}/g).join(' ')}</code>
<p>{t('mfa_step2')}</p>
<Field label={t('mfa_code')}>
<input className="code" inputMode="numeric" autoComplete="one-time-code" maxLength={7} value={code} onChange={(e) => setCode(e.target.value)} required autoFocus />
</Field>
<ErrorNote error={error} />
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" onClick={onClose}>{t('cancel')}</button>
<button className="primary">{t('mfa_confirm')}</button>
</div>
</form>}
{!setup && <ErrorNote error={error} />}
</Modal>
)
}
function MfaDisable({ onClose }) {
const t = useT()
const { setUser } = useAuth()
const [password, setPassword] = useState('')
const [code, setCode] = useState('')
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
try { setUser(await api.post('/api/auth/mfa/disable', { password, code })); onClose() } catch (err) { setError(err) }
}
return (
<Modal title={t('mfa_disable')} onClose={onClose}>
<form onSubmit={submit}>
<p>{t('mfa_disable_text')}</p>
<Field label={t('current_password')}><input type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} required /></Field>
<Field label={t('mfa_code')}><input className="code" autoComplete="one-time-code" maxLength={12} value={code} onChange={(e) => setCode(e.target.value)} required /></Field>
<ErrorNote error={error} />
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" onClick={onClose}>{t('cancel')}</button>
<button className="primary">{t('mfa_disable')}</button>
</div>
</form>
</Modal>
)
}
function MfaNewCodes({ onClose }) {
const t = useT()
const { setUser } = useAuth()
const [code, setCode] = useState('')
const [codes, setCodes] = useState(null)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
try {
const r = await api.post('/api/auth/mfa/recovery-codes', { code })
setCodes(r.recovery_codes)
setUser(r.user)
} catch (err) { setError(err); setCode('') }
}
return (
<Modal title={codes ? t('mfa_codes_title') : t('mfa_new_codes')} onClose={onClose}>
{codes ? <RecoveryCodes codes={codes} onDone={onClose} /> : (
<form onSubmit={submit}>
<p>{t('mfa_new_codes_text')}</p>
<Field label={t('mfa_code')}><input className="code" inputMode="numeric" autoComplete="one-time-code" maxLength={7} value={code} onChange={(e) => setCode(e.target.value)} required autoFocus /></Field>
<ErrorNote error={error} />
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" onClick={onClose}>{t('cancel')}</button>
<button className="primary">{t('mfa_new_codes')}</button>
</div>
</form>
)}
</Modal>
)
}