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
co-authored by Cursor
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= DeepLAPIKey=
# Passkey configuration (WebAuthn Relying Party ID and Origin) # 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 # For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
RP_ID=localhost 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 ORIGIN=http://localhost:5173
# Optional: comma-separated CORS origins (defaults to ORIGIN; dev also allows 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,http://127.0.0.1:5173 # CORS_ORIGINS=http://localhost:5173
# API session signing (min. 32 chars; required in production) # API session signing (min. 32 chars; required in production)
# Generate: openssl rand -base64 48 # Generate: openssl rand -base64 48
+8 -1
View File
@@ -3345,7 +3345,14 @@ html.theme-cupertino .events-scroll-container {
} }
.live-log-sail-pills { .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 { .live-log-modal-actions {
+76 -18
View File
@@ -1,21 +1,28 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js' import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import { import {
registerUser, registerUser,
loginUser, loginUser,
completeLoginWithRecovery, completeLoginWithRecovery,
setLocalPin, setLocalPin,
hasLocalPin, hasLocalPin,
decryptWithLocalPin, decryptWithLocalPin,
getActiveMasterKey, getActiveMasterKey,
getKnownUsernames, getKnownUsernames,
forgetUsername forgetUsername,
hasUnlockedLocalSession,
logoutUser
} from '../services/auth.js' } from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react' import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx' import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
import DisclaimerModal from './DisclaimerModal.tsx' import DisclaimerModal from './DisclaimerModal.tsx'
import BetaBadge from './BetaBadge.tsx' import BetaBadge from './BetaBadge.tsx'
import {
isPasskeyCompatibleLocation,
localizeWebAuthnError,
toPasskeyCompatibleUrl
} from '../utils/passkeyHost.ts'
interface AuthOnboardingProps { interface AuthOnboardingProps {
onAuthenticated: () => void onAuthenticated: () => void
@@ -54,6 +61,16 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const [showDisclaimer, setShowDisclaimer] = useState(false) const [showDisclaimer, setShowDisclaimer] = useState(false)
const [showHelp, setShowHelp] = 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 = () => { const finishAuth = () => {
if (isNewRegistration) { if (isNewRegistration) {
setShowDisclaimer(true) setShowDisclaimer(true)
@@ -81,7 +98,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
setRecoveryPhrase(result.recoveryPhrase) setRecoveryPhrase(result.recoveryPhrase)
} }
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Registration failed') setError(formatAuthError(err.message || 'Registration failed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -121,7 +138,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
} }
} }
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Login failed') setError(formatAuthError(err.message || 'Login failed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -185,19 +202,33 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const handlePinLoginSubmit = async (e: React.FormEvent) => { const handlePinLoginSubmit = async (e: React.FormEvent) => {
e.preventDefault() 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) setLoading(true)
setError(null) setError(null)
try { try {
const resolvedUser = username.trim() || encryptedPayloads?.username
const key = await decryptWithLocalPin(pinLoginInput.trim(), resolvedUser) const key = await decryptWithLocalPin(pinLoginInput.trim(), resolvedUser)
if (key) { if (!key) {
onAuthenticated()
} else {
setError(t('auth.error_incorrect_pin')) 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')) setError(t('auth.error_incorrect_pin'))
} finally { } finally {
setLoading(false) setLoading(false)
@@ -361,6 +392,24 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
> >
{t('auth.use_recovery_instead')} {t('auth.use_recovery_instead')}
</button> </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> </div>
</form> </form>
</div> </div>
@@ -445,12 +494,21 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
</div> </div>
<div className="auth-form" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '20px' }}> <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 */} {/* Prominent Login button */}
<button <button
type="button" type="button"
className="btn primary" className="btn primary"
onClick={() => handleLogin()} onClick={() => handleLogin()}
disabled={loading} disabled={loading || !passkeyHostOk}
style={{ width: '100%', padding: '16px' }} style={{ width: '100%', padding: '16px' }}
> >
{loading {loading
@@ -583,7 +641,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<button <button
type="submit" type="submit"
className="btn secondary" className="btn secondary"
disabled={loading || !username.trim()} disabled={loading || !username.trim() || !passkeyHostOk}
style={{ width: '100%' }} style={{ width: '100%' }}
> >
{t('auth.register')} {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 { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
@@ -47,8 +47,15 @@ import {
} from '../utils/liveEventCodes.js' } from '../utils/liveEventCodes.js'
import { getCurrentPosition } from '../utils/geolocation.js' import { getCurrentPosition } from '../utils/geolocation.js'
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js' import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
import {
dedupeSailNames,
isSailInSelection,
joinSailSelection,
toggleSailInSelection
} from '../utils/sailSelection.js'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import CourseDialInput from './CourseDialInput.tsx' import CourseDialInput from './CourseDialInput.tsx'
import i18n from '../i18n/index.js'
interface LiveLogViewProps { interface LiveLogViewProps {
logbookId: string logbookId: string
@@ -122,22 +129,37 @@ export default function LiveLogView({
const streamEndRef = useRef<HTMLDivElement | null>(null) const streamEndRef = useRef<HTMLDivElement | null>(null)
const undoTimerRef = useRef<number | null>(null) const undoTimerRef = useRef<number | null>(null)
const autoPositionBusyRef = useRef(false) 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' const defaultSails = useMemo(
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker'] () => (i18n.language === 'de'
: ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker'] ? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
const sailOptions = yachtSails.length > 0 ? yachtSails : defaultSails : ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker']),
[i18n.language]
)
const sailOptions = useMemo(
() => dedupeSailNames(yachtSails.length > 0 ? yachtSails : defaultSails),
[yachtSails, defaultSails]
)
const motorRunning = isMotorRunningFromEvents(events) const motorRunning = isMotorRunningFromEvents(events)
const motorLabel = t('logs.motor_propulsion') const motorLabel = t('logs.motor_propulsion')
const refreshEntry = useCallback(async (id: string) => { const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
const loaded = await loadEntry(logbookId, id)
if (!loaded) return
const entryEvents = (loaded.data.events as LogEventPayload[]) || [] const entryEvents = (loaded.data.events as LogEventPayload[]) || []
setDayOfTravel(String(loaded.data.dayOfTravel || '')) setDayOfTravel(String(loaded.data.dayOfTravel || ''))
setDate(String(loaded.data.date || '')) setDate(String(loaded.data.date || ''))
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e })))) 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(() => { const showUndo = useCallback(() => {
setUndoVisible(true) setUndoVisible(true)
@@ -148,42 +170,59 @@ export default function LiveLogView({
}, UNDO_TIMEOUT_MS) }, UNDO_TIMEOUT_MS)
}, []) }, [])
useEffect(() => { const runInit = useCallback(async () => {
let cancelled = false const seq = ++initSeqRef.current
setLoading(true)
setError(null)
try {
const id = await findOrCreateTodayEntry(logbookId)
if (seq !== initSeqRef.current) return
setEntryId(id)
async function init() { const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
setLoading(true) if (masterKey) {
setError(null) const yacht = await db.yachts.get(logbookId)
try { if (yacht) {
const id = await findOrCreateTodayEntry(logbookId) try {
if (cancelled) return const decrypted = await decryptJson(
setEntryId(id) yacht.encryptedData,
yacht.iv,
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() yacht.tag,
if (masterKey) { masterKey
const yacht = await db.yachts.get(logbookId) )
if (yacht) {
const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey)
if (decrypted?.sails && Array.isArray(decrypted.sails)) { if (decrypted?.sails && Array.isArray(decrypted.sails)) {
setYachtSails(decrypted.sails as string[]) setYachtSails(decrypted.sails as string[])
} }
} catch {
// Yacht profile optional for live log
} }
} }
}
await refreshEntry(id) const loaded = await loadEntry(logbookId, id)
} catch (err: unknown) { if (seq !== initSeqRef.current) return
if (!cancelled) { if (loaded) {
console.error('Failed to init live log:', err) applyLoadedEntry(loaded)
setError(err instanceof Error ? err.message : t('logs.live_load_error')) } else {
} throw new Error(i18n.t('logs.live_load_error'))
} finally { }
if (!cancelled) setLoading(false) } 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() useEffect(() => {
return () => { cancelled = true } void runInit()
}, [logbookId, refreshEntry, t]) return () => {
initSeqRef.current += 1
}
}, [runInit])
useEffect(() => { useEffect(() => {
if (!loading && entryId) { if (!loading && entryId) {
@@ -207,7 +246,7 @@ export default function LiveLogView({
const maybeAutoPosition = async () => { const maybeAutoPosition = async () => {
if (document.visibilityState !== 'visible' || autoPositionBusyRef.current || busy) return 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 if (lastMs != null && Date.now() - lastMs < AUTO_POSITION_INTERVAL_MS) return
autoPositionBusyRef.current = true autoPositionBusyRef.current = true
@@ -228,7 +267,7 @@ export default function LiveLogView({
const interval = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS) const interval = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
return () => window.clearInterval(interval) return () => window.clearInterval(interval)
}, [entryId, loading, events, date, logbookId, refreshEntry, busy]) }, [entryId, loading, logbookId, refreshEntry, busy])
const runQuickAction = async ( const runQuickAction = async (
action: () => Promise<void>, action: () => Promise<void>,
@@ -325,11 +364,11 @@ export default function LiveLogView({
} }
const confirmSails = () => { const confirmSails = () => {
if (selectedSails.length === 0) { const sailsLabel = joinSailSelection(selectedSails)
if (!sailsLabel) {
setModal('none') setModal('none')
return return
} }
const sailsLabel = selectedSails.join(' + ')
setModal('none') setModal('none')
setSelectedSails([]) setSelectedSails([])
void runQuickAction(async () => { void runQuickAction(async () => {
@@ -468,18 +507,24 @@ export default function LiveLogView({
} }
const toggleSailSelection = (sail: string) => { const toggleSailSelection = (sail: string) => {
setSelectedSails((prev) => setSelectedSails((prev) => toggleSailInSelection(prev, sail))
prev.some((s) => s.toLowerCase() === sail.toLowerCase())
? prev.filter((s) => s.toLowerCase() !== sail.toLowerCase())
: [...prev, sail]
)
} }
const closeModal = () => setModal('none')
if (loading) { if (loading) {
return ( return (
<div className="tab-placeholder"> <div className="tab-placeholder">
<Radio className="header-logo spin" size={48} /> <Radio className="header-logo spin" size={48} />
<p>{t('logs.live_loading')}</p> <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> </div>
) )
} }
@@ -629,24 +674,50 @@ export default function LiveLogView({
)} )}
{modal === 'sails' && ( {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()}> <div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
<h3>{t('logs.live_sails_pick')}</h3> <h3>{t('logs.live_sails_pick')}</h3>
<div className="sails-picker-pills live-log-sail-pills"> <p className="live-log-modal-hint">{t('logs.live_sails_pick_hint')}</p>
{sailOptions.map((sail) => ( <div
<button className="sails-picker-pills live-log-sail-pills"
key={sail} role="group"
type="button" aria-label={t('logs.live_sails_pick')}
className={`sail-pill ${selectedSails.some((s) => s.toLowerCase() === sail.toLowerCase()) ? 'active' : ''}`} >
onClick={() => toggleSailSelection(sail)} {sailOptions.map((sail) => {
> const active = isSailInSelection(selectedSails, sail)
{sail} return (
</button> <button
))} key={sail}
type="button"
className={`sail-pill ${active ? 'active' : ''}`}
aria-pressed={active}
onClick={() => toggleSailSelection(sail)}
>
{sail}
</button>
)
})}
</div> </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"> <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 secondary" onClick={closeModal}>{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 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> </div>
</div> </div>
+4 -2
View File
@@ -149,17 +149,19 @@ export default function LogEntriesList({
}, [logbookId, readOnly, preloadedEntries]) }, [logbookId, readOnly, preloadedEntries])
useEffect(() => { useEffect(() => {
if (viewMode === 'live') return
loadEntries() loadEntries()
}, [loadEntries]) }, [loadEntries, viewMode])
useEffect(() => { useEffect(() => {
if (viewMode === 'live') return
const prevSelectedEntryId = prevSelectedEntryIdRef.current const prevSelectedEntryId = prevSelectedEntryIdRef.current
prevSelectedEntryIdRef.current = selectedEntryId prevSelectedEntryIdRef.current = selectedEntryId
if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) { if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) {
loadEntries() loadEntries()
} }
}, [selectedEntryId, loadEntries]) }, [selectedEntryId, loadEntries, viewMode])
const handleDownloadCsv = async () => { const handleDownloadCsv = async () => {
setExporting(true) setExporting(true)
+10 -1
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Indtast din pinkode...", "enter_pin_placeholder": "Indtast din pinkode...",
"decrypt_with_pin": "Afkodning", "decrypt_with_pin": "Afkodning",
"use_recovery_instead": "Brug genoprettelsesnøgler i stedet", "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": { "pwa": {
"title": "Installer app", "title": "Installer app",
@@ -202,6 +207,7 @@
"live_mode": "Live", "live_mode": "Live",
"live_title": "Live-journal", "live_title": "Live-journal",
"live_loading": "Live-journal indlæses...", "live_loading": "Live-journal indlæses...",
"live_retry": "Prøv igen",
"live_load_error": "Live-journal kunne ikke indlæses.", "live_load_error": "Live-journal kunne ikke indlæses.",
"live_action_error": "Indtastning kunne ikke gemmes.", "live_action_error": "Indtastning kunne ikke gemmes.",
"live_open_editor": "Fuld editor", "live_open_editor": "Fuld editor",
@@ -215,7 +221,10 @@
"live_moor": "Anløb", "live_moor": "Anløb",
"live_sails_btn": "Sejl", "live_sails_btn": "Sejl",
"live_sails_pick": "Vælg 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": "Indtast",
"live_sails_confirm_count": "Indtast ({{count}})",
"live_sails": "Sejl: {{sails}}", "live_sails": "Sejl: {{sails}}",
"live_fix": "Fix", "live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "live_fix_coords": "Fix {{lat}}, {{lng}}",
+11 -2
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Gib deine PIN ein...", "enter_pin_placeholder": "Gib deine PIN ein...",
"decrypt_with_pin": "Entschlüsseln", "decrypt_with_pin": "Entschlüsseln",
"use_recovery_instead": "Stattdessen Wiederherstellungsschlüssel verwenden", "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": { "pwa": {
"title": "App installieren", "title": "App installieren",
@@ -202,6 +207,7 @@
"live_mode": "Live", "live_mode": "Live",
"live_title": "Live-Journal", "live_title": "Live-Journal",
"live_loading": "Live-Journal wird geladen...", "live_loading": "Live-Journal wird geladen...",
"live_retry": "Erneut versuchen",
"live_load_error": "Live-Journal konnte nicht geladen werden.", "live_load_error": "Live-Journal konnte nicht geladen werden.",
"live_action_error": "Eintrag konnte nicht gespeichert werden.", "live_action_error": "Eintrag konnte nicht gespeichert werden.",
"live_open_editor": "Vollständiger Editor", "live_open_editor": "Vollständiger Editor",
@@ -214,8 +220,11 @@
"live_cast_off": "Ablegen", "live_cast_off": "Ablegen",
"live_moor": "Anlegen", "live_moor": "Anlegen",
"live_sails_btn": "Segel", "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": "Eintragen",
"live_sails_confirm_count": "Eintragen ({{count}})",
"live_sails": "Segel: {{sails}}", "live_sails": "Segel: {{sails}}",
"live_fix": "Fix", "live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "live_fix_coords": "Fix {{lat}}, {{lng}}",
+10 -1
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Enter your PIN...", "enter_pin_placeholder": "Enter your PIN...",
"decrypt_with_pin": "Decrypt", "decrypt_with_pin": "Decrypt",
"use_recovery_instead": "Use recovery phrase instead", "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": { "pwa": {
"title": "Install app", "title": "Install app",
@@ -202,6 +207,7 @@
"live_mode": "Live", "live_mode": "Live",
"live_title": "Live Journal", "live_title": "Live Journal",
"live_loading": "Loading live journal...", "live_loading": "Loading live journal...",
"live_retry": "Try again",
"live_load_error": "Could not load live journal.", "live_load_error": "Could not load live journal.",
"live_action_error": "Could not save entry.", "live_action_error": "Could not save entry.",
"live_open_editor": "Full editor", "live_open_editor": "Full editor",
@@ -215,7 +221,10 @@
"live_moor": "Moor", "live_moor": "Moor",
"live_sails_btn": "Sails", "live_sails_btn": "Sails",
"live_sails_pick": "Select 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": "Log entry",
"live_sails_confirm_count": "Log entry ({{count}})",
"live_sails": "Sails: {{sails}}", "live_sails": "Sails: {{sails}}",
"live_fix": "Fix", "live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "live_fix_coords": "Fix {{lat}}, {{lng}}",
+10 -1
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Tast inn PIN-koden din...", "enter_pin_placeholder": "Tast inn PIN-koden din...",
"decrypt_with_pin": "Dekryptere", "decrypt_with_pin": "Dekryptere",
"use_recovery_instead": "Bruk gjenopprettingsnøkler i stedet", "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": { "pwa": {
"title": "Installer app", "title": "Installer app",
@@ -202,6 +207,7 @@
"live_mode": "Live", "live_mode": "Live",
"live_title": "Live-journal", "live_title": "Live-journal",
"live_loading": "Live-journal lastes inn...", "live_loading": "Live-journal lastes inn...",
"live_retry": "Prøv igjen",
"live_load_error": "Live-journal kunne ikke lastes inn.", "live_load_error": "Live-journal kunne ikke lastes inn.",
"live_action_error": "Oppføringen kunne ikke lagres.", "live_action_error": "Oppføringen kunne ikke lagres.",
"live_open_editor": "Full editor", "live_open_editor": "Full editor",
@@ -215,7 +221,10 @@
"live_moor": "Anløp", "live_moor": "Anløp",
"live_sails_btn": "Seil", "live_sails_btn": "Seil",
"live_sails_pick": "Velg 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": "Loggfør",
"live_sails_confirm_count": "Loggfør ({{count}})",
"live_sails": "Seil: {{sails}}", "live_sails": "Seil: {{sails}}",
"live_fix": "Fix", "live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "live_fix_coords": "Fix {{lat}}, {{lng}}",
+10 -1
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Ange din PIN-kod...", "enter_pin_placeholder": "Ange din PIN-kod...",
"decrypt_with_pin": "Dekryptera", "decrypt_with_pin": "Dekryptera",
"use_recovery_instead": "Använd återställningsnycklar istället", "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": { "pwa": {
"title": "Installera app", "title": "Installera app",
@@ -202,6 +207,7 @@
"live_mode": "Live", "live_mode": "Live",
"live_title": "Live-journal", "live_title": "Live-journal",
"live_loading": "Live-journal laddas...", "live_loading": "Live-journal laddas...",
"live_retry": "Försök igen",
"live_load_error": "Live-journal kunde inte laddas.", "live_load_error": "Live-journal kunde inte laddas.",
"live_action_error": "Posten kunde inte sparas.", "live_action_error": "Posten kunde inte sparas.",
"live_open_editor": "Fullständig editor", "live_open_editor": "Fullständig editor",
@@ -215,7 +221,10 @@
"live_moor": "Anlöp", "live_moor": "Anlöp",
"live_sails_btn": "Segel", "live_sails_btn": "Segel",
"live_sails_pick": "Välj 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": "Logga",
"live_sails_confirm_count": "Logga ({{count}})",
"live_sails": "Segel: {{sails}}", "live_sails": "Segel: {{sails}}",
"live_fix": "Fix", "live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "live_fix_coords": "Fix {{lat}}, {{lng}}",
+5
View File
@@ -12,6 +12,7 @@ import {
markReloadAttempt, markReloadAttempt,
reconcileVersionOnStartup reconcileVersionOnStartup
} from './services/pwaStartup.ts' } from './services/pwaStartup.ts'
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.ts'
/** Stale PWA precache on localhost can shadow Vite dev modules. */ /** Stale PWA precache on localhost can shadow Vite dev modules. */
async function clearDevServiceWorkerCaches(): Promise<void> { async function clearDevServiceWorkerCaches(): Promise<void> {
@@ -40,6 +41,10 @@ function renderBootstrapError(message: string): void {
} }
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
if (redirectToPasskeyCompatibleHostIfNeeded()) {
return
}
applyAppearanceToDocument() applyAppearanceToDocument()
installStaleAssetRecovery() installStaleAssetRecovery()
await clearDevServiceWorkerCaches() await clearDevServiceWorkerCaches()
+6 -1
View File
@@ -12,6 +12,7 @@ import { clearLogbookKeysCache } from './logbookKeys.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js' import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
import { db } from './db.js' import { db } from './db.js'
import { apiFetch, apiJson } from './api.js' import { apiFetch, apiJson } from './api.js'
import { isWebAuthnUserAbortError } from '../utils/passkeyHost.js'
const API_BASE = '/api/auth' const API_BASE = '/api/auth'
@@ -361,7 +362,11 @@ export async function loginUser(username?: string): Promise<LoginResult> {
const prfRequested = !!options.extensions?.prf const prfRequested = !!options.extensions?.prf
try { try {
credentialResponse = await startAuthentication({ optionsJSON: options }) 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) { if (prfRequested) {
console.warn('Passkey authentication with PRF extension failed, retrying without PRF:', err) console.warn('Passkey authentication with PRF extension failed, retrying without PRF:', err)
if (options.extensions) { 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 { db } from './db.js'
import { getActiveMasterKey } from './auth.js' import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js' import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js'
import { decryptJson, encryptJson } from './crypto.js' import { decryptJson, encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js' import { syncLogbook } from './sync.js'
import { import {
@@ -24,12 +24,36 @@ export interface LoadedEntry {
data: Record<string, unknown> data: Record<string, unknown>
} }
type EncryptedRecord = {
encryptedData: string
iv: string
tag: string
}
async function getMasterKey(logbookId: string): Promise<ArrayBuffer> { async function getMasterKey(logbookId: string): Promise<ArrayBuffer> {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.') if (!masterKey) throw new Error('Encryption key not found. Please log in.')
return masterKey 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>) { function tankLevelsFromData(data: Record<string, unknown>) {
const fw = (data.freshwater as Record<string, number> | undefined) ?? { const fw = (data.freshwater as Record<string, number> | undefined) ?? {
morning: 0, refilled: 0, evening: 0, consumption: 0 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 masterKey = await getMasterKey(logbookId)
const record = await db.entries.get(entryId) const record = await db.entries.get(entryId)
if (!record) return null 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 if (!data) return null
return { payloadId: record.payloadId, updatedAt: record.updatedAt, data } 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> { export async function findTodayEntryId(logbookId: string): Promise<string | null> {
const todayStr = new Date().toISOString().substring(0, 10) const todayStr = new Date().toISOString().substring(0, 10)
const masterKey = await getMasterKey(logbookId) 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) { 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) { if (decrypted && String(decrypted.date) === todayStr) {
return entry.payloadId return entry.payloadId
} }
@@ -135,8 +159,10 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = [] const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
for (const entry of localEntries) { for (const entry of localEntries) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey) const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable) if (decrypted) {
decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
}
} }
decryptedEntries.sort(compareTravelDaysChronological) decryptedEntries.sort(compareTravelDaysChronological)
@@ -185,6 +211,7 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
} }
export async function findOrCreateTodayEntry(logbookId: string): Promise<string> { export async function findOrCreateTodayEntry(logbookId: string): Promise<string> {
await ensureLogbookKey(logbookId)
const existing = await findTodayEntryId(logbookId) const existing = await findTodayEntryId(logbookId)
if (existing) return existing if (existing) return existing
return createTodayEntry(logbookId) 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'] include: ['leaflet']
}, },
server: { server: {
// Passkeys require localhost or a real domain — not 127.0.0.1
host: 'localhost',
port: 5173, port: 5173,
proxy: { proxy: {
'/api': { '/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 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) => { router.post('/register-options', async (req, res) => {
try { try {
const { username } = req.body const { username } = req.body
@@ -448,9 +463,14 @@ router.get('/appearance-prefs', requireUser, async (req: any, res) => {
colorScheme: prefs?.colorScheme ?? 'auto', colorScheme: prefs?.colorScheme ?? 'auto',
persisted: prefs != null 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) 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, colorScheme: prefs.colorScheme,
persisted: true 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) 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 })
} }
}) })