Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ecebc5dbb | |||
| caf85ad9eb | |||
| d637fbea16 |
+3
-2
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user