From 8c7567faf3ec4243dde63489bbcad223fba7e822 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 14 Apr 2026 21:48:25 +0200 Subject: [PATCH] fix(pwa): fix offline session redirect and file download auth (#505 #541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **#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. --- client/src/App.tsx | 10 +- client/src/components/Collab/CollabNotes.tsx | 6 +- client/src/components/Files/FileManager.tsx | 19 +-- .../src/components/Planner/PlaceInspector.tsx | 4 +- .../components/Planner/ReservationModal.tsx | 4 +- .../components/Planner/ReservationsPanel.tsx | 4 +- client/src/store/authStore.ts | 28 +++- client/src/utils/fileDownload.ts | 81 +++++++++++ client/tests/unit/stores/authStore.test.ts | 49 +++++++ client/tests/unit/utils/fileDownload.test.ts | 135 ++++++++++++++++++ 10 files changed, 314 insertions(+), 26 deletions(-) create mode 100644 client/src/utils/fileDownload.ts create mode 100644 client/tests/unit/utils/fileDownload.test.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 4179c1a9..31ac7787 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -93,7 +93,15 @@ export default function App() { useEffect(() => { if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) { - loadUser() + // If the persist snapshot already has an authenticated user, validate + // silently so the PWA shell renders immediately without a spinner. + const alreadyAuthenticated = useAuthStore.getState().isAuthenticated + if (alreadyAuthenticated) { + useAuthStore.setState({ isLoading: false }) + loadUser({ silent: true }) + } else { + loadUser() + } } authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record }) => { if (config?.demo_mode) setDemoMode(true) diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx index 2585cabf..2d6f253c 100644 --- a/client/src/components/Collab/CollabNotes.tsx +++ b/client/src/components/Collab/CollabNotes.tsx @@ -7,6 +7,7 @@ import remarkBreaks from 'remark-breaks' import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2, Loader2 } from 'lucide-react' import { collabApi } from '../../api/client' import { getAuthUrl } from '../../api/authUrl' +import { openFile } from '../../utils/fileDownload' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import { addListener, removeListener } from '../../api/websocket' @@ -111,10 +112,7 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) { const isPdf = file.mime_type === 'application/pdf' const isTxt = file.mime_type?.startsWith('text/') - const openInNewTab = async () => { - const u = await getAuthUrl(rawUrl, 'download') - window.open(u, '_blank', 'noreferrer') - } + const openInNewTab = () => openFile(rawUrl).catch(() => {}) return ReactDOM.createPortal(
diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index b4b4e200..6092806a 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -10,6 +10,7 @@ import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import { getAuthUrl } from '../../api/authUrl' +import { downloadFile, openFile } from '../../utils/fileDownload' function isImage(mimeType) { if (!mimeType) return false @@ -30,16 +31,8 @@ function formatSize(bytes) { return `${(bytes / 1024 / 1024).toFixed(1)} MB` } -async function triggerDownload(url: string, filename: string) { - const authUrl = await getAuthUrl(url, 'download') - const res = await fetch(authUrl) - const blob = await res.blob() - const a = document.createElement('a') - a.href = URL.createObjectURL(blob) - a.download = filename - document.body.appendChild(a) - a.click() - setTimeout(() => { URL.revokeObjectURL(a.href); a.remove() }, 100) +function triggerDownload(url: string, filename: string) { + downloadFile(url, filename).catch(() => {}) } function formatDateWithLocale(dateStr, locale) { @@ -120,7 +113,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
+

diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 58ffbf99..be1da19b 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' -import { getAuthUrl } from '../../api/authUrl' +import { openFile } from '../../utils/fileDownload' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react' @@ -589,7 +589,7 @@ export default function PlaceInspector({ {filesExpanded && placeFiles.length > 0 && (
{placeFiles.map(f => ( -