fix(pwa): fix offline session redirect and file download auth (#505 #541)

**#541 — File downloads broken in PWA standalone mode**
Replace getAuthUrl + window.open pattern with blob-based fetch using
credentials:include. The old approach minted a 60s single-use ephemeral
token then called window.open, which handed the URL to the system browser
on Android/iOS — losing the PWA cookie jar and producing "invalid or
expired token". The new approach fetches the file directly inside the
PWA WebView as a blob URL, so no auth handoff occurs.

New helper client/src/utils/fileDownload.ts with downloadFile and openFile.
Updated FileManager, ReservationsPanel, ReservationModal, PlaceInspector,
CollabNotes.

Security hardening in fileDownload.ts:
- assertRelativeUrl() guard prevents credentials being sent to external hosts
- openFile() checks blob.type against a safe-inline allowlist; HTML, SVG and
  other script-capable MIME types are forced to download instead of being
  opened inline, preventing same-origin XSS via blob URLs
- resp.ok check covers all non-2xx responses, not just 401

**#505 — PWA offline session lost on reload**
Wrap authStore with Zustand persist middleware, serializing only
{user, isAuthenticated} to localStorage key trek_auth_snapshot.
maps_api_key is intentionally excluded from the snapshot.

On cold start with no network: persist hydrates isAuthenticated:true,
App.tsx clears isLoading and calls loadUser({silent:true}), ProtectedRoute
renders the dashboard immediately. The network error from loadUser leaves
isAuthenticated intact so no login redirect occurs.

On 401 or logout: store state is cleared, persist writes
{isAuthenticated:false} — stale snapshot does not grant offline access
after session expiry.
This commit is contained in:
jubnl
2026-04-14 21:48:25 +02:00
parent 1268d3e7b1
commit 8c7567faf3
10 changed files with 314 additions and 26 deletions
+26 -2
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { authApi } from '../api/client'
import { connect, disconnect } from '../api/websocket'
import type { User } from '../types'
@@ -55,7 +56,9 @@ interface AuthState {
// Sequence counter to prevent stale loadUser responses from overwriting fresh auth state
let authSequence = 0
export const useAuthStore = create<AuthState>((set, get) => ({
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
isAuthenticated: false,
isLoading: true,
@@ -255,4 +258,25 @@ export const useAuthStore = create<AuthState>((set, get) => ({
throw new Error(error)
}
},
}))
}),
{
name: 'trek_auth_snapshot',
// Only persist the minimal user snapshot needed to avoid redirecting to
// login when the PWA reopens offline. The JWT remains in the httpOnly
// cookie and is still validated by the server on every request.
// maps_api_key is intentionally excluded — it's an API key that should
// not sit in localStorage any longer than the active session requires.
partialize: (state) => ({
isAuthenticated: state.isAuthenticated,
user: state.user ? {
id: state.user.id,
username: state.user.username,
email: state.user.email,
role: state.user.role,
avatar_url: state.user.avatar_url,
mfa_enabled: state.user.mfa_enabled,
must_change_password: state.user.must_change_password,
} : null,
}),
}
))