import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useLiveQuery } from 'dexie-react-hooks' import { useSyncIndicator } from '../hooks/useSyncIndicator.js' 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 UserProfilePreferences from './UserProfilePreferences.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 (
{icon}
{label} {value} {unit ? {unit} : null}
) } function SecurityCheckItem({ ok, label }: { ok: boolean; label: string }) { return (
  • {ok ?
  • ) } 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(null) const [accountStats, setAccountStats] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(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>({}) 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(null) const [recoveryCopied, setRecoveryCopied] = useState(false) const { pendingCount: pendingSyncCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator() 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 = {} 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 (

    {t('profile.title')}

    {t('profile.subtitle', { name: username })}

    {error &&
    {error}
    } {loading ? (

    {t('profile.loading')}

    ) : pendingRecoveryPhrase ? (

    {t('auth.recovery_title')}

    {t('profile.recovery_rotate_new_warning')}

    {pendingRecoveryPhrase.split(' ').map((word, idx) => (
    {idx + 1} {word}
    ))}
    ) : profile ? ( <>

    {t('profile.identity_title')}

    {t('profile.username')}
    {profile.username}
    {t('profile.user_id')}
    {profile.userId}
    {t('profile.account_since')}
    {accountAgeLabel}
    {t('profile.prf_status')}
    {profile.hasPrfEncryption ? t('profile.prf_active') : t('profile.prf_inactive')}

    {t('profile.security_title')}

    {t('profile.security_desc')}

      0} label={ profile.credentials.length > 0 ? t('profile.security_passkeys_ok') : t('profile.security_passkeys_missing') } />

    {t('profile.security_recovery_hint')}

    {t('profile.device_title')}

    {t('profile.device_desc')}

    {online ? ( showSpinner ? ( <>

    {isKnownDevice ? t('profile.device_remembered') : t('profile.device_not_remembered')}

    {isKnownDevice && (
    )}

    {t('profile.pin_title')}

    {t('auth.setup_pin_warning')}

    {t('profile.pin_status')}:{' '} {pinActive ? t('profile.pin_active') : t('profile.pin_inactive')}

    void handleSavePin(e)} className="profile-pin-form">
    setPinInput(e.target.value)} disabled={pinBusy} />
    setPinConfirm(e.target.value)} disabled={pinBusy} />
    {pinActive && ( )}

    {t('profile.passkeys_title')}

    {t('profile.passkeys_desc')}

    {profile.credentials.length === 0 ? (

    {t('profile.passkeys_empty')}

    ) : (
      {profile.credentials.map((cred) => (
    • {cred.label || t('profile.passkey_unnamed')} {cred.credentialIdPreview} {cred.transports.length > 0 && ( {cred.transports.join(', ')} )}
      setPasskeyLabels((prev) => ({ ...prev, [cred.id]: e.target.value })) } placeholder={t('profile.passkey_label_placeholder')} disabled={passkeyBusy} maxLength={64} />
    • ))}
    )}
    setNewPasskeyLabel(e.target.value)} placeholder={t('profile.passkey_label_placeholder')} disabled={passkeyBusy} maxLength={64} />

    {t('profile.stats_title')}

    {t('profile.stats_subtitle')}

    {(statsTotals || profile) && (
    } label={t('profile.stats_logbooks')} value={String(logbookCount)} /> {statsTotals && ( <> } label={t('stats.travel_days')} value={String(statsTotals.travelDayCount)} /> } label={t('stats.total_distance')} value={formatNm(statsTotals.totalDistanceNm)} unit={t('stats.unit_nm')} /> } label={t('stats.sail_distance')} value={formatNm(statsTotals.sailDistanceNm)} unit={t('stats.unit_nm')} /> } label={t('stats.motor_distance')} value={formatNm(statsTotals.motorDistanceNm)} unit={t('stats.unit_nm')} /> } label={t('stats.motor_hours_total')} value={formatHours(statsTotals.totalMotorHours)} unit={t('stats.unit_h')} /> } label={t('profile.stats_shared_logbooks')} value={String(sharedLogbookCount)} /> )} } label={t('profile.stats_account_since')} value={accountAgeLabel} />
    )}
    ) : null}
    ) }