7f0223c636
Verwendet recovery_rotate_confirm_no statt remove_passkey_confirm_no. Co-authored-by: Cursor <cursoragent@cursor.com>
783 lines
27 KiB
TypeScript
783 lines
27 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useLiveQuery } from 'dexie-react-hooks'
|
|
import {
|
|
User,
|
|
ChevronLeft,
|
|
LogOut,
|
|
KeyRound,
|
|
Copy,
|
|
Check,
|
|
Plus,
|
|
Trash2,
|
|
BookOpen,
|
|
Anchor,
|
|
Gauge,
|
|
Sailboat,
|
|
Timer,
|
|
Share2,
|
|
Calendar,
|
|
Lock,
|
|
BarChart2,
|
|
Shield,
|
|
Smartphone,
|
|
RefreshCw,
|
|
Wifi,
|
|
WifiOff,
|
|
CircleCheck,
|
|
CircleAlert
|
|
} from 'lucide-react'
|
|
import AccountDangerZone from './AccountDangerZone.tsx'
|
|
import BetaBadge from './BetaBadge.tsx'
|
|
import { useDialog } from './ModalDialog.tsx'
|
|
import {
|
|
addPasskey,
|
|
fetchUserProfile,
|
|
forgetUsername,
|
|
getActiveMasterKey,
|
|
getKnownUsernames,
|
|
hasLocalPin,
|
|
removeLocalPin,
|
|
removePasskey,
|
|
renamePasskey,
|
|
rotateRecoveryPhrase,
|
|
setLocalPin,
|
|
type UserProfile
|
|
} from '../services/auth.js'
|
|
import {
|
|
formatHours,
|
|
formatNm,
|
|
loadAccountStats,
|
|
type AccountStatsSummary
|
|
} from '../services/statsAggregation.js'
|
|
import { db } from '../services/db.js'
|
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
|
|
|
interface UserProfilePageProps {
|
|
onBack: () => void
|
|
onLogout: () => void
|
|
}
|
|
|
|
function formatAccountAge(createdAt: string, locale: string): string {
|
|
const created = new Date(createdAt)
|
|
if (Number.isNaN(created.getTime())) return createdAt
|
|
return created.toLocaleDateString(locale, {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric'
|
|
})
|
|
}
|
|
|
|
function KpiCard({
|
|
icon,
|
|
label,
|
|
value,
|
|
unit
|
|
}: {
|
|
icon: React.ReactNode
|
|
label: string
|
|
value: string
|
|
unit?: string
|
|
}) {
|
|
return (
|
|
<div className="stats-kpi-card glass">
|
|
<div className="stats-kpi-icon">{icon}</div>
|
|
<div className="stats-kpi-body">
|
|
<span className="stats-kpi-label">{label}</span>
|
|
<span className="stats-kpi-value">
|
|
{value}
|
|
{unit ? <span className="stats-kpi-unit">{unit}</span> : null}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SecurityCheckItem({ ok, label }: { ok: boolean; label: string }) {
|
|
return (
|
|
<li className={`profile-security-item ${ok ? 'profile-security-item--ok' : 'profile-security-item--warn'}`}>
|
|
{ok ? <CircleCheck size={18} aria-hidden="true" /> : <CircleAlert size={18} aria-hidden="true" />}
|
|
<span>{label}</span>
|
|
</li>
|
|
)
|
|
}
|
|
|
|
export default function UserProfilePage({ onBack, onLogout }: UserProfilePageProps) {
|
|
const { t, i18n } = useTranslation()
|
|
const { showConfirm, showAlert } = useDialog()
|
|
const username = localStorage.getItem('active_username') || 'Skipper'
|
|
|
|
const [profile, setProfile] = useState<UserProfile | null>(null)
|
|
const [accountStats, setAccountStats] = useState<AccountStatsSummary | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [copiedUserId, setCopiedUserId] = useState(false)
|
|
const [passkeyBusy, setPasskeyBusy] = useState(false)
|
|
const [pinBusy, setPinBusy] = useState(false)
|
|
const [pinInput, setPinInput] = useState('')
|
|
const [pinConfirm, setPinConfirm] = useState('')
|
|
const [pinActive, setPinActive] = useState(() => hasLocalPin(username))
|
|
const [newPasskeyLabel, setNewPasskeyLabel] = useState('')
|
|
const [passkeyLabels, setPasskeyLabels] = useState<Record<string, string>>({})
|
|
const [online, setOnline] = useState(navigator.onLine)
|
|
const [isKnownDevice, setIsKnownDevice] = useState(() =>
|
|
getKnownUsernames().some((u) => u.toLowerCase() === username.toLowerCase())
|
|
)
|
|
const [recoveryBusy, setRecoveryBusy] = useState(false)
|
|
const [pendingRecoveryPhrase, setPendingRecoveryPhrase] = useState<string | null>(null)
|
|
const [recoveryCopied, setRecoveryCopied] = useState(false)
|
|
|
|
const pendingSyncCount = useLiveQuery(() => db.syncQueue.count()) ?? 0
|
|
|
|
const sharedLogbookCount = useLiveQuery(
|
|
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
|
|
[]
|
|
) ?? 0
|
|
|
|
const loadData = useCallback(async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const profileData = await fetchUserProfile()
|
|
setProfile(profileData)
|
|
|
|
try {
|
|
const stats = await loadAccountStats(false)
|
|
setAccountStats(stats)
|
|
} catch (statsErr) {
|
|
console.error('Failed to load account stats for profile:', statsErr)
|
|
setAccountStats(null)
|
|
}
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : t('profile.load_error'))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [t])
|
|
|
|
useEffect(() => {
|
|
void loadData()
|
|
}, [loadData])
|
|
|
|
useEffect(() => {
|
|
trackPlausibleEvent(PlausibleEvents.PROFILE_OPENED)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const handleOnline = () => setOnline(true)
|
|
const handleOffline = () => setOnline(false)
|
|
window.addEventListener('online', handleOnline)
|
|
window.addEventListener('offline', handleOffline)
|
|
return () => {
|
|
window.removeEventListener('online', handleOnline)
|
|
window.removeEventListener('offline', handleOffline)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!profile) return
|
|
const labels: Record<string, string> = {}
|
|
for (const cred of profile.credentials) {
|
|
labels[cred.id] = cred.label ?? ''
|
|
}
|
|
setPasskeyLabels(labels)
|
|
}, [profile])
|
|
|
|
const statsTotals = accountStats?.totals
|
|
const logbookCount =
|
|
accountStats?.logbooks.length ?? profile?.serverMeta.ownedLogbookCount ?? 0
|
|
|
|
const accountAgeLabel = useMemo(() => {
|
|
if (!profile?.createdAt) return '—'
|
|
return formatAccountAge(profile.createdAt, i18n.language)
|
|
}, [profile?.createdAt, i18n.language])
|
|
|
|
const handleCopyUserId = async () => {
|
|
if (!profile?.userId) return
|
|
try {
|
|
await navigator.clipboard.writeText(profile.userId)
|
|
setCopiedUserId(true)
|
|
window.setTimeout(() => setCopiedUserId(false), 2000)
|
|
} catch {
|
|
showAlert(t('profile.copy_failed'))
|
|
}
|
|
}
|
|
|
|
const handleAddPasskey = async () => {
|
|
setPasskeyBusy(true)
|
|
setError(null)
|
|
try {
|
|
const hadLabel = Boolean(newPasskeyLabel.trim())
|
|
await addPasskey(newPasskeyLabel)
|
|
setNewPasskeyLabel('')
|
|
await loadData()
|
|
trackPlausibleEvent(PlausibleEvents.PASSKEY_ADDED, { labeled: hadLabel })
|
|
showAlert(t('profile.add_passkey_success'))
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : t('profile.add_passkey_failed'))
|
|
} finally {
|
|
setPasskeyBusy(false)
|
|
}
|
|
}
|
|
|
|
const handleRenamePasskey = async (credentialId: string) => {
|
|
setPasskeyBusy(true)
|
|
setError(null)
|
|
try {
|
|
await renamePasskey(credentialId, passkeyLabels[credentialId] ?? '')
|
|
await loadData()
|
|
trackPlausibleEvent(PlausibleEvents.PASSKEY_RENAMED)
|
|
showAlert(t('profile.passkey_rename_success'))
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : t('profile.passkey_rename_failed'))
|
|
} finally {
|
|
setPasskeyBusy(false)
|
|
}
|
|
}
|
|
|
|
const handleForgetDevice = async () => {
|
|
const confirmed = await showConfirm(
|
|
t('profile.device_forget_confirm_desc'),
|
|
t('profile.device_forget_confirm_title'),
|
|
t('profile.device_forget_confirm_yes'),
|
|
t('profile.device_forget_confirm_no')
|
|
)
|
|
if (!confirmed) return
|
|
|
|
forgetUsername(username)
|
|
setIsKnownDevice(false)
|
|
trackPlausibleEvent(PlausibleEvents.DEVICE_FORGOTTEN)
|
|
}
|
|
|
|
const handleRemovePasskey = async (credentialId: string) => {
|
|
if (profile && profile.credentials.length <= 1) {
|
|
trackPlausibleEvent(PlausibleEvents.LAST_PASSKEY_REMOVE_HINTED)
|
|
await showAlert(
|
|
t('profile.remove_passkey_last_desc'),
|
|
t('profile.remove_passkey_last_title')
|
|
)
|
|
return
|
|
}
|
|
|
|
const confirmed = await showConfirm(
|
|
t('profile.remove_passkey_confirm_desc'),
|
|
t('profile.remove_passkey_confirm_title'),
|
|
t('profile.remove_passkey_confirm_yes'),
|
|
t('profile.remove_passkey_confirm_no')
|
|
)
|
|
if (!confirmed) return
|
|
|
|
setPasskeyBusy(true)
|
|
setError(null)
|
|
try {
|
|
await removePasskey(credentialId)
|
|
await loadData()
|
|
trackPlausibleEvent(PlausibleEvents.PASSKEY_REMOVED)
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : t('profile.remove_passkey_failed'))
|
|
} finally {
|
|
setPasskeyBusy(false)
|
|
}
|
|
}
|
|
|
|
const handleSavePin = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (pinInput.length < 4) {
|
|
setError(t('profile.pin_length_error'))
|
|
return
|
|
}
|
|
if (pinInput !== pinConfirm) {
|
|
setError(t('profile.pin_mismatch'))
|
|
return
|
|
}
|
|
|
|
const masterKey = getActiveMasterKey()
|
|
if (!masterKey) {
|
|
setError(t('profile.pin_no_session'))
|
|
return
|
|
}
|
|
|
|
const pinAction = pinActive ? 'change' : 'set'
|
|
|
|
setPinBusy(true)
|
|
setError(null)
|
|
try {
|
|
await setLocalPin(pinInput.trim(), username, masterKey)
|
|
setPinActive(true)
|
|
setPinInput('')
|
|
setPinConfirm('')
|
|
trackPlausibleEvent(PlausibleEvents.LOCAL_PIN_SET, { action: pinAction })
|
|
showAlert(t('profile.pin_saved'))
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : t('profile.pin_save_failed'))
|
|
} finally {
|
|
setPinBusy(false)
|
|
}
|
|
}
|
|
|
|
const handleRemovePin = async () => {
|
|
const confirmed = await showConfirm(
|
|
t('profile.remove_pin_confirm_desc'),
|
|
t('profile.remove_pin_confirm_title'),
|
|
t('profile.remove_pin_confirm_yes'),
|
|
t('profile.remove_pin_confirm_no')
|
|
)
|
|
if (!confirmed) return
|
|
|
|
removeLocalPin(username)
|
|
setPinActive(false)
|
|
setPinInput('')
|
|
setPinConfirm('')
|
|
trackPlausibleEvent(PlausibleEvents.LOCAL_PIN_REMOVED)
|
|
}
|
|
|
|
const handleRotateRecovery = async () => {
|
|
const confirmed = await showConfirm(
|
|
t('profile.recovery_rotate_confirm_desc'),
|
|
t('profile.recovery_rotate_confirm_title'),
|
|
t('profile.recovery_rotate_confirm_yes'),
|
|
t('profile.recovery_rotate_confirm_no')
|
|
)
|
|
if (!confirmed) return
|
|
|
|
if (!getActiveMasterKey()) {
|
|
setError(t('profile.recovery_rotate_no_session'))
|
|
return
|
|
}
|
|
|
|
setRecoveryBusy(true)
|
|
setError(null)
|
|
try {
|
|
const phrase = await rotateRecoveryPhrase()
|
|
setPendingRecoveryPhrase(phrase)
|
|
trackPlausibleEvent(PlausibleEvents.RECOVERY_ROTATED)
|
|
} catch (err: unknown) {
|
|
if (err instanceof Error && err.message === 'NO_ACTIVE_MASTER_KEY') {
|
|
setError(t('profile.recovery_rotate_no_session'))
|
|
} else {
|
|
setError(err instanceof Error ? err.message : t('profile.recovery_rotate_failed'))
|
|
}
|
|
} finally {
|
|
setRecoveryBusy(false)
|
|
}
|
|
}
|
|
|
|
const handleCopyRecoveryPhrase = async () => {
|
|
if (!pendingRecoveryPhrase) return
|
|
try {
|
|
await navigator.clipboard.writeText(pendingRecoveryPhrase)
|
|
setRecoveryCopied(true)
|
|
window.setTimeout(() => setRecoveryCopied(false), 2000)
|
|
} catch {
|
|
showAlert(t('profile.copy_failed'))
|
|
}
|
|
}
|
|
|
|
const handleConfirmRecoverySaved = () => {
|
|
setPendingRecoveryPhrase(null)
|
|
setRecoveryCopied(false)
|
|
}
|
|
|
|
return (
|
|
<div className="dashboard-container">
|
|
<header className="dashboard-header dashboard-header--profile">
|
|
<div className="header-brand profile-header-brand">
|
|
<button className="btn-back profile-back-btn" onClick={onBack} title={t('profile.back')}>
|
|
<ChevronLeft size={16} />
|
|
<span>{t('profile.back')}</span>
|
|
</button>
|
|
<div>
|
|
<div className="header-brand-title-row">
|
|
<h1>{t('profile.title')}</h1>
|
|
<BetaBadge />
|
|
</div>
|
|
<p className="subtitle">{t('profile.subtitle', { name: username })}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="header-actions">
|
|
<button className="btn-icon logout" onClick={onLogout} title={t('dashboard.logout')}>
|
|
<LogOut size={18} />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="profile-main">
|
|
{error && <div className="auth-error mb-4">{error}</div>}
|
|
|
|
{loading ? (
|
|
<div className="tab-placeholder">
|
|
<User className="header-logo spin" size={48} />
|
|
<p>{t('profile.loading')}</p>
|
|
</div>
|
|
) : pendingRecoveryPhrase ? (
|
|
<section className="form-card profile-recovery-card">
|
|
<div className="form-header">
|
|
<KeyRound size={24} className="form-icon" />
|
|
<h2>{t('auth.recovery_title')}</h2>
|
|
</div>
|
|
<p className="profile-recovery-warning">{t('profile.recovery_rotate_new_warning')}</p>
|
|
<div className="phrase-grid">
|
|
{pendingRecoveryPhrase.split(' ').map((word, idx) => (
|
|
<div key={idx} className="phrase-word">
|
|
<span className="word-num">{idx + 1}</span>
|
|
{word}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="form-actions profile-recovery-actions">
|
|
<button type="button" className="btn secondary" onClick={() => void handleCopyRecoveryPhrase()}>
|
|
{recoveryCopied ? t('auth.copied') : t('auth.copy_phrase')}
|
|
</button>
|
|
<button type="button" className="btn primary" onClick={handleConfirmRecoverySaved}>
|
|
{t('auth.confirm_recovery')}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
) : profile ? (
|
|
<>
|
|
<section className="form-card">
|
|
<div className="form-header">
|
|
<User size={24} className="form-icon" />
|
|
<h2>{t('profile.identity_title')}</h2>
|
|
</div>
|
|
|
|
<dl className="profile-dl">
|
|
<div className="profile-dl-row">
|
|
<dt>{t('profile.username')}</dt>
|
|
<dd>{profile.username}</dd>
|
|
</div>
|
|
<div className="profile-dl-row">
|
|
<dt>{t('profile.user_id')}</dt>
|
|
<dd className="profile-user-id">
|
|
<code>{profile.userId}</code>
|
|
<button
|
|
type="button"
|
|
className="btn-icon profile-copy-btn"
|
|
onClick={() => void handleCopyUserId()}
|
|
title={t('profile.copy_user_id')}
|
|
>
|
|
{copiedUserId ? <Check size={16} /> : <Copy size={16} />}
|
|
</button>
|
|
</dd>
|
|
</div>
|
|
<div className="profile-dl-row">
|
|
<dt>{t('profile.account_since')}</dt>
|
|
<dd>{accountAgeLabel}</dd>
|
|
</div>
|
|
<div className="profile-dl-row">
|
|
<dt>{t('profile.prf_status')}</dt>
|
|
<dd>
|
|
{profile.hasPrfEncryption
|
|
? t('profile.prf_active')
|
|
: t('profile.prf_inactive')}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</section>
|
|
|
|
<section className="member-editor-card glass">
|
|
<div className="profile-section-header">
|
|
<Shield size={20} />
|
|
<h3>{t('profile.security_title')}</h3>
|
|
</div>
|
|
<p className="profile-section-desc">{t('profile.security_desc')}</p>
|
|
<ul className="profile-security-list">
|
|
<SecurityCheckItem
|
|
ok={profile.credentials.length > 0}
|
|
label={
|
|
profile.credentials.length > 0
|
|
? t('profile.security_passkeys_ok')
|
|
: t('profile.security_passkeys_missing')
|
|
}
|
|
/>
|
|
<SecurityCheckItem
|
|
ok={profile.hasPrfEncryption}
|
|
label={
|
|
profile.hasPrfEncryption
|
|
? t('profile.security_prf_ok')
|
|
: t('profile.security_prf_missing')
|
|
}
|
|
/>
|
|
<SecurityCheckItem
|
|
ok={pinActive}
|
|
label={pinActive ? t('profile.security_pin_ok') : t('profile.security_pin_missing')}
|
|
/>
|
|
<SecurityCheckItem ok label={t('profile.security_recovery_ok')} />
|
|
</ul>
|
|
<p className="profile-section-desc profile-recovery-hint">{t('profile.security_recovery_hint')}</p>
|
|
<div className="form-actions profile-recovery-actions">
|
|
<button
|
|
type="button"
|
|
className="btn secondary"
|
|
onClick={() => void handleRotateRecovery()}
|
|
disabled={recoveryBusy || passkeyBusy || pinBusy}
|
|
>
|
|
{recoveryBusy ? t('profile.processing') : t('profile.recovery_rotate_btn')}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="member-editor-card glass">
|
|
<div className="profile-section-header">
|
|
<Smartphone size={20} />
|
|
<h3>{t('profile.device_title')}</h3>
|
|
</div>
|
|
<p className="profile-section-desc">{t('profile.device_desc')}</p>
|
|
<div className={`profile-device-status conn-status ${online ? (pendingSyncCount > 0 ? 'warning' : 'online') : 'offline'}`}>
|
|
{online ? (
|
|
pendingSyncCount > 0 ? (
|
|
<>
|
|
<RefreshCw size={16} className="spin" aria-hidden="true" />
|
|
<span>{t('profile.device_sync_pending', { count: pendingSyncCount })}</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Wifi size={16} aria-hidden="true" />
|
|
<span>{t('profile.device_sync_ok')}</span>
|
|
</>
|
|
)
|
|
) : (
|
|
<>
|
|
<WifiOff size={16} aria-hidden="true" />
|
|
<span>{t('sync.status_offline')}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
<p className="profile-pin-status">
|
|
{isKnownDevice ? t('profile.device_remembered') : t('profile.device_not_remembered')}
|
|
</p>
|
|
{isKnownDevice && (
|
|
<div className="form-actions">
|
|
<button
|
|
type="button"
|
|
className="btn secondary"
|
|
onClick={() => void handleForgetDevice()}
|
|
>
|
|
{t('profile.device_forget_btn')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="member-editor-card glass">
|
|
<div className="profile-section-header">
|
|
<Lock size={20} />
|
|
<h3>{t('profile.pin_title')}</h3>
|
|
</div>
|
|
<p className="profile-section-desc">{t('auth.setup_pin_warning')}</p>
|
|
<p className="profile-pin-status">
|
|
{t('profile.pin_status')}:{' '}
|
|
<strong>{pinActive ? t('profile.pin_active') : t('profile.pin_inactive')}</strong>
|
|
</p>
|
|
|
|
<form onSubmit={(e) => void handleSavePin(e)} className="profile-pin-form">
|
|
<div className="input-group">
|
|
<label htmlFor="profile-pin">{t('auth.pin_label')}</label>
|
|
<input
|
|
id="profile-pin"
|
|
type="password"
|
|
inputMode="numeric"
|
|
autoComplete="new-password"
|
|
className="input-text"
|
|
placeholder={t('auth.pin_placeholder')}
|
|
value={pinInput}
|
|
onChange={(e) => setPinInput(e.target.value)}
|
|
disabled={pinBusy}
|
|
/>
|
|
</div>
|
|
<div className="input-group">
|
|
<label htmlFor="profile-pin-confirm">{t('profile.pin_confirm_label')}</label>
|
|
<input
|
|
id="profile-pin-confirm"
|
|
type="password"
|
|
inputMode="numeric"
|
|
autoComplete="new-password"
|
|
className="input-text"
|
|
placeholder={t('profile.pin_confirm_placeholder')}
|
|
value={pinConfirm}
|
|
onChange={(e) => setPinConfirm(e.target.value)}
|
|
disabled={pinBusy}
|
|
/>
|
|
</div>
|
|
<div className="form-actions">
|
|
<button
|
|
type="submit"
|
|
className="btn primary"
|
|
disabled={pinBusy || pinInput.length < 4 || pinConfirm.length < 4}
|
|
>
|
|
{pinActive ? t('profile.pin_change_btn') : t('profile.pin_set_btn')}
|
|
</button>
|
|
{pinActive && (
|
|
<button
|
|
type="button"
|
|
className="btn secondary"
|
|
onClick={() => void handleRemovePin()}
|
|
disabled={pinBusy}
|
|
>
|
|
{t('profile.pin_remove_btn')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<section className="member-editor-card glass">
|
|
<div className="profile-section-header">
|
|
<KeyRound size={20} />
|
|
<h3>{t('profile.passkeys_title')}</h3>
|
|
</div>
|
|
<p className="profile-section-desc">{t('profile.passkeys_desc')}</p>
|
|
|
|
{profile.credentials.length === 0 ? (
|
|
<p className="profile-empty">{t('profile.passkeys_empty')}</p>
|
|
) : (
|
|
<ul className="profile-passkey-list">
|
|
{profile.credentials.map((cred) => (
|
|
<li key={cred.id} className="profile-passkey-item">
|
|
<div className="profile-passkey-main">
|
|
<span className="profile-passkey-label">
|
|
{cred.label || t('profile.passkey_unnamed')}
|
|
</span>
|
|
<span className="profile-passkey-id">{cred.credentialIdPreview}</span>
|
|
{cred.transports.length > 0 && (
|
|
<span className="profile-passkey-transports">
|
|
{cred.transports.join(', ')}
|
|
</span>
|
|
)}
|
|
<div className="profile-passkey-rename">
|
|
<input
|
|
type="text"
|
|
className="input-text"
|
|
value={passkeyLabels[cred.id] ?? ''}
|
|
onChange={(e) =>
|
|
setPasskeyLabels((prev) => ({ ...prev, [cred.id]: e.target.value }))
|
|
}
|
|
placeholder={t('profile.passkey_label_placeholder')}
|
|
disabled={passkeyBusy}
|
|
maxLength={64}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn secondary"
|
|
onClick={() => void handleRenamePasskey(cred.id)}
|
|
disabled={passkeyBusy}
|
|
>
|
|
{t('profile.passkey_rename_btn')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="btn-icon danger"
|
|
onClick={() => void handleRemovePasskey(cred.id)}
|
|
disabled={passkeyBusy}
|
|
title={t('profile.remove_passkey_btn')}
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
<div className="profile-add-passkey">
|
|
<div className="input-group">
|
|
<label htmlFor="profile-new-passkey-label">{t('profile.passkey_label')}</label>
|
|
<input
|
|
id="profile-new-passkey-label"
|
|
type="text"
|
|
className="input-text"
|
|
value={newPasskeyLabel}
|
|
onChange={(e) => setNewPasskeyLabel(e.target.value)}
|
|
placeholder={t('profile.passkey_label_placeholder')}
|
|
disabled={passkeyBusy}
|
|
maxLength={64}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-actions mt-4">
|
|
<button
|
|
type="button"
|
|
className="btn primary"
|
|
onClick={() => void handleAddPasskey()}
|
|
disabled={passkeyBusy}
|
|
>
|
|
<Plus size={16} />
|
|
{passkeyBusy ? t('profile.processing') : t('profile.add_passkey_btn')}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="form-card">
|
|
<div className="form-header">
|
|
<BarChart2 size={24} className="form-icon" />
|
|
<div>
|
|
<h2>{t('profile.stats_title')}</h2>
|
|
<p className="stats-subtitle">{t('profile.stats_subtitle')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{(statsTotals || profile) && (
|
|
<div className="stats-kpi-grid">
|
|
<KpiCard
|
|
icon={<BookOpen size={20} />}
|
|
label={t('profile.stats_logbooks')}
|
|
value={String(logbookCount)}
|
|
/>
|
|
{statsTotals && (
|
|
<>
|
|
<KpiCard
|
|
icon={<Anchor size={20} />}
|
|
label={t('stats.travel_days')}
|
|
value={String(statsTotals.travelDayCount)}
|
|
/>
|
|
<KpiCard
|
|
icon={<Gauge size={20} />}
|
|
label={t('stats.total_distance')}
|
|
value={formatNm(statsTotals.totalDistanceNm)}
|
|
unit={t('stats.unit_nm')}
|
|
/>
|
|
<KpiCard
|
|
icon={<Sailboat size={20} />}
|
|
label={t('stats.sail_distance')}
|
|
value={formatNm(statsTotals.sailDistanceNm)}
|
|
unit={t('stats.unit_nm')}
|
|
/>
|
|
<KpiCard
|
|
icon={<Gauge size={20} />}
|
|
label={t('stats.motor_distance')}
|
|
value={formatNm(statsTotals.motorDistanceNm)}
|
|
unit={t('stats.unit_nm')}
|
|
/>
|
|
<KpiCard
|
|
icon={<Timer size={20} />}
|
|
label={t('stats.motor_hours_total')}
|
|
value={formatHours(statsTotals.totalMotorHours)}
|
|
unit={t('stats.unit_h')}
|
|
/>
|
|
<KpiCard
|
|
icon={<Share2 size={20} />}
|
|
label={t('profile.stats_shared_logbooks')}
|
|
value={String(sharedLogbookCount)}
|
|
/>
|
|
</>
|
|
)}
|
|
<KpiCard
|
|
icon={<Calendar size={20} />}
|
|
label={t('profile.stats_account_since')}
|
|
value={accountAgeLabel}
|
|
/>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<AccountDangerZone className="mt-6" />
|
|
</>
|
|
) : null}
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|