225 lines
9.6 KiB
React
225 lines
9.6 KiB
React
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>
|
|
)
|
|
}
|