diff --git a/client/index.html b/client/index.html
index f35383a..b986e09 100644
--- a/client/index.html
+++ b/client/index.html
@@ -36,7 +36,7 @@
Kapteins Daagbok – Kostenloses digitales Yacht-Logbuch (werbefrei)
-
+
diff --git a/client/src/App.tsx b/client/src/App.tsx
index e2524ad..1907a66 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -15,7 +15,13 @@ import InvitationAcceptance from './components/InvitationAcceptance.tsx'
import AppTourOverlay from './components/AppTourOverlay.tsx'
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
import { UnsavedChangesProvider, useUnsavedChangesContext } from './context/UnsavedChangesContext.tsx'
-import { getActiveMasterKey, logoutUser, checkServerSession } from './services/auth.js'
+import {
+ getActiveMasterKey,
+ logoutUser,
+ checkServerSession,
+ hasUnlockedLocalSession
+} from './services/auth.js'
+import AppErrorBoundary from './components/AppErrorBoundary.tsx'
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
import {
applyAppearanceToDocument,
@@ -208,6 +214,52 @@ function App() {
}
}, [])
+ const clearAuthenticatedAppState = useCallback(() => {
+ setIsAuthenticated(false)
+ setActiveLogbookId(null)
+ setActiveLogbookTitle(null)
+ setShowUserProfile(false)
+ setTourSelectedEntryId(null)
+ setDemoHighlightEntryId(null)
+ }, [])
+
+ /** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */
+ const enforceUnlockedSession = useCallback(() => {
+ if (isViewerMode || isDemoMode || isAcceptingInvite) return
+ if (isAuthenticated && !hasUnlockedLocalSession()) {
+ clearAuthenticatedAppState()
+ }
+ }, [
+ isAuthenticated,
+ isViewerMode,
+ isDemoMode,
+ isAcceptingInvite,
+ clearAuthenticatedAppState
+ ])
+
+ useEffect(() => {
+ enforceUnlockedSession()
+ }, [enforceUnlockedSession])
+
+ useEffect(() => {
+ const onPageShow = (event: PageTransitionEvent) => {
+ if (event.persisted) {
+ enforceUnlockedSession()
+ }
+ }
+ const onVisibility = () => {
+ if (document.visibilityState === 'visible') {
+ enforceUnlockedSession()
+ }
+ }
+ window.addEventListener('pageshow', onPageShow)
+ document.addEventListener('visibilitychange', onVisibility)
+ return () => {
+ window.removeEventListener('pageshow', onPageShow)
+ document.removeEventListener('visibilitychange', onVisibility)
+ }
+ }, [enforceUnlockedSession])
+
useEffect(() => {
let cancelled = false
@@ -220,9 +272,7 @@ function App() {
localStorage.setItem('active_userid', session.userId)
}
- const savedUser = localStorage.getItem('active_username')
- const key = getActiveMasterKey()
- if (session.authenticated && savedUser && key) {
+ if (session.authenticated && hasUnlockedLocalSession()) {
setIsAuthenticated(true)
const savedLogbookId = localStorage.getItem('active_logbook_id')
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
@@ -230,6 +280,8 @@ function App() {
setActiveLogbookId(savedLogbookId)
setActiveLogbookTitle(savedLogbookTitle)
}
+ } else if (session.authenticated) {
+ clearAuthenticatedAppState()
}
} catch (err) {
if (!cancelled) {
@@ -241,7 +293,7 @@ function App() {
return () => {
cancelled = true
}
- }, [])
+ }, [clearAuthenticatedAppState])
useEffect(() => {
syncRouteFromLocation()
@@ -630,15 +682,17 @@ function App() {
export default function AppWrapper() {
return (
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
)
}
diff --git a/client/src/components/AppErrorBoundary.tsx b/client/src/components/AppErrorBoundary.tsx
new file mode 100644
index 0000000..c421811
--- /dev/null
+++ b/client/src/components/AppErrorBoundary.tsx
@@ -0,0 +1,42 @@
+import { Component, type ErrorInfo, type ReactNode } from 'react'
+
+interface Props {
+ children: ReactNode
+}
+
+interface State {
+ error: Error | null
+}
+
+export default class AppErrorBoundary extends Component {
+ state: State = { error: null }
+
+ static getDerivedStateFromError(error: Error): State {
+ return { error }
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo) {
+ console.error('Unhandled app error:', error, info.componentStack)
+ }
+
+ render() {
+ if (!this.state.error) {
+ return this.props.children
+ }
+
+ return (
+
+
+
Kapteins Daagbok
+
+ Die App ist nach dem Neustart in einen fehlerhaften Zustand geraten. Bitte neu laden
+ oder die App vollständig beenden und erneut öffnen.
+
+
+
+
+ )
+ }
+}
diff --git a/client/src/hooks/usePwaUpdate.ts b/client/src/hooks/usePwaUpdate.ts
index 47d5ac4..60cbb88 100644
--- a/client/src/hooks/usePwaUpdate.ts
+++ b/client/src/hooks/usePwaUpdate.ts
@@ -6,6 +6,8 @@ const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
const UPDATE_SUPPRESS_MS = 30_000
const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000
const UPDATE_RELOAD_FALLBACK_MS = 2000
+/** Prevent Android PWA cold-start reload loops from onNeedReload. */
+const PWA_INITIAL_RELOAD_KEY = 'pwa_sw_initial_reload_done'
function isUpdateSuppressed(): boolean {
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
@@ -50,6 +52,11 @@ export function usePwaUpdate() {
} = useRegisterSW({
immediate: !import.meta.env.DEV,
onNeedReload() {
+ // First SW takeover requires one reload; guard against repeated reloads on Android PWA resume.
+ if (sessionStorage.getItem(PWA_INITIAL_RELOAD_KEY)) {
+ return
+ }
+ sessionStorage.setItem(PWA_INITIAL_RELOAD_KEY, '1')
clearUpdateSuppression()
setNeedRefresh(false)
window.location.reload()
diff --git a/client/src/main.tsx b/client/src/main.tsx
index 0b22ae9..a3b4d70 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -3,9 +3,11 @@ import { createRoot } from 'react-dom/client'
import 'leaflet/dist/leaflet.css'
import './themes.css'
import './index.css'
+import './i18n'
+import App from './App.tsx'
import { applyAppearanceToDocument } from './services/appearance.ts'
-/** Stale PWA precache on localhost can shadow Vite dev modules and old locale bundles. */
+/** Stale PWA precache on localhost can shadow Vite dev modules. */
async function clearDevServiceWorkerCaches(): Promise {
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return
const regs = await navigator.serviceWorker.getRegistrations()
@@ -16,16 +18,40 @@ async function clearDevServiceWorkerCaches(): Promise {
}
}
+function renderBootstrapError(message: string): void {
+ const root = document.getElementById('root')
+ if (!root) return
+ root.innerHTML = `
+
+
+
Kapteins Daagbok
+
${message}
+
+
+
`
+}
+
async function bootstrap(): Promise {
- await clearDevServiceWorkerCaches()
- await import('./i18n')
- const { default: App } = await import('./App.tsx')
applyAppearanceToDocument()
- createRoot(document.getElementById('root')!).render(
+ await clearDevServiceWorkerCaches()
+
+ const rootEl = document.getElementById('root')
+ if (!rootEl) {
+ throw new Error('Missing #root element')
+ }
+
+ createRoot(rootEl).render(
,
)
}
-void bootstrap()
+void bootstrap().catch((err) => {
+ console.error('App bootstrap failed:', err)
+ renderBootstrapError(
+ 'Die App konnte nicht gestartet werden. Bitte neu laden oder die App vollständig beenden und erneut öffnen.',
+ )
+})
diff --git a/client/src/services/auth.ts b/client/src/services/auth.ts
index f0711c6..778c064 100644
--- a/client/src/services/auth.ts
+++ b/client/src/services/auth.ts
@@ -33,13 +33,28 @@ export function setActiveMasterKey(key: ArrayBuffer | null) {
}
export async function checkServerSession(): Promise<{ authenticated: boolean; userId?: string }> {
+ const controller = new AbortController()
+ const timeoutId = window.setTimeout(() => controller.abort(), 8_000)
try {
- return await apiJson<{ authenticated: boolean; userId?: string }>(`${API_BASE}/session`)
+ return await apiJson<{ authenticated: boolean; userId?: string }>(`${API_BASE}/session`, {
+ signal: controller.signal
+ })
} catch {
return { authenticated: false }
+ } finally {
+ window.clearTimeout(timeoutId)
}
}
+/** Master key is memory-only; after process kill the HTTP session may outlive local crypto state. */
+export function hasUnlockedLocalSession(): boolean {
+ return !!(
+ getActiveMasterKey() &&
+ localStorage.getItem('active_username') &&
+ localStorage.getItem('active_userid')
+ )
+}
+
export async function reauthWithPasskey(): Promise {
const options = await apiJson(`${API_BASE}/reauth-options`, {
method: 'POST'