Compare commits

..

3 Commits

Author SHA1 Message Date
elpatron 1ecebc5dbb chore: release v0.1.0.106 2026-06-03 11:49:23 +02:00
elpatron caf85ad9eb Fix shared logbook access for crew after AI summary sync.
Correct owner detection while the logbook loads, preserve AI summaries on
live-log saves, skip corrupt entry decrypts, and never regenerate keys for
shared logbooks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:48:45 +02:00
elpatron d637fbea16 Show clear offline messages for OWM weather and AI summaries.
Users see localized feedback when OpenWeatherMap or travel-day summary
features are used without connectivity, instead of generic API errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:43:10 +02:00
15 changed files with 112 additions and 14 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0.106
0.1.0.107
+3 -2
View File
@@ -103,7 +103,7 @@ function App() {
[activeLogbookId]
)
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>('OWNER')
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>(null)
useEffect(() => {
if (!activeLogbookId) {
@@ -574,7 +574,8 @@ function App() {
const logbookReadOnly =
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
const isLogbookOwner =
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
activeAccessRole === 'OWNER' ||
(activeLogbookRecord != null && activeLogbookRecord.isShared !== 1)
if (showUserProfile) {
return (
+20
View File
@@ -152,6 +152,7 @@ export default function LiveLogView({
const [modal, setModal] = useState<LiveModal>('none')
const [weatherExpanded, setWeatherExpanded] = useState(false)
const [weatherOwmLoading, setWeatherOwmLoading] = useState(false)
const [isOnline, setIsOnline] = useState(navigator.onLine)
const [commentText, setCommentText] = useState('')
const [valueInput, setValueInput] = useState('')
const [valueInputSecondary, setValueInputSecondary] = useState('')
@@ -269,6 +270,17 @@ export default function LiveLogView({
}
}, [logbookId, applyLoadedEntry, t])
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
useEffect(() => {
void runInit()
return () => {
@@ -503,6 +515,10 @@ export default function LiveLogView({
const handleFetchOwmWeather = () => {
if (!entryId || busy || weatherOwmLoading) return
if (!isOnline) {
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
return
}
const position = getLastPositionFixWithin(
events,
@@ -533,6 +549,10 @@ export default function LiveLogView({
{ analyticsSource: 'live_log' }
)
} catch (err) {
if (err instanceof WeatherApiError && err.code === 'OFFLINE') {
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
return
}
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
return
+3 -3
View File
@@ -9,7 +9,7 @@ import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js'
import { findTodayEntryId } from '../services/quickEventLog.js'
import { findTodayEntryId, tryDecryptEntryPayload } from '../services/quickEventLog.js'
import LogEntryEditor from './LogEntryEditor.tsx'
import LiveLogView from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
@@ -136,7 +136,7 @@ export default function LogEntriesList({
}
await forEachInBatches(needsDecrypt, 8, async (entry) => {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (!decrypted) return
const listCache = await buildEntryListCache(decrypted as Record<string, unknown>)
@@ -266,7 +266,7 @@ export default function LogEntriesList({
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
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)
}
+28 -3
View File
@@ -527,6 +527,8 @@ export default function LogEntryEditor({
}, [])
useEffect(() => {
setCanSignSkipper(false)
setCanSignCrew(false)
getLogbookAccess(logbookId).then((access) => {
if (!access) return
setCanSignSkipper(access.isOwner)
@@ -538,7 +540,7 @@ export default function LogEntryEditor({
}, [logbookId])
useEffect(() => {
if (!canSignSkipper || readOnly) {
if (!canSignSkipper || readOnly || !isOnline) {
setAiSummaryRemaining(null)
return
}
@@ -553,7 +555,7 @@ export default function LogEntryEditor({
console.warn('Failed to load AI summary usage:', err)
})
return () => { cancelled = true }
}, [canSignSkipper, readOnly, logbookId, entryId])
}, [canSignSkipper, readOnly, isOnline, logbookId, entryId])
useEffect(() => {
const seq = ++entryHashSeqRef.current
@@ -986,6 +988,10 @@ export default function LogEntryEditor({
showAlert('GPS capturing failed, and no location name is entered in "Ort / Hafen" or "Start-Hafen" to look up coordinates.')
return
}
if (!isOnline) {
showAlert(t('logs.weather_offline'))
return
}
try {
const data = await fetchOpenWeatherCurrent(
@@ -999,6 +1005,10 @@ export default function LogEntryEditor({
showAlert(`Coordinates loaded for "${locationQuery}" via OpenWeatherMap.`)
}
} catch (e) {
if (e instanceof WeatherApiError && e.code === 'OFFLINE') {
showAlert(t('logs.weather_offline'))
return
}
if (e instanceof WeatherApiError && e.code === 'NO_KEY') {
showAlert(t('settings.no_key'))
return
@@ -1025,6 +1035,11 @@ export default function LogEntryEditor({
}
const handleFetchWeather = async () => {
if (!isOnline) {
showAlert(t('logs.weather_offline'))
return
}
const localToday = new Date()
const todayStr = `${localToday.getFullYear()}-${String(localToday.getMonth() + 1).padStart(2, '0')}-${String(localToday.getDate()).padStart(2, '0')}`
@@ -1066,6 +1081,10 @@ export default function LogEntryEditor({
showAlert(t('settings.weather_success'))
} catch (err) {
if (err instanceof WeatherApiError && err.code === 'OFFLINE') {
showAlert(t('logs.weather_offline'))
return
}
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
showAlert(t('settings.no_key'))
return
@@ -1079,6 +1098,10 @@ export default function LogEntryEditor({
const handleGenerateAiSummary = async () => {
if (!canSignSkipper || readOnly || aiSummaryLoading) return
if (!isOnline) {
setAiSummaryError(t('logs.ai_summary_offline'))
return
}
if (aiSummaryRemaining === 0) {
setAiSummaryError(t('logs.ai_summary_error_rate_limited'))
return
@@ -1135,7 +1158,9 @@ export default function LogEntryEditor({
})
} catch (err) {
if (err instanceof TravelDaySummaryApiError) {
if (err.code === 'NO_KEY') {
if (err.code === 'OFFLINE') {
setAiSummaryError(t('logs.ai_summary_offline'))
} else if (err.code === 'NO_KEY') {
setAiSummaryError(t('logs.ai_summary_error_no_key'))
} else if (err.code === 'RATE_LIMITED') {
setAiSummaryError(t('logs.ai_summary_error_rate_limited'))
+2
View File
@@ -358,6 +358,7 @@
"event_remarks": "Bemærkninger / hændelser",
"gps_btn": "Hent GPS-koordinater",
"weather_btn": "OpenWeatherMap Kald vejret op",
"weather_offline": "OpenWeatherMap kræver internetforbindelse. Du er offline lige nu.",
"event_wind_pressure": "Lufttryk (hPa)",
"event_heel": "Krængning (°)",
"event_sails": "Sejlhåndtering/motor",
@@ -381,6 +382,7 @@
"ai_summary_error_no_key": "Ingen OpenRouter API-nøgle konfigureret på serveren.",
"ai_summary_error_rate_limited": "Maksimalt antal genereringer nået for denne rejsedag.",
"ai_summary_error_forbidden": "Kun skipperen må generere AI-resuméer.",
"ai_summary_offline": "AI-resumé kræver internetforbindelse. Du er offline lige nu.",
"photos_title": "Vedhæftede billeder (E2E-krypteret)",
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen",
+2
View File
@@ -358,6 +358,7 @@
"event_remarks": "Bemerkungen / Vorkommnisse",
"gps_btn": "GPS-Koordinaten abrufen",
"weather_btn": "OpenWeatherMap Wetter abrufen",
"weather_offline": "OpenWeatherMap erfordert eine Internetverbindung. Du bist derzeit offline.",
"event_wind_pressure": "Luftdruck (hPa)",
"event_heel": "Krängung (°)",
"event_sails": "Segelführung / Motor",
@@ -381,6 +382,7 @@
"ai_summary_error_no_key": "Kein OpenRouter API-Schlüssel auf dem Server konfiguriert.",
"ai_summary_error_rate_limited": "Maximale Anzahl an Generierungen für diesen Reisetag erreicht.",
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
"photos_title": "Foto-Anhänge (E2E-verschlüsselt)",
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
+2
View File
@@ -358,6 +358,7 @@
"event_remarks": "Remarks / Events",
"gps_btn": "Get GPS Location",
"weather_btn": "Fetch OpenWeatherMap Weather",
"weather_offline": "OpenWeatherMap requires an internet connection. You are currently offline.",
"event_wind_pressure": "Barometer (hPa)",
"event_heel": "Heel Angle (°)",
"event_sails": "Sails / Motor Status",
@@ -381,6 +382,7 @@
"ai_summary_error_no_key": "No OpenRouter API key configured on the server.",
"ai_summary_error_rate_limited": "Maximum number of generations reached for this travel day.",
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.",
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
"photos_title": "Photo Attachments (E2E Encrypted)",
"photo_caption_label": "Photo Caption / Label (Optional)",
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
+2
View File
@@ -358,6 +358,7 @@
"event_remarks": "Merknader / hendelser",
"gps_btn": "Hent GPS-koordinater",
"weather_btn": "OpenWeatherMap Ring opp været",
"weather_offline": "OpenWeatherMap krever internettforbindelse. Du er frakoblet.",
"event_wind_pressure": "Lufttrykk (hPa)",
"event_heel": "Helning (°)",
"event_sails": "Seilhåndtering / motor",
@@ -381,6 +382,7 @@
"ai_summary_error_no_key": "Ingen OpenRouter API-nøkkel konfigurert på serveren.",
"ai_summary_error_rate_limited": "Maksimalt antall genereringer nådd for denne reisedagen.",
"ai_summary_error_forbidden": "Kun skipperen kan generere AI-sammendrag.",
"ai_summary_offline": "AI-sammendrag krever internettforbindelse. Du er frakoblet.",
"photos_title": "Bildevedlegg (E2E-kryptert)",
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
+2
View File
@@ -358,6 +358,7 @@
"event_remarks": "Anmärkningar / incidenter",
"gps_btn": "Hämta GPS-koordinater",
"weather_btn": "OpenWeatherMap Ring upp väder",
"weather_offline": "OpenWeatherMap kräver internetanslutning. Du är offline.",
"event_wind_pressure": "Lufttryck (hPa)",
"event_heel": "Krängning (°)",
"event_sails": "Segelhantering / motor",
@@ -381,6 +382,7 @@
"ai_summary_error_no_key": "Ingen OpenRouter API-nyckel konfigurerad på servern.",
"ai_summary_error_rate_limited": "Maximalt antal genereringar nått för denna resedag.",
"ai_summary_error_forbidden": "Endast skepparen får generera AI-sammanfattningar.",
"ai_summary_offline": "AI-sammanfattning kräver internetanslutning. Du är offline.",
"photos_title": "Fotobilagor (E2E-krypterade)",
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
+6 -2
View File
@@ -5,11 +5,11 @@ import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayl
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
export class TravelDaySummaryApiError extends Error {
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'REQUEST_FAILED'
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'OFFLINE' | 'REQUEST_FAILED'
constructor(
message: string,
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'REQUEST_FAILED' = 'REQUEST_FAILED'
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED'
) {
super(message)
this.name = 'TravelDaySummaryApiError'
@@ -146,6 +146,10 @@ export async function generateTravelDaySummary(params: {
language: string
context: TravelDaySummaryContext
}): Promise<{ summary: string; remainingAttempts: number; maxAttempts: number }> {
if (!navigator.onLine) {
throw new TravelDaySummaryApiError('Offline', 'OFFLINE')
}
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), SUMMARY_FETCH_TIMEOUT_MS)
+12
View File
@@ -91,6 +91,7 @@ export function clearLogbookKeysCache() {
export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer> {
const localLb = await db.logbooks.get(logbookId)
const encryptedTitle = localLb ? localLb.encryptedTitle : ''
const isShared = localLb?.isShared === 1
const masterKey = getActiveMasterKey()
let key = await getLogbookKey(logbookId)
@@ -103,6 +104,11 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
// Key works, return it
return key
} catch (err) {
if (isShared) {
throw new Error(
'Shared logbook encryption key is missing or invalid. Please go online and refresh your logbooks.'
)
}
console.warn('Stored logbook key failed to decrypt title. Testing if master key works (legacy migration)...')
try {
const parsed = JSON.parse(encryptedTitle)
@@ -145,6 +151,12 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
// If no logbook key exists yet
if (!key) {
if (isShared) {
throw new Error(
'Shared logbook encryption key not found. Please go online and refresh your logbooks.'
)
}
if (encryptedTitle && masterKey) {
try {
// Check if title is already decryptable using masterKey (meaning it is a legacy logbook)
+12 -1
View File
@@ -124,11 +124,22 @@ function buildEncryptedPayload(
})
const clear = options.clearSignatures
return {
const entryData: Record<string, unknown> = {
...payload,
signSkipper: clear ? '' : (data.signSkipper ?? ''),
signCrew: clear ? '' : (data.signCrew ?? '')
}
const summary = typeof data.aiSummary === 'string' ? data.aiSummary.trim() : ''
if (summary) {
entryData.aiSummary = summary
entryData.aiSummaryGeneratedAt =
typeof data.aiSummaryGeneratedAt === 'string' && data.aiSummaryGeneratedAt
? data.aiSummaryGeneratedAt
: new Date().toISOString()
}
return entryData
}
export async function loadEntry(logbookId: string, entryId: string): Promise<LoadedEntry | null> {
+11
View File
@@ -44,6 +44,17 @@ describe('fetchOpenWeatherCurrent', () => {
})
})
it('throws OFFLINE when navigator.onLine is false', async () => {
vi.stubGlobal('navigator', { ...navigator, onLine: false })
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as InstanceType<typeof WeatherApiError>).code).toBe('OFFLINE')
expect(apiFetch).not.toHaveBeenCalled()
})
it('does not track when the API request fails', async () => {
apiFetch.mockResolvedValue({
ok: false,
+6 -2
View File
@@ -7,9 +7,9 @@ import {
} from './analytics.js'
export class WeatherApiError extends Error {
code: 'NO_KEY' | 'REQUEST_FAILED'
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED'
constructor(message: string, code: 'NO_KEY' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
constructor(message: string, code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
super(message)
this.name = 'WeatherApiError'
this.code = code
@@ -26,6 +26,10 @@ export async function fetchOpenWeatherCurrent(
},
options?: { analyticsSource: OwmAnalyticsSource }
): Promise<Record<string, unknown>> {
if (!navigator.onLine) {
throw new WeatherApiError('Offline', 'OFFLINE')
}
const searchParams = new URLSearchParams()
if (params.lat && params.lon) {