From 0caaf681d8449cbda0483610c721f3ab590a9942 Mon Sep 17 00:00:00 2001 From: elpatron Date: Mon, 1 Jun 2026 08:49:45 +0200 Subject: [PATCH] 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 --- .env.example | 8 +- client/src/App.css | 9 +- client/src/components/AuthOnboarding.tsx | 94 ++++++++--- client/src/components/LiveLogView.tsx | 187 +++++++++++++++------- client/src/components/LogEntriesList.tsx | 6 +- client/src/i18n/locales/da.json | 11 +- client/src/i18n/locales/de.json | 13 +- client/src/i18n/locales/en.json | 11 +- client/src/i18n/locales/nb.json | 11 +- client/src/i18n/locales/sv.json | 11 +- client/src/main.tsx | 5 + client/src/services/auth.ts | 7 +- client/src/services/quickEventLog.test.ts | 19 +++ client/src/services/quickEventLog.ts | 39 ++++- client/src/utils/passkeyHost.test.ts | 57 +++++++ client/src/utils/passkeyHost.ts | 69 ++++++++ client/src/utils/sailSelection.test.ts | 44 +++++ client/src/utils/sailSelection.ts | 42 +++++ client/vite.config.ts | 2 + server/src/routes/auth.ts | 35 +++- 20 files changed, 580 insertions(+), 100 deletions(-) create mode 100644 client/src/services/quickEventLog.test.ts create mode 100644 client/src/utils/passkeyHost.test.ts create mode 100644 client/src/utils/passkeyHost.ts create mode 100644 client/src/utils/sailSelection.test.ts create mode 100644 client/src/utils/sailSelection.ts diff --git a/.env.example b/.env.example index 9a2c33b..eb1f57c 100755 --- a/.env.example +++ b/.env.example @@ -5,13 +5,13 @@ OpenWeatherMapAPIKey= 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 diff --git a/client/src/App.css b/client/src/App.css index b495458..ef7aad1 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -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 { diff --git a/client/src/components/AuthOnboarding.tsx b/client/src/components/AuthOnboarding.tsx index 784b35e..f0f983e 100644 --- a/client/src/components/AuthOnboarding.tsx +++ b/client/src/components/AuthOnboarding.tsx @@ -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')} + + @@ -445,12 +494,21 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
+ {!passkeyHostOk && passkeyCompatibleUrl && ( +
+

{t('auth.error_invalid_host')}

+ + {t('auth.use_localhost_link')} + +
+ )} + {/* Prominent Login button */} + + )}
) } @@ -629,24 +674,50 @@ export default function LiveLogView({ )} {modal === 'sails' && ( -
setModal('none')}> +
{ if (e.target === e.currentTarget) closeModal() }} + >
e.stopPropagation()}>

{t('logs.live_sails_pick')}

-
- {sailOptions.map((sail) => ( - - ))} +

{t('logs.live_sails_pick_hint')}

+
+ {sailOptions.map((sail) => { + const active = isSailInSelection(selectedSails, sail) + return ( + + ) + })}
+ {selectedSails.length > 0 && ( +

+ {t('logs.live_sails_selected', { sails: joinSailSelection(selectedSails) })} +

+ )}
- - + +
diff --git a/client/src/components/LogEntriesList.tsx b/client/src/components/LogEntriesList.tsx index a237587..2c24772 100644 --- a/client/src/components/LogEntriesList.tsx +++ b/client/src/components/LogEntriesList.tsx @@ -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) diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index d530476..1ea81cf 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -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}}", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 4b9bb8c..cea6b3b 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -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}}", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 449dc76..8fc1cc1 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -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}}", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index c3ed7a3..26462fd 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -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}}", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index cd5d75e..808de41 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -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}}", diff --git a/client/src/main.tsx b/client/src/main.tsx index dfefcb3..af3d54a 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -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 { @@ -40,6 +41,10 @@ function renderBootstrapError(message: string): void { } async function bootstrap(): Promise { + if (redirectToPasskeyCompatibleHostIfNeeded()) { + return + } + applyAppearanceToDocument() installStaleAssetRecovery() await clearDevServiceWorkerCaches() diff --git a/client/src/services/auth.ts b/client/src/services/auth.ts index 9fd9647..dc53eb9 100644 --- a/client/src/services/auth.ts +++ b/client/src/services/auth.ts @@ -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 { 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) { diff --git a/client/src/services/quickEventLog.test.ts b/client/src/services/quickEventLog.test.ts new file mode 100644 index 0000000..a3fb11f --- /dev/null +++ b/client/src/services/quickEventLog.test.ts @@ -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() + }) +}) diff --git a/client/src/services/quickEventLog.ts b/client/src/services/quickEventLog.ts index 0ccd3d3..14ce206 100644 --- a/client/src/services/quickEventLog.ts +++ b/client/src/services/quickEventLog.ts @@ -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 } +type EncryptedRecord = { + encryptedData: string + iv: string + tag: string +} + async function getMasterKey(logbookId: string): Promise { 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 | null> { + try { + return await decryptJson(record.encryptedData, record.iv, record.tag, key) + } catch { + return null + } +} + +function sortEntriesNewestFirst(entries: T[]): T[] { + return [...entries].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ) +} + function tankLevelsFromData(data: Record) { const fw = (data.freshwater as Record | undefined) ?? { morning: 0, refilled: 0, evening: 0, consumption: 0 @@ -110,7 +134,7 @@ export async function loadEntry(logbookId: string, entryId: string): Promise { 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 { const decryptedEntries: Array = [] 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 { } export async function findOrCreateTodayEntry(logbookId: string): Promise { + await ensureLogbookKey(logbookId) const existing = await findTodayEntryId(logbookId) if (existing) return existing return createTodayEntry(logbookId) diff --git a/client/src/utils/passkeyHost.test.ts b/client/src/utils/passkeyHost.test.ts new file mode 100644 index 0000000..2f585c3 --- /dev/null +++ b/client/src/utils/passkeyHost.test.ts @@ -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') + }) +}) diff --git a/client/src/utils/passkeyHost.ts b/client/src/utils/passkeyHost.ts new file mode 100644 index 0000000..c470fd9 --- /dev/null +++ b/client/src/utils/passkeyHost.ts @@ -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 +} diff --git a/client/src/utils/sailSelection.test.ts b/client/src/utils/sailSelection.test.ts new file mode 100644 index 0000000..142612a --- /dev/null +++ b/client/src/utils/sailSelection.test.ts @@ -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']) + }) +}) diff --git a/client/src/utils/sailSelection.ts b/client/src/utils/sailSelection.ts new file mode 100644 index 0000000..f8dfa7b --- /dev/null +++ b/client/src/utils/sailSelection.ts @@ -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() + 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 +} diff --git a/client/vite.config.ts b/client/vite.config.ts index 298ed85..36fa92b 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -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': { diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 237fa38..151099e 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -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 }) } })