Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b48b31580d | |||
| 7f0223c636 | |||
| 68af8c6361 | |||
| ad7e036ab7 | |||
| 12c02f6392 | |||
| 3698c6fbca | |||
| d4538ec06e | |||
| 86cb4d92ec | |||
| b72b20b66c | |||
| 6ad75ff947 | |||
| 75eba362d6 | |||
| afc5a1e200 | |||
| 79a54fdfc2 | |||
| e73c078463 | |||
| 2eb6551200 | |||
| 9baaccf239 | |||
| df53420f3b | |||
| 5271ed90c1 | |||
| a8ba998444 | |||
| 67d169080e |
+4
-1
@@ -5,11 +5,14 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Kostenloses, werbefreies digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren – auch offline als PWA." />
|
||||
<meta name="keywords" content="Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch, kostenlos, werbefrei, gratis, ohne Werbung" />
|
||||
<meta name="keywords" content="Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch, kostenlos, werbefrei, gratis, ohne Werbung, yacht logbook, sailing log, ad-free" />
|
||||
<meta name="author" content="Markus F.J. Busche" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="application-name" content="Kapteins Daagbok" />
|
||||
<link rel="canonical" href="https://kapteins-daagbok.eu/" />
|
||||
<link rel="alternate" hreflang="de" href="https://kapteins-daagbok.eu/?lng=de" />
|
||||
<link rel="alternate" hreflang="en" href="https://kapteins-daagbok.eu/?lng=en" />
|
||||
<link rel="alternate" hreflang="x-default" href="https://kapteins-daagbok.eu/" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
+278
-9
@@ -732,17 +732,13 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.skipper-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.skipper-badge.btn-icon {
|
||||
width: auto;
|
||||
border-radius: 18px;
|
||||
padding: 0 12px;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
color: var(--app-text-muted);
|
||||
cursor: default;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -800,6 +796,274 @@ 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;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dashboard-header--profile .profile-header-brand {
|
||||
align-items: flex-start;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-back-btn {
|
||||
margin-top: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-dl {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-dl-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 200px) minmax(0, 1fr);
|
||||
gap: 8px 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.profile-dl-row dt {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
text-align: left;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.profile-dl-row dd {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
word-break: break-word;
|
||||
text-align: left;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.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;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.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;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.profile-pin-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profile-pin-form .input-group label {
|
||||
display: block;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
color: var(--app-text-muted);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.profile-main .form-actions:not(.account-danger-zone__actions) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.profile-passkey-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-passkey-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
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-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-passkey-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--app-text);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.profile-passkey-rename {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.profile-passkey-rename .input-text {
|
||||
flex: 1 1 160px;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.profile-add-passkey {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.profile-add-passkey .input-group label {
|
||||
display: block;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
color: var(--app-text-muted);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.profile-security-list {
|
||||
list-style: none;
|
||||
margin: 0 0 12px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-security-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.profile-security-item--ok {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.profile-security-item--warn {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.profile-recovery-hint {
|
||||
margin-bottom: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.profile-recovery-actions {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.profile-recovery-actions .btn {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.profile-recovery-card .phrase-grid {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.profile-recovery-warning {
|
||||
margin: 0 0 20px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #fbbf24;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.profile-device-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.account-danger-zone__hint {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.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;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dashboard-header--profile .profile-header-brand {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profile-back-btn {
|
||||
margin-top: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.account-danger-zone {
|
||||
border-top: 1px solid rgba(239, 68, 68, 0.2);
|
||||
padding-top: 24px;
|
||||
@@ -1737,6 +2001,11 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skipper-badge.btn-icon {
|
||||
width: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
padding: 8px 10px;
|
||||
flex-shrink: 0;
|
||||
|
||||
+15
-4
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export default function AccountDangerZone({ className = '' }: AccountDangerZoneP
|
||||
</div>
|
||||
|
||||
<p className="account-danger-zone__desc">{t('settings.danger_zone_desc')}</p>
|
||||
<p className="account-danger-zone__hint">{t('settings.delete_backup_hint')}</p>
|
||||
|
||||
<div className="form-actions account-danger-zone__actions">
|
||||
<button
|
||||
|
||||
@@ -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
|
||||
className="skipper-badge"
|
||||
title={t('dashboard.logged_in_as', { name: username })}
|
||||
aria-label={t('dashboard.logged_in_as', { name: username })}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon skipper-badge"
|
||||
onClick={onOpenProfile}
|
||||
title={t('dashboard.open_profile', { name: username })}
|
||||
aria-label={t('dashboard.open_profile', { name: username })}
|
||||
>
|
||||
<User size={16} aria-hidden="true" />
|
||||
<User size={18} 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,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'
|
||||
@@ -401,6 +400,10 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
{t('settings.share_desc')}
|
||||
</p>
|
||||
|
||||
<p className="signature-lock-notice" style={{ marginBottom: '16px' }}>
|
||||
{t('settings.share_privacy_warning')}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '20px' }}>
|
||||
<label className="switch-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', fontSize: '14px', color: '#f1f5f9' }}>
|
||||
<input
|
||||
@@ -537,8 +540,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Danger Zone / Account Deletion */}
|
||||
<AccountDangerZone className="mt-6" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { initReactI18next } from 'react-i18next'
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
import enTranslation from './locales/en.json'
|
||||
import deTranslation from './locales/de.json'
|
||||
import { initSeo } from '../utils/seo.js'
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
@@ -17,9 +18,12 @@ i18n
|
||||
escapeValue: false // React already escapes values (prevents XSS)
|
||||
},
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator'],
|
||||
order: ['querystring', 'localStorage', 'navigator'],
|
||||
lookupQuerystring: 'lng',
|
||||
caches: ['localStorage']
|
||||
}
|
||||
})
|
||||
|
||||
initSeo(i18n)
|
||||
|
||||
export default i18n
|
||||
|
||||
@@ -269,7 +269,97 @@
|
||||
"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_title": "Letzter Passkey",
|
||||
"remove_passkey_last_desc": "Der einzige Passkey kann nicht entfernt werden, ohne den Zugang zu deinem Konto zu verlieren. Um das Konto vollständig zu löschen, nutze die Gefahrenzone am Ende dieser Seite.",
|
||||
"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",
|
||||
"security_title": "Sicherheits-Checkliste",
|
||||
"security_desc": "Überblick über die wichtigsten Schutzmechanismen deines Kontos.",
|
||||
"security_passkeys_ok": "Mindestens ein Passkey registriert",
|
||||
"security_passkeys_missing": "Kein Passkey registriert",
|
||||
"security_prf_ok": "PRF-Schlüsselableitung aktiv",
|
||||
"security_prf_missing": "PRF nicht eingerichtet",
|
||||
"security_pin_ok": "Lokaler PIN auf diesem Gerät",
|
||||
"security_pin_missing": "Kein lokaler PIN",
|
||||
"security_recovery_ok": "Wiederherstellungsschlüssel eingerichtet",
|
||||
"security_recovery_hint": "Die 12 Wörter wurden bei der Registrierung angezeigt. Bewahre sie offline und getrennt vom Gerät auf. Du kannst unten einen neuen Schlüssel erstellen — der alte wird dann ungültig.",
|
||||
"recovery_rotate_btn": "Neuen Wiederherstellungsschlüssel erstellen",
|
||||
"recovery_rotate_confirm_title": "Neuen Wiederherstellungsschlüssel erstellen?",
|
||||
"recovery_rotate_confirm_desc": "Der bisherige 12-Wörter-Schlüssel wird sofort ungültig. Stelle sicher, dass du den neuen Schlüssel sicher aufbewahrst, bevor du fortfährst.",
|
||||
"recovery_rotate_confirm_yes": "Neuen Schlüssel erstellen",
|
||||
"recovery_rotate_confirm_no": "Abbrechen",
|
||||
"recovery_rotate_new_warning": "WICHTIG: Schreib diese 12 Wörter auf und bewahre sie offline auf. Der bisherige Wiederherstellungsschlüssel ist ab sofort ungültig.",
|
||||
"recovery_rotate_failed": "Wiederherstellungsschlüssel konnte nicht erstellt werden.",
|
||||
"recovery_rotate_no_session": "Verschlüsselungssitzung abgelaufen — bitte abmelden und erneut anmelden, dann erneut versuchen.",
|
||||
"device_title": "Dieses Gerät",
|
||||
"device_desc": "Lokaler Cache, Sync-Status und Schnell-Login auf diesem Browser.",
|
||||
"device_sync_pending": "{{count}} ausstehende Sync-Einträge",
|
||||
"device_sync_ok": "Alle lokalen Änderungen synchronisiert",
|
||||
"device_remembered": "Account für Schnell-Login auf diesem Gerät gespeichert",
|
||||
"device_not_remembered": "Account nicht in der Schnell-Login-Liste",
|
||||
"device_forget_btn": "Account auf diesem Gerät vergessen",
|
||||
"device_forget_confirm_title": "Schnell-Login entfernen?",
|
||||
"device_forget_confirm_desc": "Der Account verschwindet aus der Schnell-Login-Liste auf diesem Gerät. Deine Session und lokalen Logbücher bleiben erhalten.",
|
||||
"device_forget_confirm_yes": "Entfernen",
|
||||
"device_forget_confirm_no": "Abbrechen",
|
||||
"passkey_label": "Name für neuen Passkey (optional)",
|
||||
"passkey_label_placeholder": "z. B. MacBook, iPhone",
|
||||
"passkey_rename_btn": "Name speichern",
|
||||
"passkey_rename_success": "Passkey-Name gespeichert.",
|
||||
"passkey_rename_failed": "Passkey-Name konnte nicht gespeichert werden.",
|
||||
"passkey_unnamed": "Unbenannter Passkey",
|
||||
"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",
|
||||
@@ -332,6 +422,7 @@
|
||||
"color_scheme_dark": "Dunkel",
|
||||
"share_title": "Logbuch teilen (Schreibgeschützt)",
|
||||
"share_desc": "Aktiviere diese Option, um einen öffentlichen, schreibgeschützten Link zu erstellen. Jeder mit dem Link kann deine Reisen, Yacht-Profile und Besatzung ansehen. Die Verschlüsselungsschlüssel werden niemals an den Server übertragen (sie bleiben im Hash-Teil der URL).",
|
||||
"share_privacy_warning": "Empfehlung: Teile diesen Link nur privat (z. B. per E-Mail oder Messenger), nicht in sozialen Medien.",
|
||||
"share_enable": "Öffentlichen Link aktivieren",
|
||||
"share_copied": "Link kopiert!",
|
||||
"share_copy_btn": "Link kopieren",
|
||||
@@ -343,6 +434,7 @@
|
||||
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
|
||||
"delete_account_confirm_no": "Abbrechen",
|
||||
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
||||
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.",
|
||||
"deleting_account": "Konto wird gelöscht…",
|
||||
"tour_title": "App-Tour",
|
||||
"tour_desc": "Lass dich erneut durch die wichtigsten Bereiche der App führen.",
|
||||
@@ -560,6 +652,12 @@
|
||||
"body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
|
||||
}
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
"title": "Kapteins Daagbok – Kostenloses digitales Yacht-Logbuch (werbefrei)",
|
||||
"description": "Kostenloses, werbefreies digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren – auch offline als PWA.",
|
||||
"keywords": "Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch, kostenlos, werbefrei, gratis, ohne Werbung",
|
||||
"ogImageAlt": "Kapteins Daagbok Logo"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +269,97 @@
|
||||
"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_title": "Last passkey",
|
||||
"remove_passkey_last_desc": "The only passkey cannot be removed without losing access to your account. To delete the account entirely, use the danger zone at the bottom of this page.",
|
||||
"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",
|
||||
"security_title": "Security checklist",
|
||||
"security_desc": "Overview of the most important protections for your account.",
|
||||
"security_passkeys_ok": "At least one passkey registered",
|
||||
"security_passkeys_missing": "No passkey registered",
|
||||
"security_prf_ok": "PRF key derivation active",
|
||||
"security_prf_missing": "PRF not configured",
|
||||
"security_pin_ok": "Local PIN on this device",
|
||||
"security_pin_missing": "No local PIN",
|
||||
"security_recovery_ok": "Recovery phrase configured",
|
||||
"security_recovery_hint": "The 12 words were shown at registration. Store them offline and separately from this device. You can create a new phrase below — the old one will then be invalidated.",
|
||||
"recovery_rotate_btn": "Create new recovery phrase",
|
||||
"recovery_rotate_confirm_title": "Create new recovery phrase?",
|
||||
"recovery_rotate_confirm_desc": "Your previous 12-word phrase will be invalidated immediately. Make sure you can store the new phrase securely before continuing.",
|
||||
"recovery_rotate_confirm_yes": "Create new phrase",
|
||||
"recovery_rotate_confirm_no": "Cancel",
|
||||
"recovery_rotate_new_warning": "IMPORTANT: Write down these 12 words and store them offline. Your previous recovery phrase is no longer valid.",
|
||||
"recovery_rotate_failed": "Could not create a new recovery phrase.",
|
||||
"recovery_rotate_no_session": "Encryption session expired — please sign out and sign in again, then retry.",
|
||||
"device_title": "This device",
|
||||
"device_desc": "Local cache, sync status, and quick login on this browser.",
|
||||
"device_sync_pending": "{{count}} pending sync items",
|
||||
"device_sync_ok": "All local changes synced",
|
||||
"device_remembered": "Account saved for quick login on this device",
|
||||
"device_not_remembered": "Account not in the quick-login list",
|
||||
"device_forget_btn": "Forget account on this device",
|
||||
"device_forget_confirm_title": "Remove quick login?",
|
||||
"device_forget_confirm_desc": "The account will be removed from the quick-login list on this device. Your session and local logbooks stay on this device.",
|
||||
"device_forget_confirm_yes": "Remove",
|
||||
"device_forget_confirm_no": "Cancel",
|
||||
"passkey_label": "Name for new passkey (optional)",
|
||||
"passkey_label_placeholder": "e.g. MacBook, iPhone",
|
||||
"passkey_rename_btn": "Save name",
|
||||
"passkey_rename_success": "Passkey name saved.",
|
||||
"passkey_rename_failed": "Could not save passkey name.",
|
||||
"passkey_unnamed": "Unnamed passkey",
|
||||
"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",
|
||||
@@ -332,6 +422,7 @@
|
||||
"color_scheme_dark": "Dark",
|
||||
"share_title": "Share Logbook (Read-Only)",
|
||||
"share_desc": "Enable this to generate a public, read-only link. Anyone with the link can view your travels, yacht profile, and crew members. Decryption keys are never transmitted to the server (they stay in the hash part of the URL).",
|
||||
"share_privacy_warning": "Recommendation: Share this link only privately (e.g. via email or messenger), not on social media.",
|
||||
"share_enable": "Enable Public Link",
|
||||
"share_copied": "Link copied!",
|
||||
"share_copy_btn": "Copy Link",
|
||||
@@ -343,6 +434,7 @@
|
||||
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
|
||||
"delete_account_confirm_no": "Cancel",
|
||||
"delete_account_failed": "Failed to delete account. Please try again.",
|
||||
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.",
|
||||
"deleting_account": "Deleting account…",
|
||||
"tour_title": "App tour",
|
||||
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
|
||||
@@ -560,6 +652,12 @@
|
||||
"body": "You'll land on the statistics dashboard next. You can restart the tour anytime in Settings. Fair winds!"
|
||||
}
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
"title": "Kapteins Daagbok – Free Digital Yacht Logbook (Ad-Free)",
|
||||
"description": "Free, ad-free digital yacht logbook with end-to-end encryption and Passkey sign-in. Document travel days, GPS tracks, crew and vessel data securely — offline-capable PWA.",
|
||||
"keywords": "yacht logbook, ship logbook, sailing log, maritime logbook, passkey, E2E encryption, GPS track, free, ad-free, offline PWA",
|
||||
"ogImageAlt": "Kapteins Daagbok logo"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,16 @@ export const PlausibleEvents = {
|
||||
DEMO_OPENED: 'Demo Opened',
|
||||
PUSH_ENABLED: 'Push Enabled',
|
||||
PUSH_DISABLED: 'Push Disabled',
|
||||
FOOTER_LINK_CLICKED: 'Footer Link Clicked'
|
||||
FOOTER_LINK_CLICKED: 'Footer Link Clicked',
|
||||
PROFILE_OPENED: 'Profile Opened',
|
||||
PASSKEY_ADDED: 'Passkey Added',
|
||||
PASSKEY_REMOVED: 'Passkey Removed',
|
||||
PASSKEY_RENAMED: 'Passkey Renamed',
|
||||
LAST_PASSKEY_REMOVE_HINTED: 'Last Passkey Remove Hinted',
|
||||
LOCAL_PIN_SET: 'Local PIN Set',
|
||||
LOCAL_PIN_REMOVED: 'Local PIN Removed',
|
||||
DEVICE_FORGOTTEN: 'Device Forgotten',
|
||||
RECOVERY_ROTATED: 'Recovery Rotated'
|
||||
} as const
|
||||
|
||||
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
|
||||
|
||||
@@ -543,3 +543,137 @@ export async function deleteAccount(): Promise<boolean> {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export interface UserProfileCredential {
|
||||
id: string
|
||||
label: string | null
|
||||
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(label?: string): 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,
|
||||
challenge: options.challenge,
|
||||
...(label?.trim() ? { label: label.trim() } : {})
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
export async function renamePasskey(credentialDbId: string, label: string): Promise<void> {
|
||||
await reauthWithPasskey()
|
||||
|
||||
await apiJson(`${API_BASE}/credentials/${credentialDbId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ label })
|
||||
})
|
||||
}
|
||||
|
||||
export async function rotateRecoveryPhrase(): Promise<string> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('NO_ACTIVE_MASTER_KEY')
|
||||
}
|
||||
|
||||
await reauthWithPasskey()
|
||||
|
||||
const recoveryPhrase = generateRecoveryPhrase()
|
||||
const recoveryKey = await deriveKeyFromPhrase(recoveryPhrase)
|
||||
const encryptedRecovery = await encryptBuffer(masterKey, recoveryKey)
|
||||
|
||||
await apiJson(`${API_BASE}/rotate-recovery`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
encryptedMasterKeyRec: encryptedRecovery.ciphertext,
|
||||
encryptedMasterKeyRecIv: encryptedRecovery.iv,
|
||||
encryptedMasterKeyRecTag: encryptedRecovery.tag
|
||||
})
|
||||
})
|
||||
|
||||
return recoveryPhrase
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { i18n as I18nInstance } from 'i18next'
|
||||
|
||||
const SITE_ORIGIN = 'https://kapteins-daagbok.eu'
|
||||
|
||||
export type SeoLang = 'de' | 'en'
|
||||
|
||||
let i18nRef: I18nInstance | null = null
|
||||
|
||||
export function normalizeSeoLang(lng: string): SeoLang {
|
||||
return lng.startsWith('de') ? 'de' : 'en'
|
||||
}
|
||||
|
||||
function setMeta(attr: 'name' | 'property', key: string, content: string) {
|
||||
let el = document.querySelector(`meta[${attr}="${key}"]`)
|
||||
if (!el) {
|
||||
el = document.createElement('meta')
|
||||
el.setAttribute(attr, key)
|
||||
document.head.appendChild(el)
|
||||
}
|
||||
el.setAttribute('content', content)
|
||||
}
|
||||
|
||||
function syncLanguageUrl(lang: SeoLang) {
|
||||
const url = new URL(window.location.href)
|
||||
const currentLng = url.searchParams.get('lng')
|
||||
if (currentLng && normalizeSeoLang(currentLng) === lang) return
|
||||
|
||||
url.searchParams.set('lng', lang)
|
||||
const next = `${url.pathname}${url.search}${url.hash}`
|
||||
window.history.replaceState({}, '', next)
|
||||
}
|
||||
|
||||
export function updatePageSeo(lng?: string) {
|
||||
if (!i18nRef?.isInitialized) return
|
||||
|
||||
const lang = normalizeSeoLang(lng ?? i18nRef.language)
|
||||
document.documentElement.lang = lang
|
||||
|
||||
const title = i18nRef.t('seo.title')
|
||||
document.title = title
|
||||
|
||||
const description = i18nRef.t('seo.description')
|
||||
const keywords = i18nRef.t('seo.keywords')
|
||||
const imageAlt = i18nRef.t('seo.ogImageAlt')
|
||||
|
||||
setMeta('name', 'description', description)
|
||||
setMeta('name', 'keywords', keywords)
|
||||
setMeta('property', 'og:title', title)
|
||||
setMeta('property', 'og:description', description)
|
||||
setMeta('property', 'og:locale', lang === 'de' ? 'de_DE' : 'en_US')
|
||||
setMeta('property', 'og:locale:alternate', lang === 'de' ? 'en_US' : 'de_DE')
|
||||
setMeta('name', 'twitter:title', title)
|
||||
setMeta('name', 'twitter:description', description)
|
||||
setMeta('property', 'og:image:alt', imageAlt)
|
||||
setMeta('name', 'twitter:image:alt', imageAlt)
|
||||
|
||||
syncLanguageUrl(lang)
|
||||
}
|
||||
|
||||
export function initSeo(i18n: I18nInstance) {
|
||||
i18nRef = i18n
|
||||
i18n.on('initialized', () => updatePageSeo())
|
||||
i18n.on('languageChanged', (lng) => updatePageSeo(lng))
|
||||
if (i18n.isInitialized) {
|
||||
updatePageSeo()
|
||||
}
|
||||
}
|
||||
|
||||
export function hreflangUrl(lang: SeoLang): string {
|
||||
return `${SITE_ORIGIN}/?lng=${lang}`
|
||||
}
|
||||
|
||||
export const seoSiteOrigin = SITE_ORIGIN
|
||||
@@ -49,7 +49,9 @@ export default defineConfig({
|
||||
manifest: {
|
||||
name: 'Kapteins Daagbok',
|
||||
short_name: 'Daagbok',
|
||||
description: 'Free, ad-free maritime logbook with E2E encryption and Passkeys',
|
||||
lang: 'de',
|
||||
description:
|
||||
'Digitales Yacht-Logbuch — E2E-verschlüsselt, offline-fähig.',
|
||||
theme_color: '#1e293b',
|
||||
background_color: '#0f172a',
|
||||
display: 'standalone',
|
||||
|
||||
@@ -169,6 +169,33 @@
|
||||
width: 4mm;
|
||||
}
|
||||
|
||||
.lang-list {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1.5mm;
|
||||
}
|
||||
|
||||
.lang-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1.2mm;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feature-flag {
|
||||
display: inline-block;
|
||||
width: 5mm;
|
||||
height: 3.5mm;
|
||||
border-radius: 0.3mm;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 0 0.15mm rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.lang-sep {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.beta-box {
|
||||
background: rgba(30, 41, 59, 0.85);
|
||||
border: 1px solid rgba(251, 191, 36, 0.35);
|
||||
@@ -301,7 +328,7 @@
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Verschlüsseltes Backup & Wiederherstellung</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Logbuch mit Freunden teilen</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Beliebig viele Schiffe und Logbücher</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Deutsch & Englisch</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span class="lang-list"><span class="lang-item"><svg class="feature-flag" viewBox="0 0 5 3" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="5" height="1" fill="#000"/><rect y="1" width="5" height="1" fill="#D00"/><rect y="2" width="5" height="1" fill="#FFCE00"/></svg>Deutsch</span><span class="lang-sep">&</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 60 30" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><clipPath id="gb-a"><path d="M0 0v30h60V0z"/></clipPath><clipPath id="gb-b"><path d="M30 15h30v15zv15H0z"/></clipPath><g clip-path="url(#gb-a)"><path d="M0 0v30h60V0z" fill="#012169"/><path d="M0 0l60 30m0-30L0 30" stroke="#fff" stroke-width="6"/><path d="M0 0l60 30m0-30L0 30" clip-path="url(#gb-b)" stroke="#C8102E" stroke-width="4"/><path d="M30 0v30M0 15h60" stroke="#fff" stroke-width="10"/><path d="M30 0v30M0 15h60" stroke="#C8102E" stroke-width="6"/></g></svg>Englisch</span></span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Crafted in Kiel.Sailing.City.</span></div>
|
||||
</section>
|
||||
|
||||
|
||||
Binary file not shown.
@@ -40,12 +40,23 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
||||
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
|
||||
| Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — |
|
||||
| Footer Link Clicked | Klick auf Autoren-Link im App-Footer (`AppFooter.tsx`) | — |
|
||||
| Profile Opened | Profilseite geöffnet (`UserProfilePage.tsx`, einmal pro Mount) | — |
|
||||
| Passkey Added | Passkey erfolgreich registriert (`UserProfilePage.tsx`) | `labeled`: `true` \| `false` (optionaler Name gesetzt) |
|
||||
| Passkey Removed | Passkey entfernt, mindestens ein Key verbleibt (`UserProfilePage.tsx`) | — |
|
||||
| Passkey Renamed | Passkey-Name gespeichert (`UserProfilePage.tsx`) | — |
|
||||
| Last Passkey Remove Hinted | Löschen des einzigen Passkeys abgebrochen — Hinweisdialog zur Kontolöschung (`UserProfilePage.tsx`) | — |
|
||||
| Local PIN Set | Lokaler PIN gesetzt oder geändert (`UserProfilePage.tsx`) | `action`: `set` \| `change` |
|
||||
| Local PIN Removed | Lokaler PIN entfernt (`UserProfilePage.tsx`) | — |
|
||||
| Device Forgotten | Account aus Schnell-Login-Liste dieses Geräts entfernt (`UserProfilePage.tsx`) | — |
|
||||
| Recovery Rotated | Neuer 12-Wörter-Wiederherstellungsschlüssel erstellt (`UserProfilePage.tsx`) | — |
|
||||
|
||||
## Bewusst nicht getrackt
|
||||
|
||||
- **Demo-Logbuch:** Beim automatischen Seed (`demoLogbook.ts`) werden keine Events ausgelöst — nur echte Nutzeraktionen zählen.
|
||||
- **Manuelle Signaturen:** Nur Passkey-Signaturen lösen `Entry Signed` aus.
|
||||
- **PII:** Keine Inhalte aus verschlüsselten Logbüchern in Properties.
|
||||
- **Profil-KPIs:** Statistik-Karten und User-ID-Kopieren werden nicht getrackt (reine Anzeige bzw. zu granular).
|
||||
- **Kontolöschung:** `Account Deleted` bleibt in `auth.ts` — unabhängig davon, ob die Gefahrenzone auf der Profilseite oder früher in den Einstellungen genutzt wurde.
|
||||
|
||||
## Typische Funnels (Plausible Goals)
|
||||
|
||||
@@ -57,6 +68,7 @@ Empfohlene Goal-Ketten für Auswertung:
|
||||
4. **Öffentliche Freigabe:** Logbook Shared → Public Link Opened
|
||||
5. **Export:** Travel Day Saved → PDF Exported / CSV Exported
|
||||
6. **Datensicherung:** Backup Exported → Backup Restored
|
||||
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
|
||||
|
||||
## Entwicklung
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ model Credential {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
credentialId String @unique
|
||||
label String?
|
||||
publicKey Bytes
|
||||
counter BigInt
|
||||
transports String[] // WebAuthn transports list
|
||||
|
||||
@@ -22,8 +22,22 @@ const rpID = process.env.RP_ID || 'localhost'
|
||||
const origin = process.env.ORIGIN || 'http://localhost:5173'
|
||||
|
||||
const registrationChallenges = new Map<string, string>()
|
||||
/** WebAuthn registration challenges for add-credential flow: challenge -> userId */
|
||||
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)}`
|
||||
}
|
||||
|
||||
function normalizeCredentialLabel(label: unknown): string | null {
|
||||
if (typeof label !== 'string') return null
|
||||
const trimmed = label.trim()
|
||||
if (!trimmed) return null
|
||||
return trimmed.slice(0, 64)
|
||||
}
|
||||
|
||||
router.post('/register-options', async (req, res) => {
|
||||
try {
|
||||
const { username } = req.body
|
||||
@@ -381,4 +395,259 @@ router.post('/enroll-prf', requireReauth, async (req: any, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/rotate-recovery', requireReauth, async (req: any, res) => {
|
||||
try {
|
||||
const { encryptedMasterKeyRec, encryptedMasterKeyRecIv, encryptedMasterKeyRecTag } = req.body
|
||||
if (!encryptedMasterKeyRec || !encryptedMasterKeyRecIv || !encryptedMasterKeyRecTag) {
|
||||
return res.status(400).json({ error: 'Missing required recovery key fields' })
|
||||
}
|
||||
|
||||
if (
|
||||
typeof encryptedMasterKeyRec !== 'string' ||
|
||||
typeof encryptedMasterKeyRecIv !== 'string' ||
|
||||
typeof encryptedMasterKeyRecTag !== 'string'
|
||||
) {
|
||||
return res.status(400).json({ error: 'Invalid recovery key fields format' })
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: req.userId },
|
||||
data: {
|
||||
encryptedMasterKeyRec,
|
||||
encryptedMasterKeyRecIv,
|
||||
encryptedMasterKeyRecTag
|
||||
}
|
||||
})
|
||||
|
||||
return res.json({ success: true })
|
||||
} catch (error: any) {
|
||||
console.error('Error rotating recovery key:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
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,
|
||||
label: cred.label,
|
||||
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(options.challenge, req.userId)
|
||||
|
||||
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, challenge } = req.body
|
||||
if (!credentialResponse || !challenge) {
|
||||
return res.status(400).json({ error: 'credentialResponse and challenge are required' })
|
||||
}
|
||||
|
||||
const label = normalizeCredentialLabel(req.body.label)
|
||||
|
||||
const challengeUserId = addCredentialChallenges.get(challenge)
|
||||
if (!challengeUserId) {
|
||||
return res.status(400).json({ error: 'Challenge not found or expired' })
|
||||
}
|
||||
|
||||
if (challengeUserId !== req.userId) {
|
||||
return res.status(403).json({ error: 'Challenge does not belong to this account' })
|
||||
}
|
||||
|
||||
// Single-use: invalidate before verification so failed attempts cannot be retried
|
||||
addCredentialChallenges.delete(challenge)
|
||||
|
||||
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: challenge,
|
||||
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,
|
||||
label,
|
||||
publicKey: Buffer.from(credentialPublicKey),
|
||||
counter: BigInt(counter),
|
||||
transports: credentialResponse.response.transports || []
|
||||
}
|
||||
})
|
||||
|
||||
return res.json({
|
||||
verified: true,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
label: credential.label,
|
||||
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.patch('/credentials/:id', requireReauth, async (req: any, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const label = normalizeCredentialLabel(req.body?.label)
|
||||
|
||||
const credential = await prisma.credential.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!credential || credential.userId !== req.userId) {
|
||||
return res.status(404).json({ error: 'Credential not found' })
|
||||
}
|
||||
|
||||
const updated = await prisma.credential.update({
|
||||
where: { id },
|
||||
data: { label }
|
||||
})
|
||||
|
||||
return res.json({
|
||||
credential: {
|
||||
id: updated.id,
|
||||
label: updated.label,
|
||||
credentialIdPreview: previewCredentialId(updated.credentialId),
|
||||
transports: updated.transports
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error updating credential label:', 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
|
||||
|
||||
Reference in New Issue
Block a user