Files
TREK/client/src/components/Settings/AccountTab.tsx
jubnl c6148ba4f2 fix(mfa): generate SVG QR code
Replace the rasterized 180px PNG QR code with a crisp 250px SVG
2026-04-05 17:15:19 +02:00

599 lines
29 KiB
TypeScript

import React, { useState, useEffect } from 'react'
import { User, Save, Lock, KeyRound, AlertTriangle, Shield, Camera, Trash2, Copy, Download, Printer } from 'lucide-react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useTranslation } from '../../i18n'
import { useAuthStore } from '../../store/authStore'
import { useToast } from '../shared/Toast'
import { authApi, adminApi } from '../../api/client'
import { getApiErrorMessage } from '../../types'
import type { UserWithOidc } from '../../types'
import Section from './Section'
const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending'
export default function AccountTab(): React.ReactElement {
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore()
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const { t } = useTranslation()
const toast = useToast()
const avatarInputRef = React.useRef<HTMLInputElement>(null)
const [saving, setSaving] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
// Profile
const [username, setUsername] = useState<string>(user?.username || '')
const [email, setEmail] = useState<string>(user?.email || '')
useEffect(() => {
setUsername(user?.username || '')
setEmail(user?.email || '')
}, [user])
// Password
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [oidcOnlyMode, setOidcOnlyMode] = useState(false)
useEffect(() => {
authApi.getAppConfig?.().then(config => {
if (config?.oidc_only_mode) setOidcOnlyMode(true)
}).catch(() => {})
}, [])
// MFA
const [mfaQr, setMfaQr] = useState<string | null>(null)
const [mfaSecret, setMfaSecret] = useState<string | null>(null)
const [mfaSetupCode, setMfaSetupCode] = useState('')
const [mfaDisablePwd, setMfaDisablePwd] = useState('')
const [mfaDisableCode, setMfaDisableCode] = useState('')
const [mfaLoading, setMfaLoading] = useState(false)
const [backupCodes, setBackupCodes] = useState<string[] | null>(null)
const mfaRequiredByPolicy =
!demoMode &&
!user?.mfa_enabled &&
(searchParams.get('mfa') === 'required' || appRequireMfa)
const backupCodesText = backupCodes?.join('\n') || ''
useEffect(() => {
if (!user?.mfa_enabled || backupCodes) return
try {
const raw = sessionStorage.getItem(MFA_BACKUP_SESSION_KEY)
if (!raw) return
const parsed = JSON.parse(raw) as unknown
if (Array.isArray(parsed) && parsed.length > 0 && parsed.every(x => typeof x === 'string')) {
setBackupCodes(parsed)
}
} catch {
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
}
}, [user?.mfa_enabled, backupCodes])
const dismissBackupCodes = () => {
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
setBackupCodes(null)
}
const copyBackupCodes = async () => {
if (!backupCodesText) return
try {
await navigator.clipboard.writeText(backupCodesText)
toast.success(t('settings.mfa.backupCopied'))
} catch {
toast.error(t('common.error'))
}
}
const downloadBackupCodes = () => {
if (!backupCodesText) return
const blob = new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'trek-mfa-backup-codes.txt'
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
const printBackupCodes = () => {
if (!backupCodesText) return
const html = `<!doctype html><html><head><meta charset="utf-8"/><title>TREK MFA Backup Codes</title>
<style>body{font-family:Arial,sans-serif;padding:32px}h1{font-size:20px}pre{font-size:16px;line-height:1.6}</style>
</head><body><h1>TREK MFA Backup Codes</h1><p>${new Date().toLocaleString()}</p><pre>${backupCodesText}</pre></body></html>`
const w = window.open('', '_blank', 'width=900,height=700')
if (!w) return
w.document.open()
w.document.write(html)
w.document.close()
w.focus()
w.print()
}
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
try {
await uploadAvatar(file)
toast.success(t('settings.avatarUploaded'))
} catch {
toast.error(t('settings.avatarError'))
}
if (avatarInputRef.current) avatarInputRef.current.value = ''
}
const handleAvatarRemove = async () => {
try {
await deleteAvatar()
toast.success(t('settings.avatarRemoved'))
} catch {
toast.error(t('settings.avatarError'))
}
}
const saveProfile = async () => {
setSaving(true)
try {
await updateProfile({ username, email })
toast.success(t('settings.toast.profileSaved'))
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Error')
} finally {
setSaving(false)
}
}
return (
<>
<Section title={t('settings.account')} icon={User}>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')}</label>
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.email')}</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
{/* Change Password */}
{!oidcOnlyMode && (
<div style={{ paddingTop: 16, marginTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
<label className="block text-sm font-medium text-slate-700 mb-3">{t('settings.changePassword')}</label>
<div className="space-y-3">
<input
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
placeholder={t('settings.currentPassword')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<input
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder={t('settings.newPassword')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<input
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
placeholder={t('settings.confirmPassword')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<button
onClick={async () => {
if (!currentPassword) return toast.error(t('settings.currentPasswordRequired'))
if (!newPassword) return toast.error(t('settings.passwordRequired'))
if (newPassword.length < 8) return toast.error(t('settings.passwordTooShort'))
if (newPassword !== confirmPassword) return toast.error(t('settings.passwordMismatch'))
try {
await authApi.changePassword({ current_password: currentPassword, new_password: newPassword })
toast.success(t('settings.passwordChanged'))
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
await loadUser({ silent: true })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
style={{ border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<Lock size={14} />
{t('settings.updatePassword')}
</button>
</div>
</div>
)}
{/* MFA */}
<div style={{ paddingTop: 16, marginTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
<div className="flex items-center gap-2 mb-3">
<KeyRound className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
<h3 className="font-semibold text-base m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.title')}</h3>
</div>
<div className="space-y-3">
{mfaRequiredByPolicy && (
<div className="flex gap-3 p-3 rounded-lg border text-sm"
style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
<AlertTriangle className="w-5 h-5 flex-shrink-0 text-amber-600" />
<p className="m-0 leading-relaxed">{t('settings.mfa.requiredByPolicy')}</p>
</div>
)}
<p className="text-sm m-0" style={{ color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.mfa.description')}</p>
{demoMode ? (
<p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p>
) : (
<>
<p className="text-sm font-medium m-0" style={{ color: 'var(--text-secondary)' }}>
{user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
</p>
{!user?.mfa_enabled && !mfaQr && (
<button
type="button"
disabled={mfaLoading}
onClick={async () => {
setMfaLoading(true)
try {
const data = await authApi.mfaSetup() as { qr_svg: string; secret: string }
setMfaQr(data.qr_svg)
setMfaSecret(data.secret)
setMfaSetupCode('')
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setMfaLoading(false)
}
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
style={{ border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
>
{mfaLoading ? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" /> : <KeyRound size={14} />}
{t('settings.mfa.setup')}
</button>
)}
{!user?.mfa_enabled && mfaQr && (
<div className="space-y-3">
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.scanQr')}</p>
<div className="rounded-lg border mx-auto block overflow-hidden" style={{ width: 'fit-content', borderColor: 'var(--border-primary)' }} dangerouslySetInnerHTML={{ __html: mfaQr! }} />
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.secretLabel')}</label>
<code className="block text-xs p-2 rounded break-all" style={{ background: 'var(--bg-hover)', color: 'var(--text-primary)' }}>{mfaSecret}</code>
</div>
<input
type="text"
inputMode="numeric"
value={mfaSetupCode}
onChange={e => setMfaSetupCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder={t('settings.mfa.codePlaceholder')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={mfaLoading || mfaSetupCode.length < 6}
onClick={async () => {
setMfaLoading(true)
try {
const resp = await authApi.mfaEnable({ code: mfaSetupCode }) as { backup_codes?: string[] }
toast.success(t('settings.mfa.toastEnabled'))
setMfaQr(null)
setMfaSecret(null)
setMfaSetupCode('')
const codes = resp.backup_codes || null
if (codes?.length) {
try { sessionStorage.setItem(MFA_BACKUP_SESSION_KEY, JSON.stringify(codes)) } catch { /* ignore */ }
}
setBackupCodes(codes)
await loadUser({ silent: true })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setMfaLoading(false)
}
}}
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:opacity-50"
>
{t('settings.mfa.enable')}
</button>
<button
type="button"
onClick={() => { setMfaQr(null); setMfaSecret(null); setMfaSetupCode('') }}
className="px-4 py-2 rounded-lg text-sm border"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}
>
{t('settings.mfa.cancelSetup')}
</button>
</div>
</div>
)}
{user?.mfa_enabled && (
<div className="space-y-3">
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.disableTitle')}</p>
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.disableHint')}</p>
<input
type="password"
value={mfaDisablePwd}
onChange={e => setMfaDisablePwd(e.target.value)}
placeholder={t('settings.currentPassword')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
<input
type="text"
inputMode="numeric"
value={mfaDisableCode}
onChange={e => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder={t('settings.mfa.codePlaceholder')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
<button
type="button"
disabled={mfaLoading || !mfaDisablePwd || mfaDisableCode.length < 6}
onClick={async () => {
setMfaLoading(true)
try {
await authApi.mfaDisable({ password: mfaDisablePwd, code: mfaDisableCode })
toast.success(t('settings.mfa.toastDisabled'))
setMfaDisablePwd('')
setMfaDisableCode('')
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
setBackupCodes(null)
await loadUser({ silent: true })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setMfaLoading(false)
}
}}
className="px-4 py-2 rounded-lg text-sm font-medium text-red-600 border border-red-200 hover:bg-red-50 disabled:opacity-50"
>
{t('settings.mfa.disable')}
</button>
</div>
)}
{backupCodes && backupCodes.length > 0 && (
<div className="space-y-3 p-3 rounded-lg border" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-hover)' }}>
<p className="text-sm font-semibold m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.backupTitle')}</p>
<p className="text-xs m-0" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.backupDescription')}</p>
<pre className="text-xs m-0 p-2 rounded border overflow-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)', maxHeight: 220 }}>{backupCodesText}</pre>
<p className="text-xs m-0" style={{ color: '#b45309' }}>{t('settings.mfa.backupWarning')}</p>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={copyBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
<Copy size={13} /> {t('settings.mfa.backupCopy')}
</button>
<button type="button" onClick={downloadBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
<Download size={13} /> {t('settings.mfa.backupDownload')}
</button>
<button type="button" onClick={printBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
<Printer size={13} /> {t('settings.mfa.backupPrint')}
</button>
<button type="button" onClick={dismissBackupCodes} className="px-3 py-2 rounded-lg text-xs border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.ok')}
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
{/* Avatar */}
<div className="flex items-center gap-4">
<div style={{ position: 'relative', flexShrink: 0 }}>
{user?.avatar_url ? (
<img src={user.avatar_url} alt="" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover' }} />
) : (
<div style={{
width: 64, height: 64, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 24, fontWeight: 700,
background: 'var(--bg-hover)', color: 'var(--text-secondary)',
}}>
{user?.username?.charAt(0).toUpperCase()}
</div>
)}
<input ref={avatarInputRef} type="file" accept="image/*" onChange={handleAvatarUpload} style={{ display: 'none' }} />
<button
onClick={() => avatarInputRef.current?.click()}
style={{
position: 'absolute', bottom: -3, right: -3,
width: 28, height: 28, borderRadius: '50%',
background: 'var(--text-primary)', color: 'var(--bg-card)',
border: '2px solid var(--bg-card)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0, transition: 'transform 0.15s, opacity 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.15)'; e.currentTarget.style.opacity = '0.85' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.opacity = '1' }}
>
<Camera size={14} />
</button>
{user?.avatar_url && (
<button
onClick={handleAvatarRemove}
style={{
position: 'absolute', top: -2, right: -2,
width: 20, height: 20, borderRadius: '50%',
background: '#ef4444', color: 'white',
border: '2px solid var(--bg-card)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0,
}}
>
<Trash2 size={10} />
</button>
)}
</div>
<div className="flex flex-col gap-1">
<div className="text-sm" style={{ color: 'var(--text-muted)' }}>
<span className="font-medium" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--text-secondary)' }}>
{user?.role === 'admin' ? <><Shield size={13} /> {t('settings.roleAdmin')}</> : t('settings.roleUser')}
</span>
{(user as UserWithOidc)?.oidc_issuer && (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 10, fontWeight: 500, padding: '1px 8px', borderRadius: 99,
background: '#dbeafe', color: '#1d4ed8', marginLeft: 6,
}}>
SSO
</span>
)}
</div>
{(user as UserWithOidc)?.oidc_issuer && (
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -2 }}>
{t('settings.oidcLinked')} {(user as UserWithOidc).oidc_issuer!.replace('https://', '').replace(/\/+$/, '')}
</p>
)}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}>
<button
onClick={saveProfile}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
>
{saving ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
<span className="hidden sm:inline">{t('settings.saveProfile')}</span>
<span className="sm:hidden">{t('common.save')}</span>
</button>
<button
onClick={async () => {
if (user?.role === 'admin') {
try {
await adminApi.stats()
const adminUsers = (await adminApi.users()).users.filter((u: { role: string }) => u.role === 'admin')
if (adminUsers.length <= 1) {
setShowDeleteConfirm('blocked')
return
}
} catch {}
}
setShowDeleteConfirm(true)
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors text-red-500 hover:bg-red-50"
style={{ border: '1px solid #fecaca' }}
>
<Trash2 size={14} />
<span className="hidden sm:inline">{t('settings.deleteAccount')}</span>
<span className="sm:hidden">{t('common.delete')}</span>
</button>
</div>
</Section>
{/* Delete Account Blocked */}
{showDeleteConfirm === 'blocked' && (
<div style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
}} onClick={() => setShowDeleteConfirm(false)}>
<div style={{
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef3c7', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Shield size={18} style={{ color: '#d97706' }} />
</div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('settings.deleteBlockedTitle')}</h3>
</div>
<p style={{ fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0 0 20px' }}>
{t('settings.deleteBlockedMessage')}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
onClick={() => setShowDeleteConfirm(false)}
style={{
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)',
cursor: 'pointer', fontFamily: 'inherit',
}}
>
{t('common.ok') || 'OK'}
</button>
</div>
</div>
</div>
)}
{/* Delete Account Confirm */}
{showDeleteConfirm === true && (
<div style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
}} onClick={() => setShowDeleteConfirm(false)}>
<div style={{
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Trash2 size={18} style={{ color: '#ef4444' }} />
</div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('settings.deleteAccountTitle')}</h3>
</div>
<p style={{ fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0 0 20px' }}>
{t('settings.deleteAccountWarning')}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button
onClick={() => setShowDeleteConfirm(false)}
style={{
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)',
cursor: 'pointer', fontFamily: 'inherit',
}}
>
{t('common.cancel')}
</button>
<button
onClick={async () => {
try {
await authApi.deleteOwnAccount()
logout()
navigate('/login')
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
setShowDeleteConfirm(false)
}
}}
style={{
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 600,
border: 'none', background: '#ef4444', color: 'white',
cursor: 'pointer', fontFamily: 'inherit',
}}
>
{t('settings.deleteAccountConfirm')}
</button>
</div>
</div>
</div>
)}
</>
)
}