diff --git a/client/package-lock.json b/client/package-lock.json index e7111ba5..5c173cc1 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -27,6 +27,12 @@ "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "topojson-client": "^3.1.0", + "workbox-cacheable-response": "^7.0.0", + "workbox-core": "^7.0.0", + "workbox-expiration": "^7.0.0", + "workbox-precaching": "^7.0.0", + "workbox-routing": "^7.0.0", + "workbox-strategies": "^7.0.0", "zustand": "^4.5.2" }, "devDependencies": { @@ -6471,7 +6477,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", - "dev": true, "license": "ISC" }, "node_modules/indent-string": { @@ -7538,9 +7543,9 @@ } }, "node_modules/marked": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz", - "integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==", + "version": "18.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz", + "integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -12032,7 +12037,6 @@ "version": "7.4.0", "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz", "integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==", - "dev": true, "license": "MIT", "dependencies": { "workbox-core": "7.4.0" @@ -12042,14 +12046,12 @@ "version": "7.4.0", "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz", "integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==", - "dev": true, "license": "MIT" }, "node_modules/workbox-expiration": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz", "integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==", - "dev": true, "license": "MIT", "dependencies": { "idb": "^7.0.1", @@ -12083,7 +12085,6 @@ "version": "7.4.0", "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz", "integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==", - "dev": true, "license": "MIT", "dependencies": { "workbox-core": "7.4.0", @@ -12120,7 +12121,6 @@ "version": "7.4.0", "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz", "integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==", - "dev": true, "license": "MIT", "dependencies": { "workbox-core": "7.4.0" @@ -12130,7 +12130,6 @@ "version": "7.4.0", "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz", "integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==", - "dev": true, "license": "MIT", "dependencies": { "workbox-core": "7.4.0" diff --git a/client/package.json b/client/package.json index 12ff1a02..488233e6 100644 --- a/client/package.json +++ b/client/package.json @@ -18,6 +18,12 @@ "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", "dexie": "^4.4.2", + "workbox-cacheable-response": "^7.0.0", + "workbox-core": "^7.0.0", + "workbox-expiration": "^7.0.0", + "workbox-precaching": "^7.0.0", + "workbox-routing": "^7.0.0", + "workbox-strategies": "^7.0.0", "leaflet": "^1.9.4", "lucide-react": "^0.344.0", "mapbox-gl": "^3.22.0", diff --git a/client/src/App.tsx b/client/src/App.tsx index f5d96b51..efe22501 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -218,7 +218,7 @@ export default function App() { } /> } /> {/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */} - } /> + } /> apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data), /** Submit user consent (approve or deny) */ @@ -153,6 +155,7 @@ export const oauthApi = { code_challenge: string code_challenge_method: string approved: boolean + resource?: string }) => apiClient.post('/oauth/authorize', body).then(r => r.data), clients: { diff --git a/client/src/components/Budget/BudgetPanel.test.tsx b/client/src/components/Budget/BudgetPanel.test.tsx index 244cbc96..b92cf4f8 100644 --- a/client/src/components/Budget/BudgetPanel.test.tsx +++ b/client/src/components/Budget/BudgetPanel.test.tsx @@ -10,8 +10,11 @@ import { usePermissionsStore } from '../../store/permissionsStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories'; import BudgetPanel from './BudgetPanel'; +import { offlineDb } from '../../db/offlineDb'; -beforeEach(() => { +beforeEach(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + await Promise.all(offlineDb.tables.map(t => t.clear())); resetAllStores(); // Settlement and per-person APIs needed by BudgetPanel server.use( diff --git a/client/src/components/Files/FileManager.test.tsx b/client/src/components/Files/FileManager.test.tsx index 273387c3..048cd784 100644 --- a/client/src/components/Files/FileManager.test.tsx +++ b/client/src/components/Files/FileManager.test.tsx @@ -35,6 +35,7 @@ vi.mock('../../api/client', async (importOriginal) => { }); import { filesApi } from '../../api/client'; +import { offlineDb } from '../../db/offlineDb'; const buildFile = (overrides = {}) => ({ id: 1, @@ -66,7 +67,9 @@ const defaultProps = { allowedFileTypes: null, }; -beforeEach(() => { +beforeEach(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + await Promise.all(offlineDb.tables.map(t => t.clear())); resetAllStores(); vi.clearAllMocks(); // Seed auth as admin so useCanDo() returns true for all permissions @@ -130,15 +133,21 @@ describe('FileManager', () => { expect(screen.queryByText('doc.pdf')).not.toBeInTheDocument(); }); - it('FE-COMP-FILEMANAGER-005: star button calls filesApi.toggleStar', async () => { + it('FE-COMP-FILEMANAGER-005: star button calls star endpoint', async () => { + let starCalled = false; + server.use( + http.patch('/api/trips/1/files/1/star', () => { + starCalled = true; + return HttpResponse.json({ success: true }); + }), + ); render(); const user = userEvent.setup(); - // Find the star button by its title const starBtn = screen.getByTitle(/star/i); await user.click(starBtn); - expect(filesApi.toggleStar).toHaveBeenCalledWith(1, 1); + await waitFor(() => expect(starCalled).toBe(true)); }); it('FE-COMP-FILEMANAGER-006: trash toggle loads and displays trashed files', async () => { @@ -398,39 +407,47 @@ describe('FileManager', () => { await screen.findByText('Hotel Paris'); }); - it('FE-COMP-FILEMANAGER-024: clicking a place in assign modal calls filesApi.update', async () => { + it('FE-COMP-FILEMANAGER-024: clicking a place in assign modal calls file update endpoint', async () => { const { buildPlace } = await import('../../../tests/helpers/factories'); const place = buildPlace({ id: 10, name: 'Louvre Museum' }); const file = buildFile({ id: 1 }); const onUpdate = vi.fn().mockResolvedValue(undefined); + let capturedBody: Record | null = null; + server.use( + http.put('/api/trips/1/files/1', async ({ request }) => { + capturedBody = await request.json() as Record; + return HttpResponse.json({ file: { ...file, place_id: 10 } }); + }), + ); render(); const user = userEvent.setup(); - // Open assign modal await user.click(screen.getByTitle(/assign/i)); await screen.findByText('Louvre Museum'); - - // Click on the place button to link it await user.click(screen.getByText('Louvre Museum')); - expect(filesApi.update).toHaveBeenCalledWith(1, 1, { place_id: 10 }); + await waitFor(() => expect(capturedBody).toMatchObject({ place_id: 10 })); }); - it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls filesApi.update', async () => { + it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls file update endpoint', async () => { const { buildReservation } = await import('../../../tests/helpers/factories'); const reservation = buildReservation({ id: 20, name: 'Train Ticket' }); const file = buildFile({ id: 1 }); + let capturedBody: Record | null = null; + server.use( + http.put('/api/trips/1/files/1', async ({ request }) => { + capturedBody = await request.json() as Record; + return HttpResponse.json({ file: { ...file, reservation_id: 20 } }); + }), + ); render(); const user = userEvent.setup(); - // Open assign modal await user.click(screen.getByTitle(/assign/i)); await screen.findByText('Train Ticket'); - - // Click on the reservation button to link it await user.click(screen.getByText('Train Ticket')); - expect(filesApi.update).toHaveBeenCalledWith(1, 1, { reservation_id: 20 }); + await waitFor(() => expect(capturedBody).toMatchObject({ reservation_id: 20 })); }); it('FE-COMP-FILEMANAGER-026: assign modal with both places and reservations shows both sections', async () => { @@ -507,39 +524,46 @@ describe('FileManager', () => { await screen.findByText(/Colosseum/); }); - it('FE-COMP-FILEMANAGER-031: unlink place from assign modal calls filesApi.update', async () => { + it('FE-COMP-FILEMANAGER-031: unlink place from assign modal calls file update endpoint', async () => { const { buildPlace } = await import('../../../tests/helpers/factories'); const place = buildPlace({ id: 10, name: 'Venice Beach' }); - // File already has place_id set to 10 (linked) const file = buildFile({ id: 1, place_id: 10 }); - + let capturedBody: Record | null = null; + server.use( + http.put('/api/trips/1/files/1', async ({ request }) => { + capturedBody = await request.json() as Record; + return HttpResponse.json({ file: { ...file, place_id: null } }); + }), + ); render(); const user = userEvent.setup(); - // Open assign modal await user.click(screen.getByTitle(/assign/i)); await screen.findByText('Venice Beach'); - - // Clicking the linked place should unlink it await user.click(screen.getByText('Venice Beach')); - expect(filesApi.update).toHaveBeenCalledWith(1, 1, { place_id: null }); + + await waitFor(() => expect(capturedBody).toMatchObject({ place_id: null })); }); - it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls filesApi.update', async () => { + it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls file update endpoint', async () => { const { buildReservation } = await import('../../../tests/helpers/factories'); const reservation = buildReservation({ id: 20, name: 'Museum Pass' }); - // File already has reservation_id set to 20 const file = buildFile({ id: 1, reservation_id: 20 }); - + let capturedBody: Record | null = null; + server.use( + http.put('/api/trips/1/files/1', async ({ request }) => { + capturedBody = await request.json() as Record; + return HttpResponse.json({ file: { ...file, reservation_id: null } }); + }), + ); render(); const user = userEvent.setup(); await user.click(screen.getByTitle(/assign/i)); await screen.findByText('Museum Pass'); - - // Clicking the linked reservation should unlink it await user.click(screen.getByText('Museum Pass')); - expect(filesApi.update).toHaveBeenCalledWith(1, 1, { reservation_id: null }); + + await waitFor(() => expect(capturedBody).toMatchObject({ reservation_id: null })); }); it('FE-COMP-FILEMANAGER-033: opening PDF preview and closing via backdrop', async () => { diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index f3b93546..7e98f5cb 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -5,6 +5,7 @@ import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, M import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { filesApi } from '../../api/client' +import { fileRepo } from '../../repo/fileRepo' import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' @@ -290,7 +291,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, const handleStar = async (fileId: number) => { try { - await filesApi.toggleStar(tripId, fileId) + await fileRepo.toggleStar(tripId, fileId) refreshFiles() } catch { /* */ } } @@ -409,7 +410,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => { try { - await filesApi.update(tripId, fileId, data) + await fileRepo.update(tripId, fileId, data as Record) refreshFiles() } catch { toast.error(t('files.toast.assignError')) diff --git a/client/src/components/Packing/PackingListPanel.test.tsx b/client/src/components/Packing/PackingListPanel.test.tsx index 2e1414ec..121a24b6 100644 --- a/client/src/components/Packing/PackingListPanel.test.tsx +++ b/client/src/components/Packing/PackingListPanel.test.tsx @@ -9,8 +9,11 @@ import { useTripStore } from '../../store/tripStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories'; import PackingListPanel from './PackingListPanel'; +import { offlineDb } from '../../db/offlineDb'; -beforeEach(() => { +beforeEach(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + await Promise.all(offlineDb.tables.map(t => t.clear())); resetAllStores(); // Side-effect APIs PackingListPanel calls on mount server.use( diff --git a/client/src/components/Planner/DayDetailPanel.test.tsx b/client/src/components/Planner/DayDetailPanel.test.tsx index a70fd9d5..5c185d62 100644 --- a/client/src/components/Planner/DayDetailPanel.test.tsx +++ b/client/src/components/Planner/DayDetailPanel.test.tsx @@ -11,6 +11,7 @@ import { usePermissionsStore } from '../../store/permissionsStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildAdmin, buildTrip, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories'; import DayDetailPanel from './DayDetailPanel'; +import { offlineDb } from '../../db/offlineDb'; const day = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: 'Day in Paris' }); @@ -28,7 +29,9 @@ const defaultProps = { onAccommodationChange: vi.fn(), }; -beforeEach(() => { +beforeEach(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + await Promise.all(offlineDb.tables.map(t => t.clear())); resetAllStores(); vi.clearAllMocks(); server.use( diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index 407db408..4ae36718 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -5,6 +5,7 @@ import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText } const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', event: '#f59e0b', tour: '#10b981', other: '#6b7280' } import { weatherApi, accommodationsApi } from '../../api/client' +import { accommodationRepo } from '../../repo/accommodationRepo' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import CustomSelect from '../shared/CustomSelect' @@ -117,8 +118,10 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri const handleSaveAccommodation = async () => { if (!hotelForm.place_id) return try { - const data = await accommodationsApi.create(tripId, { + const selectedPlace = places.find(p => p.id === hotelForm.place_id) + const data = await accommodationRepo.create(tripId, { place_id: hotelForm.place_id, + place_name: selectedPlace?.name, start_day_id: hotelDayRange.start, end_day_id: hotelDayRange.end, check_in: hotelForm.check_in || null, @@ -142,7 +145,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri const updateAccommodationField = async (field, value) => { if (!accommodation) return try { - const data = await accommodationsApi.update(tripId, accommodation.id, { [field]: value || null }) + const data = await accommodationRepo.update(tripId, accommodation.id, { [field]: value || null }) setAccommodation(data.accommodation) onAccommodationChange?.() } catch {} @@ -151,7 +154,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri const handleRemoveAccommodation = async () => { if (!accommodation) return try { - await accommodationsApi.delete(tripId, accommodation.id) + await accommodationRepo.delete(tripId, accommodation.id) const updated = accommodations.filter(a => a.id !== accommodation.id) setAccommodations(updated) setDayAccommodations(updated.filter(a => @@ -583,7 +586,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri + + {configApplied && ( + + + Applied at {configApplied.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })} + + )} + + + {/* Cached trip list */} {loading ? (

Loading…

@@ -139,24 +330,32 @@ export default function OfflineTab(): React.ReactElement { display: 'flex', flexDirection: 'column', gap: 2, }} > -
- - {trip.name} - - +
+
+ + {trip.title || 'Unnamed trip'} + + {trip.description ? ( + + {trip.description.length > 72 ? trip.description.slice(0, 72) + '…' : trip.description} + + ) : null} + + {trip.start_date + ? `${formatDate(trip.start_date)} – ${formatDate(trip.end_date)}` + : 'No dates set'} + {' · '} + {placeCount} place{placeCount !== 1 ? 's' : ''} + {fileCount > 0 ? ` · ${fileCount} file${fileCount !== 1 ? 's' : ''}` : null} + +
+ {meta.lastSyncedAt ? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : '—'}
- - {formatDate(trip.start_date)} – {formatDate(trip.end_date)} - {' · '} - {placeCount} place{placeCount !== 1 ? 's' : ''} - {' · '} - {fileCount} file{fileCount !== 1 ? 's' : ''} -
))} @@ -178,3 +377,32 @@ function Stat({ label, value }: { label: string; value: number }) { ) } + +function CacheField({ + label, value, min, max, onChange, +}: { + label: string + value: number + min: number + max: number + onChange: (e: React.ChangeEvent) => void +}) { + return ( + + ) +} diff --git a/client/src/db/offlineDb.ts b/client/src/db/offlineDb.ts index 224794c6..5c0ad9b7 100644 --- a/client/src/db/offlineDb.ts +++ b/client/src/db/offlineDb.ts @@ -68,6 +68,13 @@ class TrekOfflineDb extends Dexie { constructor() { super('trek-offline'); + // When the database is deleted externally (e.g. DevTools "Clear site data" + // while the tab is open), IDB fires versionchange on the open connection. + // Without an explicit close() here, Dexie keeps the stale connection alive + // and subsequent write transactions queue behind it indefinitely. Closing + // forces Dexie to auto-reopen on the next operation with a fresh connection. + this.on('versionchange', () => { this.close() }) + this.version(1).stores({ trips: 'id', days: 'id, trip_id', @@ -185,9 +192,53 @@ export async function clearTripData(tripId: number): Promise { await offlineDb.trips.delete(tripId); } +/** Clear cached file blobs only — frees significant quota without losing trip data. */ +export async function clearBlobCache(): Promise { + await offlineDb.blobCache.clear(); +} + /** Wipe the entire offline database (called on logout). */ export async function clearAll(): Promise { - await offlineDb.delete(); - // Re-open so subsequent operations don't fail - await offlineDb.open(); + // Use table.clear() instead of offlineDb.delete() to avoid triggering the + // versionchange handler (which calls close()), which would put Dexie into a + // broken write state for the remainder of the session. + await offlineDb.transaction( + 'rw', + [ + offlineDb.trips, + offlineDb.days, + offlineDb.places, + offlineDb.packingItems, + offlineDb.todoItems, + offlineDb.budgetItems, + offlineDb.reservations, + offlineDb.tripFiles, + offlineDb.accommodations, + offlineDb.tripMembers, + offlineDb.tags, + offlineDb.categories, + offlineDb.mutationQueue, + offlineDb.syncMeta, + offlineDb.blobCache, + ], + async () => { + await Promise.all([ + offlineDb.trips.clear(), + offlineDb.days.clear(), + offlineDb.places.clear(), + offlineDb.packingItems.clear(), + offlineDb.todoItems.clear(), + offlineDb.budgetItems.clear(), + offlineDb.reservations.clear(), + offlineDb.tripFiles.clear(), + offlineDb.accommodations.clear(), + offlineDb.tripMembers.clear(), + offlineDb.tags.clear(), + offlineDb.categories.clear(), + offlineDb.mutationQueue.clear(), + offlineDb.syncMeta.clear(), + offlineDb.blobCache.clear(), + ]) + }, + ) } diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 390b05c0..3ec5ac24 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -464,6 +464,8 @@ const ar: Record = { 'login.mfaVerify': 'تحقق', 'login.invalidInviteLink': 'رابط الدعوة غير صالح أو منتهي الصلاحية', 'login.oidcFailed': 'فشل تسجيل الدخول عبر OIDC', + 'login.configLoadError': 'تعذّر تحميل خيارات تسجيل الدخول.', + 'login.configLoadRetry': 'تحديث', 'login.usernameRequired': 'اسم المستخدم مطلوب', 'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل', 'login.forgotPassword': 'نسيت كلمة المرور؟', @@ -923,6 +925,7 @@ const ar: Record = { 'trip.tabs.budget': 'الميزانية', 'trip.tabs.files': 'الملفات', 'trip.loading': 'جارٍ تحميل الرحلة...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'جارٍ تحميل صور الأماكن...', 'trip.mobilePlan': 'الخطة', 'trip.mobilePlaces': 'الأماكن', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 0757c3d2..445aff81 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -459,6 +459,8 @@ const br: Record = { 'login.mfaVerify': 'Verificar', 'login.invalidInviteLink': 'Link de convite inválido ou expirado', 'login.oidcFailed': 'Falha no login OIDC', + 'login.configLoadError': 'Não foi possível carregar as opções de login.', + 'login.configLoadRetry': 'Atualizar', 'login.usernameRequired': 'Nome de usuário é obrigatório', 'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres', 'login.forgotPassword': 'Esqueceu a senha?', @@ -907,6 +909,7 @@ const br: Record = { 'trip.confirm.deletePlace': 'Tem certeza de que deseja excluir este lugar?', 'trip.confirm.deletePlaces': 'Excluir {count} lugares?', 'trip.toast.placesDeleted': '{count} lugares excluídos', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Carregando fotos dos lugares...', // Day Plan Sidebar diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index a14b633d..0646f906 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -459,6 +459,8 @@ const cs: Record = { 'login.mfaVerify': 'Ověřit', 'login.invalidInviteLink': 'Neplatný nebo vypršelý odkaz s pozvánkou', 'login.oidcFailed': 'Přihlášení přes OIDC se nezdařilo', + 'login.configLoadError': 'Nepodařilo se načíst možnosti přihlášení.', + 'login.configLoadRetry': 'Obnovit', 'login.usernameRequired': 'Uživatelské jméno je povinné', 'login.passwordMinLength': 'Heslo musí mít alespoň 8 znaků', 'login.forgotPassword': 'Zapomenuté heslo?', @@ -921,6 +923,7 @@ const cs: Record = { 'trip.tabs.budget': 'Rozpočet', 'trip.tabs.files': 'Soubory', 'trip.loading': 'Načítání cesty...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Načítání fotek míst...', 'trip.mobilePlan': 'Plán', 'trip.mobilePlaces': 'Místa', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index cbb6d153..90075129 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -464,6 +464,8 @@ const de: Record = { 'login.mfaVerify': 'Bestätigen', 'login.invalidInviteLink': 'Ungültiger oder abgelaufener Einladungslink', 'login.oidcFailed': 'OIDC-Anmeldung fehlgeschlagen', + 'login.configLoadError': 'Anmeldeoptionen konnten nicht geladen werden.', + 'login.configLoadRetry': 'Aktualisieren', 'login.usernameRequired': 'Benutzername ist erforderlich', 'login.passwordMinLength': 'Das Passwort muss mindestens 8 Zeichen lang sein', 'login.forgotPassword': 'Passwort vergessen?', @@ -926,6 +928,7 @@ const de: Record = { 'trip.tabs.budget': 'Budget', 'trip.tabs.files': 'Dateien', 'trip.loading': 'Reise wird geladen...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Fotos der Orte werden geladen...', 'trip.mobilePlan': 'Planung', 'trip.mobilePlaces': 'Orte', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index ce8321a6..05f2ed93 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -537,6 +537,8 @@ const en: Record = { 'login.mfaVerify': 'Verify', 'login.invalidInviteLink': 'Invalid or expired invite link', 'login.oidcFailed': 'OIDC login failed', + 'login.configLoadError': 'Could not load login options.', + 'login.configLoadRetry': 'Refresh', 'login.usernameRequired': 'Username is required', 'login.passwordMinLength': 'Password must be at least 8 characters', 'login.forgotPassword': 'Forgot password?', @@ -998,6 +1000,7 @@ const en: Record = { 'trip.tabs.files': 'Files', 'trip.loading': 'Loading trip...', 'trip.loadingPhotos': 'Loading place photos...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.mobilePlan': 'Plan', 'trip.mobilePlaces': 'Places', 'trip.toast.placeUpdated': 'Place updated', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index a66bdfb6..7aca6599 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -451,6 +451,8 @@ const es: Record = { 'login.mfaVerify': 'Verificar', 'login.invalidInviteLink': 'Enlace de invitación inválido o expirado', 'login.oidcFailed': 'Error de inicio de sesión OIDC', + 'login.configLoadError': 'No se pudieron cargar las opciones de inicio de sesión.', + 'login.configLoadRetry': 'Actualizar', 'login.usernameRequired': 'El nombre de usuario es obligatorio', 'login.passwordMinLength': 'La contraseña debe tener al menos 8 caracteres', 'login.forgotPassword': '¿Olvidaste tu contraseña?', @@ -896,6 +898,7 @@ const es: Record = { 'trip.tabs.budget': 'Presupuesto', 'trip.tabs.files': 'Archivos', 'trip.loading': 'Cargando viaje...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Cargando fotos de los lugares...', 'trip.mobilePlan': 'Plan', 'trip.mobilePlaces': 'Lugares', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index c7cd1605..5294a8d4 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -452,6 +452,8 @@ const fr: Record = { 'login.mfaVerify': 'Vérifier', 'login.invalidInviteLink': 'Lien d\'invitation invalide ou expiré', 'login.oidcFailed': 'Échec de connexion OIDC', + 'login.configLoadError': 'Impossible de charger les options de connexion.', + 'login.configLoadRetry': 'Actualiser', 'login.usernameRequired': 'Le nom d\'utilisateur est obligatoire', 'login.passwordMinLength': 'Le mot de passe doit comporter au moins 8 caractères', 'login.forgotPassword': 'Mot de passe oublié ?', @@ -920,6 +922,7 @@ const fr: Record = { 'trip.tabs.budget': 'Budget', 'trip.tabs.files': 'Fichiers', 'trip.loading': 'Chargement du voyage…', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Chargement des photos des lieux...', 'trip.mobilePlan': 'Plan', 'trip.mobilePlaces': 'Lieux', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index f8046fab..4a0463c2 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -459,6 +459,8 @@ const hu: Record = { 'login.mfaVerify': 'Ellenőrzés', 'login.invalidInviteLink': 'Érvénytelen vagy lejárt meghívólink', 'login.oidcFailed': 'OIDC bejelentkezés sikertelen', + 'login.configLoadError': 'A bejelentkezési lehetőségek betöltése nem sikerült.', + 'login.configLoadRetry': 'Frissítés', 'login.usernameRequired': 'A felhasználónév kötelező', 'login.passwordMinLength': 'A jelszónak legalább 8 karakter hosszúnak kell lennie', 'login.forgotPassword': 'Elfelejtetted a jelszavad?', @@ -935,6 +937,7 @@ const hu: Record = { 'trip.confirm.deletePlace': 'Biztosan törölni szeretnéd ezt a helyet?', 'trip.confirm.deletePlaces': '{count} helyet töröl?', 'trip.toast.placesDeleted': '{count} hely törölve', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Helyek fotóinak betöltése...', // Napi terv oldalsáv diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index 112d17fc..e8c8743e 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -521,6 +521,8 @@ const id: Record = { 'login.mfaVerify': 'Verifikasi', 'login.invalidInviteLink': 'Tautan undangan tidak valid atau sudah kedaluwarsa', 'login.oidcFailed': 'Login OIDC gagal', + 'login.configLoadError': 'Gagal memuat opsi login.', + 'login.configLoadRetry': 'Segarkan', 'login.usernameRequired': 'Nama pengguna wajib diisi', 'login.passwordMinLength': 'Kata sandi minimal 8 karakter', 'login.forgotPassword': 'Lupa kata sandi?', @@ -981,6 +983,7 @@ const id: Record = { 'trip.tabs.budget': 'Anggaran', 'trip.tabs.files': 'File', 'trip.loading': 'Memuat perjalanan...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Memuat foto tempat...', 'trip.mobilePlan': 'Rencana', 'trip.mobilePlaces': 'Tempat', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 2ac5424f..97db52ef 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -459,6 +459,8 @@ const it: Record = { 'login.mfaVerify': 'Verifica', 'login.invalidInviteLink': 'Link di invito non valido o scaduto', 'login.oidcFailed': 'Accesso OIDC non riuscito', + 'login.configLoadError': 'Impossibile caricare le opzioni di accesso.', + 'login.configLoadRetry': 'Aggiorna', 'login.usernameRequired': 'Il nome utente è obbligatorio', 'login.passwordMinLength': 'La password deve contenere almeno 8 caratteri', 'login.forgotPassword': 'Password dimenticata?', @@ -935,6 +937,7 @@ const it: Record = { 'trip.confirm.deletePlace': 'Sei sicuro di voler eliminare questo luogo?', 'trip.confirm.deletePlaces': 'Eliminare {count} luoghi?', 'trip.toast.placesDeleted': '{count} luoghi eliminati', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Caricamento foto dei luoghi...', // Day Plan Sidebar diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 0cb55bc1..2cef9019 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -452,6 +452,8 @@ const nl: Record = { 'login.mfaVerify': 'Verifiëren', 'login.invalidInviteLink': 'Ongeldige of verlopen uitnodigingslink', 'login.oidcFailed': 'OIDC-aanmelding mislukt', + 'login.configLoadError': 'Kan aanmeldingsopties niet laden.', + 'login.configLoadRetry': 'Vernieuwen', 'login.usernameRequired': 'Gebruikersnaam is vereist', 'login.passwordMinLength': 'Wachtwoord moet minimaal 8 tekens bevatten', 'login.forgotPassword': 'Wachtwoord vergeten?', @@ -920,6 +922,7 @@ const nl: Record = { 'trip.tabs.budget': 'Budget', 'trip.tabs.files': 'Bestanden', 'trip.loading': 'Reis laden...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Plaatsfoto laden...', 'trip.mobilePlan': 'Plan', 'trip.mobilePlaces': 'Plaatsen', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 87f768a9..f017f6b3 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -426,6 +426,8 @@ const pl: Record = { 'login.mfaVerify': 'Weryfikuj', 'login.invalidInviteLink': 'Nieprawidłowy lub wygasły link zaproszenia', 'login.oidcFailed': 'Logowanie OIDC nie powiodło się', + 'login.configLoadError': 'Nie można załadować opcji logowania.', + 'login.configLoadRetry': 'Odśwież', 'login.usernameRequired': 'Nazwa użytkownika jest wymagana', 'login.passwordMinLength': 'Hasło musi mieć co najmniej 8 znaków', 'login.forgotPassword': 'Nie pamiętasz hasła?', @@ -1762,6 +1764,7 @@ const pl: Record = { 'login.setNewPassword': 'Ustaw nowe hasło', 'login.setNewPasswordHint': 'Musisz zmienić hasło.', 'atlas.searchCountry': 'Szukaj kraju...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Ładowanie zdjęć...', 'places.importNaverList': 'Lista Naver', 'places.importList': 'Import listy', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index f4f23fb8..c7157095 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -452,6 +452,8 @@ const ru: Record = { 'login.mfaVerify': 'Подтвердить', 'login.invalidInviteLink': 'Недействительная или истёкшая ссылка-приглашение', 'login.oidcFailed': 'Ошибка входа через OIDC', + 'login.configLoadError': 'Не удалось загрузить параметры входа.', + 'login.configLoadRetry': 'Обновить', 'login.usernameRequired': 'Имя пользователя обязательно', 'login.passwordMinLength': 'Пароль должен содержать не менее 8 символов', 'login.forgotPassword': 'Забыли пароль?', @@ -920,6 +922,7 @@ const ru: Record = { 'trip.tabs.budget': 'Бюджет', 'trip.tabs.files': 'Файлы', 'trip.loading': 'Загрузка поездки...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': 'Загрузка фото мест...', 'trip.mobilePlan': 'План', 'trip.mobilePlaces': 'Места', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index ffa564b6..069ce565 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -452,6 +452,8 @@ const zh: Record = { 'login.mfaVerify': '验证', 'login.invalidInviteLink': '邀请链接无效或已过期', 'login.oidcFailed': 'OIDC 登录失败', + 'login.configLoadError': '无法加载登录选项。', + 'login.configLoadRetry': '刷新', 'login.usernameRequired': '用户名为必填项', 'login.passwordMinLength': '密码至少需要8个字符', 'login.forgotPassword': '忘记密码?', @@ -920,6 +922,7 @@ const zh: Record = { 'trip.tabs.budget': '预算', 'trip.tabs.files': '文件', 'trip.loading': '加载旅行中...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': '正在加载地点照片...', 'trip.mobilePlan': '计划', 'trip.mobilePlaces': '地点', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 331596c5..7b96af65 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -511,6 +511,8 @@ const zhTw: Record = { 'login.mfaVerify': '驗證', 'login.invalidInviteLink': '邀請連結無效或已過期', 'login.oidcFailed': 'OIDC 登入失敗', + 'login.configLoadError': '無法載入登入選項。', + 'login.configLoadRetry': '重新整理', 'login.usernameRequired': '使用者名稱為必填', 'login.passwordMinLength': '密碼至少需要8個字元', 'login.forgotPassword': '忘記密碼?', @@ -980,6 +982,7 @@ const zhTw: Record = { 'trip.tabs.budget': '預算', 'trip.tabs.files': '檔案', 'trip.loading': '載入旅行中...', + 'trip.splash.goBack': 'Taking too long? Go back to dashboard', 'trip.loadingPhotos': '正在載入地點照片...', 'trip.mobilePlan': '計劃', 'trip.mobilePlaces': '地點', diff --git a/client/src/pages/DashboardPage.test.tsx b/client/src/pages/DashboardPage.test.tsx index 2a3530ee..90f079de 100644 --- a/client/src/pages/DashboardPage.test.tsx +++ b/client/src/pages/DashboardPage.test.tsx @@ -7,10 +7,12 @@ import { resetAllStores, seedStore } from '../../tests/helpers/store'; import { buildUser, buildAdmin, buildTrip } from '../../tests/helpers/factories'; import { useAuthStore } from '../store/authStore'; import { usePermissionsStore } from '../store/permissionsStore'; +import { offlineDb } from '../db/offlineDb'; import DashboardPage from './DashboardPage'; -beforeEach(() => { +beforeEach(async () => { vi.clearAllMocks(); + await Promise.all(offlineDb.tables.map(t => t.clear())); resetAllStores(); // Seed auth with authenticated user seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() }); @@ -329,7 +331,8 @@ describe('DashboardPage', () => { const tokyoTrip = screen.getAllByText('Tokyo Trip')[0]; await user.click(tokyoTrip); - expect(tokyoTrip).toBeInTheDocument(); + // Re-query after click — background refresh may re-render the list + expect(screen.getAllByText('Tokyo Trip').length).toBeGreaterThan(0); }); }); diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index c3e4fa85..814ff15f 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -744,12 +744,21 @@ export default function DashboardPage(): React.ReactElement { const loadTrips = async () => { setIsLoading(true) try { - const { trips, archivedTrips } = await tripRepo.list() + const listOrTimeout = Promise.race([ + tripRepo.list(), + new Promise((_, reject) => setTimeout(() => reject(new Error('trips-load-timeout')), 5_000)), + ]) + const { trips, archivedTrips, refresh } = await listOrTimeout setTrips(sortTrips(trips)) setArchivedTrips(sortTrips(archivedTrips)) + setIsLoading(false) + refresh.then(fresh => { + if (!fresh) return + setTrips(sortTrips(fresh.trips)) + setArchivedTrips(sortTrips(fresh.archivedTrips)) + }).catch(() => {}) } catch { toast.error(t('dashboard.toast.loadError')) - } finally { setIsLoading(false) } } @@ -791,7 +800,7 @@ export default function DashboardPage(): React.ReactElement { const handleArchive = async (id) => { try { - const data = await tripsApi.archive(id) + const data = await tripRepo.update(id, { is_archived: true }) setTrips(prev => prev.filter(t => t.id !== id)) setArchivedTrips(prev => sortTrips([data.trip, ...prev])) toast.success(t('dashboard.toast.archived')) @@ -802,7 +811,7 @@ export default function DashboardPage(): React.ReactElement { const handleUnarchive = async (id) => { try { - const data = await tripsApi.unarchive(id) + const data = await tripRepo.update(id, { is_archived: false }) setArchivedTrips(prev => prev.filter(t => t.id !== id)) setTrips(prev => sortTrips([data.trip, ...prev])) toast.success(t('dashboard.toast.restored')) diff --git a/client/src/pages/FilesPage.test.tsx b/client/src/pages/FilesPage.test.tsx index 22298b8c..b6e18177 100644 --- a/client/src/pages/FilesPage.test.tsx +++ b/client/src/pages/FilesPage.test.tsx @@ -9,6 +9,7 @@ import { buildUser, buildTrip, buildTripFile } from '../../tests/helpers/factori import { useAuthStore } from '../store/authStore'; import { useTripStore } from '../store/tripStore'; import FilesPage from './FilesPage'; +import { offlineDb } from '../db/offlineDb'; vi.mock('../components/Files/FileManager', () => ({ default: ({ files }: { files: unknown[]; onUpload: unknown; onDelete: unknown }) => @@ -29,7 +30,9 @@ function renderFilesPage(tripId: number | string = 1) { ); } -beforeEach(() => { +beforeEach(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + await Promise.all(offlineDb.tables.map(t => t.clear())); vi.clearAllMocks(); resetAllStores(); seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() }); diff --git a/client/src/pages/LoginPage.oidc-redirect.test.tsx b/client/src/pages/LoginPage.oidc-redirect.test.tsx index c14e0d96..bcddda56 100644 --- a/client/src/pages/LoginPage.oidc-redirect.test.tsx +++ b/client/src/pages/LoginPage.oidc-redirect.test.tsx @@ -39,11 +39,11 @@ describe('LoginPage — OIDC redirect preservation', () => { describe('FE-PAGE-LOGIN-022: redirect param stashed in sessionStorage on mount', () => { it('saves decoded redirect to sessionStorage when ?redirect= is present', async () => { - setSearch('?redirect=%2Foauth%2Fauthorize%3Fclient_id%3Dfoo'); + setSearch('?redirect=%2Foauth%2Fconsent%3Fclient_id%3Dfoo'); render(); await waitFor(() => { - expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/authorize?client_id=foo'); + expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/consent?client_id=foo'); }); }); @@ -67,13 +67,13 @@ describe('LoginPage — OIDC redirect preservation', () => { }); it('navigates to the saved sessionStorage redirect after successful OIDC exchange', async () => { - sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo&state=xyz'); + sessionStorage.setItem('oidc_redirect', '/oauth/consent?client_id=foo&state=xyz'); setSearch('?oidc_code=testcode123'); render(); await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith( - '/oauth/authorize?client_id=foo&state=xyz', + '/oauth/consent?client_id=foo&state=xyz', { replace: true }, ); }); @@ -93,7 +93,7 @@ describe('LoginPage — OIDC redirect preservation', () => { describe('FE-PAGE-LOGIN-024: OIDC error clears sessionStorage redirect', () => { it('removes oidc_redirect from sessionStorage on OIDC error', async () => { - sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo'); + sessionStorage.setItem('oidc_redirect', '/oauth/consent?client_id=foo'); setSearch('?oidc_error=token_failed'); render(); diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 6fa2c192..0104f97e 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -33,6 +33,7 @@ export default function LoginPage(): React.ReactElement { const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState('') const [appConfig, setAppConfig] = useState(null) + const [configError, setConfigError] = useState(false) const [inviteToken, setInviteToken] = useState('') const [inviteValid, setInviteValid] = useState(false) const exchangeInitiated = useRef(false) @@ -117,15 +118,15 @@ export default function LoginPage(): React.ReactElement { return } - authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => { - if (config) { + authApi.getAppConfig?.() + .then((config: AppConfig) => { setAppConfig(config) if (!config.has_users) setMode('register') if (!config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) { window.location.href = '/api/auth/oidc/login' } - } - }) + }) + .catch(() => setConfigError(true)) }, [navigate, t, noRedirect]) // Language detection chain (runs once on mount, only if user has no saved preference): @@ -860,6 +861,20 @@ export default function LoginPage(): React.ReactElement { )} + {/* Config load error — shown when /api/auth/app-config fails (e.g. ZT redirect, + network blip). Hides the SSO button; prompt user to refresh. */} + {configError && !appConfig && ( +
+ {t('login.configLoadError')} + +
+ )} + {/* Demo login button */} {appConfig?.demo_mode && ( + )} ) } diff --git a/client/src/repo/accommodationRepo.ts b/client/src/repo/accommodationRepo.ts index 75e8c345..03a880d2 100644 --- a/client/src/repo/accommodationRepo.ts +++ b/client/src/repo/accommodationRepo.ts @@ -1,16 +1,88 @@ import { accommodationsApi } from '../api/client' import { offlineDb, upsertAccommodations } from '../db/offlineDb' +import { mutationQueue, generateUUID } from '../sync/mutationQueue' import type { Accommodation } from '../types' export const accommodationRepo = { - async list(tripId: number | string): Promise<{ accommodations: Accommodation[] }> { - if (!navigator.onLine) { - const accommodations = await offlineDb.accommodations - .where('trip_id').equals(Number(tripId)).toArray() - return { accommodations } - } - const result = await accommodationsApi.list(tripId) - upsertAccommodations(result.accommodations || []).catch(() => {}) - return result + async list(tripId: number | string): Promise<{ accommodations: Accommodation[]; refresh: Promise<{ accommodations: Accommodation[] } | null> }> { + const cached = await offlineDb.accommodations + .where('trip_id').equals(Number(tripId)).toArray() + + const refresh = (async () => { + try { + const result = await accommodationsApi.list(tripId) + upsertAccommodations(result.accommodations || []).catch(() => {}) + return result + } catch { + return null + } + })() + + if (cached.length > 0) return { accommodations: cached, refresh } + + const fresh = await refresh + if (!fresh) return { accommodations: [], refresh: Promise.resolve(null) } + return { accommodations: fresh.accommodations, refresh: Promise.resolve(null) } + }, + + async create(tripId: number | string, data: Record): Promise<{ accommodation: Accommodation }> { + const tempId = -(Date.now()) + const tempAccommodation: Accommodation = { + ...(data as Partial), + id: tempId, + trip_id: Number(tripId), + name: (data.name as string) ?? 'New accommodation', + address: null, + check_in: null, + check_in_end: null, + check_out: null, + confirmation_number: null, + notes: null, + url: null, + created_at: new Date().toISOString(), + } as Accommodation + await offlineDb.accommodations.put(tempAccommodation) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'POST', + url: `/trips/${tripId}/accommodations`, + body: data, + resource: 'accommodations', + tempId, + }) + mutationQueue.flush().catch(() => {}) + return { accommodation: tempAccommodation } + }, + + async update(tripId: number | string, id: number, data: Record): Promise<{ accommodation: Accommodation }> { + const existing = await offlineDb.accommodations.get(id) + const optimistic: Accommodation = { ...(existing ?? {} as Accommodation), ...(data as Partial), id } + await offlineDb.accommodations.put(optimistic) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'PUT', + url: `/trips/${tripId}/accommodations/${id}`, + body: data, + resource: 'accommodations', + }) + mutationQueue.flush().catch(() => {}) + return { accommodation: optimistic } + }, + + async delete(tripId: number | string, id: number): Promise { + await offlineDb.accommodations.delete(id) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'DELETE', + url: `/trips/${tripId}/accommodations/${id}`, + body: undefined, + resource: 'accommodations', + entityId: id, + }) + mutationQueue.flush().catch(() => {}) + return { success: true } }, } diff --git a/client/src/repo/budgetRepo.ts b/client/src/repo/budgetRepo.ts index 3ea50a7b..99e66e15 100644 --- a/client/src/repo/budgetRepo.ts +++ b/client/src/repo/budgetRepo.ts @@ -1,18 +1,85 @@ import { budgetApi } from '../api/client' import { offlineDb, upsertBudgetItems } from '../db/offlineDb' +import { mutationQueue, generateUUID } from '../sync/mutationQueue' import type { BudgetItem } from '../types' export const budgetRepo = { - async list(tripId: number | string): Promise<{ items: BudgetItem[] }> { - if (!navigator.onLine) { - const cached = await offlineDb.budgetItems - .where('trip_id') - .equals(Number(tripId)) - .toArray() - return { items: cached } - } - const result = await budgetApi.list(tripId) - upsertBudgetItems(result.items) - return result + async list(tripId: number | string): Promise<{ items: BudgetItem[]; refresh: Promise<{ items: BudgetItem[] } | null> }> { + const cached = await offlineDb.budgetItems + .where('trip_id') + .equals(Number(tripId)) + .toArray() + + const refresh = (async () => { + try { + const result = await budgetApi.list(tripId) + upsertBudgetItems(result.items).catch(() => {}) + return result + } catch { + return null + } + })() + + if (cached.length > 0) return { items: cached, refresh } + + const fresh = await refresh + if (!fresh) return { items: [], refresh: Promise.resolve(null) } + return { items: fresh.items, refresh: Promise.resolve(null) } + }, + + async create(tripId: number | string, data: Record): Promise<{ item: BudgetItem }> { + const tempId = -(Date.now()) + const tempItem: BudgetItem = { + ...(data as Partial), + id: tempId, + trip_id: Number(tripId), + name: (data.name as string) ?? 'New expense', + amount: (data.amount as number) ?? 0, + currency: (data.currency as string) ?? 'USD', + members: [], + } as BudgetItem + await offlineDb.budgetItems.put(tempItem) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'POST', + url: `/trips/${tripId}/budget`, + body: data, + resource: 'budgetItems', + tempId, + }) + mutationQueue.flush().catch(() => {}) + return { item: tempItem } + }, + + async update(tripId: number | string, id: number, data: Record): Promise<{ item: BudgetItem }> { + const existing = await offlineDb.budgetItems.get(id) + const optimistic: BudgetItem = { ...(existing ?? {} as BudgetItem), ...(data as Partial), id } + await offlineDb.budgetItems.put(optimistic) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'PUT', + url: `/trips/${tripId}/budget/${id}`, + body: data, + resource: 'budgetItems', + }) + mutationQueue.flush().catch(() => {}) + return { item: optimistic } + }, + + async delete(tripId: number | string, id: number): Promise { + await offlineDb.budgetItems.delete(id) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'DELETE', + url: `/trips/${tripId}/budget/${id}`, + body: undefined, + resource: 'budgetItems', + entityId: id, + }) + mutationQueue.flush().catch(() => {}) + return { success: true } }, } diff --git a/client/src/repo/dayRepo.ts b/client/src/repo/dayRepo.ts index de105748..7db0a2b0 100644 --- a/client/src/repo/dayRepo.ts +++ b/client/src/repo/dayRepo.ts @@ -1,18 +1,45 @@ import { daysApi } from '../api/client' import { offlineDb, upsertDays } from '../db/offlineDb' +import { mutationQueue, generateUUID } from '../sync/mutationQueue' import type { Day } from '../types' export const dayRepo = { - async list(tripId: number | string): Promise<{ days: Day[] }> { - if (!navigator.onLine) { - const cached = await offlineDb.days - .where('trip_id') - .equals(Number(tripId)) - .sortBy('day_number' as keyof Day) - return { days: cached as Day[] } - } - const result = await daysApi.list(tripId) - upsertDays(result.days) - return result + async list(tripId: number | string): Promise<{ days: Day[]; refresh: Promise<{ days: Day[] } | null> }> { + const cached = (await offlineDb.days + .where('trip_id') + .equals(Number(tripId)) + .sortBy('day_number' as keyof Day)) as Day[] + + const refresh = (async () => { + try { + const result = await daysApi.list(tripId) + upsertDays(result.days).catch(() => {}) + return result + } catch { + return null + } + })() + + if (cached.length > 0) return { days: cached, refresh } + + const fresh = await refresh + if (!fresh) return { days: [], refresh: Promise.resolve(null) } + return { days: fresh.days, refresh: Promise.resolve(null) } + }, + + async update(tripId: number | string, dayId: number | string, data: Record): Promise<{ day: Day }> { + const existing = await offlineDb.days.get(Number(dayId)) + const optimistic: Day = { ...(existing ?? {} as Day), ...data, id: Number(dayId) } + await offlineDb.days.put(optimistic) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'PUT', + url: `/trips/${tripId}/days/${dayId}`, + body: data, + resource: 'days', + }) + mutationQueue.flush().catch(() => {}) + return { day: optimistic } }, } diff --git a/client/src/repo/fileRepo.ts b/client/src/repo/fileRepo.ts index db96bad8..d1a89476 100644 --- a/client/src/repo/fileRepo.ts +++ b/client/src/repo/fileRepo.ts @@ -1,18 +1,76 @@ import { filesApi } from '../api/client' import { offlineDb, upsertTripFiles } from '../db/offlineDb' +import { mutationQueue, generateUUID } from '../sync/mutationQueue' import type { TripFile } from '../types' export const fileRepo = { - async list(tripId: number | string): Promise<{ files: TripFile[] }> { - if (!navigator.onLine) { - const cached = await offlineDb.tripFiles - .where('trip_id') - .equals(Number(tripId)) - .toArray() - return { files: cached } + async list(tripId: number | string): Promise<{ files: TripFile[]; refresh: Promise<{ files: TripFile[] } | null> }> { + const cached = await offlineDb.tripFiles + .where('trip_id') + .equals(Number(tripId)) + .toArray() + + const refresh = (async () => { + try { + const result = await filesApi.list(tripId) + upsertTripFiles(result.files).catch(() => {}) + return result + } catch { + return null + } + })() + + if (cached.length > 0) return { files: cached, refresh } + + const fresh = await refresh + if (!fresh) return { files: [], refresh: Promise.resolve(null) } + return { files: fresh.files, refresh: Promise.resolve(null) } + }, + + async update(tripId: number | string, id: number, data: Record): Promise<{ file: TripFile }> { + const existing = await offlineDb.tripFiles.get(id) + const optimistic: TripFile = { ...(existing ?? {} as TripFile), ...(data as Partial), id: Number(id) } + await offlineDb.tripFiles.put(optimistic) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'PUT', + url: `/trips/${tripId}/files/${id}`, + body: data, + resource: 'tripFiles', + }) + mutationQueue.flush().catch(() => {}) + return { file: optimistic } + }, + + async toggleStar(tripId: number | string, id: number): Promise { + const existing = await offlineDb.tripFiles.get(id) + if (existing) { + await offlineDb.tripFiles.put({ ...existing, starred: existing.starred ? 0 : 1 }) } - const result = await filesApi.list(tripId) - upsertTripFiles(result.files) - return result + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'PATCH', + url: `/trips/${tripId}/files/${id}/star`, + body: undefined, + }) + mutationQueue.flush().catch(() => {}) + return { success: true } + }, + + async delete(tripId: number | string, id: number): Promise { + await offlineDb.tripFiles.delete(id) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'DELETE', + url: `/trips/${tripId}/files/${id}`, + body: undefined, + resource: 'tripFiles', + entityId: id, + }) + mutationQueue.flush().catch(() => {}) + return { success: true } }, } diff --git a/client/src/repo/packingRepo.ts b/client/src/repo/packingRepo.ts index 30859fc6..d036f1b3 100644 --- a/client/src/repo/packingRepo.ts +++ b/client/src/repo/packingRepo.ts @@ -4,85 +4,80 @@ import { mutationQueue, generateUUID } from '../sync/mutationQueue' import type { PackingItem } from '../types' export const packingRepo = { - async list(tripId: number | string): Promise<{ items: PackingItem[] }> { - if (!navigator.onLine) { - const cached = await offlineDb.packingItems - .where('trip_id') - .equals(Number(tripId)) - .toArray() - return { items: cached } - } - const result = await packingApi.list(tripId) - upsertPackingItems(result.items) - return result + async list(tripId: number | string): Promise<{ items: PackingItem[]; refresh: Promise<{ items: PackingItem[] } | null> }> { + const cached = await offlineDb.packingItems + .where('trip_id') + .equals(Number(tripId)) + .toArray() + + const refresh = (async () => { + try { + const result = await packingApi.list(tripId) + upsertPackingItems(result.items).catch(() => {}) + return result + } catch { + return null + } + })() + + if (cached.length > 0) return { items: cached, refresh } + + const fresh = await refresh + if (!fresh) return { items: [], refresh: Promise.resolve(null) } + return { items: fresh.items, refresh: Promise.resolve(null) } }, async create(tripId: number | string, data: Record): Promise<{ item: PackingItem }> { - if (!navigator.onLine) { - const tempId = -(Date.now()) - const tempItem: PackingItem = { - ...(data as Partial), - id: tempId, - trip_id: Number(tripId), - name: (data.name as string) ?? 'New item', - checked: 0, - } as PackingItem - await offlineDb.packingItems.put(tempItem) - const id = generateUUID() - await mutationQueue.enqueue({ - id, - tripId: Number(tripId), - method: 'POST', - url: `/trips/${tripId}/packing`, - body: data, - resource: 'packingItems', - tempId, - }) - return { item: tempItem } - } - const result = await packingApi.create(tripId, data) - offlineDb.packingItems.put(result.item) - return result + const tempId = -(Date.now()) + const tempItem: PackingItem = { + ...(data as Partial), + id: tempId, + trip_id: Number(tripId), + name: (data.name as string) ?? 'New item', + checked: 0, + } as PackingItem + await offlineDb.packingItems.put(tempItem) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'POST', + url: `/trips/${tripId}/packing`, + body: data, + resource: 'packingItems', + tempId, + }) + mutationQueue.flush().catch(() => {}) + return { item: tempItem } }, async update(tripId: number | string, id: number, data: Record): Promise<{ item: PackingItem }> { - if (!navigator.onLine) { - const existing = await offlineDb.packingItems.get(id) - const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial), id } - await offlineDb.packingItems.put(optimistic) - const mutId = generateUUID() - await mutationQueue.enqueue({ - id: mutId, - tripId: Number(tripId), - method: 'PUT', - url: `/trips/${tripId}/packing/${id}`, - body: data, - resource: 'packingItems', - }) - return { item: optimistic } - } - const result = await packingApi.update(tripId, id, data) - offlineDb.packingItems.put(result.item) - return result + const existing = await offlineDb.packingItems.get(id) + const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial), id } + await offlineDb.packingItems.put(optimistic) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'PUT', + url: `/trips/${tripId}/packing/${id}`, + body: data, + resource: 'packingItems', + }) + mutationQueue.flush().catch(() => {}) + return { item: optimistic } }, async delete(tripId: number | string, id: number): Promise { - if (!navigator.onLine) { - await offlineDb.packingItems.delete(id) - const mutId = generateUUID() - await mutationQueue.enqueue({ - id: mutId, - tripId: Number(tripId), - method: 'DELETE', - url: `/trips/${tripId}/packing/${id}`, - body: undefined, - resource: 'packingItems', - entityId: id, - }) - return { success: true } - } - const result = await packingApi.delete(tripId, id) - offlineDb.packingItems.delete(id) - return result + await offlineDb.packingItems.delete(id) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'DELETE', + url: `/trips/${tripId}/packing/${id}`, + body: undefined, + resource: 'packingItems', + entityId: id, + }) + mutationQueue.flush().catch(() => {}) + return { success: true } }, } diff --git a/client/src/repo/placeRepo.ts b/client/src/repo/placeRepo.ts index 36b1acc2..b1d59d12 100644 --- a/client/src/repo/placeRepo.ts +++ b/client/src/repo/placeRepo.ts @@ -4,106 +4,96 @@ import { mutationQueue, generateUUID } from '../sync/mutationQueue' import type { Place } from '../types' export const placeRepo = { - async list(tripId: number | string, params?: Record): Promise<{ places: Place[] }> { - if (!navigator.onLine) { - const cached = await offlineDb.places - .where('trip_id') - .equals(Number(tripId)) - .toArray() - return { places: cached } - } - const result = await placesApi.list(tripId, params) - upsertPlaces(result.places) - return result + async list(tripId: number | string, params?: Record): Promise<{ places: Place[]; refresh: Promise<{ places: Place[] } | null> }> { + const cached = await offlineDb.places + .where('trip_id') + .equals(Number(tripId)) + .toArray() + + const refresh = (async () => { + try { + const result = await placesApi.list(tripId, params) + upsertPlaces(result.places).catch(() => {}) + return result + } catch { + return null + } + })() + + if (cached.length > 0) return { places: cached, refresh } + + const fresh = await refresh + if (!fresh) return { places: [], refresh: Promise.resolve(null) } + return { places: fresh.places, refresh: Promise.resolve(null) } }, async create(tripId: number | string, data: Record): Promise<{ place: Place }> { - if (!navigator.onLine) { - const tempId = -(Date.now()) - const tempPlace: Place = { - ...(data as Partial), - id: tempId, - trip_id: Number(tripId), - name: (data.name as string) ?? 'New place', - } as Place - await offlineDb.places.put(tempPlace) - const id = generateUUID() - await mutationQueue.enqueue({ - id, - tripId: Number(tripId), - method: 'POST', - url: `/trips/${tripId}/places`, - body: data, - resource: 'places', - tempId, - }) - return { place: tempPlace } - } - const result = await placesApi.create(tripId, data) - offlineDb.places.put(result.place) - return result + const tempId = -(Date.now()) + const tempPlace: Place = { + ...(data as Partial), + id: tempId, + trip_id: Number(tripId), + name: (data.name as string) ?? 'New place', + } as Place + await offlineDb.places.put(tempPlace) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'POST', + url: `/trips/${tripId}/places`, + body: data, + resource: 'places', + tempId, + }) + mutationQueue.flush().catch(() => {}) + return { place: tempPlace } }, async update(tripId: number | string, id: number | string, data: Record): Promise<{ place: Place }> { - if (!navigator.onLine) { - const existing = await offlineDb.places.get(Number(id)) - const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial), id: Number(id) } - await offlineDb.places.put(optimistic) - const mutId = generateUUID() - await mutationQueue.enqueue({ - id: mutId, - tripId: Number(tripId), - method: 'PUT', - url: `/trips/${tripId}/places/${id}`, - body: data, - resource: 'places', - }) - return { place: optimistic } - } - const result = await placesApi.update(tripId, id, data) - offlineDb.places.put(result.place) - return result + const existing = await offlineDb.places.get(Number(id)) + const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial), id: Number(id) } + await offlineDb.places.put(optimistic) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'PUT', + url: `/trips/${tripId}/places/${id}`, + body: data, + resource: 'places', + }) + mutationQueue.flush().catch(() => {}) + return { place: optimistic } }, async delete(tripId: number | string, id: number | string): Promise { - if (!navigator.onLine) { - await offlineDb.places.delete(Number(id)) - const mutId = generateUUID() + await offlineDb.places.delete(Number(id)) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'DELETE', + url: `/trips/${tripId}/places/${id}`, + body: undefined, + resource: 'places', + entityId: Number(id), + }) + mutationQueue.flush().catch(() => {}) + return { success: true } + }, + + async deleteMany(tripId: number | string, ids: number[]): Promise { + await offlineDb.places.bulkDelete(ids) + for (const id of ids) { await mutationQueue.enqueue({ - id: mutId, + id: generateUUID(), tripId: Number(tripId), method: 'DELETE', url: `/trips/${tripId}/places/${id}`, body: undefined, resource: 'places', - entityId: Number(id), + entityId: id, }) - return { success: true } } - const result = await placesApi.delete(tripId, id) - offlineDb.places.delete(Number(id)) - return result - }, - - async deleteMany(tripId: number | string, ids: number[]): Promise { - if (!navigator.onLine) { - await offlineDb.places.bulkDelete(ids) - for (const id of ids) { - const mutId = generateUUID() - await mutationQueue.enqueue({ - id: mutId, - tripId: Number(tripId), - method: 'DELETE', - url: `/trips/${tripId}/places/${id}`, - body: undefined, - resource: 'places', - entityId: id, - }) - } - return { deleted: ids, count: ids.length } - } - const result = await placesApi.bulkDelete(tripId, ids) - await offlineDb.places.bulkDelete(ids) - return result + mutationQueue.flush().catch(() => {}) + return { deleted: ids, count: ids.length } }, } diff --git a/client/src/repo/reservationRepo.ts b/client/src/repo/reservationRepo.ts index 575b8075..2b23395d 100644 --- a/client/src/repo/reservationRepo.ts +++ b/client/src/repo/reservationRepo.ts @@ -1,18 +1,90 @@ import { reservationsApi } from '../api/client' import { offlineDb, upsertReservations } from '../db/offlineDb' +import { mutationQueue, generateUUID } from '../sync/mutationQueue' import type { Reservation } from '../types' export const reservationRepo = { - async list(tripId: number | string): Promise<{ reservations: Reservation[] }> { - if (!navigator.onLine) { - const cached = await offlineDb.reservations - .where('trip_id') - .equals(Number(tripId)) - .toArray() - return { reservations: cached } - } - const result = await reservationsApi.list(tripId) - upsertReservations(result.reservations) - return result + async list(tripId: number | string): Promise<{ reservations: Reservation[]; refresh: Promise<{ reservations: Reservation[] } | null> }> { + const cached = await offlineDb.reservations + .where('trip_id') + .equals(Number(tripId)) + .toArray() + + const refresh = (async () => { + try { + const result = await reservationsApi.list(tripId) + upsertReservations(result.reservations).catch(() => {}) + return result + } catch { + return null + } + })() + + if (cached.length > 0) return { reservations: cached, refresh } + + const fresh = await refresh + if (!fresh) return { reservations: [], refresh: Promise.resolve(null) } + return { reservations: fresh.reservations, refresh: Promise.resolve(null) } + }, + + async create(tripId: number | string, data: Record): Promise<{ reservation: Reservation }> { + const tempId = -(Date.now()) + const tempReservation: Reservation = { + ...(data as Partial), + id: tempId, + trip_id: Number(tripId), + name: (data.name as string) ?? 'New reservation', + type: (data.type as string) ?? 'other', + status: 'pending', + date: (data.date as string) ?? null, + time: null, + confirmation_number: null, + notes: null, + url: null, + created_at: new Date().toISOString(), + } as Reservation + await offlineDb.reservations.put(tempReservation) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'POST', + url: `/trips/${tripId}/reservations`, + body: data, + resource: 'reservations', + tempId, + }) + mutationQueue.flush().catch(() => {}) + return { reservation: tempReservation } + }, + + async update(tripId: number | string, id: number, data: Record): Promise<{ reservation: Reservation }> { + const existing = await offlineDb.reservations.get(id) + const optimistic: Reservation = { ...(existing ?? {} as Reservation), ...(data as Partial), id } + await offlineDb.reservations.put(optimistic) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'PUT', + url: `/trips/${tripId}/reservations/${id}`, + body: data, + resource: 'reservations', + }) + mutationQueue.flush().catch(() => {}) + return { reservation: optimistic } + }, + + async delete(tripId: number | string, id: number): Promise { + await offlineDb.reservations.delete(id) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'DELETE', + url: `/trips/${tripId}/reservations/${id}`, + body: undefined, + resource: 'reservations', + entityId: id, + }) + mutationQueue.flush().catch(() => {}) + return { success: true } }, } diff --git a/client/src/repo/todoRepo.ts b/client/src/repo/todoRepo.ts index e284b23a..7b37ea62 100644 --- a/client/src/repo/todoRepo.ts +++ b/client/src/repo/todoRepo.ts @@ -1,18 +1,88 @@ import { todoApi } from '../api/client' import { offlineDb, upsertTodoItems } from '../db/offlineDb' +import { mutationQueue, generateUUID } from '../sync/mutationQueue' import type { TodoItem } from '../types' export const todoRepo = { - async list(tripId: number | string): Promise<{ items: TodoItem[] }> { - if (!navigator.onLine) { - const cached = await offlineDb.todoItems - .where('trip_id') - .equals(Number(tripId)) - .toArray() - return { items: cached } - } - const result = await todoApi.list(tripId) - upsertTodoItems(result.items) - return result + async list(tripId: number | string): Promise<{ items: TodoItem[]; refresh: Promise<{ items: TodoItem[] } | null> }> { + const cached = await offlineDb.todoItems + .where('trip_id') + .equals(Number(tripId)) + .toArray() + + const refresh = (async () => { + try { + const result = await todoApi.list(tripId) + upsertTodoItems(result.items).catch(() => {}) + return result + } catch { + return null + } + })() + + if (cached.length > 0) return { items: cached, refresh } + + const fresh = await refresh + if (!fresh) return { items: [], refresh: Promise.resolve(null) } + return { items: fresh.items, refresh: Promise.resolve(null) } + }, + + async create(tripId: number | string, data: Record): Promise<{ item: TodoItem }> { + const tempId = -(Date.now()) + const tempItem: TodoItem = { + ...(data as Partial), + id: tempId, + trip_id: Number(tripId), + name: (data.name as string) ?? 'New todo', + checked: 0, + sort_order: 0, + due_date: null, + description: null, + assigned_user_id: null, + priority: 0, + } as TodoItem + await offlineDb.todoItems.put(tempItem) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'POST', + url: `/trips/${tripId}/todo`, + body: data, + resource: 'todoItems', + tempId, + }) + mutationQueue.flush().catch(() => {}) + return { item: tempItem } + }, + + async update(tripId: number | string, id: number, data: Record): Promise<{ item: TodoItem }> { + const existing = await offlineDb.todoItems.get(id) + const optimistic: TodoItem = { ...(existing ?? {} as TodoItem), ...(data as Partial), id } + await offlineDb.todoItems.put(optimistic) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'PUT', + url: `/trips/${tripId}/todo/${id}`, + body: data, + resource: 'todoItems', + }) + mutationQueue.flush().catch(() => {}) + return { item: optimistic } + }, + + async delete(tripId: number | string, id: number): Promise { + await offlineDb.todoItems.delete(id) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'DELETE', + url: `/trips/${tripId}/todo/${id}`, + body: undefined, + resource: 'todoItems', + entityId: id, + }) + mutationQueue.flush().catch(() => {}) + return { success: true } }, } diff --git a/client/src/repo/tripRepo.ts b/client/src/repo/tripRepo.ts index 082e346a..53dcd0be 100644 --- a/client/src/repo/tripRepo.ts +++ b/client/src/repo/tripRepo.ts @@ -1,33 +1,88 @@ import { tripsApi } from '../api/client' import { offlineDb, upsertTrip } from '../db/offlineDb' +import { mutationQueue, generateUUID } from '../sync/mutationQueue' import type { Trip } from '../types' +type TripsRefresh = Promise<{ trips: Trip[]; archivedTrips: Trip[] } | null> +type TripRefresh = Promise<{ trip: Trip } | null> + export const tripRepo = { - async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[] }> { - if (!navigator.onLine) { - const all = await offlineDb.trips.toArray() + async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[]; refresh: TripsRefresh }> { + // Guard: if Dexie is in a bad state (e.g. externally deleted while tab was + // open and the versionchange close() races with this read), fall back to the + // cold/network path rather than throwing or hanging. + const all = await Promise.race([ + offlineDb.trips.toArray().catch(() => [] as Trip[]), + new Promise(resolve => setTimeout(() => resolve([]), 2000)), + ]) + + const refresh: TripsRefresh = (async () => { + try { + const [active, archived] = await Promise.all([ + tripsApi.list(), + tripsApi.list({ archived: 1 }), + ]) + // Fire-and-forget IDB writes: returning data immediately unblocks the cold + // path even when Dexie write transactions stall after an external DB clear. + Promise.all([ + ...active.trips.map(t => upsertTrip(t)), + ...archived.trips.map(t => upsertTrip(t)), + ]).catch(() => {}) + return { trips: active.trips, archivedTrips: archived.trips } + } catch { + return null + } + })() + + if (all.length > 0) { return { trips: all.filter(t => !t.is_archived), archivedTrips: all.filter(t => t.is_archived), + refresh, } } - const [active, archived] = await Promise.all([ - tripsApi.list(), - tripsApi.list({ archived: 1 }), - ]) - active.trips.forEach(t => upsertTrip(t)) - archived.trips.forEach(t => upsertTrip(t)) - return { trips: active.trips, archivedTrips: archived.trips } + + const fresh = await refresh + if (!fresh) return { trips: [], archivedTrips: [], refresh: Promise.resolve(null) } + return { ...fresh, refresh: Promise.resolve(null) } }, - async get(tripId: number | string): Promise<{ trip: Trip }> { - if (!navigator.onLine) { - const cached = await offlineDb.trips.get(Number(tripId)) - if (cached) return { trip: cached } - throw new Error('No cached trip data available offline') - } - const result = await tripsApi.get(tripId) - upsertTrip(result.trip) - return result + async get(tripId: number | string): Promise<{ trip: Trip; refresh: TripRefresh }> { + const cached = await Promise.race([ + offlineDb.trips.get(Number(tripId)).catch(() => undefined), + new Promise(resolve => setTimeout(() => resolve(undefined), 2000)), + ]) + + const refresh: TripRefresh = (async () => { + try { + const result = await tripsApi.get(tripId) + upsertTrip(result.trip).catch(() => {}) + return result + } catch { + return null + } + })() + + if (cached) return { trip: cached, refresh } + + const fresh = await refresh + if (!fresh) throw new Error('No cached trip data available offline') + return { trip: fresh.trip, refresh: Promise.resolve(null) } + }, + + async update(tripId: number | string, data: Partial): Promise<{ trip: Trip }> { + const existing = await offlineDb.trips.get(Number(tripId)) + const optimistic: Trip = { ...(existing ?? {} as Trip), ...(data as Partial), id: Number(tripId) } + await offlineDb.trips.put(optimistic) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'PUT', + url: `/trips/${tripId}`, + body: data as Record, + resource: 'trips', + }) + mutationQueue.flush().catch(() => {}) + return { trip: optimistic } }, } diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 8d8c342d..6237cd6d 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -4,7 +4,6 @@ import { authApi } from '../api/client' import { connect, disconnect } from '../api/websocket' import type { User } from '../types' import { getApiErrorMessage } from '../types' -import { tripSyncManager } from '../sync/tripSyncManager' import { clearAll } from '../db/offlineDb' import { useSystemNoticeStore } from './systemNoticeStore.js' @@ -100,7 +99,6 @@ export const useAuthStore = create()( error: null, }) connect() - tripSyncManager.syncAll().catch(console.error) if (!data.user?.must_change_password) { useSystemNoticeStore.getState().fetch() } @@ -124,7 +122,6 @@ export const useAuthStore = create()( error: null, }) connect() - tripSyncManager.syncAll().catch(console.error) if (!data.user?.must_change_password) { useSystemNoticeStore.getState().fetch() } @@ -148,7 +145,6 @@ export const useAuthStore = create()( error: null, }) connect() - tripSyncManager.syncAll().catch(console.error) useSystemNoticeStore.getState().fetch() return data } catch (err: unknown) { diff --git a/client/src/store/slices/assignmentsSlice.ts b/client/src/store/slices/assignmentsSlice.ts index 8d44fb50..a8cc4f5e 100644 --- a/client/src/store/slices/assignmentsSlice.ts +++ b/client/src/store/slices/assignmentsSlice.ts @@ -1,4 +1,6 @@ import { assignmentsApi } from '../../api/client' +import { offlineDb } from '../../db/offlineDb' +import { mutationQueue, generateUUID } from '../../sync/mutationQueue' import type { StoreApi } from 'zustand' import type { TripStoreState } from '../tripStore' import type { Assignment, AssignmentsMap } from '../../types' @@ -40,6 +42,23 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment } })) + if (!navigator.onLine) { + const day = await offlineDb.days.get(parseInt(String(dayId))) + if (day) { + const updated = [...(day.assignments || [])] + updated.splice(insertIdx, 0, tempAssignment) + await offlineDb.days.put({ ...day, assignments: updated }) + } + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'POST', + url: `/trips/${tripId}/days/${dayId}/assignments`, + body: { place_id: placeId }, + }) + return tempAssignment + } + try { const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId }) const newAssignment: Assignment = { @@ -99,6 +118,24 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment } })) + if (!navigator.onLine) { + const day = await offlineDb.days.get(parseInt(String(dayId))) + if (day) { + await offlineDb.days.put({ + ...day, + assignments: (day.assignments || []).filter(a => a.id !== assignmentId), + }) + } + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'DELETE', + url: `/trips/${tripId}/days/${dayId}/assignments/${assignmentId}`, + body: undefined, + }) + return + } + try { await assignmentsApi.delete(tripId, dayId, assignmentId) } catch (err: unknown) { diff --git a/client/src/store/slices/budgetSlice.test.ts b/client/src/store/slices/budgetSlice.test.ts index 371dd682..48033d69 100644 --- a/client/src/store/slices/budgetSlice.test.ts +++ b/client/src/store/slices/budgetSlice.test.ts @@ -4,8 +4,11 @@ import { server } from '../../../tests/helpers/msw/server'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildBudgetItem } from '../../../tests/helpers/factories'; import { useTripStore } from '../tripStore'; +import { offlineDb } from '../../db/offlineDb'; -beforeEach(() => { +beforeEach(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + await Promise.all(offlineDb.tables.map(t => t.clear())); resetAllStores(); server.resetHandlers(); }); @@ -34,25 +37,28 @@ describe('budgetSlice', () => { expect(useTripStore.getState().budgetItems).toEqual([]); }); - it('FE-STORE-BUDGET-003: addBudgetItem appends to store and returns item', async () => { - const newItem = buildBudgetItem({ name: 'Hotel', trip_id: 1 }); + it('FE-STORE-BUDGET-003: addBudgetItem appends to store optimistically', async () => { server.use( http.post('/api/trips/1/budget', () => - HttpResponse.json({ item: newItem }) + HttpResponse.json({ item: buildBudgetItem({ name: 'Hotel', trip_id: 1 }) }) ) ); const result = await useTripStore.getState().addBudgetItem(1, { name: 'Hotel' }); - expect(result.id).toBe(newItem.id); - expect(useTripStore.getState().budgetItems).toContainEqual(newItem); + expect(result.name).toBe('Hotel'); + const items = useTripStore.getState().budgetItems; + expect(items).toHaveLength(1); + expect(items[0].name).toBe('Hotel'); }); - it('FE-STORE-BUDGET-004: addBudgetItem throws on API error', async () => { + it('FE-STORE-BUDGET-004: addBudgetItem adds item optimistically even on API error', async () => { server.use( http.post('/api/trips/1/budget', () => HttpResponse.json({ error: 'Validation failed' }, { status: 422 }) ) ); - await expect(useTripStore.getState().addBudgetItem(1, {})).rejects.toThrow(); + const result = await useTripStore.getState().addBudgetItem(1, { name: 'Item' }); + expect(result.name).toBe('Item'); + expect(useTripStore.getState().budgetItems).toHaveLength(1); }); it('FE-STORE-BUDGET-005: updateBudgetItem replaces item in store', async () => { @@ -71,24 +77,21 @@ describe('budgetSlice', () => { expect(items[0].name).toBe('New'); }); - it('FE-STORE-BUDGET-006: updateBudgetItem calls loadReservations when reservation_id + total_price provided', async () => { - const existing = buildBudgetItem({ id: 20, trip_id: 1 }); + it('FE-STORE-BUDGET-006: updateBudgetItem resolves and updates store optimistically', async () => { + const existing = buildBudgetItem({ id: 20, trip_id: 1, amount: 100 }); seedStore(useTripStore, { budgetItems: [existing] }); - const loadReservations = vi.fn().mockResolvedValue(undefined); - seedStore(useTripStore, { loadReservations }); - - const itemWithReservation = { ...existing, reservation_id: 99 }; server.use( http.put('/api/trips/1/budget/20', () => - HttpResponse.json({ item: itemWithReservation }) + HttpResponse.json({ item: { ...existing, amount: 50 } }) ) ); - await useTripStore.getState().updateBudgetItem(1, 20, { total_price: 50 }); - expect(loadReservations).toHaveBeenCalledWith(1); + const result = await useTripStore.getState().updateBudgetItem(1, 20, { amount: 50 }); + expect(result.amount).toBe(50); + expect(useTripStore.getState().budgetItems[0].amount).toBe(50); }); - it('FE-STORE-BUDGET-007: deleteBudgetItem optimistically removes and rolls back on error', async () => { + it('FE-STORE-BUDGET-007: deleteBudgetItem removes item permanently even on API error', async () => { const item = buildBudgetItem({ id: 5, trip_id: 1 }); seedStore(useTripStore, { budgetItems: [item] }); @@ -97,11 +100,9 @@ describe('budgetSlice', () => { HttpResponse.json({ error: 'forbidden' }, { status: 403 }) ) ); - // The item is removed immediately (optimistic), then restored on error - const deletePromise = useTripStore.getState().deleteBudgetItem(1, 5); - await expect(deletePromise).rejects.toThrow(); - // After rollback, item is back - expect(useTripStore.getState().budgetItems).toContainEqual(item); + await useTripStore.getState().deleteBudgetItem(1, 5); + // Permanently removed (queued for sync, no rollback) + expect(useTripStore.getState().budgetItems).toHaveLength(0); }); it('FE-STORE-BUDGET-008: setBudgetItemMembers updates members on matching item', async () => { diff --git a/client/src/store/slices/budgetSlice.ts b/client/src/store/slices/budgetSlice.ts index 9f63bc45..35b1dc44 100644 --- a/client/src/store/slices/budgetSlice.ts +++ b/client/src/store/slices/budgetSlice.ts @@ -24,6 +24,9 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => try { const data = await budgetRepo.list(tripId) set({ budgetItems: data.items }) + data.refresh.then(fresh => { + if (fresh) set({ budgetItems: fresh.items }) + }).catch(() => {}) } catch (err: unknown) { console.error('Failed to load budget items:', err) } @@ -31,7 +34,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => addBudgetItem: async (tripId, data) => { try { - const result = await budgetApi.create(tripId, data) + const result = await budgetRepo.create(tripId, data as Record) set(state => ({ budgetItems: [...state.budgetItems, result.item] })) return result.item } catch (err: unknown) { @@ -41,7 +44,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => updateBudgetItem: async (tripId, id, data) => { try { - const result = await budgetApi.update(tripId, id, data) + const result = await budgetRepo.update(tripId, id, data as Record) set(state => ({ budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item) })) @@ -58,7 +61,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => const prev = get().budgetItems set(state => ({ budgetItems: state.budgetItems.filter(item => item.id !== id) })) try { - await budgetApi.delete(tripId, id) + await budgetRepo.delete(tripId, id) } catch (err: unknown) { set({ budgetItems: prev }) throw new Error(getApiErrorMessage(err, 'Error deleting budget item')) diff --git a/client/src/store/slices/dayNotesSlice.ts b/client/src/store/slices/dayNotesSlice.ts index 53ab0c6d..f48d8b1d 100644 --- a/client/src/store/slices/dayNotesSlice.ts +++ b/client/src/store/slices/dayNotesSlice.ts @@ -1,4 +1,7 @@ -import { daysApi, dayNotesApi } from '../../api/client' +import { dayNotesApi } from '../../api/client' +import { offlineDb } from '../../db/offlineDb' +import { dayRepo } from '../../repo/dayRepo' +import { mutationQueue, generateUUID } from '../../sync/mutationQueue' import type { StoreApi } from 'zustand' import type { TripStoreState } from '../tripStore' import type { DayNote } from '../../types' @@ -19,7 +22,7 @@ export interface DayNotesSlice { export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice => ({ updateDayNotes: async (tripId, dayId, notes) => { try { - await daysApi.update(tripId, dayId, { notes }) + await dayRepo.update(tripId, dayId, { notes }) set(state => ({ days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, notes } : d) })) @@ -30,7 +33,7 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice updateDayTitle: async (tripId, dayId, title) => { try { - await daysApi.update(tripId, dayId, { title }) + await dayRepo.update(tripId, dayId, { title }) set(state => ({ days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, title } : d) })) @@ -48,6 +51,22 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice [String(dayId)]: [...(state.dayNotes[String(dayId)] || []), tempNote], } })) + + if (!navigator.onLine) { + const day = await offlineDb.days.get(Number(dayId)) + if (day) { + await offlineDb.days.put({ ...day, notes_items: [...(day.notes_items || []), tempNote] }) + } + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'POST', + url: `/trips/${tripId}/days/${dayId}/notes`, + body: data as Record, + }) + return tempNote + } + try { const result = await dayNotesApi.create(tripId, dayId, data) set(state => ({ @@ -69,6 +88,32 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice }, updateDayNote: async (tripId, dayId, id, data) => { + if (!navigator.onLine) { + const existing = get().dayNotes[String(dayId)]?.find(n => n.id === id) + const optimistic: DayNote = { ...(existing ?? {} as DayNote), ...(data as Partial), id } + set(state => ({ + dayNotes: { + ...state.dayNotes, + [String(dayId)]: (state.dayNotes[String(dayId)] || []).map(n => n.id === id ? optimistic : n), + } + })) + const day = await offlineDb.days.get(Number(dayId)) + if (day) { + await offlineDb.days.put({ + ...day, + notes_items: (day.notes_items || []).map(n => n.id === id ? optimistic : n), + }) + } + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'PUT', + url: `/trips/${tripId}/days/${dayId}/notes/${id}`, + body: data as Record, + }) + return optimistic + } + try { const result = await dayNotesApi.update(tripId, dayId, id, data) set(state => ({ @@ -91,6 +136,25 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice [String(dayId)]: (state.dayNotes[String(dayId)] || []).filter(n => n.id !== id), } })) + + if (!navigator.onLine) { + const day = await offlineDb.days.get(Number(dayId)) + if (day) { + await offlineDb.days.put({ + ...day, + notes_items: (day.notes_items || []).filter(n => n.id !== id), + }) + } + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'DELETE', + url: `/trips/${tripId}/days/${dayId}/notes/${id}`, + body: undefined, + }) + return + } + try { await dayNotesApi.delete(tripId, dayId, id) } catch (err: unknown) { diff --git a/client/src/store/slices/filesSlice.ts b/client/src/store/slices/filesSlice.ts index 13617515..322e8882 100644 --- a/client/src/store/slices/filesSlice.ts +++ b/client/src/store/slices/filesSlice.ts @@ -35,10 +35,12 @@ export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({ }, deleteFile: async (tripId, id) => { + const prev = get().files + set(state => ({ files: state.files.filter(f => f.id !== id) })) try { - await filesApi.delete(tripId, id) - set(state => ({ files: state.files.filter(f => f.id !== id) })) + await fileRepo.delete(tripId, id) } catch (err: unknown) { + set({ files: prev }) throw new Error(getApiErrorMessage(err, 'Error deleting file')) } }, diff --git a/client/src/store/slices/placesSlice.ts b/client/src/store/slices/placesSlice.ts index 224a0488..53ec214e 100644 --- a/client/src/store/slices/placesSlice.ts +++ b/client/src/store/slices/placesSlice.ts @@ -20,6 +20,9 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => try { const data = await placeRepo.list(tripId) set({ places: data.places }) + data.refresh.then(fresh => { + if (fresh) set({ places: fresh.places }) + }).catch(() => {}) } catch (err: unknown) { console.error('Failed to refresh places:', err) } diff --git a/client/src/store/slices/reservationsSlice.ts b/client/src/store/slices/reservationsSlice.ts index c020a593..ace7ddf0 100644 --- a/client/src/store/slices/reservationsSlice.ts +++ b/client/src/store/slices/reservationsSlice.ts @@ -1,4 +1,3 @@ -import { reservationsApi } from '../../api/client' import { reservationRepo } from '../../repo/reservationRepo' import type { StoreApi } from 'zustand' import type { TripStoreState } from '../tripStore' @@ -28,7 +27,7 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati addReservation: async (tripId, data) => { try { - const result = await reservationsApi.create(tripId, data) + const result = await reservationRepo.create(tripId, data as Record) set(state => ({ reservations: [result.reservation, ...state.reservations] })) return result.reservation } catch (err: unknown) { @@ -38,7 +37,7 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati updateReservation: async (tripId, id, data) => { try { - const result = await reservationsApi.update(tripId, id, data) + const result = await reservationRepo.update(tripId, id, data as Record) set(state => ({ reservations: state.reservations.map(r => r.id === id ? result.reservation : r) })) @@ -57,17 +56,19 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati reservations: state.reservations.map(r => r.id === id ? { ...r, status: newStatus } : r) })) try { - await reservationsApi.update(tripId, id, { status: newStatus }) + await reservationRepo.update(tripId, id, { status: newStatus }) } catch { set({ reservations: prev }) } }, deleteReservation: async (tripId, id) => { + const prev = get().reservations + set(state => ({ reservations: state.reservations.filter(r => r.id !== id) })) try { - await reservationsApi.delete(tripId, id) - set(state => ({ reservations: state.reservations.filter(r => r.id !== id) })) + await reservationRepo.delete(tripId, id) } catch (err: unknown) { + set({ reservations: prev }) throw new Error(getApiErrorMessage(err, 'Error deleting reservation')) } }, diff --git a/client/src/store/slices/todoSlice.ts b/client/src/store/slices/todoSlice.ts index 58070a85..abdaf3d8 100644 --- a/client/src/store/slices/todoSlice.ts +++ b/client/src/store/slices/todoSlice.ts @@ -1,4 +1,4 @@ -import { todoApi } from '../../api/client' +import { todoRepo } from '../../repo/todoRepo' import type { StoreApi } from 'zustand' import type { TripStoreState } from '../tripStore' import type { TodoItem } from '../../types' @@ -17,7 +17,7 @@ export interface TodoSlice { export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({ addTodoItem: async (tripId, data) => { try { - const result = await todoApi.create(tripId, data) + const result = await todoRepo.create(tripId, data as Record) set(state => ({ todoItems: [...state.todoItems, result.item] })) return result.item } catch (err: unknown) { @@ -27,7 +27,7 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({ updateTodoItem: async (tripId, id, data) => { try { - const result = await todoApi.update(tripId, id, data) + const result = await todoRepo.update(tripId, id, data as Record) set(state => ({ todoItems: state.todoItems.map(item => item.id === id ? result.item : item) })) @@ -41,7 +41,7 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({ const prev = get().todoItems set(state => ({ todoItems: state.todoItems.filter(item => item.id !== id) })) try { - await todoApi.delete(tripId, id) + await todoRepo.delete(tripId, id) } catch (err: unknown) { set({ todoItems: prev }) throw new Error(getApiErrorMessage(err, 'Error deleting todo')) @@ -55,7 +55,7 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({ ) })) try { - await todoApi.update(tripId, id, { checked }) + await todoRepo.update(tripId, id, { checked }) } catch { set(state => ({ todoItems: state.todoItems.map(item => diff --git a/client/src/store/tripStore.ts b/client/src/store/tripStore.ts index 5168c078..2b88c4e3 100644 --- a/client/src/store/tripStore.ts +++ b/client/src/store/tripStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' import type { StoreApi } from 'zustand' -import { tripsApi, tagsApi, categoriesApi } from '../api/client' -import { offlineDb } from '../db/offlineDb' +import { tagsApi, categoriesApi } from '../api/client' +import { offlineDb, upsertTags, upsertCategories } from '../db/offlineDb' import { tripRepo } from '../repo/tripRepo' import { dayRepo } from '../repo/dayRepo' import { placeRepo } from '../repo/placeRepo' @@ -89,27 +89,38 @@ export const useTripStore = create((set, get) => ({ loadTrip: async (tripId: number | string) => { set({ isLoading: true, error: null }) try { - const [tripData, daysData, placesData, packingData, todoData, tagsData, categoriesData] = await Promise.all([ + // Fire tags/categories network refresh immediately — they're global (not trip-specific) + // and must be in-flight before the await below so MSW resolves them during the wait + const tagsRefresh = tagsApi.list() + .then(fresh => { upsertTags(fresh.tags).catch(() => {}); return fresh }) + .catch(() => null) + const categoriesRefresh = categoriesApi.list() + .then(fresh => { upsertCategories(fresh.categories).catch(() => {}); return fresh }) + .catch(() => null) + + // All reads from IndexedDB — instant, no network wait + const [tripData, daysData, placesData, packingData, todoData, cachedTags, cachedCategories] = await Promise.all([ tripRepo.get(tripId), dayRepo.list(tripId), placeRepo.list(tripId), packingRepo.list(tripId), todoRepo.list(tripId), - navigator.onLine - ? tagsApi.list().catch(() => offlineDb.tags.toArray().then(tags => ({ tags }))) - : offlineDb.tags.toArray().then(tags => ({ tags })), - navigator.onLine - ? categoriesApi.list().catch(() => offlineDb.categories.toArray().then(categories => ({ categories }))) - : offlineDb.categories.toArray().then(categories => ({ categories })), + offlineDb.tags.toArray(), + offlineDb.categories.toArray(), ]) - const assignmentsMap: AssignmentsMap = {} - const dayNotesMap: DayNotesMap = {} - for (const day of daysData.days) { - assignmentsMap[String(day.id)] = day.assignments || [] - dayNotesMap[String(day.id)] = day.notes_items || [] + const buildMaps = (days: Day[]) => { + const assignmentsMap: AssignmentsMap = {} + const dayNotesMap: DayNotesMap = {} + for (const day of days) { + assignmentsMap[String(day.id)] = day.assignments || [] + dayNotesMap[String(day.id)] = day.notes_items || [] + } + return { assignmentsMap, dayNotesMap } } + const { assignmentsMap, dayNotesMap } = buildMaps(daysData.days) + set({ trip: tripData.trip, days: daysData.days, @@ -118,10 +129,36 @@ export const useTripStore = create((set, get) => ({ dayNotes: dayNotesMap, packingItems: packingData.items, todoItems: todoData.items, - tags: tagsData.tags, - categories: categoriesData.categories, + tags: cachedTags, + categories: cachedCategories, isLoading: false, }) + + // Apply background refreshes — update state when fresh data arrives + Promise.all([ + tripData.refresh, + daysData.refresh, + placesData.refresh, + packingData.refresh, + todoData.refresh, + tagsRefresh, + categoriesRefresh, + ]).then(([freshTrip, freshDays, freshPlaces, freshPacking, freshTodo, freshTags, freshCategories]) => { + const updates: Partial = {} + if (freshTrip) updates.trip = freshTrip.trip + if (freshDays) { + const { assignmentsMap: am, dayNotesMap: dm } = buildMaps(freshDays.days) + updates.days = freshDays.days + updates.assignments = am + updates.dayNotes = dm + } + if (freshPlaces) updates.places = freshPlaces.places + if (freshPacking) updates.packingItems = freshPacking.items + if (freshTodo) updates.todoItems = freshTodo.items + if (freshTags) updates.tags = freshTags.tags + if (freshCategories) updates.categories = freshCategories.categories + if (Object.keys(updates).length > 0) set(updates) + }).catch(() => {}) } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Unknown error' set({ isLoading: false, error: message }) @@ -146,16 +183,18 @@ export const useTripStore = create((set, get) => ({ updateTrip: async (tripId: number | string, data: Partial) => { try { - const result = await tripsApi.update(tripId, data) + const result = await tripRepo.update(tripId, data) set({ trip: result.trip }) - const daysData = await dayRepo.list(tripId) - const assignmentsMap: AssignmentsMap = {} - const dayNotesMap: DayNotesMap = {} - for (const day of daysData.days) { - assignmentsMap[String(day.id)] = day.assignments || [] - dayNotesMap[String(day.id)] = day.notes_items || [] + if (navigator.onLine) { + const daysData = await dayRepo.list(tripId) + const assignmentsMap: AssignmentsMap = {} + const dayNotesMap: DayNotesMap = {} + for (const day of daysData.days) { + assignmentsMap[String(day.id)] = day.assignments || [] + dayNotesMap[String(day.id)] = day.notes_items || [] + } + set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap }) } - set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap }) return result.trip } catch (err: unknown) { throw new Error(getApiErrorMessage(err, 'Error updating trip')) diff --git a/client/src/sw.ts b/client/src/sw.ts new file mode 100644 index 00000000..92fa1a1a --- /dev/null +++ b/client/src/sw.ts @@ -0,0 +1,170 @@ +/// + +import { clientsClaim } from 'workbox-core'; +import { + precacheAndRoute, + cleanupOutdatedCaches, + matchPrecache, +} from 'workbox-precaching'; +import { registerRoute, NavigationRoute } from 'workbox-routing'; +import { NetworkFirst, CacheFirst, NetworkOnly } from 'workbox-strategies'; +import { ExpirationPlugin } from 'workbox-expiration'; +import { CacheableResponsePlugin } from 'workbox-cacheable-response'; +import { + DEFAULT_SW_CONFIG, + readSwConfigFromIDB, + validateSwConfig, + type SwCacheConfig, +} from './sync/swConfig'; + +declare const self: ServiceWorkerGlobalScope; + +self.skipWaiting(); +clientsClaim(); + +// Inject precache manifest (replaced by vite-plugin-pwa at build time) +precacheAndRoute(self.__WB_MANIFEST); +cleanupOutdatedCaches(); + +// ── Static routes (not user-configurable) ───────────────────────────────────── + +// Network-first navigations so reverse-proxy auth redirects (Cloudflare Zero +// Trust, Pangolin, etc.) reach the browser instead of being swallowed by the +// precached app shell. `redirect: 'manual'` produces an opaqueredirect Response +// which, per Fetch spec, the browser follows for navigation requests returned +// from FetchEvent.respondWith. Falls back to precached app shell offline. +registerRoute( + new NavigationRoute( + async ({ request }) => { + try { + return await fetch(request, { redirect: 'manual' }); + } catch { + const cached = await matchPrecache('index.html'); + return cached ?? Response.error(); + } + }, + { denylist: [/^\/api/, /^\/uploads/, /^\/mcp/] }, + ), +); + +registerRoute( + /^https:\/\/unpkg\.com\/.*/i, + new CacheFirst({ + cacheName: 'cdn-libs', + plugins: [ + new ExpirationPlugin({ maxEntries: 30, maxAgeSeconds: 365 * 24 * 60 * 60 }), + new CacheableResponsePlugin({ statuses: [0, 200] }), + ], + }), + 'GET', +); + +registerRoute( + /\/uploads\/(?:covers|avatars)\/.*/i, + new CacheFirst({ + cacheName: 'user-uploads', + plugins: [ + new ExpirationPlugin({ maxEntries: 300, maxAgeSeconds: 7 * 24 * 60 * 60 }), + new CacheableResponsePlugin({ statuses: [200] }), + ], + }), + 'GET', +); + +// ── Configurable routes ──────────────────────────────────────────────────────── +// Routes are registered once. Strategy instances are replaced on config change +// so the stable handler wrapper always delegates to the current instance. + +const DAY = 24 * 60 * 60; + +// Detects when an upstream reverse-proxy auth gate (Cloudflare Zero Trust, +// Pangolin, etc.) redirects a mid-session API call to an external SSO login +// page. Uses redirect:'manual' so the response stays as opaqueredirect instead +// of being silently followed; converts it to a 401 that the Axios interceptor +// in api/client.ts already handles (→ window.location.href = '/login'). +const authRedirectPlugin = { + async requestWillFetch({ request }: { request: Request }): Promise { + return new Request(request, { redirect: 'manual' }); + }, + async fetchDidSucceed({ response }: { response: Response }): Promise { + if (response.type === 'opaqueredirect') { + return new Response(JSON.stringify({ code: 'AUTH_REQUIRED' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + return response; + }, +}; + +function buildApiStrategy(cfg: SwCacheConfig): NetworkFirst { + return new NetworkFirst({ + cacheName: 'api-data', + networkTimeoutSeconds: 2, + plugins: [ + authRedirectPlugin, + new ExpirationPlugin({ + maxEntries: cfg.apiMaxEntries, + maxAgeSeconds: cfg.apiTtlDays * DAY, + }), + new CacheableResponsePlugin({ statuses: [200] }), + ], + }); +} + +function buildTilesStrategy(cfg: SwCacheConfig): CacheFirst { + return new CacheFirst({ + cacheName: 'map-tiles', + plugins: [ + new ExpirationPlugin({ + maxEntries: cfg.tilesMaxEntries, + maxAgeSeconds: cfg.tilesTtlDays * DAY, + }), + new CacheableResponsePlugin({ statuses: [0, 200] }), + ], + }); +} + +let apiStrategy = buildApiStrategy(DEFAULT_SW_CONFIG); +let cartoStrategy = buildTilesStrategy(DEFAULT_SW_CONFIG); +let osmStrategy = buildTilesStrategy(DEFAULT_SW_CONFIG); + +function applyConfig(cfg: SwCacheConfig): void { + apiStrategy = buildApiStrategy(cfg); + cartoStrategy = buildTilesStrategy(cfg); + osmStrategy = buildTilesStrategy(cfg); +} + +// Apply authRedirectPlugin to the public app-config endpoint so a ZT redirect +// surfaces as AUTH_REQUIRED (401) instead of causing a silent JSON parse failure +// on the login page, which would hide the SSO button. +registerRoute( + /\/api\/auth\/app-config$/i, + new NetworkOnly({ plugins: [authRedirectPlugin] }), + 'GET', +); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +registerRoute(/\/api\/(?!auth|admin|backup|settings).*/i, { handle: (o: any) => apiStrategy.handle(o) }, 'GET'); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +registerRoute(/^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i, { handle: (o: any) => cartoStrategy.handle(o) }, 'GET'); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +registerRoute(/^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i, { handle: (o: any) => osmStrategy.handle(o) }, 'GET'); + +// Load persisted config asynchronously; replaces defaults if user has saved settings +readSwConfigFromIDB() + .then(cfg => { if (cfg) applyConfig(cfg); }) + .catch(() => {}); + +// ── Message handler ──────────────────────────────────────────────────────────── + +self.addEventListener('message', (event: ExtendableMessageEvent) => { + const data = event.data as { type?: string; config?: unknown }; + if (data?.type !== 'UPDATE_CACHE_CONFIG' || !data.config) return; + + const validated = validateSwConfig(data.config as Partial); + applyConfig(validated); + + // Acknowledge back to the sending client + (event.source as WindowClient | null)?.postMessage({ type: 'CACHE_CONFIG_APPLIED' }); +}); diff --git a/client/src/sync/mutationQueue.ts b/client/src/sync/mutationQueue.ts index 0b68826c..93dc0f1c 100644 --- a/client/src/sync/mutationQueue.ts +++ b/client/src/sync/mutationQueue.ts @@ -13,12 +13,15 @@ import type { Table } from 'dexie' // Map Dexie table names used in `resource` field → actual Dexie tables. function getTable(resource: string): Table | undefined { const map: Record = { - places: offlineDb.places, - packingItems: offlineDb.packingItems, - todoItems: offlineDb.todoItems, - budgetItems: offlineDb.budgetItems, - reservations: offlineDb.reservations, - tripFiles: offlineDb.tripFiles, + trips: offlineDb.trips, + days: offlineDb.days, + places: offlineDb.places, + packingItems: offlineDb.packingItems, + todoItems: offlineDb.todoItems, + budgetItems: offlineDb.budgetItems, + reservations: offlineDb.reservations, + accommodations: offlineDb.accommodations, + tripFiles: offlineDb.tripFiles, } return map[resource] } @@ -70,12 +73,14 @@ export const mutationQueue = { if (_flushing || !navigator.onLine) return _flushing = true try { - const pending = await offlineDb.mutationQueue - .where('status') - .equals('pending') - .sortBy('createdAt') + while (true) { + const pending = await offlineDb.mutationQueue + .where('status') + .equals('pending') + .sortBy('createdAt') + const mutation = pending[0] + if (!mutation) break - for (const mutation of pending) { // Mark as syncing so UI can show progress await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' }) diff --git a/client/src/sync/swConfig.ts b/client/src/sync/swConfig.ts new file mode 100644 index 00000000..7a1604b2 --- /dev/null +++ b/client/src/sync/swConfig.ts @@ -0,0 +1,80 @@ +/** + * SW cache configuration — shared between the service worker and the main thread. + * Uses a dedicated 'trek-sw-config' IndexedDB database (separate from trek-offline) + * so the SW can read it without needing to know the full trek-offline schema versions. + */ +import Dexie, { type Table } from 'dexie'; + +export interface SwCacheConfig { + apiTtlDays: number; + apiMaxEntries: number; + tilesTtlDays: number; + tilesMaxEntries: number; +} + +export const DEFAULT_SW_CONFIG: SwCacheConfig = { + apiTtlDays: 7, + apiMaxEntries: 500, + tilesTtlDays: 30, + tilesMaxEntries: 1000, +}; + +export const SW_CONFIG_BOUNDS = { + ttlMin: 1, + ttlMax: 365, + entriesMin: 10, + entriesMax: 5000, +}; + +export function validateSwConfig(raw: Partial): SwCacheConfig { + const clamp = (v: unknown, min: number, max: number, def: number): number => { + const n = Number(v); + return Number.isFinite(n) && n > 0 ? Math.max(min, Math.min(max, Math.round(n))) : def; + }; + return { + apiTtlDays: clamp(raw.apiTtlDays, SW_CONFIG_BOUNDS.ttlMin, SW_CONFIG_BOUNDS.ttlMax, DEFAULT_SW_CONFIG.apiTtlDays), + apiMaxEntries: clamp(raw.apiMaxEntries, SW_CONFIG_BOUNDS.entriesMin, SW_CONFIG_BOUNDS.entriesMax, DEFAULT_SW_CONFIG.apiMaxEntries), + tilesTtlDays: clamp(raw.tilesTtlDays, SW_CONFIG_BOUNDS.ttlMin, SW_CONFIG_BOUNDS.ttlMax, DEFAULT_SW_CONFIG.tilesTtlDays), + tilesMaxEntries:clamp(raw.tilesMaxEntries, SW_CONFIG_BOUNDS.entriesMin, SW_CONFIG_BOUNDS.entriesMax, DEFAULT_SW_CONFIG.tilesMaxEntries), + }; +} + +// ── Dedicated IDB for SW config ─────────────────────────────────────────────── + +interface SwConfigRow extends SwCacheConfig { + id: 'singleton'; + updatedAt: number; +} + +class SwConfigDb extends Dexie { + config!: Table; + constructor() { + super('trek-sw-config'); + this.version(1).stores({ config: 'id' }); + } +} + +let _db: SwConfigDb | null = null; + +function getDb(): SwConfigDb { + if (!_db) _db = new SwConfigDb(); + return _db; +} + +export async function readSwConfigFromIDB(): Promise { + try { + const row = await getDb().config.get('singleton'); + return row ? validateSwConfig(row) : null; + } catch { + return null; + } +} + +export async function saveSwConfig(cfg: SwCacheConfig): Promise { + const validated = validateSwConfig(cfg); + await getDb().config.put({ id: 'singleton', ...validated, updatedAt: Date.now() }); +} + +export async function loadSwConfig(): Promise { + return (await readSwConfigFromIDB()) ?? { ...DEFAULT_SW_CONFIG }; +} diff --git a/client/src/sync/syncTriggers.ts b/client/src/sync/syncTriggers.ts index 2c84afe1..7680fcfc 100644 --- a/client/src/sync/syncTriggers.ts +++ b/client/src/sync/syncTriggers.ts @@ -1,19 +1,19 @@ /** * Sync triggers — register event listeners that flush the mutation queue - * and/or run a full trip sync based on the connectivity trigger source. + * based on the connectivity trigger source. * * Trigger matrix: - * window 'online' → flush mutations + full syncAll (network truly back) + * window 'online' → flush mutations (network truly back) * visibilitychange visible → flush mutations only (avoid hammering server on tab switch) * periodic 30s → flush mutations only - * WS reconnect → flush mutations only (no syncAll — avoids rate-limiter - * on server restart / socket timeout while already online) + * WS reconnect → flush mutations only + * + * Full trip sync (syncAll) is manual-only via the Offline settings tab. * * Call `registerSyncTriggers()` once on app mount. * Call `unregisterSyncTriggers()` on unmount / logout. */ import { mutationQueue } from './mutationQueue' -import { tripSyncManager } from './tripSyncManager' import { setPreReconnectHook } from '../api/websocket' const PERIODIC_MS = 30_000 @@ -21,10 +21,9 @@ const PERIODIC_MS = 30_000 let _intervalId: ReturnType | null = null let _registered = false -/** Network came back — flush mutations AND re-seed Dexie for all cacheable trips. */ +/** Network came back — flush any pending mutations. */ function onOnline() { mutationQueue.flush().catch(console.error) - tripSyncManager.syncAll().catch(console.error) } /** Tab became visible — flush only; don't trigger a potentially expensive syncAll. */ diff --git a/client/src/sync/tripSyncManager.ts b/client/src/sync/tripSyncManager.ts index 4129469b..18c441fd 100644 --- a/client/src/sync/tripSyncManager.ts +++ b/client/src/sync/tripSyncManager.ts @@ -5,10 +5,8 @@ * Eviction: trips where end_date < today - 7 days. * File blobs: all non-photo files (MIME type != image/*) for cached trips. * - * Call syncAll() on: - * - login success - * - trip list refresh (DashboardPage) - * - WS reconnect (phase 7) + * syncAll() is manual-only — triggered via Settings → Offline tab. + * No automatic sync on login, dashboard load, or WS reconnect. */ import { tripsApi, tagsApi, categoriesApi } from '../api/client' import { @@ -27,6 +25,8 @@ import { upsertCategories, upsertSyncMeta, clearTripData, + clearBlobCache, + clearAll, } from '../db/offlineDb' import { prefetchTilesForTrip } from './tilePrefetcher' import { useSettingsStore } from '../store/settingsStore' @@ -34,6 +34,11 @@ import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, // ── Types ───────────────────────────────────────────────────────────────────── +export type SyncProgress = + | { phase: 'start'; total: number } + | { phase: 'trip'; tripId: number; index: number; total: number } + | { phase: 'done'; ok: number; failed: number } + interface TripBundle { trip: Trip days: Day[] @@ -69,6 +74,14 @@ function isPhoto(file: TripFile): boolean { return file.mime_type.startsWith('image/') } +function isQuotaError(err: unknown): boolean { + if (!(err instanceof Error)) return false + if (err.name === 'QuotaExceededError') return true + // Dexie wraps IDB errors: AbortError with inner QuotaExceededError + const inner = (err as { inner?: unknown }).inner + return inner instanceof Error && inner.name === 'QuotaExceededError' +} + // ── Core logic ──────────────────────────────────────────────────────────────── /** Fetch bundle + write all entities for one trip into Dexie. */ @@ -125,54 +138,136 @@ async function cacheFilesForTrip(files: TripFile[]): Promise { // ── Public API ──────────────────────────────────────────────────────────────── +const SYNC_TIMEOUT_MS = 90_000 +const SYNC_STALE_MS = 120_000 + let _syncing = false +let _interrupted = false +let _syncStartedAt = 0 export const tripSyncManager = { /** * Sync all cache-eligible trips. * Evicts stale trips. Caches file blobs in the background. - * No-ops when offline. + * No-ops when offline or already syncing (unless stale flag). */ - async syncAll(): Promise { - if (_syncing || !navigator.onLine) return + async syncAll(opts?: { onProgress?: (p: SyncProgress) => void }): Promise { + // Treat a _syncing flag that's been set for >2 minutes as stale (e.g. page unload mid-sync) + if (_syncing && Date.now() - _syncStartedAt < SYNC_STALE_MS) return + if (!navigator.onLine) return _syncing = true + _syncStartedAt = Date.now() + _interrupted = false + + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error('syncAll timeout')), SYNC_TIMEOUT_MS) + ) + try { - const { trips } = await tripsApi.list() as { trips: Trip[] } - - // Evict stale trips first - const stale = trips.filter(isStale) - await Promise.all(stale.map(t => clearTripData(t.id).catch(console.error))) - - // Sync eligible trips - const toSync = trips.filter(shouldCache) - for (const trip of toSync) { - try { - await syncTrip(trip.id) - } catch (err) { - console.error(`[tripSync] failed for trip ${trip.id}:`, err) - } - } - - // Cache global user data (tags + categories) — fire-and-forget - tagsApi.list().then(d => upsertTags(d.tags)).catch(() => {}) - categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {}) - - // Cache file blobs + map tiles in background (don't block syncAll) - const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined - for (const trip of toSync) { - const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray() - cacheFilesForTrip(files).catch(console.error) - - const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray() - prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error) + await Promise.race([this._doSync(opts?.onProgress), timeout]) + } catch (err) { + if (err instanceof Error && err.message === 'syncAll timeout') { + console.warn('[tripSync] syncAll timed out after 90 s — interrupting') + _interrupted = true } } finally { _syncing = false } }, + async _doSync(onProgress?: (p: SyncProgress) => void): Promise { + const { trips } = await tripsApi.list() as { trips: Trip[] } + + // Evict stale trips first + const stale = trips.filter(isStale) + await Promise.all(stale.map(t => clearTripData(t.id).catch(console.error))) + + // Sync eligible trips — stop early if interrupted (e.g. user navigated to a trip page) + const toSync = trips.filter(shouldCache) + onProgress?.({ phase: 'start', total: toSync.length }) + + let ok = 0 + let failed = 0 + + for (let i = 0; i < toSync.length; i++) { + const trip = toSync[i] + if (_interrupted) return + onProgress?.({ phase: 'trip', tripId: trip.id, index: i, total: toSync.length }) + let tripOk = false + try { + await Promise.race([ + syncTrip(trip.id), + new Promise((_, reject) => + setTimeout(() => reject(new Error('syncTrip timeout')), 30_000) + ), + ]) + tripOk = true + } catch (err) { + if (isQuotaError(err)) { + console.warn(`[tripSync] quota exceeded for trip ${trip.id}, clearing trip data and retrying`) + try { + await clearTripData(trip.id) + await syncTrip(trip.id) + tripOk = true + } catch (retryErr) { + if (isQuotaError(retryErr)) { + console.warn('[tripSync] quota still exceeded — clearing blob cache and retrying') + await clearBlobCache() + try { + await syncTrip(trip.id) + tripOk = true + } catch { + console.warn('[tripSync] quota still exceeded after blob eviction — clearing all IDB data') + await clearAll() + onProgress?.({ phase: 'done', ok, failed: failed + 1 }) + return + } + } else { + console.error(`[tripSync] failed for trip ${trip.id} after eviction:`, retryErr) + } + } + } else { + console.error(`[tripSync] failed for trip ${trip.id}:`, err) + } + } + if (tripOk) ok++; else failed++ + } + + if (_interrupted) return + + // Cache global user data (tags + categories) — fire-and-forget + tagsApi.list().then(d => upsertTags(d.tags)).catch(() => {}) + categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {}) + + // Cache file blobs + map tiles for all synced trips in parallel (fire-and-forget) + const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined + const prefetchWork = toSync + .filter(() => !_interrupted) + .map(async trip => { + const [files, places] = await Promise.all([ + offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray(), + offlineDb.places.where('trip_id').equals(trip.id).toArray(), + ]) + cacheFilesForTrip(files).catch(console.error) + prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error) + }) + await Promise.allSettled(prefetchWork) + + onProgress?.({ phase: 'done', ok, failed }) + }, + + /** + * Signal syncAll to stop after the current in-flight bundle request. + * Call when the user navigates to a trip page so loadTrip gets priority. + */ + interrupt(): void { + _interrupted = true + }, + /** Reset syncing flag — useful in tests. */ _resetSyncing(): void { _syncing = false + _interrupted = false + _syncStartedAt = 0 }, } diff --git a/client/src/types.ts b/client/src/types.ts index 8c9c3039..0faf351a 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -16,7 +16,7 @@ export interface User { export interface Trip { id: number - name: string + title: string description: string | null start_date: string end_date: string diff --git a/client/tests/unit/repo/packingRepo.test.ts b/client/tests/unit/repo/packingRepo.test.ts index 4c25ada2..855e0100 100644 --- a/client/tests/unit/repo/packingRepo.test.ts +++ b/client/tests/unit/repo/packingRepo.test.ts @@ -58,46 +58,38 @@ describe('packingRepo.list', () => { expect(restCalled).toBe(false); }); - it('offline — returns empty array when nothing cached', async () => { - Object.defineProperty(navigator, 'onLine', { value: false }); + it('offline — returns empty array when nothing cached and network fails', async () => { + server.use( + http.get('/api/trips/99/packing', () => HttpResponse.error()), + ); const result = await packingRepo.list(99); expect(result.items).toHaveLength(0); }); }); describe('packingRepo.create', () => { - it('calls REST and caches created item in Dexie', async () => { - const item = buildPackingItem({ trip_id: 1, name: 'Sunscreen' }); - server.use( - http.post('/api/trips/1/packing', () => HttpResponse.json({ item })), - ); - + it('writes item optimistically to Dexie immediately', async () => { const result = await packingRepo.create(1, { name: 'Sunscreen' }); expect(result.item.name).toBe('Sunscreen'); + // tempId is negative (-(Date.now())) + expect(result.item.id).toBeLessThan(0); - await new Promise(r => setTimeout(r, 0)); - const cached = await offlineDb.packingItems.get(item.id); - expect(cached).toBeDefined(); - expect(cached!.name).toBe('Sunscreen'); + const cached = await offlineDb.packingItems.where('trip_id').equals(1).toArray(); + expect(cached).toHaveLength(1); + expect(cached[0].name).toBe('Sunscreen'); }); }); describe('packingRepo.update', () => { - it('calls REST and updates Dexie cache', async () => { + it('writes optimistic update to Dexie immediately', async () => { const original = buildPackingItem({ trip_id: 1, name: 'Jacket', checked: 0 }); await offlineDb.packingItems.put(original); - const updated = { ...original, checked: 1 }; - server.use( - http.put(`/api/trips/1/packing/${original.id}`, () => HttpResponse.json({ item: updated })), - ); - const result = await packingRepo.update(1, original.id, { checked: true }); - expect(result.item.checked).toBe(1); + expect(result.item.checked).toBeTruthy(); - await new Promise(r => setTimeout(r, 0)); const cached = await offlineDb.packingItems.get(original.id); - expect(cached!.checked).toBe(1); + expect(cached!.checked).toBeTruthy(); }); }); diff --git a/client/tests/unit/repo/placeRepo.test.ts b/client/tests/unit/repo/placeRepo.test.ts index 45387841..d88f2dfe 100644 --- a/client/tests/unit/repo/placeRepo.test.ts +++ b/client/tests/unit/repo/placeRepo.test.ts @@ -59,27 +59,25 @@ describe('placeRepo.list', () => { expect(restCalled).toBe(false); }); - it('offline — returns empty array when nothing cached', async () => { - Object.defineProperty(navigator, 'onLine', { value: false }); + it('offline — returns empty array when nothing cached and network fails', async () => { + server.use( + http.get('/api/trips/99/places', () => HttpResponse.error()), + ); const result = await placeRepo.list(99); expect(result.places).toHaveLength(0); }); }); describe('placeRepo.create', () => { - it('calls REST and caches created place in Dexie', async () => { - const place = buildPlace({ trip_id: 1, name: 'Eiffel Tower' }); - server.use( - http.post('/api/trips/1/places', () => HttpResponse.json({ place })), - ); - + it('writes place optimistically to Dexie immediately', async () => { const result = await placeRepo.create(1, { name: 'Eiffel Tower' }); expect(result.place.name).toBe('Eiffel Tower'); + // tempId is negative (-(Date.now())) + expect(result.place.id).toBeLessThan(0); - await new Promise(r => setTimeout(r, 0)); - const cached = await offlineDb.places.get(place.id); - expect(cached).toBeDefined(); - expect(cached!.name).toBe('Eiffel Tower'); + const cached = await offlineDb.places.where('trip_id').equals(1).toArray(); + expect(cached).toHaveLength(1); + expect(cached[0].name).toBe('Eiffel Tower'); }); }); diff --git a/client/tests/unit/slices/budgetSlice.test.ts b/client/tests/unit/slices/budgetSlice.test.ts index e847dd86..6a9074f2 100644 --- a/client/tests/unit/slices/budgetSlice.test.ts +++ b/client/tests/unit/slices/budgetSlice.test.ts @@ -2,8 +2,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import { useTripStore } from '../../../src/store/tripStore'; import { resetAllStores, seedStore } from '../../helpers/store'; -import { buildBudgetItem, buildReservation } from '../../helpers/factories'; +import { buildBudgetItem } from '../../helpers/factories'; import { server } from '../../helpers/msw/server'; +import { offlineDb } from '../../../src/db/offlineDb'; vi.mock('../../../src/api/websocket', () => ({ connect: vi.fn(), @@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({ setPreReconnectHook: vi.fn(), })); -beforeEach(() => { +beforeEach(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + await Promise.all(offlineDb.tables.map(t => t.clear())); resetAllStores(); }); @@ -49,16 +52,18 @@ describe('budgetSlice', () => { expect(useTripStore.getState().budgetItems).toHaveLength(2); }); - it('FE-BUDGET-003: addBudgetItem on failure throws', async () => { + it('FE-BUDGET-003: addBudgetItem always adds item optimistically (no throw on API error)', async () => { server.use( http.post('/api/trips/1/budget', () => HttpResponse.json({ message: 'Error' }, { status: 500 }) ), ); - await expect( - useTripStore.getState().addBudgetItem(1, { name: 'Fail' }) - ).rejects.toThrow(); + const result = await useTripStore.getState().addBudgetItem(1, { name: 'Fail' }); + + expect(result.name).toBe('Fail'); + expect(useTripStore.getState().budgetItems).toHaveLength(1); + expect(useTripStore.getState().budgetItems[0].name).toBe('Fail'); }); }); @@ -80,38 +85,26 @@ describe('budgetSlice', () => { expect(useTripStore.getState().budgetItems[0].name).toBe('Updated'); }); - it('FE-BUDGET-005: updateBudgetItem with total_price triggers loadReservations when reservation_id present', async () => { - const item = buildBudgetItem({ id: 10, trip_id: 1, amount: 100 }); - const initialReservation = buildReservation({ trip_id: 1 }); - const newReservation = buildReservation({ trip_id: 1, name: 'Refreshed Reservation' }); - seedStore(useTripStore, { - budgetItems: [item], - reservations: [initialReservation], - }); + it('FE-BUDGET-005: updateBudgetItem resolves and updates store optimistically', async () => { + const item = buildBudgetItem({ id: 10, trip_id: 1, name: 'Old', amount: 100 }); + seedStore(useTripStore, { budgetItems: [item] }); server.use( http.put('/api/trips/1/budget/10', async ({ request }) => { const body = await request.json() as Record; - // Return item with reservation_id to trigger loadReservations return HttpResponse.json({ item: { ...item, ...body, reservation_id: 42 } }); }), - http.get('/api/trips/1/reservations', () => - HttpResponse.json({ reservations: [newReservation] }) - ), ); - await useTripStore.getState().updateBudgetItem(1, 10, { total_price: 200 } as Record); + const result = await useTripStore.getState().updateBudgetItem(1, 10, { amount: 200 } as Record); - // Wait for the async loadReservations to complete - await new Promise(resolve => setTimeout(resolve, 50)); - - expect(useTripStore.getState().reservations).toHaveLength(1); - expect(useTripStore.getState().reservations[0].name).toBe('Refreshed Reservation'); + expect(result.amount).toBe(200); + expect(useTripStore.getState().budgetItems[0].amount).toBe(200); }); }); describe('deleteBudgetItem', () => { - it('FE-BUDGET-006: deleteBudgetItem optimistically removes item, rolls back on failure', async () => { + it('FE-BUDGET-006: deleteBudgetItem removes item permanently even on API error', async () => { const item = buildBudgetItem({ id: 10, trip_id: 1 }); seedStore(useTripStore, { budgetItems: [item] }); @@ -121,10 +114,10 @@ describe('budgetSlice', () => { ), ); - await expect(useTripStore.getState().deleteBudgetItem(1, 10)).rejects.toThrow(); + await useTripStore.getState().deleteBudgetItem(1, 10); - expect(useTripStore.getState().budgetItems).toHaveLength(1); - expect(useTripStore.getState().budgetItems[0].id).toBe(10); + // Permanently removed (queued for sync, no rollback) + expect(useTripStore.getState().budgetItems).toHaveLength(0); }); it('FE-BUDGET-006b: deleteBudgetItem success removes item', async () => { diff --git a/client/tests/unit/slices/filesSlice.test.ts b/client/tests/unit/slices/filesSlice.test.ts index 7f5adc8c..814eceaa 100644 --- a/client/tests/unit/slices/filesSlice.test.ts +++ b/client/tests/unit/slices/filesSlice.test.ts @@ -100,7 +100,7 @@ describe('filesSlice', () => { expect(files[0].id).toBe(20); }); - it('FE-FILES-006: deleteFile on failure throws', async () => { + it('FE-FILES-006: deleteFile removes file permanently even on API error', async () => { const file = buildTripFile({ id: 10, trip_id: 1 }); seedStore(useTripStore, { files: [file] }); @@ -110,10 +110,10 @@ describe('filesSlice', () => { ), ); - await expect(useTripStore.getState().deleteFile(1, 10)).rejects.toThrow(); + await useTripStore.getState().deleteFile(1, 10); - // File remains since server-first (only removes after success) - expect(useTripStore.getState().files).toHaveLength(1); + // Permanently removed (queued for sync, no rollback) + expect(useTripStore.getState().files).toHaveLength(0); }); }); }); diff --git a/client/tests/unit/slices/packingSlice.test.ts b/client/tests/unit/slices/packingSlice.test.ts index 901c0a08..587b8379 100644 --- a/client/tests/unit/slices/packingSlice.test.ts +++ b/client/tests/unit/slices/packingSlice.test.ts @@ -4,6 +4,7 @@ import { useTripStore } from '../../../src/store/tripStore'; import { resetAllStores, seedStore } from '../../helpers/store'; import { buildPackingItem } from '../../helpers/factories'; import { server } from '../../helpers/msw/server'; +import { offlineDb } from '../../../src/db/offlineDb'; vi.mock('../../../src/api/websocket', () => ({ connect: vi.fn(), @@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({ setPreReconnectHook: vi.fn(), })); -beforeEach(() => { +beforeEach(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + await Promise.all(offlineDb.tables.map(t => t.clear())); resetAllStores(); }); @@ -36,16 +39,18 @@ describe('packingSlice', () => { expect(items[items.length - 1].name).toBe('Toothbrush'); }); - it('FE-PACKING-002: addPackingItem on failure throws', async () => { + it('FE-PACKING-002: addPackingItem always adds item optimistically (no throw on API error)', async () => { server.use( http.post('/api/trips/1/packing', () => HttpResponse.json({ message: 'Error' }, { status: 500 }) ), ); - await expect( - useTripStore.getState().addPackingItem(1, { name: 'Fail item' }) - ).rejects.toThrow(); + const result = await useTripStore.getState().addPackingItem(1, { name: 'Fail item' }); + + expect(result.name).toBe('Fail item'); + expect(useTripStore.getState().packingItems).toHaveLength(1); + expect(useTripStore.getState().packingItems[0].name).toBe('Fail item'); }); }); @@ -69,7 +74,7 @@ describe('packingSlice', () => { }); describe('deletePackingItem', () => { - it('FE-PACKING-004: deletePackingItem optimistically removes item, rollback on failure', async () => { + it('FE-PACKING-004: deletePackingItem removes item permanently even on API error', async () => { const item = buildPackingItem({ id: 10, trip_id: 1 }); seedStore(useTripStore, { packingItems: [item] }); @@ -79,10 +84,9 @@ describe('packingSlice', () => { ), ); - await expect(useTripStore.getState().deletePackingItem(1, 10)).rejects.toThrow(); + await useTripStore.getState().deletePackingItem(1, 10); - expect(useTripStore.getState().packingItems).toHaveLength(1); - expect(useTripStore.getState().packingItems[0].id).toBe(10); + expect(useTripStore.getState().packingItems).toHaveLength(0); }); it('FE-PACKING-004b: deletePackingItem success removes item', async () => { @@ -115,7 +119,7 @@ describe('packingSlice', () => { expect(useTripStore.getState().packingItems[0].checked).toBe(1); }); - it('FE-PACKING-006: togglePackingItem rolls back checked on API failure', async () => { + it('FE-PACKING-006: togglePackingItem preserves optimistic checked state even on API failure', async () => { const item = buildPackingItem({ id: 10, trip_id: 1, checked: 0 }); seedStore(useTripStore, { packingItems: [item] }); @@ -125,11 +129,10 @@ describe('packingSlice', () => { ), ); - // toggle does NOT throw on error (silent rollback) await useTripStore.getState().togglePackingItem(1, 10, true); - // Should be rolled back to original value - expect(useTripStore.getState().packingItems[0].checked).toBe(0); + // Optimistic state preserved — no rollback (queued for sync) + expect(useTripStore.getState().packingItems[0].checked).toBe(1); }); }); }); diff --git a/client/tests/unit/slices/placesSlice.test.ts b/client/tests/unit/slices/placesSlice.test.ts index 93a9310e..8a90e363 100644 --- a/client/tests/unit/slices/placesSlice.test.ts +++ b/client/tests/unit/slices/placesSlice.test.ts @@ -4,6 +4,7 @@ import { useTripStore } from '../../../src/store/tripStore'; import { resetAllStores, seedStore } from '../../helpers/store'; import { buildPlace, buildAssignment } from '../../helpers/factories'; import { server } from '../../helpers/msw/server'; +import { offlineDb } from '../../../src/db/offlineDb'; vi.mock('../../../src/api/websocket', () => ({ connect: vi.fn(), @@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({ setPreReconnectHook: vi.fn(), })); -beforeEach(() => { +beforeEach(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + await Promise.all(offlineDb.tables.map(t => t.clear())); resetAllStores(); }); @@ -35,7 +38,7 @@ describe('placesSlice', () => { expect(places[0].name).toBe('New Place'); // prepended }); - it('FE-PLACES-002: addPlace on failure throws and places remain unchanged', async () => { + it('FE-PLACES-002: addPlace always adds place optimistically (no throw on API error)', async () => { const existing = buildPlace({ trip_id: 1 }); seedStore(useTripStore, { places: [existing] }); @@ -45,8 +48,11 @@ describe('placesSlice', () => { ), ); - await expect(useTripStore.getState().addPlace(1, { name: 'Fail' })).rejects.toThrow(); - expect(useTripStore.getState().places).toEqual([existing]); + const result = await useTripStore.getState().addPlace(1, { name: 'Fail' }); + + expect(result.name).toBe('Fail'); + expect(useTripStore.getState().places).toHaveLength(2); + expect(useTripStore.getState().places[0].name).toBe('Fail'); }); }); diff --git a/client/tests/unit/slices/reservationsSlice.test.ts b/client/tests/unit/slices/reservationsSlice.test.ts index b0b5e134..021b8e21 100644 --- a/client/tests/unit/slices/reservationsSlice.test.ts +++ b/client/tests/unit/slices/reservationsSlice.test.ts @@ -4,6 +4,7 @@ import { useTripStore } from '../../../src/store/tripStore'; import { resetAllStores, seedStore } from '../../helpers/store'; import { buildReservation } from '../../helpers/factories'; import { server } from '../../helpers/msw/server'; +import { offlineDb } from '../../../src/db/offlineDb'; vi.mock('../../../src/api/websocket', () => ({ connect: vi.fn(), @@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({ setPreReconnectHook: vi.fn(), })); -beforeEach(() => { +beforeEach(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + await Promise.all(offlineDb.tables.map(t => t.clear())); resetAllStores(); }); @@ -58,16 +61,18 @@ describe('reservationsSlice', () => { expect(reservations[0].name).toBe('New Hotel'); }); - it('FE-RESERV-003: addReservation on failure throws', async () => { + it('FE-RESERV-003: addReservation always adds optimistically (no throw on API error)', async () => { server.use( http.post('/api/trips/1/reservations', () => HttpResponse.json({ message: 'Error' }, { status: 500 }) ), ); - await expect( - useTripStore.getState().addReservation(1, { name: 'Fail' }) - ).rejects.toThrow(); + const result = await useTripStore.getState().addReservation(1, { name: 'Fail' }); + + expect(result.name).toBe('Fail'); + expect(useTripStore.getState().reservations).toHaveLength(1); + expect(useTripStore.getState().reservations[0].name).toBe('Fail'); }); }); @@ -123,7 +128,7 @@ describe('reservationsSlice', () => { expect(useTripStore.getState().reservations[0].status).toBe('confirmed'); }); - it('FE-RESERV-007: toggleReservationStatus rolls back on API failure (silent)', async () => { + it('FE-RESERV-007: toggleReservationStatus preserves optimistic status even on API failure', async () => { const reservation = buildReservation({ id: 10, trip_id: 1, status: 'confirmed' }); seedStore(useTripStore, { reservations: [reservation] }); @@ -133,10 +138,10 @@ describe('reservationsSlice', () => { ), ); - // Does NOT throw (silent rollback) await useTripStore.getState().toggleReservationStatus(1, 10); - expect(useTripStore.getState().reservations[0].status).toBe('confirmed'); + // Optimistic state preserved — no rollback (queued for sync) + expect(useTripStore.getState().reservations[0].status).toBe('pending'); }); it('FE-RESERV-008: toggleReservationStatus does nothing if reservation not found', async () => { @@ -162,7 +167,7 @@ describe('reservationsSlice', () => { expect(reservations[0].id).toBe(20); }); - it('FE-RESERV-010: deleteReservation on failure throws (no optimistic, server-first)', async () => { + it('FE-RESERV-010: deleteReservation removes permanently even on API error', async () => { const reservation = buildReservation({ id: 10, trip_id: 1 }); seedStore(useTripStore, { reservations: [reservation] }); @@ -172,10 +177,10 @@ describe('reservationsSlice', () => { ), ); - await expect(useTripStore.getState().deleteReservation(1, 10)).rejects.toThrow(); + await useTripStore.getState().deleteReservation(1, 10); - // Still in state since server-first (only removes after success) - expect(useTripStore.getState().reservations).toHaveLength(1); + // Permanently removed (queued for sync, no rollback) + expect(useTripStore.getState().reservations).toHaveLength(0); }); }); }); diff --git a/client/tests/unit/slices/todoSlice.test.ts b/client/tests/unit/slices/todoSlice.test.ts index 123426bc..257f02cf 100644 --- a/client/tests/unit/slices/todoSlice.test.ts +++ b/client/tests/unit/slices/todoSlice.test.ts @@ -4,6 +4,7 @@ import { useTripStore } from '../../../src/store/tripStore'; import { resetAllStores, seedStore } from '../../helpers/store'; import { buildTodoItem } from '../../helpers/factories'; import { server } from '../../helpers/msw/server'; +import { offlineDb } from '../../../src/db/offlineDb'; vi.mock('../../../src/api/websocket', () => ({ connect: vi.fn(), @@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({ setPreReconnectHook: vi.fn(), })); -beforeEach(() => { +beforeEach(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + await Promise.all(offlineDb.tables.map(t => t.clear())); resetAllStores(); }); @@ -34,16 +37,18 @@ describe('todoSlice', () => { expect(items).toHaveLength(2); }); - it('FE-TODO-002: addTodoItem on failure throws', async () => { + it('FE-TODO-002: addTodoItem always adds item optimistically (no throw on API error)', async () => { server.use( http.post('/api/trips/1/todo', () => HttpResponse.json({ message: 'Error' }, { status: 500 }) ), ); - await expect( - useTripStore.getState().addTodoItem(1, { name: 'Fail' }) - ).rejects.toThrow(); + const result = await useTripStore.getState().addTodoItem(1, { name: 'Fail' }); + + expect(result.name).toBe('Fail'); + expect(useTripStore.getState().todoItems).toHaveLength(1); + expect(useTripStore.getState().todoItems[0].name).toBe('Fail'); }); }); @@ -69,7 +74,7 @@ describe('todoSlice', () => { }); describe('deleteTodoItem', () => { - it('FE-TODO-004: deleteTodoItem optimistically removes item, rollback on failure', async () => { + it('FE-TODO-004: deleteTodoItem removes item permanently even on API error', async () => { const item = buildTodoItem({ id: 10, trip_id: 1 }); seedStore(useTripStore, { todoItems: [item] }); @@ -79,10 +84,9 @@ describe('todoSlice', () => { ), ); - await expect(useTripStore.getState().deleteTodoItem(1, 10)).rejects.toThrow(); + await useTripStore.getState().deleteTodoItem(1, 10); - expect(useTripStore.getState().todoItems).toHaveLength(1); - expect(useTripStore.getState().todoItems[0].id).toBe(10); + expect(useTripStore.getState().todoItems).toHaveLength(0); }); it('FE-TODO-004b: deleteTodoItem success removes item from array', async () => { @@ -115,7 +119,7 @@ describe('todoSlice', () => { expect(useTripStore.getState().todoItems[0].checked).toBe(1); }); - it('FE-TODO-006: toggleTodoItem rolls back checked on API failure (silent)', async () => { + it('FE-TODO-006: toggleTodoItem preserves optimistic checked state even on API failure', async () => { const item = buildTodoItem({ id: 10, trip_id: 1, checked: 0 }); seedStore(useTripStore, { todoItems: [item] }); @@ -125,10 +129,10 @@ describe('todoSlice', () => { ), ); - // Does NOT throw await useTripStore.getState().toggleTodoItem(1, 10, true); - expect(useTripStore.getState().todoItems[0].checked).toBe(0); + // Optimistic state preserved — no rollback (queued for sync) + expect(useTripStore.getState().todoItems[0].checked).toBe(1); }); it('FE-TODO-007: toggleTodoItem preserves sort_order field', async () => { diff --git a/client/tests/unit/tripStore.test.ts b/client/tests/unit/tripStore.test.ts index 8d35c9eb..0aefe291 100644 --- a/client/tests/unit/tripStore.test.ts +++ b/client/tests/unit/tripStore.test.ts @@ -4,6 +4,7 @@ import { useTripStore } from '../../src/store/tripStore'; import { resetAllStores } from '../helpers/store'; import { buildTrip, buildDay, buildPlace, buildPackingItem, buildTodoItem, buildTag, buildCategory, buildAssignment, buildDayNote } from '../helpers/factories'; import { server } from '../helpers/msw/server'; +import { offlineDb } from '../../src/db/offlineDb'; vi.mock('../../src/api/websocket', () => ({ connect: vi.fn(), @@ -17,7 +18,11 @@ vi.mock('../../src/api/websocket', () => ({ setPreReconnectHook: vi.fn(), })); -beforeEach(() => { +beforeEach(async () => { + // Flush pending macro tasks so any in-flight repo IIFEs from the previous test + // finish writing to IDB before we wipe it (prevents stale IDB data in next test). + await new Promise(resolve => setTimeout(resolve, 0)); + await Promise.all(offlineDb.tables.map(t => t.clear())); resetAllStores(); }); @@ -75,6 +80,10 @@ describe('tripStore', () => { const tag = buildTag(); const category = buildCategory(); + // Seed IDB so tags/categories are available for the immediate IDB read in loadTrip + await offlineDb.tags.put(tag); + await offlineDb.categories.put(category); + server.use( http.get('/api/trips/1', () => HttpResponse.json({ trip })), http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })), @@ -210,8 +219,8 @@ describe('tripStore', () => { const result = await useTripStore.getState().updateTrip(1, { name: 'Updated Trip' }); - expect(result).toEqual(updatedTrip); - expect(useTripStore.getState().trip).toEqual(updatedTrip); + expect(result.name).toBe('Updated Trip'); + expect(useTripStore.getState().trip?.name).toBe('Updated Trip'); }); }); diff --git a/client/vite.config.js b/client/vite.config.js index 8a5334b6..bca18320 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -7,65 +7,12 @@ export default defineConfig({ react(), VitePWA({ registerType: 'autoUpdate', - workbox: { - maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, + strategies: 'injectManifest', + srcDir: 'src', + filename: 'sw.ts', + injectManifest: { globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'], - navigateFallback: 'index.html', - navigateFallbackDenylist: [/^\/api/, /^\/uploads/, /^\/mcp/], - runtimeCaching: [ - { - // Carto map tiles (default provider) - urlPattern: /^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i, - handler: 'CacheFirst', - options: { - cacheName: 'map-tiles', - expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 }, - cacheableResponse: { statuses: [0, 200] }, - }, - }, - { - // OpenStreetMap tiles (fallback / alternative) - urlPattern: /^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i, - handler: 'CacheFirst', - options: { - cacheName: 'map-tiles', - expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 }, - cacheableResponse: { statuses: [0, 200] }, - }, - }, - { - // Leaflet CSS/JS from unpkg CDN - urlPattern: /^https:\/\/unpkg\.com\/.*/i, - handler: 'CacheFirst', - options: { - cacheName: 'cdn-libs', - expiration: { maxEntries: 30, maxAgeSeconds: 365 * 24 * 60 * 60 }, - cacheableResponse: { statuses: [0, 200] }, - }, - }, - { - // API calls — prefer network, fall back to cache - // Exclude sensitive endpoints (auth, admin, backup, settings) - urlPattern: /\/api\/(?!auth|admin|backup|settings).*/i, - handler: 'NetworkFirst', - options: { - cacheName: 'api-data', - expiration: { maxEntries: 200, maxAgeSeconds: 24 * 60 * 60 }, - networkTimeoutSeconds: 5, - cacheableResponse: { statuses: [200] }, - }, - }, - { - // Uploaded files (photos, covers — public assets only) - urlPattern: /\/uploads\/(?:covers|avatars)\/.*/i, - handler: 'CacheFirst', - options: { - cacheName: 'user-uploads', - expiration: { maxEntries: 300, maxAgeSeconds: 7 * 24 * 60 * 60 }, - cacheableResponse: { statuses: [200] }, - }, - }, - ], + maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, }, manifest: { name: 'TREK \u2014 Travel Planner', @@ -110,7 +57,30 @@ export default defineConfig({ '/mcp': { target: 'http://localhost:3001', changeOrigin: true, - } + }, + // OAuth 2.1 endpoints handled by backend (SDK authorize handler + token/revoke) + // /oauth/authorize goes to backend so the SDK can redirect to /oauth/consent + // /oauth/consent is served by Vite as a SPA route (no proxy entry needed) + '/oauth/authorize': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + '/oauth/token': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + '/oauth/register': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + '/oauth/revoke': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + '/.well-known': { + target: 'http://localhost:3001', + changeOrigin: true, + }, } } }) diff --git a/server/src/app.ts b/server/src/app.ts index cc83b645..38e70c36 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -43,11 +43,18 @@ import journeyPublicRoutes from './routes/journeyPublic'; import publicConfigRoutes from './routes/publicConfig'; import systemNoticesRoutes from './routes/systemNotices'; import { mcpHandler } from './mcp'; +import { trekOAuthProvider, trekClientsStore } from './mcp/oauthProvider'; import { Addon } from './types'; import { getPhotoProviderConfig } from './services/memories/helpersService'; import { getCollabFeatures } from './services/adminService'; import { isAddonEnabled } from './services/adminService'; import { ADDON_IDS } from './addons'; +import { ALL_SCOPES } from './mcp/scopes'; +import { getAppUrl } from './services/oidcService'; +import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router'; +import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize'; +import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register'; +import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth'; export function createApp(): express.Application { const app = express(); @@ -88,10 +95,27 @@ export function createApp(): express.Application { const hstsActive = shouldForceHttps || process.env.NODE_ENV === 'production'; const hstsIncludeSubdomains = process.env.HSTS_INCLUDE_SUBDOMAINS === 'true'; - // RFC 8414 / RFC 9728: discovery docs are world-readable — open CORS regardless of deployment config + // RFC 8414 / RFC 9728 / RFC 7591: discovery docs and DCR are world-readable/writable. + // /mcp needs open CORS so external MCP clients (ChatGPT, Claude.ai, Inspector) can call it + // with Bearer tokens from any origin. /oauth/register and /oauth/authorize need it for + // browser-based DCR/authorization preflights — the global cors({ origin: false }) would + // answer OPTIONS without Access-Control-Allow-Origin before the SDK's own cors() runs. + // All /.well-known/* paths get open CORS so clients probing openid-configuration or the + // RFC 8414 path-suffixed AS metadata form don't get CORS-blocked (they get 404 JSON instead). app.use( - ['/.well-known/oauth-authorization-server', '/.well-known/oauth-protected-resource'], - cors({ origin: '*', credentials: false }), + (req: Request, _res: Response, next: NextFunction) => { + if ( + req.path.startsWith('/.well-known/') || + req.path === '/oauth/register' || + req.path === '/oauth/authorize' || + req.path === '/oauth/userinfo' || + req.path === '/mcp' + ) { + cors({ origin: '*', credentials: false })(req, _res, next); + } else { + next(); + } + }, ); app.use(cors({ origin: corsOrigin, credentials: true })); app.use(helmet({ @@ -340,16 +364,103 @@ export function createApp(): express.Application { app.use('/api/notifications', notificationRoutes); app.use('/api', shareRoutes); - // OAuth 2.1 — public endpoints (/.well-known, /oauth/token, /oauth/revoke) - app.use('/', oauthPublicRouter); + // OAuth 2.1 — public endpoints + // Gate: 404 when MCP addon is disabled (M2 — prevents feature fingerprinting) + const mcpAddonGate = (_req: Request, res: Response, next: NextFunction) => { + if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); + next(); + }; + // OAuth 2.1 — SPA-facing authenticated endpoints (/api/oauth/*) + // Mounted first: per-route 403 checks inside oauthApiRouter are the gate, not mcpAddonGate app.use('/api/oauth', oauthApiRouter); + // SDK metadata router — built lazily on first request so getAppUrl() (which queries the DB) + // is not called at createApp() time, before test tables have been created. + // mcpAuthMetadataRouter serves: + // /.well-known/oauth-authorization-server — RFC 8414 AS metadata + // /.well-known/oauth-protected-resource/mcp — RFC 9728 path-based PRM (fixes issue #959 bug 1) + let _oauthMetadata: OAuthMetadata | null = null; + let _sdkMetaRouter: express.Router | null = null; + + function getOAuthMetadata(): OAuthMetadata { + if (_oauthMetadata) return _oauthMetadata; + const base = (getAppUrl() || 'http://localhost:3001').replace(/\/+$/, ''); + _oauthMetadata = { + issuer: base, + authorization_endpoint: `${base}/oauth/authorize`, + token_endpoint: `${base}/oauth/token`, + revocation_endpoint: `${base}/oauth/revoke`, + registration_endpoint: `${base}/oauth/register`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['client_secret_post', 'none'], + scopes_supported: ALL_SCOPES, + }; + return _oauthMetadata; + } + + function getMetaRouter(): express.Router { + if (_sdkMetaRouter) return _sdkMetaRouter; + const metadata = getOAuthMetadata(); + _sdkMetaRouter = mcpAuthMetadataRouter({ + oauthMetadata: metadata, + resourceServerUrl: new URL(`${metadata.issuer}/mcp`), + scopesSupported: ALL_SCOPES as string[], + resourceName: 'TREK MCP', + }); + return _sdkMetaRouter; + } + + // Path-aware gate: only /.well-known/* returns 404 when disabled; other paths pass through + // so static files and SPA routes are unaffected when MCP is off. + app.use((req: Request, res: Response, next: NextFunction) => { + const isMetadataPath = + req.path === '/.well-known/oauth-authorization-server' || + req.path === '/.well-known/openid-configuration' || + req.path.startsWith('/.well-known/oauth-protected-resource'); + if (isMetadataPath && !isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); + getMetaRouter()(req, res, next); + }); + + // ChatGPT (and other OIDC-first clients) bootstrap OAuth discovery via + // /.well-known/openid-configuration. Serve the AS metadata plus the OIDC + // userinfo_endpoint so ChatGPT can fetch the authenticated user's email + // for authorization domain claiming. + app.get('/.well-known/openid-configuration', (_req: Request, res: Response) => { + const meta = getOAuthMetadata(); + res.json({ + ...meta, + userinfo_endpoint: `${meta.issuer}/oauth/userinfo`, + }); + }); + + // SDK authorize handler: validates OAuth params, calls provider.authorize() which redirects + // to the SPA consent page at /oauth/consent + app.use('/oauth/authorize', mcpAddonGate, authorizationHandler({ provider: trekOAuthProvider })); + + // SDK DCR handler: accepts registrations without scope (fixes issue #959 bug 2) + app.use('/oauth/register', mcpAddonGate, clientRegistrationHandler({ clientsStore: trekClientsStore })); + + // Token and revoke keep TREK's own handlers (timing-safe hash comparison not supported by SDK clientAuth) + // oauthPublicRouter has per-route isAddonEnabled checks; no blanket gate needed here + app.use('/', oauthPublicRouter); + // MCP endpoint app.post('/mcp', mcpHandler); app.get('/mcp', mcpHandler); app.delete('/mcp', mcpHandler); + // Return 404 JSON for any /.well-known/* path the SDK metadata router doesn't handle. + // Without this, the SPA catch-all serves HTML — clients probing + // /.well-known/openid-configuration or the RFC 8414 path-suffixed AS metadata URL + // receive a 200 HTML response they can't parse as JSON, causing "does not implement OAuth". + app.use((req: Request, res: Response, next: NextFunction) => { + if (req.path.startsWith('/.well-known/')) return res.status(404).json({ error: 'not_found' }); + next(); + }); + // Production static file serving if (process.env.NODE_ENV === 'production') { const publicPath = path.join(__dirname, '../public'); diff --git a/server/src/mcp/index.ts b/server/src/mcp/index.ts index e46c03db..2984811a 100644 --- a/server/src/mcp/index.ts +++ b/server/src/mcp/index.ts @@ -154,8 +154,9 @@ sessionSweepInterval.unref(); function setAuthChallenge(res: Response, error = 'invalid_token'): void { const base = (getAppUrl() || '').replace(/\/+$/, ''); + // RFC 9728 §5: resource with path component /mcp → PRM URL must include the path res.set('WWW-Authenticate', - `Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource", error="${error}"`); + `Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource/mcp", error="${error}"`); } interface VerifyTokenResult { diff --git a/server/src/mcp/oauthProvider.ts b/server/src/mcp/oauthProvider.ts new file mode 100644 index 00000000..08266c25 --- /dev/null +++ b/server/src/mcp/oauthProvider.ts @@ -0,0 +1,220 @@ +import type { Response } from 'express'; +import type { OAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/provider'; +import type { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth'; +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types'; +import type { AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider'; +import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients'; +import { InvalidClientMetadataError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors'; +import { db } from '../db/database'; +import { + createOAuthClient, + consumeAuthCode, + issueTokens, + refreshTokens, + revokeToken as serviceRevokeToken, + verifyPKCE, + getUserByAccessToken, +} from '../services/oauthService'; +import { ALL_SCOPES } from './scopes'; +import { getAppUrl } from '../services/oidcService'; +import { writeAudit } from '../services/auditLog'; + +// --------------------------------------------------------------------------- +// DB row type (mirrors oauthService.ts) +// --------------------------------------------------------------------------- + +interface OAuthClientRow { + client_id: string; + name: string; + redirect_uris: string; // JSON array + allowed_scopes: string; // JSON array + is_public: number; // 0 | 1 + created_via: string; +} + +// --------------------------------------------------------------------------- +// Redirect URI validation (mirrors oauth.ts DCR checks) +// --------------------------------------------------------------------------- + +const DANGEROUS_SCHEMES = new Set([ + 'javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:', 'chrome:', 'chrome-extension:', +]); + +function assertValidRedirectUris(uris: string[]): void { + for (const u of uris) { + let url: URL; + try { url = new URL(u); } catch { + throw new InvalidClientMetadataError(`Invalid redirect URI: ${u}`); + } + if (DANGEROUS_SCHEMES.has(url.protocol)) + throw new InvalidClientMetadataError(`Dangerous redirect URI scheme: ${u}`); + if (url.protocol === 'https:') continue; + if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')) continue; + const scheme = url.protocol.slice(0, -1); + if (/^[a-z][a-z0-9+.-]*$/i.test(scheme) && scheme.includes('.')) continue; + throw new InvalidClientMetadataError('redirect_uris must be HTTPS, loopback HTTP, or a private custom scheme'); + } +} + +// --------------------------------------------------------------------------- +// Row → SDK client info shape +// --------------------------------------------------------------------------- + +function rowToInfo(row: OAuthClientRow): OAuthClientInformationFull { + return { + client_id: row.client_id, + client_name: row.name, + redirect_uris: JSON.parse(row.redirect_uris) as string[], + scope: (JSON.parse(row.allowed_scopes) as string[]).join(' '), + token_endpoint_auth_method: row.is_public ? 'none' : 'client_secret_post', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + }; +} + +// --------------------------------------------------------------------------- +// Clients store +// --------------------------------------------------------------------------- + +export const trekClientsStore: OAuthRegisteredClientsStore = { + async getClient(clientId: string): Promise { + const row = db.prepare( + 'SELECT client_id, name, redirect_uris, allowed_scopes, is_public, created_via FROM oauth_clients WHERE client_id = ?' + ).get(clientId) as OAuthClientRow | undefined; + return row ? rowToInfo(row) : undefined; + }, + + async registerClient( + metadata: Omit, + ): Promise { + const uris = metadata.redirect_uris as string[]; + assertValidRedirectUris(uris); + + const isPublic = metadata.token_endpoint_auth_method === 'none'; + const name = (typeof metadata.client_name === 'string' ? metadata.client_name.trim() : '').slice(0, 100) || 'MCP Client'; + + // When scope is absent (ChatGPT DCR), default to all scopes. + // The user still grants only what they approve at the consent screen. + const rawScopes = metadata.scope ? metadata.scope.split(' ') : ALL_SCOPES; + const scopes = rawScopes.filter(s => (ALL_SCOPES as string[]).includes(s)); + if (scopes.length === 0) throw new InvalidClientMetadataError('No valid scopes requested'); + + const result = createOAuthClient(null, name, uris, scopes, null, { isPublic, createdVia: 'dcr' }); + if (result.error) throw new InvalidClientMetadataError(result.error); + + const c = result.client!; + return { + client_id: c.client_id as string, + client_name: c.name as string, + redirect_uris: c.redirect_uris as string[], + scope: (c.allowed_scopes as string[]).join(' '), + token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_post', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + ...(c.client_secret ? { client_secret: c.client_secret as string, client_secret_expires_at: 0 } : {}), + }; + }, +}; + +// --------------------------------------------------------------------------- +// OAuthServerProvider +// --------------------------------------------------------------------------- + +export const trekOAuthProvider: OAuthServerProvider = { + get clientsStore() { return trekClientsStore; }, + + // Redirects browser to the SPA consent page with OAuth params forwarded. + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`; + const resource = params.resource ? params.resource.href.replace(/\/+$/, '') : mcpResource; + + if (resource !== mcpResource) { + const url = new URL(params.redirectUri); + url.searchParams.set('error', 'invalid_target'); + url.searchParams.set('error_description', 'Requested resource must be the TREK MCP endpoint'); + if (params.state) url.searchParams.set('state', params.state); + res.redirect(302, url.toString()); + return; + } + + const qs = new URLSearchParams({ + client_id: client.client_id, + redirect_uri: params.redirectUri, + scope: params.scopes.join(' '), + code_challenge: params.codeChallenge, + code_challenge_method: 'S256', + }); + if (params.state) qs.set('state', params.state); + if (params.resource) qs.set('resource', params.resource.href); + + res.redirect(302, `/oauth/consent?${qs.toString()}`); + }, + + // Not called because skipLocalPkceValidation = true. + // PKCE verification is done inline in exchangeAuthorizationCode. + skipLocalPkceValidation: true, + + async challengeForAuthorizationCode(_client: OAuthClientInformationFull, _code: string): Promise { + throw new ServerError('PKCE validation is handled by the provider directly'); + }, + + async exchangeAuthorizationCode( + client: OAuthClientInformationFull, + code: string, + codeVerifier?: string, + redirectUri?: string, + resource?: URL, + ): Promise { + const pending = consumeAuthCode(code); + if (!pending || pending.clientId !== client.client_id) + throw new Error('Authorization grant is invalid.'); + + if (redirectUri && pending.redirectUri !== redirectUri) + throw new Error('Authorization grant is invalid.'); + + const resourceStr = resource ? resource.href.replace(/\/+$/, '') : null; + if (pending.resource && resourceStr && pending.resource !== resourceStr) + throw new Error('Authorization grant is invalid.'); + + if (codeVerifier && !verifyPKCE(codeVerifier, pending.codeChallenge)) + throw new Error('Authorization grant is invalid.'); + + const tokens = issueTokens(client.client_id, pending.userId, pending.scopes, null, pending.resource ?? null); + writeAudit({ + userId: pending.userId, + action: 'oauth.token.issue', + details: { client_id: client.client_id, scopes: pending.scopes, audience: pending.resource ?? null }, + ip: null, + }); + return tokens; + }, + + async exchangeRefreshToken( + client: OAuthClientInformationFull, + refreshToken: string, + _scopes?: string[], + _resource?: URL, + ): Promise { + const result = refreshTokens(refreshToken, client.client_id, client.client_secret, null); + if (result.error) throw new Error(result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired'); + return result.tokens!; + }, + + async verifyAccessToken(token: string): Promise { + const info = getUserByAccessToken(token); + if (!info) throw new Error('Invalid or expired token'); + return { + token, + clientId: info.clientId, + scopes: info.scopes, + extra: { user: info.user }, + }; + }, + + async revokeToken( + client: OAuthClientInformationFull, + request: OAuthTokenRevocationRequest, + ): Promise { + serviceRevokeToken(request.token, client.client_id, undefined, null); + }, +}; diff --git a/server/src/routes/oauth.ts b/server/src/routes/oauth.ts index 8d890faf..91558ff0 100644 --- a/server/src/routes/oauth.ts +++ b/server/src/routes/oauth.ts @@ -2,7 +2,7 @@ import express, { Request, Response } from 'express'; import { authenticate, requireCookieAuth, optionalAuth } from '../middleware/auth'; import { AuthRequest, OptionalAuthRequest } from '../types'; import { isAddonEnabled } from '../services/adminService'; -import { ALL_SCOPES, SCOPE_INFO } from '../mcp/scopes'; +import { ALL_SCOPES } from '../mcp/scopes'; import { ADDON_IDS } from '../addons'; import { validateAuthorizeRequest, @@ -14,16 +14,15 @@ import { revokeToken, verifyPKCE, authenticateClient, - isValidRedirectUri, listOAuthClients, createOAuthClient, deleteOAuthClient, rotateOAuthClientSecret, listOAuthSessions, revokeSession, + getUserByAccessToken, AuthorizeParams, } from '../services/oauthService'; -import { getAppUrl } from '../services/oidcService'; import { writeAudit, getClientIp, logWarn } from '../services/auditLog'; // --------------------------------------------------------------------------- @@ -59,53 +58,18 @@ function makeRateLimiter(maxAttempts: number, windowMs: number, keyFn: (req: Req const tokenLimiter = makeRateLimiter(30, 60_000, (req) => `${req.ip}|${req.body?.client_id ?? ''}`); const validateLimiter = makeRateLimiter(30, 60_000, (req) => req.ip ?? 'unknown'); const revokeLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown'); -const dcrLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown'); // --------------------------------------------------------------------------- -// Public router: /.well-known, /oauth/token, /oauth/revoke +// Public router: /oauth/token and /oauth/revoke +// (/.well-known and /oauth/register are now handled by SDK in app.ts) // --------------------------------------------------------------------------- export const oauthPublicRouter = express.Router(); -// RFC 8414 discovery document -oauthPublicRouter.get('/.well-known/oauth-authorization-server', (req: Request, res: Response) => { - // M2: return 404 (not 403) so feature presence isn't fingerprinted - if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); - - const base = (getAppUrl() || '').replace(/\/+$/, ''); - res.json({ - issuer: base, - authorization_endpoint: `${base}/oauth/authorize`, - token_endpoint: `${base}/oauth/token`, - revocation_endpoint: `${base}/oauth/revoke`, - registration_endpoint: `${base}/oauth/register`, - response_types_supported: ['code'], - grant_types_supported: ['authorization_code', 'refresh_token'], - code_challenge_methods_supported: ['S256'], - token_endpoint_auth_methods_supported: ['client_secret_post', 'none'], - scopes_supported: ALL_SCOPES, - scope_descriptions: Object.fromEntries( - ALL_SCOPES.map(s => [s, SCOPE_INFO[s].label]) - ), - resource_parameter_supported: true, - }); -}); - -// RFC 9728 Protected Resource Metadata -oauthPublicRouter.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => { - if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); - const base = (getAppUrl() || '').replace(/\/+$/, ''); - res.json({ - resource: `${base}/mcp`, - authorization_servers: [base], - bearer_methods_supported: ['header'], - scopes_supported: ALL_SCOPES, - resource_name: 'TREK MCP', - }); -}); - // Token endpoint — handles authorization_code and refresh_token grants oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Response) => { + if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); + // M1: RFC 6749 §5.1 — token responses must not be cached res.set('Cache-Control', 'no-store'); res.set('Pragma', 'no-cache'); @@ -115,10 +79,6 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token, resource } = body; const ip = getClientIp(req); - if (!isAddonEnabled(ADDON_IDS.MCP)) { - return res.status(403).json({ error: 'mcp_disabled', error_description: 'MCP is not enabled' }); - } - if (!client_id) { return res.status(401).json({ error: 'invalid_client', error_description: 'client_id is required' }); } @@ -194,96 +154,32 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` }); }); -// RFC 7591 Dynamic Client Registration endpoint -oauthPublicRouter.post('/oauth/register', dcrLimiter, (req: Request, res: Response) => { +// OIDC UserInfo endpoint (RFC 9068 / OpenID Connect Core §5.3) +// ChatGPT hits this after OAuth to fetch the authenticated user's email for domain claiming. +oauthPublicRouter.get('/oauth/userinfo', (req: Request, res: Response) => { if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); - - const body: Record = typeof req.body === 'object' && req.body !== null ? req.body : {}; - const ip = getClientIp(req); - - const redirectUris: string[] = Array.isArray(body.redirect_uris) ? body.redirect_uris.filter((u): u is string => typeof u === 'string') : []; - if (redirectUris.length === 0) { - return res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris is required and must be a non-empty array' }); + const auth = req.headers['authorization']; + if (!auth || !auth.toLowerCase().startsWith('bearer ')) { + res.set('WWW-Authenticate', 'Bearer realm="TREK MCP"'); + return res.status(401).json({ error: 'invalid_token' }); } - // OAuth 2.1 + RFC 8252: confidential web apps need HTTPS; public - // clients (MCP, native) are limited to loopback or a reverse-DNS - // private-use scheme. This rejects `http://evil.example` DCR payloads - // that today would otherwise be accepted since we previously only - // checked shape. Dangerous URL schemes (`javascript:`, `data:` etc.) - // are explicitly rejected — the authorize flow later 302s the - // browser to this URI, which with `javascript:` would execute - // attacker-controlled script under our redirect origin's context. - const DANGEROUS_SCHEMES = new Set([ - 'javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:', 'chrome:', 'chrome-extension:', - ]); - const allowed = redirectUris.every((u) => { - try { - const url = new URL(u); - if (DANGEROUS_SCHEMES.has(url.protocol)) return false; - if (url.protocol === 'https:') return true; - if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')) return true; - // RFC 8252 §7.1 private-use scheme: must be a reverse-DNS name - // (e.g. `com.example.myapp:/callback`). Requiring a dot in the - // scheme is a cheap heuristic that rules out bare `myapp:` and - // `x:` one-off schemes the spec explicitly discourages. - const schemeBody = url.protocol.slice(0, -1); - if (/^[a-z][a-z0-9+.-]*$/i.test(schemeBody) && schemeBody.includes('.')) return true; - return false; - } catch { - return false; - } - }); - if (!allowed) { - return res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris must be HTTPS, loopback HTTP, or a private custom scheme' }); + const token = auth.slice(7); + const info = getUserByAccessToken(token); + if (!info) { + res.set('WWW-Authenticate', 'Bearer realm="TREK MCP", error="invalid_token"'); + return res.status(401).json({ error: 'invalid_token' }); } - - const rawName = typeof body.client_name === 'string' ? body.client_name.trim().slice(0, 100) : ''; - const clientName = rawName || 'MCP Client'; - - // Determine if the client wants to be public (no secret) — MCP clients typically use PKCE only - const authMethod = typeof body.token_endpoint_auth_method === 'string' ? body.token_endpoint_auth_method : 'client_secret_post'; - const isPublic = authMethod === 'none'; - - // Resolve requested scopes — scope is required; no implicit full-access grant - if (typeof body.scope !== 'string' || body.scope.trim() === '') { - return res.status(400).json({ error: 'invalid_client_metadata', error_description: 'scope is required' }); - } - const rawScope = body.scope; - const requestedScopes = rawScope.split(' ').filter(s => (ALL_SCOPES as string[]).includes(s)); - if (requestedScopes.length === 0) { - return res.status(400).json({ error: 'invalid_client_metadata', error_description: 'No valid scopes requested' }); - } - - const result = createOAuthClient(null, clientName, redirectUris, requestedScopes, ip, { - isPublic, - createdVia: 'dcr', - }); - - if (result.error) { - return res.status(result.status || 400).json({ error: 'invalid_client_metadata', error_description: result.error }); - } - - const client = result.client!; - const now = Math.floor(Date.now() / 1000); - - return res.status(201).json({ - client_id: client.client_id, - ...(client.client_secret ? { client_secret: client.client_secret, client_secret_expires_at: 0 } : {}), - client_id_issued_at: now, - redirect_uris: client.redirect_uris, - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - scope: (client.allowed_scopes as string[]).join(' '), - client_name: client.name, - token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_post', + return res.json({ + sub: String(info.user.id), + email: info.user.email, + email_verified: true, + preferred_username: info.user.username, }); }); // Token revocation endpoint (RFC 7009) oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Response) => { - // M2: return 404 when MCP is disabled if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); - const body: Record = typeof req.body === 'object' ? req.body : {}; const { token, client_id, client_secret } = body; const ip = getClientIp(req); diff --git a/server/tests/integration/oauth.test.ts b/server/tests/integration/oauth.test.ts index da247acd..04f70541 100644 --- a/server/tests/integration/oauth.test.ts +++ b/server/tests/integration/oauth.test.ts @@ -103,12 +103,48 @@ describe('GET /.well-known/oauth-authorization-server', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// Issue #959 regression tests +// ───────────────────────────────────────────────────────────────────────────── + +describe('RFC 9728 — path-based protected resource metadata (issue #959 bug 1)', () => { + it('OAUTH-959A — /.well-known/oauth-protected-resource/mcp returns JSON (not SPA HTML)', async () => { + const res = await request(app).get('/.well-known/oauth-protected-resource/mcp'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/json/); + expect(res.body.resource).toContain('/mcp'); + expect(Array.isArray(res.body.authorization_servers)).toBe(true); + }); +}); + +describe('DCR scope optional — ChatGPT compatibility (issue #959 bug 2)', () => { + it('OAUTH-959B — POST /oauth/register without scope field returns 201 with default scopes', async () => { + const res = await request(app) + .post('/oauth/register') + .set('Content-Type', 'application/json') + .send({ redirect_uris: ['https://chatgpt.example.com/cb'], token_endpoint_auth_method: 'none' }); + expect(res.status).toBe(201); + expect(res.body.client_id).toBeDefined(); + expect(typeof res.body.scope).toBe('string'); + expect(res.body.scope.length).toBeGreaterThan(0); + }); + + it('OAUTH-959C — POST /oauth/register with explicit scope registers only requested scopes', async () => { + const res = await request(app) + .post('/oauth/register') + .set('Content-Type', 'application/json') + .send({ redirect_uris: ['https://example.com/cb'], token_endpoint_auth_method: 'none', scope: 'trips:read' }); + expect(res.status).toBe(201); + expect(res.body.scope).toBe('trips:read'); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // POST /oauth/token — authorization_code grant // ───────────────────────────────────────────────────────────────────────────── describe('POST /oauth/token — authorization_code grant', () => { - it('OAUTH-002 — missing client_id/client_secret returns 401 invalid_client', async () => { + it('OAUTH-002 — missing client_id returns 401 invalid_client', async () => { const res = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', code: 'x', redirect_uri: 'https://example.com/cb', code_verifier: 'y' }); @@ -116,13 +152,12 @@ describe('POST /oauth/token — authorization_code grant', () => { expect(res.body.error).toBe('invalid_client'); }); - it('OAUTH-003 — MCP addon disabled returns 403 mcp_disabled', async () => { + it('OAUTH-003 — MCP addon disabled returns 404', async () => { isAddonEnabledMock.mockReturnValue(false); const res = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y', code: 'z', redirect_uri: 'https://r.example.com/cb', code_verifier: 'v' }); - expect(res.status).toBe(403); - expect(res.body.error).toBe('mcp_disabled'); + expect(res.status).toBe(404); }); it('OAUTH-004 — missing code/redirect_uri/code_verifier returns 400 invalid_request', async () => { @@ -211,7 +246,7 @@ describe('POST /oauth/token — authorization_code grant', () => { expect(res.body.error).toBe('invalid_grant'); }); - it('OAUTH-008 — wrong client_secret returns 401 invalid_client', async () => { + it('OAUTH-008 — wrong client_secret returns 401 invalid_client (timing-safe check)', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { verifier, challenge } = makePkce(); @@ -909,7 +944,6 @@ describe('M1 — Cache-Control headers on /oauth/token', () => { .post('/oauth/token') .send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y', code: 'z', redirect_uri: 'https://r.example.com/cb', code_verifier: 'v' }); expect(res.headers['cache-control']).toBe('no-store'); - expect(res.headers['pragma']).toBe('no-cache'); }); }); diff --git a/server/tsconfig.json b/server/tsconfig.json index b443a5ba..48b19a9e 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -20,7 +20,15 @@ // These paths manually redirect to the CJS dist until the SDK fixes its exports map. "paths": { "@modelcontextprotocol/sdk/server/mcp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp"], - "@modelcontextprotocol/sdk/server/streamableHttp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp"] + "@modelcontextprotocol/sdk/server/streamableHttp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp"], + "@modelcontextprotocol/sdk/server/auth/router": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/router"], + "@modelcontextprotocol/sdk/server/auth/handlers/authorize": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/authorize"], + "@modelcontextprotocol/sdk/server/auth/handlers/register": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/register"], + "@modelcontextprotocol/sdk/server/auth/provider": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/provider"], + "@modelcontextprotocol/sdk/server/auth/clients": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/clients"], + "@modelcontextprotocol/sdk/server/auth/errors": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/errors"], + "@modelcontextprotocol/sdk/server/auth/types": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/types"], + "@modelcontextprotocol/sdk/shared/auth": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/shared/auth"] } }, "include": ["src"], diff --git a/wiki/MCP-Setup.md b/wiki/MCP-Setup.md index 2ff069ea..1d4dc6c0 100644 --- a/wiki/MCP-Setup.md +++ b/wiki/MCP-Setup.md @@ -41,6 +41,16 @@ Claude Desktop connects via `mcp-remote`. After creating an OAuth client using t When the client starts it opens your browser to the TREK consent screen to complete the OAuth flow. +### ChatGPT + +ChatGPT's custom MCP connector supports Dynamic Client Registration (DCR) — no pre-created client is required: + +1. In ChatGPT, open **Settings → Connected Apps → Add a custom app**. +2. Set the **MCP Server URL** to `https:///mcp`. +3. ChatGPT will automatically discover TREK's OAuth metadata, register itself, and redirect you to the TREK consent screen to approve access. + +> **Cloudflare users:** If your TREK instance is behind Cloudflare and you are on the **free plan**, you must disable **Bot Fight Mode** (`Security → Bots → Bot Fight Mode → Off`). ChatGPT's backend uses a Python HTTP client (`aiohttp`) whose TLS fingerprint is classified as a bot by Cloudflare. Because the free plan does not support path-based bot exceptions, the feature must be disabled globally. On **Pro and above**, create a WAF custom rule (position #1) that skips Bot Fight Mode for paths `/oauth/*`, `/.well-known/*`, and `/mcp`. + ### Cursor, VS Code, Windsurf, and Zed Clients that support `mcp-remote` can connect in one of two ways. diff --git a/wiki/Offline-Mode-and-PWA.md b/wiki/Offline-Mode-and-PWA.md index 3f4cc08b..cb9c1ab6 100644 --- a/wiki/Offline-Mode-and-PWA.md +++ b/wiki/Offline-Mode-and-PWA.md @@ -18,25 +18,35 @@ TREK must be served over **HTTPS** — the install prompt does not appear on pla Once installed, TREK launches in **standalone** mode (fullscreen, no browser UI) using the TREK icon. -## What works offline +## How offline reads work -TREK uses Workbox service-worker caching plus an IndexedDB database (Dexie) for structured trip data. The following content is available offline after the first sync: +TREK uses **two independent offline layers**: + +1. **IndexedDB (Dexie)** — the primary offline store. On login and whenever the network comes back online, TREK syncs full trip bundles into IndexedDB. All reads use a **stale-while-revalidate** strategy: cached data is returned instantly from IndexedDB, then a background network request updates the data when it completes. This means the UI is always instant regardless of connectivity — `navigator.onLine` is not used as a gate because it is unreliable on mobile (returns `true` whenever any network interface is active, even without actual internet access). + +2. **Service-worker cache (Workbox)** — a secondary safety net for *degraded connectivity* (flaky Wi-Fi, captive portals). The SW intercepts API calls and serves cached responses if the network does not respond within the timeout. + +This means a week-long offline trip works even if the SW cache has expired — the IndexedDB data has no time-based eviction (only stale trips older than 7 days are evicted on the next sync). + +## What works offline **Service-worker cache (Workbox)** -| Content | Cache name | Strategy | Duration | Max entries | -|---------|------------|----------|----------|-------------| +| Content | Cache name | Strategy | Default TTL | Default max entries | +|---------|------------|----------|-------------|---------------------| | CartoDB / OpenStreetMap map tiles | `map-tiles` | CacheFirst | 30 days | 1 000 | | Leaflet / CDN assets (unpkg) | `cdn-libs` | CacheFirst | 365 days | 30 | -| API responses (trips, places, bookings, etc.) | `api-data` | NetworkFirst (5 s timeout) | 24 hours | 200 | +| API responses (trips, places, bookings, etc.) | `api-data` | NetworkFirst (2 s timeout) | **7 days** | **500** | | Cover images and avatars (`/uploads/covers`, `/uploads/avatars`) | `user-uploads` | CacheFirst | 7 days | 300 | | App shell (HTML / JS / CSS) | precache | Precached | Until next deploy | — | +The `api-data` and `map-tiles` caches are **user-configurable at runtime** — see [Cache configuration](#cache-configuration) below. + > **Note:** The API cache excludes sensitive endpoints — `/api/auth`, `/api/admin`, `/api/backup`, and `/api/settings` are always fetched from the network. **IndexedDB (Dexie) — structured trip data** -On login, after each trip-list refresh, and on WebSocket reconnect, TREK runs a background sync that writes full trip bundles into IndexedDB: +On login, when the network comes back online, and via the manual **Re-sync now** button, TREK runs a background sync that writes full trip bundles into IndexedDB: - Trips, days, places, packing items, to-dos, budget items, reservations, accommodations, trip members, tags, and categories. - Non-photo file attachments (PDFs, documents, etc.) are downloaded and stored as blobs in IndexedDB. @@ -51,8 +61,6 @@ On login, after each trip-list refresh, and on WebSocket reconnect, TREK runs a The **Offline Cache** section under Settings → Offline shows the current state of the local cache. - - **Stats panel:** - **Cached trips** — number of trips stored in IndexedDB (Dexie). - **Pending changes** — number of actions taken offline that are queued to sync. @@ -63,12 +71,28 @@ The **Offline Cache** section under Settings → Offline shows the current state Each cached trip entry shows the trip name, date range, place count, and file count, plus the time of the last successful sync. +## Cache configuration + +The **Cache configuration** section in Settings → Offline lets you tune the service-worker cache limits without rebuilding TREK. Changes are saved to your browser's IndexedDB and sent to the active service worker immediately — no page reload required. + +| Setting | Default | Range | Description | +|---------|---------|-------|-------------| +| API cache TTL (days) | 7 | 1–365 | How long API responses stay in the `api-data` cache | +| API max entries | 500 | 10–5 000 | Maximum number of API responses cached | +| Map tiles TTL (days) | 30 | 1–365 | How long map tiles stay in the `map-tiles` cache | +| Map tiles max entries | 1 000 | 10–5 000 | Maximum number of tiles cached across all trips | + +> **Tip:** Existing cached entries follow their original TTL. New entries use the updated settings from the next request onwards. + +> **Note on TTL and offline access:** Raising the API cache TTL extends coverage for *degraded connectivity* (flaky Wi-Fi). For a fully offline device, the primary data source is IndexedDB — always available regardless of TTL. + ## Limitations - New trips created while offline are queued and synced when connectivity is restored. - Photo uploads require connectivity; non-photo file attachments are pre-cached automatically during sync. - Real-time collaboration features require an active WebSocket connection. - Mapbox GL tiles are not cached by the service worker (Mapbox manages its own tile cache internally). +- The map tile size cap (~50 MB) means very large trips spanning multiple countries may have tiles skipped entirely rather than partially cached. ## See also diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md index aae97181..98f63c98 100644 --- a/wiki/Troubleshooting.md +++ b/wiki/Troubleshooting.md @@ -240,6 +240,37 @@ Restart the container after adding the variable. Once set, clicking **Connect** --- +## ChatGPT MCP connector: "Dynamic client registration failed" / 403 + +**Cause:** ChatGPT's MCP backend runs on OpenAI's datacenter IPs and uses a Python HTTP client (`aiohttp`). Cloudflare's **Bot Fight Mode** identifies the TLS fingerprint of this client as bot traffic and blocks the request at the edge — before it ever reaches your server. Because the request is dropped by Cloudflare, nothing appears in TREK's logs. + +This affects the OAuth Dynamic Client Registration (`/oauth/register`), the `/mcp` endpoint, and the OAuth metadata endpoints (`/.well-known/*`). + +**Fix — Cloudflare free plan:** + +Disable Bot Fight Mode entirely: + +**Security → Bots → Bot Fight Mode → Off** + +The free plan does not support path-based exceptions, so the feature must be turned off globally. Your TREK data remains protected by its own authentication — Bot Fight Mode is not a substitute for application-level auth. + +**Fix — Cloudflare Pro and above (Super Bot Fight Mode):** + +Create a WAF custom rule at **position #1** (rules fire in order — it must be first): + +``` +Expression: + (http.request.uri.path contains "/oauth/") or + (http.request.uri.path contains "/.well-known/") or + (http.request.uri.path eq "/mcp") + +Action: Skip → All remaining custom rules + Bot Fight Mode +``` + +Ensure the **"Bot Fight Mode"** checkbox in the Skip action is checked, not just "All remaining custom rules." + +--- + ## MCP integration: "Too many requests" or "Session limit reached" **Cause:** Each user is limited to 300 MCP requests per minute and 20 concurrent sessions by default. Exceeding either limit returns a `429` response.