feat(profile): Benutzerprofilseite mit Passkeys, PIN und Statistiken

Zentralisiert Account-Verwaltung vom Dashboard aus: Identität, Passkey-CRUD, lokaler PIN und KPIs; Kontolöschung wandert ausschließlich in die Profilseite.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 09:19:56 +02:00
parent 79a54fdfc2
commit afc5a1e200
9 changed files with 1032 additions and 20 deletions
+136 -1
View File
@@ -742,8 +742,21 @@ html.scheme-dark .themed-select-option.is-selected {
background: rgba(148, 163, 184, 0.08);
border: 1px solid rgba(148, 163, 184, 0.18);
color: var(--app-text-muted);
cursor: default;
cursor: pointer;
user-select: none;
font-family: inherit;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.skipper-badge:hover {
background: rgba(148, 163, 184, 0.14);
border-color: rgba(148, 163, 184, 0.28);
color: var(--app-text);
}
.skipper-badge:focus-visible {
outline: 2px solid var(--app-accent, #38bdf8);
outline-offset: 2px;
}
.skipper-badge__name {
@@ -800,6 +813,128 @@ html.scheme-dark .themed-select-option.is-selected {
padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px));
}
.profile-main {
max-width: 900px;
margin: 0 auto;
padding: 0 24px 48px;
display: flex;
flex-direction: column;
gap: 24px;
}
.profile-back-btn {
margin-right: 12px;
}
.profile-dl {
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.profile-dl-row {
display: grid;
grid-template-columns: minmax(120px, 180px) 1fr;
gap: 8px 16px;
align-items: center;
}
.profile-dl-row dt {
margin: 0;
font-size: 13px;
color: var(--app-text-muted);
}
.profile-dl-row dd {
margin: 0;
font-size: 14px;
word-break: break-all;
}
.profile-user-id {
display: flex;
align-items: center;
gap: 8px;
}
.profile-user-id code {
font-size: 12px;
background: rgba(148, 163, 184, 0.08);
padding: 4px 8px;
border-radius: 6px;
}
.profile-copy-btn {
flex-shrink: 0;
}
.profile-section-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.profile-section-header h3 {
margin: 0;
font-size: 16px;
}
.profile-section-desc,
.profile-pin-status,
.profile-empty {
margin: 0 0 12px;
font-size: 13px;
color: var(--app-text-muted);
line-height: 1.5;
}
.profile-pin-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.profile-passkey-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.profile-passkey-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 10px;
background: rgba(148, 163, 184, 0.06);
border: 1px solid rgba(148, 163, 184, 0.12);
}
.profile-passkey-id {
display: block;
font-family: ui-monospace, monospace;
font-size: 13px;
}
.profile-passkey-transports {
display: block;
font-size: 11px;
color: var(--app-text-muted);
margin-top: 2px;
}
@media (max-width: 640px) {
.profile-dl-row {
grid-template-columns: 1fr;
}
}
.account-danger-zone {
border-top: 1px solid rgba(239, 68, 68, 0.2);
padding-top: 24px;
+15 -4
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import './App.css'
import { DialogProvider } from './components/ModalDialog.tsx'
import AuthOnboarding from './components/AuthOnboarding.tsx'
import UserProfilePage from './components/UserProfilePage.tsx'
import LogbookDashboard from './components/LogbookDashboard.tsx'
import VesselForm from './components/VesselForm.tsx'
import CrewForm from './components/CrewForm.tsx'
@@ -61,6 +62,7 @@ function App() {
const [online, setOnline] = useState(navigator.onLine)
const [isSyncing, setIsSyncing] = useState(false)
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
const [showUserProfile, setShowUserProfile] = useState(false)
// Viewer mode for read-only shared links
const [isViewerMode, setIsViewerMode] = useState(false)
@@ -361,6 +363,7 @@ function App() {
setIsAuthenticated(false)
setActiveLogbookId(null)
setActiveLogbookTitle(null)
setShowUserProfile(false)
setTourSelectedEntryId(null)
setDemoHighlightEntryId(null)
localStorage.removeItem('active_logbook_id')
@@ -442,10 +445,18 @@ function App() {
return (
<div style={{ display: 'contents' }}>
{pwaInstallBanner}
<LogbookDashboard
onSelectLogbook={selectLogbook}
onLogout={handleLogout}
/>
{showUserProfile ? (
<UserProfilePage
onBack={() => setShowUserProfile(false)}
onLogout={handleLogout}
/>
) : (
<LogbookDashboard
onSelectLogbook={selectLogbook}
onLogout={handleLogout}
onOpenProfile={() => setShowUserProfile(true)}
/>
)}
</div>
)
}
+8 -10
View File
@@ -8,7 +8,6 @@ import BetaBadge from './BetaBadge.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx'
import AccountDangerZone from './AccountDangerZone.tsx'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
@@ -16,9 +15,10 @@ import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
interface LogbookDashboardProps {
onSelectLogbook: (id: string, title: string) => void
onLogout: () => void
onOpenProfile: () => void
}
export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookDashboardProps) {
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) {
const { t, i18n } = useTranslation()
const { showConfirm } = useDialog()
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
@@ -210,14 +210,16 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
</div>
{/* Skipper profile */}
<div
<button
type="button"
className="skipper-badge"
title={t('dashboard.logged_in_as', { name: username })}
aria-label={t('dashboard.logged_in_as', { name: username })}
onClick={onOpenProfile}
title={t('dashboard.open_profile', { name: username })}
aria-label={t('dashboard.open_profile', { name: username })}
>
<User size={16} aria-hidden="true" />
<span className="skipper-badge__name">{username}</span>
</div>
</button>
{/* Lang toggle */}
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
@@ -289,10 +291,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
)}
</section>
</main>
<section className="dashboard-account-section" aria-label={t('settings.danger_zone_title')}>
<AccountDangerZone />
</section>
</div>
)
}
-3
View File
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, Compass } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js'
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
import AccountDangerZone from './AccountDangerZone.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
import PushNotificationSettings from './PushNotificationSettings.tsx'
import { useDialog } from './ModalDialog.tsx'
@@ -541,8 +540,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
)}
</div>
)}
{/* Danger Zone / Account Deletion */}
<AccountDangerZone className="mt-6" />
</div>
)
}
+479
View File
@@ -0,0 +1,479 @@
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
} from 'lucide-react'
import AccountDangerZone from './AccountDangerZone.tsx'
import BetaBadge from './BetaBadge.tsx'
import { useDialog } from './ModalDialog.tsx'
import {
addPasskey,
fetchUserProfile,
getActiveMasterKey,
hasLocalPin,
removeLocalPin,
removePasskey,
setLocalPin,
type UserProfile
} from '../services/auth.js'
import {
formatHours,
formatNm,
loadAccountStats,
type AccountStatsSummary
} from '../services/statsAggregation.js'
import { db } from '../services/db.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>
)
}
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 sharedLogbookCount = useLiveQuery(
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
[]
) ?? 0
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [profileData, stats] = await Promise.all([
fetchUserProfile(),
loadAccountStats(false)
])
setProfile(profileData)
setAccountStats(stats)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('profile.load_error'))
} finally {
setLoading(false)
}
}, [t])
useEffect(() => {
void loadData()
}, [loadData])
const statsTotals = accountStats?.totals
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 {
await addPasskey()
await loadData()
showAlert(t('profile.add_passkey_success'))
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('profile.add_passkey_failed'))
} finally {
setPasskeyBusy(false)
}
}
const handleRemovePasskey = async (credentialId: string) => {
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()
} 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
}
setPinBusy(true)
setError(null)
try {
await setLocalPin(pinInput.trim(), username, masterKey)
setPinActive(true)
setPinInput('')
setPinConfirm('')
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('')
}
return (
<div className="dashboard-container">
<header className="dashboard-header">
<div className="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>
) : 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">
<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>
<span className="profile-passkey-id">{cred.credentialIdPreview}</span>
{cred.transports.length > 0 && (
<span className="profile-passkey-transports">
{cred.transports.join(', ')}
</span>
)}
</div>
<button
type="button"
className="btn-icon danger"
onClick={() => void handleRemovePasskey(cred.id)}
disabled={passkeyBusy || profile.credentials.length <= 1}
title={
profile.credentials.length <= 1
? t('profile.remove_passkey_last')
: t('profile.remove_passkey_btn')
}
>
<Trash2 size={16} />
</button>
</li>
))}
</ul>
)}
<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 && (
<div className="stats-kpi-grid">
<KpiCard
icon={<BookOpen size={20} />}
label={t('profile.stats_logbooks')}
value={String(accountStats?.logbooks.length ?? profile.serverMeta.ownedLogbookCount)}
/>
<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={<Calendar size={20} />}
label={t('profile.stats_account_since')}
value={accountAgeLabel}
/>
<KpiCard
icon={<Share2 size={20} />}
label={t('profile.stats_shared_logbooks')}
value={String(sharedLogbookCount)}
/>
</div>
)}
</section>
<AccountDangerZone className="mt-6" />
</>
) : null}
</main>
</div>
)
}
+55 -1
View File
@@ -269,7 +269,61 @@
"role_crew": "Crew-Zugang",
"role_crew_hint": "Eingeladenes Logbuch — du kannst als Crew mitarbeiten und signieren",
"role_read": "Nur Lesen",
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung"
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
"open_profile": "Profil von {{name}} öffnen"
},
"profile": {
"title": "Benutzerprofil",
"subtitle": "Konto, Passkeys und Statistiken für {{name}}",
"back": "Zurück zum Dashboard",
"loading": "Profil wird geladen…",
"load_error": "Profil konnte nicht geladen werden.",
"copy_failed": "Kopieren fehlgeschlagen.",
"processing": "Wird verarbeitet…",
"identity_title": "Konto-Identität",
"username": "Benutzername",
"user_id": "Benutzer-ID",
"copy_user_id": "Benutzer-ID kopieren",
"account_since": "Konto seit",
"prf_status": "Passkey-Schlüsselableitung (PRF)",
"prf_active": "Aktiv",
"prf_inactive": "Nicht eingerichtet",
"passkeys_title": "Passkeys",
"passkeys_desc": "Registriere auf jedem Gerät einen eigenen Passkey. So kannst du dich auch nach einem Plattformwechsel anmelden.",
"passkeys_empty": "Keine Passkeys gefunden.",
"add_passkey_btn": "Neuen Passkey hinzufügen",
"add_passkey_success": "Passkey erfolgreich hinzugefügt.",
"add_passkey_failed": "Passkey konnte nicht hinzugefügt werden.",
"remove_passkey_btn": "Passkey entfernen",
"remove_passkey_last": "Der letzte Passkey kann nicht entfernt werden.",
"remove_passkey_failed": "Passkey konnte nicht entfernt werden.",
"remove_passkey_confirm_title": "Passkey entfernen?",
"remove_passkey_confirm_desc": "Dieses Gerät kann sich danach nicht mehr mit diesem Passkey anmelden.",
"remove_passkey_confirm_yes": "Entfernen",
"remove_passkey_confirm_no": "Abbrechen",
"pin_title": "Lokaler PIN",
"pin_status": "Status",
"pin_active": "Aktiv auf diesem Gerät",
"pin_inactive": "Nicht eingerichtet",
"pin_confirm_label": "PIN bestätigen",
"pin_confirm_placeholder": "PIN erneut eingeben",
"pin_set_btn": "PIN einrichten",
"pin_change_btn": "PIN ändern",
"pin_remove_btn": "PIN entfernen",
"pin_saved": "PIN gespeichert.",
"pin_save_failed": "PIN konnte nicht gespeichert werden.",
"pin_mismatch": "Die PIN-Eingaben stimmen nicht überein.",
"pin_length_error": "Die PIN muss mindestens 4 Zeichen haben.",
"pin_no_session": "Sitzung abgelaufen — bitte erneut anmelden.",
"remove_pin_confirm_title": "PIN entfernen?",
"remove_pin_confirm_desc": "Du musst dich auf diesem Gerät wieder mit Passkey oder Wiederherstellungsschlüssel anmelden.",
"remove_pin_confirm_yes": "PIN entfernen",
"remove_pin_confirm_no": "Abbrechen",
"stats_title": "Statistiken",
"stats_subtitle": "Über alle deine Logbücher auf diesem Gerät",
"stats_logbooks": "Logbücher",
"stats_account_since": "Konto seit",
"stats_shared_logbooks": "Geteilte Logbücher"
},
"crew": {
"title": "Skipper- & Crew-Profile",
+55 -1
View File
@@ -269,7 +269,61 @@
"role_crew": "Crew access",
"role_crew_hint": "Invited logbook — you can collaborate and sign as crew",
"role_read": "Read only",
"role_read_hint": "Shared logbook — view only, no editing"
"role_read_hint": "Shared logbook — view only, no editing",
"open_profile": "Open profile for {{name}}"
},
"profile": {
"title": "User profile",
"subtitle": "Account, passkeys and statistics for {{name}}",
"back": "Back to dashboard",
"loading": "Loading profile…",
"load_error": "Could not load profile.",
"copy_failed": "Copy failed.",
"processing": "Processing…",
"identity_title": "Account identity",
"username": "Username",
"user_id": "User ID",
"copy_user_id": "Copy user ID",
"account_since": "Account since",
"prf_status": "Passkey key derivation (PRF)",
"prf_active": "Active",
"prf_inactive": "Not configured",
"passkeys_title": "Passkeys",
"passkeys_desc": "Register a passkey on each device you use. This helps when switching platforms or browsers.",
"passkeys_empty": "No passkeys found.",
"add_passkey_btn": "Add new passkey",
"add_passkey_success": "Passkey added successfully.",
"add_passkey_failed": "Could not add passkey.",
"remove_passkey_btn": "Remove passkey",
"remove_passkey_last": "The last passkey cannot be removed.",
"remove_passkey_failed": "Could not remove passkey.",
"remove_passkey_confirm_title": "Remove passkey?",
"remove_passkey_confirm_desc": "This device will no longer be able to sign in with this passkey.",
"remove_passkey_confirm_yes": "Remove",
"remove_passkey_confirm_no": "Cancel",
"pin_title": "Local PIN",
"pin_status": "Status",
"pin_active": "Active on this device",
"pin_inactive": "Not configured",
"pin_confirm_label": "Confirm PIN",
"pin_confirm_placeholder": "Re-enter PIN",
"pin_set_btn": "Set PIN",
"pin_change_btn": "Change PIN",
"pin_remove_btn": "Remove PIN",
"pin_saved": "PIN saved.",
"pin_save_failed": "Could not save PIN.",
"pin_mismatch": "PIN entries do not match.",
"pin_length_error": "PIN must be at least 4 characters.",
"pin_no_session": "Session expired — please sign in again.",
"remove_pin_confirm_title": "Remove PIN?",
"remove_pin_confirm_desc": "You will need to sign in on this device with passkey or recovery phrase again.",
"remove_pin_confirm_yes": "Remove PIN",
"remove_pin_confirm_no": "Cancel",
"stats_title": "Statistics",
"stats_subtitle": "Across all your logbooks on this device",
"stats_logbooks": "Logbooks",
"stats_account_since": "Account since",
"stats_shared_logbooks": "Shared logbooks"
},
"crew": {
"title": "Skipper & Crew Profiles",
+96
View File
@@ -543,3 +543,99 @@ export async function deleteAccount(): Promise<boolean> {
}
return false
}
export interface UserProfileCredential {
id: string
credentialIdPreview: string
transports: string[]
}
export interface UserProfile {
userId: string
username: string
createdAt: string
hasPrfEncryption: boolean
credentials: UserProfileCredential[]
serverMeta: {
ownedLogbookCount: number
collaborationCount: number
}
}
export async function fetchUserProfile(): Promise<UserProfile> {
return apiJson<UserProfile>(`${API_BASE}/profile`)
}
async function enrollPrfFromMasterKey(masterKey: ArrayBuffer, prfFirst: ArrayBuffer): Promise<void> {
const prfKey = await deriveKeyFromPrf(prfFirst)
const encryptedPrf = await encryptBuffer(masterKey, prfKey)
await apiJson(`${API_BASE}/enroll-prf`, {
method: 'POST',
body: JSON.stringify({
encryptedMasterKeyPrf: encryptedPrf.ciphertext,
encryptedMasterKeyPrfIv: encryptedPrf.iv,
encryptedMasterKeyPrfTag: encryptedPrf.tag
})
})
}
export async function addPasskey(): Promise<void> {
await reauthWithPasskey()
const options = await apiJson<any>(`${API_BASE}/add-credential-options`, {
method: 'POST'
})
if (!options.extensions) {
options.extensions = {}
}
options.extensions.prf = { eval: { first: PRF_SALT.buffer } }
let credentialResponse
const prfRequested = !!options.extensions?.prf
try {
credentialResponse = await startRegistration({ optionsJSON: options })
} catch (err: any) {
const isOptionError = err.name === 'NotSupportedError' ||
err.message?.toLowerCase().includes('options') ||
err.message?.toLowerCase().includes('process') ||
err.message?.toLowerCase().includes('unable to')
if (prfRequested && isOptionError) {
console.warn('Add passkey with PRF extension failed, retrying without PRF:', err)
if (options.extensions) {
delete options.extensions.prf
}
credentialResponse = await startRegistration({ optionsJSON: options })
} else {
throw err
}
}
await apiJson(`${API_BASE}/add-credential-verify`, {
method: 'POST',
body: JSON.stringify({ credentialResponse })
})
const masterKey = getActiveMasterKey()
const prfFirstBuffer = extractPrfFirst(credentialResponse.clientExtensionResults || {})
if (masterKey && prfFirstBuffer) {
try {
await enrollPrfFromMasterKey(masterKey, prfFirstBuffer)
} catch (err) {
console.error('Failed to enroll PRF after adding passkey:', err)
}
}
}
export async function removePasskey(credentialDbId: string): Promise<void> {
await reauthWithPasskey()
const res = await apiFetch(`${API_BASE}/credentials/${credentialDbId}`, {
method: 'DELETE'
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'Failed to remove passkey')
}
}
+188
View File
@@ -22,8 +22,14 @@ const rpID = process.env.RP_ID || 'localhost'
const origin = process.env.ORIGIN || 'http://localhost:5173'
const registrationChallenges = new Map<string, string>()
const addCredentialChallenges = new Map<string, string>()
const activeChallenges = new Set<string>()
function previewCredentialId(credentialId: string): string {
if (credentialId.length <= 16) return credentialId
return `${credentialId.slice(0, 8)}${credentialId.slice(-8)}`
}
router.post('/register-options', async (req, res) => {
try {
const { username } = req.body
@@ -381,4 +387,186 @@ router.post('/enroll-prf', requireReauth, async (req: any, res) => {
}
})
router.get('/profile', requireUser, async (req: any, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.userId },
include: {
credentials: {
orderBy: { id: 'asc' }
},
_count: {
select: {
logbooks: true,
collaborations: true
}
}
}
})
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
return res.json({
userId: user.id,
username: user.username,
createdAt: user.createdAt.toISOString(),
hasPrfEncryption: user.encryptedMasterKeyPrf != null,
credentials: user.credentials.map((cred) => ({
id: cred.id,
credentialIdPreview: previewCredentialId(cred.credentialId),
transports: cred.transports
})),
serverMeta: {
ownedLogbookCount: user._count.logbooks,
collaborationCount: user._count.collaborations
}
})
} catch (error: any) {
console.error('Error fetching user profile:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
router.post('/add-credential-options', requireReauth, async (req: any, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.userId },
include: { credentials: true }
})
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
const userID = Buffer.from(user.username, 'utf8').toString('base64url')
const excludeCredentials = user.credentials.map((cred) => ({
id: Buffer.from(cred.credentialId, 'base64url'),
type: 'public-key' as const,
transports: cred.transports as any[]
}))
const options = await generateRegistrationOptions({
rpName,
rpID,
userID,
userName: user.username,
userDisplayName: user.username,
attestationType: 'none',
authenticatorSelection: {
residentKey: 'required',
userVerification: 'preferred'
},
supportedAlgorithmIDs: [-7, -257],
excludeCredentials
})
addCredentialChallenges.set(req.userId, options.challenge)
return res.json(options)
} catch (error: any) {
console.error('Error generating add-credential options:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
router.post('/add-credential-verify', requireReauth, async (req: any, res) => {
try {
const { credentialResponse } = req.body
if (!credentialResponse) {
return res.status(400).json({ error: 'credentialResponse is required' })
}
const expectedChallenge = addCredentialChallenges.get(req.userId)
if (!expectedChallenge) {
return res.status(400).json({ error: 'Challenge not found or expired' })
}
const user = await prisma.user.findUnique({
where: { id: req.userId }
})
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
const verification = await verifyRegistrationResponse({
response: credentialResponse,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID
})
if (!verification.verified || !verification.registrationInfo) {
return res.status(400).json({ error: 'WebAuthn verification failed' })
}
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo
const credentialId = Buffer.from(credentialID).toString('base64url')
const existing = await prisma.credential.findUnique({
where: { credentialId }
})
if (existing) {
return res.status(400).json({ error: 'Credential already registered' })
}
const credential = await prisma.credential.create({
data: {
userId: req.userId,
credentialId,
publicKey: Buffer.from(credentialPublicKey),
counter: BigInt(counter),
transports: credentialResponse.response.transports || []
}
})
addCredentialChallenges.delete(req.userId)
return res.json({
verified: true,
credential: {
id: credential.id,
credentialIdPreview: previewCredentialId(credential.credentialId),
transports: credential.transports
}
})
} catch (error: any) {
console.error('Error verifying add-credential response:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
router.delete('/credentials/:id', requireReauth, async (req: any, res) => {
try {
const { id } = req.params
const credential = await prisma.credential.findUnique({
where: { id }
})
if (!credential || credential.userId !== req.userId) {
return res.status(404).json({ error: 'Credential not found' })
}
const credentialCount = await prisma.credential.count({
where: { userId: req.userId }
})
if (credentialCount <= 1) {
return res.status(400).json({ error: 'Cannot remove the last passkey' })
}
await prisma.credential.delete({
where: { id }
})
return res.json({ success: true })
} catch (error: any) {
console.error('Error deleting credential:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
export default router