Fix live journal freeze and passkey login on localhost.

Harden live log init with safe per-entry decrypt, stable loading state, and no parallel list scan in live mode. Improve multi-sail picker UX, stop WebAuthn retry after user cancel, redirect 127.0.0.1 to localhost, and tolerate missing appearance prefs table.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-01 08:49:45 +02:00
parent 43dc994c4f
commit 0caaf681d8
20 changed files with 580 additions and 100 deletions
+4 -4
View File
@@ -5,13 +5,13 @@ OpenWeatherMapAPIKey=<owm_api_key>
DeepLAPIKey=
# Passkey configuration (WebAuthn Relying Party ID and Origin)
# For local dev: localhost and http://localhost
# For local dev: use localhost (NOT 127.0.0.1 — browsers reject IP addresses for Passkeys)
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
RP_ID=localhost
# Must match the frontend URL (Vite dev: http://localhost:5173; Docker: http://localhost)
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
ORIGIN=http://localhost:5173
# Optional: comma-separated CORS origins (defaults to ORIGIN; dev also allows 127.0.0.1:5173)
# CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
# CORS_ORIGINS=http://localhost:5173
# API session signing (min. 32 chars; required in production)
# Generate: openssl rand -base64 48
+8 -1
View File
@@ -3345,7 +3345,14 @@ html.theme-cupertino .events-scroll-container {
}
.live-log-sail-pills {
margin-bottom: 16px;
margin-bottom: 12px;
}
.live-log-sails-selection {
margin: 0 0 12px;
font-size: 13px;
color: var(--app-accent-light, #93c5fd);
line-height: 1.4;
}
.live-log-modal-actions {
+76 -18
View File
@@ -1,21 +1,28 @@
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import {
registerUser,
loginUser,
completeLoginWithRecovery,
setLocalPin,
hasLocalPin,
decryptWithLocalPin,
import {
registerUser,
loginUser,
completeLoginWithRecovery,
setLocalPin,
hasLocalPin,
decryptWithLocalPin,
getActiveMasterKey,
getKnownUsernames,
forgetUsername
forgetUsername,
hasUnlockedLocalSession,
logoutUser
} from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
import DisclaimerModal from './DisclaimerModal.tsx'
import BetaBadge from './BetaBadge.tsx'
import {
isPasskeyCompatibleLocation,
localizeWebAuthnError,
toPasskeyCompatibleUrl
} from '../utils/passkeyHost.ts'
interface AuthOnboardingProps {
onAuthenticated: () => void
@@ -54,6 +61,16 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const [showDisclaimer, setShowDisclaimer] = useState(false)
const [showHelp, setShowHelp] = useState(false)
const passkeyHostOk = isPasskeyCompatibleLocation()
const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href)
const formatAuthError = (message: string) =>
localizeWebAuthnError(message, {
invalidHost: t('auth.error_invalid_host'),
cancelled: t('auth.error_passkey_cancelled'),
invalidRpId: t('auth.error_invalid_rp_id')
})
const finishAuth = () => {
if (isNewRegistration) {
setShowDisclaimer(true)
@@ -81,7 +98,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
setRecoveryPhrase(result.recoveryPhrase)
}
} catch (err: any) {
setError(err.message || 'Registration failed')
setError(formatAuthError(err.message || 'Registration failed'))
} finally {
setLoading(false)
}
@@ -121,7 +138,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
}
}
} catch (err: any) {
setError(err.message || 'Login failed')
setError(formatAuthError(err.message || 'Login failed'))
} finally {
setLoading(false)
}
@@ -185,19 +202,33 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const handlePinLoginSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!pinLoginInput.trim()) return
if (!pinLoginInput.trim() || loading) return
const resolvedUser =
username.trim() ||
encryptedPayloads?.username ||
localStorage.getItem('active_username') ||
''
if (!resolvedUser) {
setError(t('auth.error_session_incomplete'))
return
}
setLoading(true)
setError(null)
try {
const resolvedUser = username.trim() || encryptedPayloads?.username
const key = await decryptWithLocalPin(pinLoginInput.trim(), resolvedUser)
if (key) {
onAuthenticated()
} else {
if (!key) {
setError(t('auth.error_incorrect_pin'))
return
}
} catch (err: any) {
if (!hasUnlockedLocalSession()) {
setError(t('auth.error_session_incomplete'))
return
}
setShowPinLogin(false)
onAuthenticated()
} catch {
setError(t('auth.error_incorrect_pin'))
} finally {
setLoading(false)
@@ -361,6 +392,24 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
>
{t('auth.use_recovery_instead')}
</button>
<button
type="button"
className="btn secondary"
onClick={() => {
void (async () => {
setShowPinLogin(false)
setPinLoginInput('')
setEncryptedPayloads(null)
setError(null)
await logoutUser()
})()
}}
disabled={loading}
style={{ width: '100%' }}
>
{t('auth.back')}
</button>
</div>
</form>
</div>
@@ -445,12 +494,21 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
</div>
<div className="auth-form" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '20px' }}>
{!passkeyHostOk && passkeyCompatibleUrl && (
<div className="auth-error" role="alert">
<p style={{ margin: '0 0 8px' }}>{t('auth.error_invalid_host')}</p>
<a href={passkeyCompatibleUrl} className="btn secondary" style={{ display: 'inline-block', textDecoration: 'none' }}>
{t('auth.use_localhost_link')}
</a>
</div>
)}
{/* Prominent Login button */}
<button
type="button"
className="btn primary"
onClick={() => handleLogin()}
disabled={loading}
disabled={loading || !passkeyHostOk}
style={{ width: '100%', padding: '16px' }}
>
{loading
@@ -583,7 +641,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<button
type="submit"
className="btn secondary"
disabled={loading || !username.trim()}
disabled={loading || !username.trim() || !passkeyHostOk}
style={{ width: '100%' }}
>
{t('auth.register')}
+129 -58
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import {
@@ -47,8 +47,15 @@ import {
} from '../utils/liveEventCodes.js'
import { getCurrentPosition } from '../utils/geolocation.js'
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
import {
dedupeSailNames,
isSailInSelection,
joinSailSelection,
toggleSailInSelection
} from '../utils/sailSelection.js'
import { useDialog } from './ModalDialog.tsx'
import CourseDialInput from './CourseDialInput.tsx'
import i18n from '../i18n/index.js'
interface LiveLogViewProps {
logbookId: string
@@ -122,22 +129,37 @@ export default function LiveLogView({
const streamEndRef = useRef<HTMLDivElement | null>(null)
const undoTimerRef = useRef<number | null>(null)
const autoPositionBusyRef = useRef(false)
const initSeqRef = useRef(0)
const eventsRef = useRef(events)
const dateRef = useRef(date)
eventsRef.current = events
dateRef.current = date
const defaultSails = i18n.language === 'de'
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
: ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker']
const sailOptions = yachtSails.length > 0 ? yachtSails : defaultSails
const defaultSails = useMemo(
() => (i18n.language === 'de'
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
: ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker']),
[i18n.language]
)
const sailOptions = useMemo(
() => dedupeSailNames(yachtSails.length > 0 ? yachtSails : defaultSails),
[yachtSails, defaultSails]
)
const motorRunning = isMotorRunningFromEvents(events)
const motorLabel = t('logs.motor_propulsion')
const refreshEntry = useCallback(async (id: string) => {
const loaded = await loadEntry(logbookId, id)
if (!loaded) return
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
setDayOfTravel(String(loaded.data.dayOfTravel || ''))
setDate(String(loaded.data.date || ''))
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
}, [logbookId])
}, [])
const refreshEntry = useCallback(async (id: string) => {
const loaded = await loadEntry(logbookId, id)
if (!loaded) return
applyLoadedEntry(loaded)
}, [logbookId, applyLoadedEntry])
const showUndo = useCallback(() => {
setUndoVisible(true)
@@ -148,42 +170,59 @@ export default function LiveLogView({
}, UNDO_TIMEOUT_MS)
}, [])
useEffect(() => {
let cancelled = false
const runInit = useCallback(async () => {
const seq = ++initSeqRef.current
setLoading(true)
setError(null)
try {
const id = await findOrCreateTodayEntry(logbookId)
if (seq !== initSeqRef.current) return
setEntryId(id)
async function init() {
setLoading(true)
setError(null)
try {
const id = await findOrCreateTodayEntry(logbookId)
if (cancelled) return
setEntryId(id)
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (masterKey) {
const yacht = await db.yachts.get(logbookId)
if (yacht) {
const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey)
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (masterKey) {
const yacht = await db.yachts.get(logbookId)
if (yacht) {
try {
const decrypted = await decryptJson(
yacht.encryptedData,
yacht.iv,
yacht.tag,
masterKey
)
if (decrypted?.sails && Array.isArray(decrypted.sails)) {
setYachtSails(decrypted.sails as string[])
}
} catch {
// Yacht profile optional for live log
}
}
}
await refreshEntry(id)
} catch (err: unknown) {
if (!cancelled) {
console.error('Failed to init live log:', err)
setError(err instanceof Error ? err.message : t('logs.live_load_error'))
}
} finally {
if (!cancelled) setLoading(false)
const loaded = await loadEntry(logbookId, id)
if (seq !== initSeqRef.current) return
if (loaded) {
applyLoadedEntry(loaded)
} else {
throw new Error(i18n.t('logs.live_load_error'))
}
} catch (err: unknown) {
if (seq !== initSeqRef.current) return
console.error('Failed to init live log:', err)
setError(err instanceof Error ? err.message : i18n.t('logs.live_load_error'))
} finally {
if (seq === initSeqRef.current) {
setLoading(false)
}
}
}, [logbookId, applyLoadedEntry])
void init()
return () => { cancelled = true }
}, [logbookId, refreshEntry, t])
useEffect(() => {
void runInit()
return () => {
initSeqRef.current += 1
}
}, [runInit])
useEffect(() => {
if (!loading && entryId) {
@@ -207,7 +246,7 @@ export default function LiveLogView({
const maybeAutoPosition = async () => {
if (document.visibilityState !== 'visible' || autoPositionBusyRef.current || busy) return
const lastMs = getLastAutoPositionMs(events, date)
const lastMs = getLastAutoPositionMs(eventsRef.current, dateRef.current)
if (lastMs != null && Date.now() - lastMs < AUTO_POSITION_INTERVAL_MS) return
autoPositionBusyRef.current = true
@@ -228,7 +267,7 @@ export default function LiveLogView({
const interval = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
return () => window.clearInterval(interval)
}, [entryId, loading, events, date, logbookId, refreshEntry, busy])
}, [entryId, loading, logbookId, refreshEntry, busy])
const runQuickAction = async (
action: () => Promise<void>,
@@ -325,11 +364,11 @@ export default function LiveLogView({
}
const confirmSails = () => {
if (selectedSails.length === 0) {
const sailsLabel = joinSailSelection(selectedSails)
if (!sailsLabel) {
setModal('none')
return
}
const sailsLabel = selectedSails.join(' + ')
setModal('none')
setSelectedSails([])
void runQuickAction(async () => {
@@ -468,18 +507,24 @@ export default function LiveLogView({
}
const toggleSailSelection = (sail: string) => {
setSelectedSails((prev) =>
prev.some((s) => s.toLowerCase() === sail.toLowerCase())
? prev.filter((s) => s.toLowerCase() !== sail.toLowerCase())
: [...prev, sail]
)
setSelectedSails((prev) => toggleSailInSelection(prev, sail))
}
const closeModal = () => setModal('none')
if (loading) {
return (
<div className="tab-placeholder">
<Radio className="header-logo spin" size={48} />
<p>{t('logs.live_loading')}</p>
{error && (
<>
<p className="auth-error" style={{ marginTop: 12 }}>{error}</p>
<button type="button" className="btn secondary" style={{ marginTop: 12 }} onClick={() => void runInit()}>
{t('logs.live_retry')}
</button>
</>
)}
</div>
)
}
@@ -629,24 +674,50 @@ export default function LiveLogView({
)}
{modal === 'sails' && (
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
<div
className="live-log-modal-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) closeModal() }}
>
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
<h3>{t('logs.live_sails_pick')}</h3>
<div className="sails-picker-pills live-log-sail-pills">
{sailOptions.map((sail) => (
<button
key={sail}
type="button"
className={`sail-pill ${selectedSails.some((s) => s.toLowerCase() === sail.toLowerCase()) ? 'active' : ''}`}
onClick={() => toggleSailSelection(sail)}
>
{sail}
</button>
))}
<p className="live-log-modal-hint">{t('logs.live_sails_pick_hint')}</p>
<div
className="sails-picker-pills live-log-sail-pills"
role="group"
aria-label={t('logs.live_sails_pick')}
>
{sailOptions.map((sail) => {
const active = isSailInSelection(selectedSails, sail)
return (
<button
key={sail}
type="button"
className={`sail-pill ${active ? 'active' : ''}`}
aria-pressed={active}
onClick={() => toggleSailSelection(sail)}
>
{sail}
</button>
)
})}
</div>
{selectedSails.length > 0 && (
<p className="live-log-sails-selection" aria-live="polite">
{t('logs.live_sails_selected', { sails: joinSailSelection(selectedSails) })}
</p>
)}
<div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button>
<button type="button" className="btn primary" onClick={confirmSails} disabled={selectedSails.length === 0}>{t('logs.live_sails_confirm')}</button>
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.confirm_no')}</button>
<button
type="button"
className="btn primary"
onClick={confirmSails}
disabled={selectedSails.length === 0}
>
{selectedSails.length > 0
? t('logs.live_sails_confirm_count', { count: selectedSails.length })
: t('logs.live_sails_confirm')}
</button>
</div>
</div>
</div>
+4 -2
View File
@@ -149,17 +149,19 @@ export default function LogEntriesList({
}, [logbookId, readOnly, preloadedEntries])
useEffect(() => {
if (viewMode === 'live') return
loadEntries()
}, [loadEntries])
}, [loadEntries, viewMode])
useEffect(() => {
if (viewMode === 'live') return
const prevSelectedEntryId = prevSelectedEntryIdRef.current
prevSelectedEntryIdRef.current = selectedEntryId
if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) {
loadEntries()
}
}, [selectedEntryId, loadEntries])
}, [selectedEntryId, loadEntries, viewMode])
const handleDownloadCsv = async () => {
setExporting(true)
+10 -1
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Indtast din pinkode...",
"decrypt_with_pin": "Afkodning",
"use_recovery_instead": "Brug genoprettelsesnøgler i stedet",
"error_incorrect_pin": "Forkert PIN-kode. Dekryptering mislykkedes."
"error_incorrect_pin": "Forkert PIN-kode. Dekryptering mislykkedes.",
"error_invalid_host": "Passkeys virker ikke via 127.0.0.1. Åbn appen via localhost.",
"use_localhost_link": "Skift til localhost",
"error_passkey_cancelled": "Passkey-login blev annulleret eller udløb. Prøv igen.",
"error_invalid_rp_id": "Passkey-domæne matcher ikke (RP ID). Brug http://localhost:5173 med RP_ID=localhost i .env til lokal udvikling.",
"error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen."
},
"pwa": {
"title": "Installer app",
@@ -202,6 +207,7 @@
"live_mode": "Live",
"live_title": "Live-journal",
"live_loading": "Live-journal indlæses...",
"live_retry": "Prøv igen",
"live_load_error": "Live-journal kunne ikke indlæses.",
"live_action_error": "Indtastning kunne ikke gemmes.",
"live_open_editor": "Fuld editor",
@@ -215,7 +221,10 @@
"live_moor": "Anløb",
"live_sails_btn": "Sejl",
"live_sails_pick": "Vælg sejl",
"live_sails_pick_hint": "Tryk på flere sejl (tryk igen for at fravælge), og indtast derefter.",
"live_sails_selected": "Valgt: {{sails}}",
"live_sails_confirm": "Indtast",
"live_sails_confirm_count": "Indtast ({{count}})",
"live_sails": "Sejl: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
+11 -2
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Gib deine PIN ein...",
"decrypt_with_pin": "Entschlüsseln",
"use_recovery_instead": "Stattdessen Wiederherstellungsschlüssel verwenden",
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen."
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen.",
"error_invalid_host": "Passkeys funktionieren nicht über 127.0.0.1. Bitte die App über localhost öffnen.",
"use_localhost_link": "Zu localhost wechseln",
"error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.",
"error_invalid_rp_id": "Passkey-Domain passt nicht (RP ID). Lokal nur http://localhost:5173 mit RP_ID=localhost in .env verwenden.",
"error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden."
},
"pwa": {
"title": "App installieren",
@@ -202,6 +207,7 @@
"live_mode": "Live",
"live_title": "Live-Journal",
"live_loading": "Live-Journal wird geladen...",
"live_retry": "Erneut versuchen",
"live_load_error": "Live-Journal konnte nicht geladen werden.",
"live_action_error": "Eintrag konnte nicht gespeichert werden.",
"live_open_editor": "Vollständiger Editor",
@@ -214,8 +220,11 @@
"live_cast_off": "Ablegen",
"live_moor": "Anlegen",
"live_sails_btn": "Segel",
"live_sails_pick": "Segel wählen",
"live_sails_pick": "Segel auswählen",
"live_sails_pick_hint": "Mehrere Segel antippen (erneut antippen zum Abwählen), dann Eintragen.",
"live_sails_selected": "Auswahl: {{sails}}",
"live_sails_confirm": "Eintragen",
"live_sails_confirm_count": "Eintragen ({{count}})",
"live_sails": "Segel: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
+10 -1
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Enter your PIN...",
"decrypt_with_pin": "Decrypt",
"use_recovery_instead": "Use recovery phrase instead",
"error_incorrect_pin": "Incorrect PIN. Decryption failed."
"error_incorrect_pin": "Incorrect PIN. Decryption failed.",
"error_invalid_host": "Passkeys do not work on 127.0.0.1. Please open the app via localhost.",
"use_localhost_link": "Switch to localhost",
"error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.",
"error_invalid_rp_id": "Passkey domain mismatch (RP ID). For local dev use http://localhost:5173 with RP_ID=localhost in .env.",
"error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again."
},
"pwa": {
"title": "Install app",
@@ -202,6 +207,7 @@
"live_mode": "Live",
"live_title": "Live Journal",
"live_loading": "Loading live journal...",
"live_retry": "Try again",
"live_load_error": "Could not load live journal.",
"live_action_error": "Could not save entry.",
"live_open_editor": "Full editor",
@@ -215,7 +221,10 @@
"live_moor": "Moor",
"live_sails_btn": "Sails",
"live_sails_pick": "Select sails",
"live_sails_pick_hint": "Tap multiple sails (tap again to deselect), then log.",
"live_sails_selected": "Selected: {{sails}}",
"live_sails_confirm": "Log entry",
"live_sails_confirm_count": "Log entry ({{count}})",
"live_sails": "Sails: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
+10 -1
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Tast inn PIN-koden din...",
"decrypt_with_pin": "Dekryptere",
"use_recovery_instead": "Bruk gjenopprettingsnøkler i stedet",
"error_incorrect_pin": "Feil PIN-kode. Dekryptering mislyktes."
"error_incorrect_pin": "Feil PIN-kode. Dekryptering mislyktes.",
"error_invalid_host": "Passkeys fungerer ikke via 127.0.0.1. Åpne appen via localhost.",
"use_localhost_link": "Bytt til localhost",
"error_passkey_cancelled": "Passkey-innlogging ble avbrutt eller utløp. Prøv igjen.",
"error_invalid_rp_id": "Passkey-domene stemmer ikke (RP ID). Bruk http://localhost:5173 med RP_ID=localhost i .env for lokal utvikling.",
"error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen."
},
"pwa": {
"title": "Installer app",
@@ -202,6 +207,7 @@
"live_mode": "Live",
"live_title": "Live-journal",
"live_loading": "Live-journal lastes inn...",
"live_retry": "Prøv igjen",
"live_load_error": "Live-journal kunne ikke lastes inn.",
"live_action_error": "Oppføringen kunne ikke lagres.",
"live_open_editor": "Full editor",
@@ -215,7 +221,10 @@
"live_moor": "Anløp",
"live_sails_btn": "Seil",
"live_sails_pick": "Velg seil",
"live_sails_pick_hint": "Trykk flere seil (trykk igjen for å fjerne), deretter loggfør.",
"live_sails_selected": "Valgt: {{sails}}",
"live_sails_confirm": "Loggfør",
"live_sails_confirm_count": "Loggfør ({{count}})",
"live_sails": "Seil: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
+10 -1
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Ange din PIN-kod...",
"decrypt_with_pin": "Dekryptera",
"use_recovery_instead": "Använd återställningsnycklar istället",
"error_incorrect_pin": "Felaktig PIN-kod. Dekryptering misslyckades."
"error_incorrect_pin": "Felaktig PIN-kod. Dekryptering misslyckades.",
"error_invalid_host": "Passkeys fungerar inte via 127.0.0.1. Öppna appen via localhost.",
"use_localhost_link": "Byt till localhost",
"error_passkey_cancelled": "Passkey-inloggning avbröts eller gick ut. Försök igen.",
"error_invalid_rp_id": "Passkey-domänen matchar inte (RP ID). Använd http://localhost:5173 med RP_ID=localhost i .env för lokal utveckling.",
"error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen."
},
"pwa": {
"title": "Installera app",
@@ -202,6 +207,7 @@
"live_mode": "Live",
"live_title": "Live-journal",
"live_loading": "Live-journal laddas...",
"live_retry": "Försök igen",
"live_load_error": "Live-journal kunde inte laddas.",
"live_action_error": "Posten kunde inte sparas.",
"live_open_editor": "Fullständig editor",
@@ -215,7 +221,10 @@
"live_moor": "Anlöp",
"live_sails_btn": "Segel",
"live_sails_pick": "Välj segel",
"live_sails_pick_hint": "Tryck på flera segel (tryck igen för att avmarkera), logga sedan.",
"live_sails_selected": "Valt: {{sails}}",
"live_sails_confirm": "Logga",
"live_sails_confirm_count": "Logga ({{count}})",
"live_sails": "Segel: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
+5
View File
@@ -12,6 +12,7 @@ import {
markReloadAttempt,
reconcileVersionOnStartup
} from './services/pwaStartup.ts'
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.ts'
/** Stale PWA precache on localhost can shadow Vite dev modules. */
async function clearDevServiceWorkerCaches(): Promise<void> {
@@ -40,6 +41,10 @@ function renderBootstrapError(message: string): void {
}
async function bootstrap(): Promise<void> {
if (redirectToPasskeyCompatibleHostIfNeeded()) {
return
}
applyAppearanceToDocument()
installStaleAssetRecovery()
await clearDevServiceWorkerCaches()
+6 -1
View File
@@ -12,6 +12,7 @@ import { clearLogbookKeysCache } from './logbookKeys.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
import { db } from './db.js'
import { apiFetch, apiJson } from './api.js'
import { isWebAuthnUserAbortError } from '../utils/passkeyHost.js'
const API_BASE = '/api/auth'
@@ -361,7 +362,11 @@ export async function loginUser(username?: string): Promise<LoginResult> {
const prfRequested = !!options.extensions?.prf
try {
credentialResponse = await startAuthentication({ optionsJSON: options })
} catch (err: any) {
} catch (err: unknown) {
// User cancelled or timed out — never open a second platform prompt.
if (isWebAuthnUserAbortError(err)) {
throw err
}
if (prfRequested) {
console.warn('Passkey authentication with PRF extension failed, retrying without PRF:', err)
if (options.extensions) {
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it, vi } from 'vitest'
import { tryDecryptEntryPayload } from './quickEventLog.js'
vi.mock('./crypto.js', () => ({
decryptJson: vi.fn(async (_c: string, _i: string, _t: string) => {
throw new Error('decrypt failed')
}),
encryptJson: vi.fn()
}))
describe('tryDecryptEntryPayload', () => {
it('returns null when decryption fails', async () => {
const result = await tryDecryptEntryPayload(
{ encryptedData: 'x', iv: 'y', tag: 'z' },
new ArrayBuffer(32)
)
expect(result).toBeNull()
})
})
+33 -6
View File
@@ -1,6 +1,6 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js'
import { decryptJson, encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import {
@@ -24,12 +24,36 @@ export interface LoadedEntry {
data: Record<string, unknown>
}
type EncryptedRecord = {
encryptedData: string
iv: string
tag: string
}
async function getMasterKey(logbookId: string): Promise<ArrayBuffer> {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
return masterKey
}
/** Decrypt one record; skip corrupt or legacy entries instead of aborting the whole scan. */
export async function tryDecryptEntryPayload(
record: EncryptedRecord,
key: ArrayBuffer
): Promise<Record<string, unknown> | null> {
try {
return await decryptJson(record.encryptedData, record.iv, record.tag, key)
} catch {
return null
}
}
function sortEntriesNewestFirst<T extends { updatedAt: string }>(entries: T[]): T[] {
return [...entries].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
)
}
function tankLevelsFromData(data: Record<string, unknown>) {
const fw = (data.freshwater as Record<string, number> | undefined) ?? {
morning: 0, refilled: 0, evening: 0, consumption: 0
@@ -110,7 +134,7 @@ export async function loadEntry(logbookId: string, entryId: string): Promise<Loa
const masterKey = await getMasterKey(logbookId)
const record = await db.entries.get(entryId)
if (!record) return null
const data = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
const data = await tryDecryptEntryPayload(record, masterKey)
if (!data) return null
return { payloadId: record.payloadId, updatedAt: record.updatedAt, data }
}
@@ -118,10 +142,10 @@ export async function loadEntry(logbookId: string, entryId: string): Promise<Loa
export async function findTodayEntryId(logbookId: string): Promise<string | null> {
const todayStr = new Date().toISOString().substring(0, 10)
const masterKey = await getMasterKey(logbookId)
const local = await db.entries.where({ logbookId }).toArray()
const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray())
for (const entry of local) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (decrypted && String(decrypted.date) === todayStr) {
return entry.payloadId
}
@@ -135,8 +159,10 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
for (const entry of localEntries) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (decrypted) {
decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
}
}
decryptedEntries.sort(compareTravelDaysChronological)
@@ -185,6 +211,7 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
}
export async function findOrCreateTodayEntry(logbookId: string): Promise<string> {
await ensureLogbookKey(logbookId)
const existing = await findTodayEntryId(logbookId)
if (existing) return existing
return createTodayEntry(logbookId)
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import {
isPasskeyCompatibleHostname,
isPasskeyInvalidDomainError,
isWebAuthnUserAbortError,
localizeWebAuthnError,
toPasskeyCompatibleUrl
} from './passkeyHost.js'
describe('isPasskeyCompatibleHostname', () => {
it('accepts localhost and real domains', () => {
expect(isPasskeyCompatibleHostname('localhost')).toBe(true)
expect(isPasskeyCompatibleHostname('kapteins-daagbok.eu')).toBe(true)
})
it('rejects IP addresses', () => {
expect(isPasskeyCompatibleHostname('127.0.0.1')).toBe(false)
})
})
describe('toPasskeyCompatibleUrl', () => {
it('rewrites 127.0.0.1 to localhost', () => {
expect(toPasskeyCompatibleUrl('http://127.0.0.1:5173/demo?lng=de')).toBe(
'http://localhost:5173/demo?lng=de'
)
})
})
describe('isPasskeyInvalidDomainError', () => {
it('detects simplewebauthn browser message', () => {
expect(isPasskeyInvalidDomainError('127.0.0.1 is an invalid domain')).toBe(true)
expect(isPasskeyInvalidDomainError('User cancelled')).toBe(false)
})
})
describe('isWebAuthnUserAbortError', () => {
it('detects NotAllowedError and timeout messages', () => {
expect(isWebAuthnUserAbortError({ name: 'NotAllowedError', message: 'timed out' })).toBe(true)
expect(
isWebAuthnUserAbortError(
new Error('The operation either timed out or was not allowed.')
)
).toBe(true)
expect(isWebAuthnUserAbortError({ name: 'SecurityError', message: 'bad rp' })).toBe(false)
})
})
describe('localizeWebAuthnError', () => {
it('maps cancellation to a friendly message', () => {
expect(
localizeWebAuthnError('The operation either timed out or was not allowed.', {
invalidHost: 'host',
cancelled: 'cancelled'
})
).toBe('cancelled')
})
})
+69
View File
@@ -0,0 +1,69 @@
/**
* WebAuthn / Passkeys require a valid domain (see WHATWG valid domain).
* IP addresses such as 127.0.0.1 are rejected by browsers and @simplewebauthn/browser.
*/
export function isPasskeyCompatibleHostname(hostname: string): boolean {
return (
hostname === 'localhost' ||
/^((xn--[a-z0-9-]+|[a-z0-9]+(-[a-z0-9]+)*)\.)+([a-z]{2,}|xn--[a-z0-9-]+)$/i.test(hostname)
)
}
export function isPasskeyCompatibleLocation(loc: Location = window.location): boolean {
return isPasskeyCompatibleHostname(loc.hostname)
}
/** Same page on localhost — for dev links when opened via 127.0.0.1. */
export function toPasskeyCompatibleUrl(href: string): string {
const url = new URL(href)
if (url.hostname === '127.0.0.1' || url.hostname === '[::1]' || url.hostname === '::1') {
url.hostname = 'localhost'
}
return url.toString()
}
/**
* Redirect 127.0.0.1 / ::1 to localhost (dev). Returns true if navigation was started.
*/
export function redirectToPasskeyCompatibleHostIfNeeded(loc: Location = window.location): boolean {
if (isPasskeyCompatibleHostname(loc.hostname)) return false
const target = toPasskeyCompatibleUrl(loc.href)
if (target === loc.href) return false
window.location.replace(target)
return true
}
export function isPasskeyInvalidDomainError(message: string): boolean {
return /is an invalid domain$/i.test(message)
}
export function localizePasskeyHostError(message: string, invalidHostMessage: string): string {
return isPasskeyInvalidDomainError(message) ? invalidHostMessage : message
}
/** User dismissed or denied the platform passkey prompt (do not auto-retry WebAuthn). */
export function isWebAuthnUserAbortError(err: unknown): boolean {
if (!err || typeof err !== 'object') return false
const name = 'name' in err ? String((err as { name: string }).name) : ''
if (name === 'NotAllowedError' || name === 'AbortError') return true
const message = 'message' in err ? String((err as { message: string }).message) : String(err)
return /timed out|not allowed|cancel/i.test(message)
}
export function localizeWebAuthnError(
message: string,
messages: {
invalidHost: string
cancelled: string
invalidRpId?: string
}
): string {
if (isPasskeyInvalidDomainError(message)) return messages.invalidHost
if (/timed out|not allowed|cancel/i.test(message)) return messages.cancelled
if (/invalid for this domain/i.test(message) && messages.invalidRpId) {
return messages.invalidRpId
}
return message
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import {
dedupeSailNames,
isSailInSelection,
joinSailSelection,
splitSailSelection,
toggleSailInSelection
} from './sailSelection.js'
describe('toggleSailInSelection', () => {
it('adds a second sail without removing the first', () => {
const first = toggleSailInSelection([], 'Mainsail')
expect(first).toEqual(['Mainsail'])
const second = toggleSailInSelection(first, 'Genoa')
expect(second).toEqual(['Mainsail', 'Genoa'])
})
it('removes sail when toggled again', () => {
const selected = toggleSailInSelection(
toggleSailInSelection([], 'Mainsail'),
'Genoa'
)
expect(toggleSailInSelection(selected, 'Mainsail')).toEqual(['Genoa'])
})
it('matches case-insensitively', () => {
expect(toggleSailInSelection(['genua'], 'Genua')).toEqual([])
expect(isSailInSelection(['Großsegel'], 'großsegel')).toBe(true)
})
})
describe('joinSailSelection / splitSailSelection', () => {
it('round-trips multiple sails', () => {
const joined = joinSailSelection(['Großsegel', 'Genua'])
expect(joined).toBe('Großsegel + Genua')
expect(splitSailSelection(joined)).toEqual(['Großsegel', 'Genua'])
})
})
describe('dedupeSailNames', () => {
it('removes duplicate names', () => {
expect(dedupeSailNames(['Genua', 'genua', 'Fock'])).toEqual(['Genua', 'Fock'])
})
})
+42
View File
@@ -0,0 +1,42 @@
/** Toggle one sail label in a multi-select list (case-insensitive). */
export function toggleSailInSelection(selected: readonly string[], sail: string): string[] {
const normalized = sail.trim()
if (!normalized) return [...selected]
return selected.some((s) => s.toLowerCase() === normalized.toLowerCase())
? selected.filter((s) => s.toLowerCase() !== normalized.toLowerCase())
: [...selected, normalized]
}
export function isSailInSelection(selected: readonly string[], sail: string): boolean {
const normalized = sail.trim().toLowerCase()
if (!normalized) return false
return selected.some((s) => s.toLowerCase() === normalized)
}
/** Join selected sails for logbook `sailsOrMotor` (matches LogEntryEditor). */
export function joinSailSelection(selected: readonly string[]): string {
return selected.map((s) => s.trim()).filter(Boolean).join(' + ')
}
export function splitSailSelection(value: string): string[] {
return value
.split(/\s*(?:\+|\bplus\b|,)\s*/i)
.map((s) => s.trim())
.filter(Boolean)
}
/** Deduplicate sail names for picker UI (case-insensitive, keeps first spelling). */
export function dedupeSailNames(sails: readonly string[]): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const sail of sails) {
const trimmed = sail.trim()
if (!trimmed) continue
const key = trimmed.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
result.push(trimmed)
}
return result
}
+2
View File
@@ -46,6 +46,8 @@ export default defineConfig({
include: ['leaflet']
},
server: {
// Passkeys require localhost or a real domain — not 127.0.0.1
host: 'localhost',
port: 5173,
proxy: {
'/api': {
+31 -4
View File
@@ -49,6 +49,21 @@ function parseColorSchemePreference(value: unknown): string | null {
return typeof value === 'string' && VALID_COLOR_SCHEMES.has(value) ? value : null
}
function isMissingAppearancePrefsTable(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code: string }).code === 'P2021'
)
}
const DEFAULT_APPEARANCE_PREFS = {
theme: 'auto',
colorScheme: 'auto',
persisted: false
} as const
router.post('/register-options', async (req, res) => {
try {
const { username } = req.body
@@ -448,9 +463,14 @@ router.get('/appearance-prefs', requireUser, async (req: any, res) => {
colorScheme: prefs?.colorScheme ?? 'auto',
persisted: prefs != null
})
} catch (error: any) {
} catch (error: unknown) {
if (isMissingAppearancePrefsTable(error)) {
console.warn('UserAppearancePrefs table missing — run: npx prisma db push (in server/)')
return res.json({ ...DEFAULT_APPEARANCE_PREFS })
}
console.error('Error reading appearance prefs:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
const message = error instanceof Error ? error.message : 'Internal server error'
return res.status(500).json({ error: message })
}
})
@@ -482,9 +502,16 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
colorScheme: prefs.colorScheme,
persisted: true
})
} catch (error: any) {
} catch (error: unknown) {
if (isMissingAppearancePrefsTable(error)) {
console.warn('UserAppearancePrefs table missing — run: npx prisma db push (in server/)')
return res.status(503).json({
error: 'Appearance preferences storage is not migrated. Run prisma db push on the server.'
})
}
console.error('Error updating appearance prefs:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
const message = error instanceof Error ? error.message : 'Internal server error'
return res.status(500).json({ error: message })
}
})