mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
47b880221d
Three distinct bugs caused infinite OIDC redirect loops:
1. After logout, navigating to /login with no signal to suppress the
auto-redirect caused the login page to immediately re-trigger the
OIDC flow. Fixed by passing `{ state: { noRedirect: true } }` via
React Router's navigation state (not URL params, which were fragile
due to async cleanup timing) from all logout call sites.
2. On the OIDC callback page (/login?oidc_code=...), App.tsx's
mount-level loadUser() fired concurrently with the LoginPage's
exchange fetch. The App-level call had no cookie yet and got a 401,
which (if it resolved after the successful exchange loadUser()) would
overwrite isAuthenticated back to false. Fixed by skipping loadUser()
in App.tsx when the initial path is /login.
3. React 18 StrictMode double-invokes useEffect. The first run called
window.history.replaceState to clean the oidc_code from the URL
before starting the async exchange, so the second run saw no
oidc_code and fell through to the getAppConfig auto-redirect, firing
window.location.href = '/api/auth/oidc/login' before the exchange
could complete. Fixed by adding a useRef guard to prevent
double-execution and moving replaceState into the fetch callbacks so
the URL is only cleaned after the exchange resolves.
Also adds login.oidcLoggedOut translation key in all 14 languages to
show "You have been logged out" instead of the generic OIDC-only
message when landing on /login after an intentional logout.
Closes #491
599 lines
29 KiB
TypeScript
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', { state: { noRedirect: true } })
|
|
} 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>
|
|
)}
|
|
</>
|
|
)
|
|
}
|