v2.4.0 — OIDC login, OpenStreetMap search, account management

Features:
- Single Sign-On (OIDC) — login with Google, Apple, Authentik, Keycloak
- OpenStreetMap place search as free fallback when no Google API key
- Change password in user settings
- Delete own account (with last-admin protection)
- Last login column in admin user management
- SSO badge and provider info in user settings
- Google API key "Recommended" badge in admin panel

Improvements:
- API keys load correctly after page reload
- Validate auto-saves keys before testing
- Time format respects 12h/24h setting everywhere
- Dark mode fixes for popups and backup buttons
- Admin stats: removed photos, 4-column layout
- Profile picture upload button on avatar overlay
- TravelStats duplicate key fix
- Backup panel dark mode support
This commit is contained in:
Maurice
2026-03-19 23:49:07 +01:00
parent 74be63555d
commit c887acddee
21 changed files with 779 additions and 97 deletions

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { adminApi, authApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { useTranslation } from '../i18n'
import Navbar from '../components/Layout/Navbar'
import Modal from '../components/shared/Modal'
@@ -13,7 +14,8 @@ import CustomSelect from '../components/shared/CustomSelect'
export default function AdminPage() {
const { demoMode } = useAuthStore()
const { t } = useTranslation()
const { t, locale } = useTranslation()
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
const TABS = [
{ id: 'users', label: t('admin.tabs.users') },
{ id: 'categories', label: t('admin.tabs.categories') },
@@ -30,6 +32,10 @@ export default function AdminPage() {
const [showCreateUser, setShowCreateUser] = useState(false)
const [createForm, setCreateForm] = useState({ username: '', email: '', password: '', role: 'user' })
// OIDC config
const [oidcConfig, setOidcConfig] = useState({ issuer: '', client_id: '', client_secret: '', display_name: '' })
const [savingOidc, setSavingOidc] = useState(false)
// Registration toggle
const [allowRegistration, setAllowRegistration] = useState(true)
@@ -49,6 +55,7 @@ export default function AdminPage() {
loadData()
loadAppConfig()
loadApiKeys()
adminApi.getOidc().then(setOidcConfig).catch(() => {})
}, [])
const loadData = async () => {
@@ -238,12 +245,11 @@ export default function AdminPage() {
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4 mb-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
{[
{ label: t('admin.stats.users'), value: stats.totalUsers, icon: Users },
{ label: t('admin.stats.trips'), value: stats.totalTrips, icon: Briefcase },
{ label: t('admin.stats.places'), value: stats.totalPlaces, icon: Map },
{ label: t('admin.stats.photos'), value: stats.totalPhotos || 0, icon: Camera },
{ label: t('admin.stats.files'), value: stats.totalFiles || 0, icon: FileText },
].map(({ label, value, icon: Icon }) => (
<div key={label} className="rounded-xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
@@ -303,6 +309,7 @@ export default function AdminPage() {
<th className="px-5 py-3">{t('admin.table.email')}</th>
<th className="px-5 py-3">{t('admin.table.role')}</th>
<th className="px-5 py-3">{t('admin.table.created')}</th>
<th className="px-5 py-3">{t('admin.table.lastLogin')}</th>
<th className="px-5 py-3 text-right">{t('admin.table.actions')}</th>
</tr>
</thead>
@@ -334,7 +341,10 @@ export default function AdminPage() {
</span>
</td>
<td className="px-5 py-3 text-sm text-slate-500">
{new Date(u.created_at).toLocaleDateString('de-DE')}
{new Date(u.created_at).toLocaleDateString(locale)}
</td>
<td className="px-5 py-3 text-sm text-slate-500">
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
</td>
<td className="px-5 py-3">
<div className="flex items-center gap-2 justify-end">
@@ -403,7 +413,10 @@ export default function AdminPage() {
<div className="p-6 space-y-4">
{/* Google Maps Key */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.mapsKey')}</label>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-1.5">
{t('admin.mapsKey')}
<span style={{ fontSize: 10, fontWeight: 500, padding: '1px 7px', borderRadius: 99, background: '#dbeafe', color: '#1d4ed8' }}>{t('admin.recommended')}</span>
</label>
<div className="flex gap-2">
<div className="relative flex-1">
<input
@@ -436,7 +449,7 @@ export default function AdminPage() {
{t('admin.validateKey')}
</button>
</div>
<p className="text-xs text-slate-400 mt-1">{t('admin.mapsKeyHint')}</p>
<p className="text-xs text-slate-400 mt-1">{t('admin.mapsKeyHintLong')}</p>
{validation.maps === true && (
<p className="text-xs text-emerald-600 mt-1 flex items-center gap-1">
<span className="w-2 h-2 bg-emerald-500 rounded-full inline-block"></span>
@@ -511,6 +524,73 @@ export default function AdminPage() {
</button>
</div>
</div>
{/* OIDC / SSO Configuration */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
<h2 className="font-semibold text-slate-900">{t('admin.oidcTitle')}</h2>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.oidcSubtitle')}</p>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.oidcDisplayName')}</label>
<input
type="text"
value={oidcConfig.display_name}
onChange={e => setOidcConfig(c => ({ ...c, display_name: e.target.value }))}
placeholder='z.B. Google, Authentik, Keycloak'
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('admin.oidcIssuer')}</label>
<input
type="url"
value={oidcConfig.issuer}
onChange={e => setOidcConfig(c => ({ ...c, issuer: e.target.value }))}
placeholder='https://accounts.google.com'
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"
/>
<p className="text-xs text-slate-400 mt-1">{t('admin.oidcIssuerHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Client ID</label>
<input
type="text"
value={oidcConfig.client_id}
onChange={e => setOidcConfig(c => ({ ...c, client_id: 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">Client Secret</label>
<input
type="password"
value={oidcConfig.client_secret}
onChange={e => setOidcConfig(c => ({ ...c, client_secret: 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>
<button
onClick={async () => {
setSavingOidc(true)
try {
await adminApi.updateOidc(oidcConfig)
toast.success(t('admin.oidcSaved'))
} catch (err) {
toast.error(err.response?.data?.error || t('common.error'))
} finally {
setSavingOidc(false)
}
}}
disabled={savingOidc}
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"
>
{savingOidc ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
{t('common.save')}
</button>
</div>
</div>
</div>
)}

View File

@@ -4,7 +4,7 @@ import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { useTranslation } from '../i18n'
import { authApi } from '../api/client'
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route } from 'lucide-react'
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield } from 'lucide-react'
export default function LoginPage() {
const { t, language } = useTranslation()
@@ -28,6 +28,28 @@ export default function LoginPage() {
if (!config.has_users) setMode('register')
}
})
// Handle OIDC callback token
const params = new URLSearchParams(window.location.search)
const token = params.get('token')
const oidcError = params.get('oidc_error')
if (token) {
localStorage.setItem('auth_token', token)
window.history.replaceState({}, '', '/login')
login.__fromOidc = true
navigate('/dashboard')
window.location.reload()
}
if (oidcError) {
const errorMessages = {
registration_disabled: language === 'de' ? 'Registrierung ist deaktiviert. Kontaktiere den Administrator.' : 'Registration is disabled. Contact your administrator.',
no_email: language === 'de' ? 'Keine E-Mail vom Provider erhalten.' : 'No email received from provider.',
token_failed: language === 'de' ? 'Authentifizierung fehlgeschlagen.' : 'Authentication failed.',
invalid_state: language === 'de' ? 'Ungueltige Sitzung. Bitte erneut versuchen.' : 'Invalid session. Please try again.',
}
setError(errorMessages[oidcError] || oidcError)
window.history.replaceState({}, '', '/login')
}
}, [])
const handleDemoLogin = async () => {
@@ -339,6 +361,33 @@ export default function LoginPage() {
)}
</div>
{/* OIDC / SSO login button */}
{appConfig?.oidc_configured && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 16 }}>
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
<span style={{ fontSize: 12, color: '#9ca3af' }}>{language === 'de' ? 'oder' : 'or'}</span>
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
</div>
<a href="/api/auth/oidc/login"
style={{
marginTop: 12, width: '100%', padding: '12px',
background: 'white', color: '#374151',
border: '1px solid #d1d5db', borderRadius: 12,
fontSize: 14, fontWeight: 600, cursor: 'pointer',
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
textDecoration: 'none', transition: 'all 0.15s',
boxSizing: 'border-box',
}}
onMouseEnter={e => { e.currentTarget.style.background = '#f9fafb'; e.currentTarget.style.borderColor = '#9ca3af' }}
onMouseLeave={e => { e.currentTarget.style.background = 'white'; e.currentTarget.style.borderColor = '#d1d5db' }}
>
<Shield size={16} />
{language === 'de' ? `Anmelden mit ${appConfig.oidc_display_name}` : `Sign in with ${appConfig.oidc_display_name}`}
</a>
</>
)}
{/* Demo login button */}
{appConfig?.demo_mode && (
<button onClick={handleDemoLogin} disabled={isLoading}

View File

@@ -6,7 +6,8 @@ import { useTranslation } from '../i18n'
import Navbar from '../components/Layout/Navbar'
import CustomSelect from '../components/shared/CustomSelect'
import { useToast } from '../components/shared/Toast'
import { Save, Map, Palette, User, Moon, Sun, Shield, Camera, Trash2 } from 'lucide-react'
import { Save, Map, Palette, User, Moon, Sun, Shield, Camera, Trash2, Lock } from 'lucide-react'
import { authApi, adminApi } from '../api/client'
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
@@ -31,7 +32,8 @@ function Section({ title, icon: Icon, children }) {
}
export default function SettingsPage() {
const { user, updateProfile, uploadAvatar, deleteAvatar } = useAuthStore()
const { user, updateProfile, uploadAvatar, deleteAvatar, logout } = useAuthStore()
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const avatarInputRef = React.useRef(null)
const { settings, updateSetting, updateSettings } = useSettingsStore()
const { t, locale } = useTranslation()
@@ -52,6 +54,8 @@ export default function SettingsPage() {
// Account
const [username, setUsername] = useState(user?.username || '')
const [email, setEmail] = useState(user?.email || '')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
useEffect(() => {
setMapTileUrl(settings.map_tile_url || '')
@@ -344,76 +348,244 @@ export default function SettingsPage() {
/>
</div>
{/* Change Password */}
<div style={{ paddingTop: 8, marginTop: 8, 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={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 (!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({ new_password: newPassword })
toast.success(t('settings.passwordChanged'))
setNewPassword(''); setConfirmPassword('')
} catch (err) {
toast.error(err.response?.data?.error || 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>
<div className="flex items-center gap-4">
{user?.avatar_url ? (
<img src={user.avatar_url} alt="" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
) : (
<div style={{
width: 64, height: 64, borderRadius: '50%', flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 24, fontWeight: 700,
background: 'var(--bg-hover)', color: 'var(--text-secondary)',
}}>
{user?.username?.charAt(0).toUpperCase()}
</div>
)}
<div className="flex flex-col gap-2">
<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>
</div>
<div className="flex items-center gap-2">
<input
ref={avatarInputRef}
type="file"
accept="image/*"
onChange={handleAvatarUpload}
style={{ display: 'none' }}
/>
<button
onClick={() => avatarInputRef.current?.click()}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs 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)'}
>
<Camera size={14} />
{t('settings.uploadAvatar')}
</button>
{user?.avatar_url && (
<button
onClick={handleAvatarRemove}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{
border: '1px solid var(--border-primary)',
background: 'var(--bg-card)',
color: '#ef4444',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<Trash2 size={14} />
{t('settings.removeAvatar')}
</button>
{user?.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?.oidc_issuer && (
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -2 }}>
{t('settings.oidcLinked')} {user.oidc_issuer.replace('https://', '').replace(/\/+$/, '')}
</p>
)}
</div>
</div>
<button
onClick={saveProfile}
disabled={saving.profile}
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.profile ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
{t('settings.saveProfile')}
</button>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}>
<button
onClick={saveProfile}
disabled={saving.profile}
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.profile ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
{t('settings.saveProfile')}
</button>
<button
onClick={async () => {
if (user?.role === 'admin') {
try {
const data = await adminApi.stats()
const adminUsers = (await adminApi.users()).users.filter(u => 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} />
{t('settings.deleteAccount')}
</button>
</div>
</Section>
{/* Delete Account Confirmation */}
{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>
)}
{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) {
toast.error(err.response?.data?.error || 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>
)}
</div>
</div>
</div>