mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
Merge c64101b12a into 640e5616e9
This commit is contained in:
Generated
+9
-10
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -218,7 +218,7 @@ export default function App() {
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
||||
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
|
||||
<Route path="/oauth/consent" element={<OAuthAuthorizePage />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
|
||||
@@ -33,6 +33,7 @@ function translateRateLimit(): string {
|
||||
export const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: '/api',
|
||||
withCredentials: true,
|
||||
timeout: 8000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@@ -142,6 +143,7 @@ export const oauthApi = {
|
||||
state?: string
|
||||
code_challenge: string
|
||||
code_challenge_method: string
|
||||
resource?: string
|
||||
}) => 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: {
|
||||
|
||||
@@ -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<void>(resolve => setTimeout(resolve, 0));
|
||||
await Promise.all(offlineDb.tables.map(t => t.clear()));
|
||||
resetAllStores();
|
||||
// Settlement and per-person APIs needed by BudgetPanel
|
||||
server.use(
|
||||
|
||||
@@ -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<void>(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(<FileManager {...defaultProps} files={[buildFile()]} />);
|
||||
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<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.put('/api/trips/1/files/1', async ({ request }) => {
|
||||
capturedBody = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ file: { ...file, place_id: 10 } });
|
||||
}),
|
||||
);
|
||||
render(<FileManager {...defaultProps} files={[file]} places={[place]} onUpdate={onUpdate} />);
|
||||
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<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.put('/api/trips/1/files/1', async ({ request }) => {
|
||||
capturedBody = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ file: { ...file, reservation_id: 20 } });
|
||||
}),
|
||||
);
|
||||
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
|
||||
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<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.put('/api/trips/1/files/1', async ({ request }) => {
|
||||
capturedBody = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ file: { ...file, place_id: null } });
|
||||
}),
|
||||
);
|
||||
render(<FileManager {...defaultProps} files={[file]} places={[place]} />);
|
||||
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<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.put('/api/trips/1/files/1', async ({ request }) => {
|
||||
capturedBody = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ file: { ...file, reservation_id: null } });
|
||||
}),
|
||||
);
|
||||
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
|
||||
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 () => {
|
||||
|
||||
@@ -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<string, unknown>)
|
||||
refreshFiles()
|
||||
} catch {
|
||||
toast.error(t('files.toast.assignError'))
|
||||
|
||||
@@ -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<void>(resolve => setTimeout(resolve, 0));
|
||||
await Promise.all(offlineDb.tables.map(t => t.clear()));
|
||||
resetAllStores();
|
||||
// Side-effect APIs PackingListPanel calls on mount
|
||||
server.use(
|
||||
|
||||
@@ -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<void>(resolve => setTimeout(resolve, 0));
|
||||
await Promise.all(offlineDb.tables.map(t => t.clear()));
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
server.use(
|
||||
|
||||
@@ -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
|
||||
<button onClick={async () => {
|
||||
if (showHotelPicker === 'edit' && accommodation) {
|
||||
// Update existing
|
||||
await accommodationsApi.update(tripId, accommodation.id, {
|
||||
await accommodationRepo.update(tripId, accommodation.id, {
|
||||
place_id: hotelForm.place_id,
|
||||
start_day_id: hotelDayRange.start,
|
||||
end_day_id: hotelDayRange.end,
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
/**
|
||||
* Offline settings tab — shows cached trips, storage info, and controls
|
||||
* to re-sync or clear the offline cache.
|
||||
* to re-sync or clear the offline cache. Also exposes runtime SW cache config.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Wifi, RefreshCw, Trash2, Database } from 'lucide-react'
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Wifi, RefreshCw, Trash2, Database, Settings2, RotateCcw, CheckCircle } from 'lucide-react'
|
||||
import Section from './Section'
|
||||
import { offlineDb, clearAll } from '../../db/offlineDb'
|
||||
import { tripSyncManager } from '../../sync/tripSyncManager'
|
||||
import type { SyncProgress } from '../../sync/tripSyncManager'
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
import {
|
||||
DEFAULT_SW_CONFIG,
|
||||
loadSwConfig,
|
||||
saveSwConfig,
|
||||
validateSwConfig,
|
||||
SW_CONFIG_BOUNDS,
|
||||
type SwCacheConfig,
|
||||
} from '../../sync/swConfig'
|
||||
import type { SyncMeta } from '../../db/offlineDb'
|
||||
import type { Trip } from '../../types'
|
||||
|
||||
@@ -22,9 +31,18 @@ export default function OfflineTab(): React.ReactElement {
|
||||
const [rows, setRows] = useState<CachedTripRow[]>([])
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [syncProgress, setSyncProgress] = useState<{ current: number; total: number } | null>(null)
|
||||
const [syncResult, setSyncResult] = useState<{ ok: number; failed: number } | null>(null)
|
||||
const syncResultTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Cache config state
|
||||
const [cacheConfig, setCacheConfig] = useState<SwCacheConfig>({ ...DEFAULT_SW_CONFIG })
|
||||
const [configSaving, setConfigSaving] = useState(false)
|
||||
const [configApplied, setConfigApplied] = useState<Date | null>(null)
|
||||
const appliedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -53,10 +71,89 @@ export default function OfflineTab(): React.ReactElement {
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
// Load persisted cache config on mount
|
||||
useEffect(() => {
|
||||
loadSwConfig().then(setCacheConfig).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Listen for SW acknowledgement
|
||||
useEffect(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (event.data?.type === 'CACHE_CONFIG_APPLIED') {
|
||||
setConfigApplied(new Date())
|
||||
setConfigSaving(false)
|
||||
if (appliedTimerRef.current) clearTimeout(appliedTimerRef.current)
|
||||
appliedTimerRef.current = setTimeout(() => setConfigApplied(null), 5000)
|
||||
}
|
||||
}
|
||||
navigator.serviceWorker?.addEventListener('message', handler)
|
||||
return () => {
|
||||
navigator.serviceWorker?.removeEventListener('message', handler)
|
||||
if (appliedTimerRef.current) clearTimeout(appliedTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => { if (syncResultTimerRef.current) clearTimeout(syncResultTimerRef.current) }
|
||||
}, [])
|
||||
|
||||
async function handleSaveConfig() {
|
||||
const validated = validateSwConfig(cacheConfig)
|
||||
setCacheConfig(validated)
|
||||
setConfigSaving(true)
|
||||
try {
|
||||
await saveSwConfig(validated)
|
||||
const controller = navigator.serviceWorker?.controller
|
||||
if (controller) {
|
||||
controller.postMessage({ type: 'UPDATE_CACHE_CONFIG', config: validated })
|
||||
// configSaving cleared by the SW message handler
|
||||
} else {
|
||||
// No active SW yet (e.g. first install) — config saved to IDB, applied on next SW activation
|
||||
setConfigApplied(new Date())
|
||||
setConfigSaving(false)
|
||||
}
|
||||
} catch {
|
||||
setConfigSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleResetConfig() {
|
||||
setCacheConfig({ ...DEFAULT_SW_CONFIG })
|
||||
}
|
||||
|
||||
function updateField(field: keyof SwCacheConfig) {
|
||||
return (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = parseInt(e.target.value, 10)
|
||||
if (!isNaN(v)) setCacheConfig(prev => ({ ...prev, [field]: v }))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResync() {
|
||||
setSyncing(true)
|
||||
setSyncProgress(null)
|
||||
setSyncResult(null)
|
||||
if (syncResultTimerRef.current) clearTimeout(syncResultTimerRef.current)
|
||||
|
||||
function handleProgress(p: SyncProgress) {
|
||||
if (p.phase === 'trip') {
|
||||
setSyncProgress({ current: p.index + 1, total: p.total })
|
||||
} else if (p.phase === 'done') {
|
||||
setSyncProgress(null)
|
||||
setSyncResult({ ok: p.ok, failed: p.failed })
|
||||
syncResultTimerRef.current = setTimeout(() => setSyncResult(null), 5000)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await tripSyncManager.syncAll()
|
||||
const timeout = new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 120_000))
|
||||
const result = await Promise.race([
|
||||
tripSyncManager.syncAll({ onProgress: handleProgress }).then(() => 'done' as const),
|
||||
timeout,
|
||||
])
|
||||
if (result === 'timeout') {
|
||||
tripSyncManager.interrupt()
|
||||
console.warn('[OfflineTab] sync timed out after 120 s')
|
||||
}
|
||||
await load()
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
@@ -101,7 +198,11 @@ export default function OfflineTab(): React.ReactElement {
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} style={syncing ? { animation: 'spin 1s linear infinite' } : {}} />
|
||||
{syncing ? 'Syncing…' : 'Re-sync now'}
|
||||
{syncing
|
||||
? syncProgress
|
||||
? `Syncing ${syncProgress.current}/${syncProgress.total}…`
|
||||
: 'Syncing…'
|
||||
: 'Re-sync now'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -120,6 +221,96 @@ export default function OfflineTab(): React.ReactElement {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sync result */}
|
||||
{syncResult && (
|
||||
<span style={{ fontSize: 12, color: syncResult.failed > 0 ? '#ef4444' : '#22c55e', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<CheckCircle size={12} />
|
||||
{syncResult.failed > 0
|
||||
? `Synced ${syncResult.ok} trip${syncResult.ok !== 1 ? 's' : ''} · ${syncResult.failed} failed`
|
||||
: `Synced ${syncResult.ok} trip${syncResult.ok !== 1 ? 's' : ''}`}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Cache configuration */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Settings2 size={14} style={{ color: 'var(--text-muted)' }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>Cache configuration</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>
|
||||
Changes apply immediately to the service worker and persist across reloads.
|
||||
Existing cached entries follow their original TTL; new entries use the updated settings.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<CacheField
|
||||
label="API cache TTL (days)"
|
||||
value={cacheConfig.apiTtlDays}
|
||||
min={SW_CONFIG_BOUNDS.ttlMin}
|
||||
max={SW_CONFIG_BOUNDS.ttlMax}
|
||||
onChange={updateField('apiTtlDays')}
|
||||
/>
|
||||
<CacheField
|
||||
label="API max entries"
|
||||
value={cacheConfig.apiMaxEntries}
|
||||
min={SW_CONFIG_BOUNDS.entriesMin}
|
||||
max={SW_CONFIG_BOUNDS.entriesMax}
|
||||
onChange={updateField('apiMaxEntries')}
|
||||
/>
|
||||
<CacheField
|
||||
label="Map tiles TTL (days)"
|
||||
value={cacheConfig.tilesTtlDays}
|
||||
min={SW_CONFIG_BOUNDS.ttlMin}
|
||||
max={SW_CONFIG_BOUNDS.ttlMax}
|
||||
onChange={updateField('tilesTtlDays')}
|
||||
/>
|
||||
<CacheField
|
||||
label="Map tiles max entries"
|
||||
value={cacheConfig.tilesMaxEntries}
|
||||
min={SW_CONFIG_BOUNDS.entriesMin}
|
||||
max={SW_CONFIG_BOUNDS.entriesMax}
|
||||
onChange={updateField('tilesMaxEntries')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={handleSaveConfig}
|
||||
disabled={configSaving}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px',
|
||||
borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-secondary)', color: 'var(--text-primary)',
|
||||
cursor: configSaving ? 'not-allowed' : 'pointer',
|
||||
fontSize: 13, fontWeight: 500, opacity: configSaving ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} style={configSaving ? { animation: 'spin 1s linear infinite' } : {}} />
|
||||
{configSaving ? 'Applying…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetConfig}
|
||||
disabled={configSaving}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px',
|
||||
borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-secondary)', color: 'var(--text-muted)',
|
||||
cursor: configSaving ? 'not-allowed' : 'pointer',
|
||||
fontSize: 13, fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
Reset to defaults
|
||||
</button>
|
||||
{configApplied && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: '#22c55e' }}>
|
||||
<CheckCircle size={12} />
|
||||
Applied at {configApplied.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cached trip list */}
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading…</p>
|
||||
@@ -139,24 +330,32 @@ export default function OfflineTab(): React.ReactElement {
|
||||
display: 'flex', flexDirection: 'column', gap: 2,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>
|
||||
{trip.name}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14, color: trip.title ? 'var(--text-primary)' : 'var(--text-muted)', fontStyle: trip.title ? 'normal' : 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{trip.title || 'Unnamed trip'}
|
||||
</span>
|
||||
{trip.description ? (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{trip.description.length > 72 ? trip.description.slice(0, 72) + '…' : trip.description}
|
||||
</span>
|
||||
) : null}
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{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}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
<Wifi size={10} style={{ display: 'inline', marginRight: 3 }} />
|
||||
{meta.lastSyncedAt
|
||||
? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{formatDate(trip.start_date)} – {formatDate(trip.end_date)}
|
||||
{' · '}
|
||||
{placeCount} place{placeCount !== 1 ? 's' : ''}
|
||||
{' · '}
|
||||
{fileCount} file{fileCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -178,3 +377,32 @@ function Stat({ label, value }: { label: string; value: number }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CacheField({
|
||||
label, value, min, max, onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
}) {
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 500 }}>{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={onChange}
|
||||
style={{
|
||||
padding: '6px 10px', borderRadius: 6,
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-secondary)', color: 'var(--text-primary)',
|
||||
fontSize: 13, width: '100%', boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
await offlineDb.trips.delete(tripId);
|
||||
}
|
||||
|
||||
/** Clear cached file blobs only — frees significant quota without losing trip data. */
|
||||
export async function clearBlobCache(): Promise<void> {
|
||||
await offlineDb.blobCache.clear();
|
||||
}
|
||||
|
||||
/** Wipe the entire offline database (called on logout). */
|
||||
export async function clearAll(): Promise<void> {
|
||||
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(),
|
||||
])
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -464,6 +464,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'trip.tabs.budget': 'الميزانية',
|
||||
'trip.tabs.files': 'الملفات',
|
||||
'trip.loading': 'جارٍ تحميل الرحلة...',
|
||||
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
|
||||
'trip.loadingPhotos': 'جارٍ تحميل صور الأماكن...',
|
||||
'trip.mobilePlan': 'الخطة',
|
||||
'trip.mobilePlaces': 'الأماكن',
|
||||
|
||||
@@ -459,6 +459,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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
|
||||
|
||||
@@ -459,6 +459,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -464,6 +464,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -537,6 +537,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -451,6 +451,8 @@ const es: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -452,6 +452,8 @@ const fr: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -459,6 +459,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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
|
||||
|
||||
@@ -521,6 +521,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -459,6 +459,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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
|
||||
|
||||
@@ -452,6 +452,8 @@ const nl: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -426,6 +426,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -452,6 +452,8 @@ const ru: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'trip.tabs.budget': 'Бюджет',
|
||||
'trip.tabs.files': 'Файлы',
|
||||
'trip.loading': 'Загрузка поездки...',
|
||||
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
|
||||
'trip.loadingPhotos': 'Загрузка фото мест...',
|
||||
'trip.mobilePlan': 'План',
|
||||
'trip.mobilePlaces': 'Места',
|
||||
|
||||
@@ -452,6 +452,8 @@ const zh: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'trip.tabs.budget': '预算',
|
||||
'trip.tabs.files': '文件',
|
||||
'trip.loading': '加载旅行中...',
|
||||
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
|
||||
'trip.loadingPhotos': '正在加载地点照片...',
|
||||
'trip.mobilePlan': '计划',
|
||||
'trip.mobilePlaces': '地点',
|
||||
|
||||
@@ -511,6 +511,8 @@ const zhTw: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'trip.tabs.budget': '預算',
|
||||
'trip.tabs.files': '檔案',
|
||||
'trip.loading': '載入旅行中...',
|
||||
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
|
||||
'trip.loadingPhotos': '正在載入地點照片...',
|
||||
'trip.mobilePlan': '計劃',
|
||||
'trip.mobilePlaces': '地點',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<never>((_, 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'))
|
||||
|
||||
@@ -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<void>(resolve => setTimeout(resolve, 0));
|
||||
await Promise.all(offlineDb.tables.map(t => t.clear()));
|
||||
vi.clearAllMocks();
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
|
||||
|
||||
@@ -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(<LoginPage />);
|
||||
|
||||
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(<LoginPage />);
|
||||
|
||||
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(<LoginPage />);
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
|
||||
const [configError, setConfigError] = useState<boolean>(false)
|
||||
const [inviteToken, setInviteToken] = useState<string>('')
|
||||
const [inviteValid, setInviteValid] = useState<boolean>(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 && (
|
||||
<div style={{ marginTop: 16, padding: '10px 14px', background: '#fef3c7', border: '1px solid #fde68a', borderRadius: 12, fontSize: 13, color: '#92400e', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<span>{t('login.configLoadError')}</span>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{ background: 'none', border: '1px solid #d97706', borderRadius: 8, padding: '4px 10px', fontSize: 12, fontWeight: 600, color: '#92400e', cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{t('login.configLoadRetry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Demo login button */}
|
||||
{appConfig?.demo_mode && (
|
||||
<button onClick={handleDemoLogin} disabled={isLoading}
|
||||
|
||||
@@ -12,7 +12,7 @@ import OAuthAuthorizePage from './OAuthAuthorizePage';
|
||||
const DEFAULT_SEARCH = '?client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A4000%2Fcallback&scope=trips%3Aread&state=abc&code_challenge=challenge&code_challenge_method=S256';
|
||||
|
||||
function setSearchParams(search: string) {
|
||||
window.history.pushState({}, '', '/oauth/authorize' + search);
|
||||
window.history.pushState({}, '', '/oauth/consent' + search);
|
||||
}
|
||||
|
||||
const VALIDATE_OK = {
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
const state = params.get('state') || ''
|
||||
const codeChallenge = params.get('code_challenge') || ''
|
||||
const ccMethod = params.get('code_challenge_method') || ''
|
||||
const resource = params.get('resource') || undefined
|
||||
|
||||
// Load auth state once, then validate
|
||||
useEffect(() => {
|
||||
@@ -57,6 +58,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: ccMethod,
|
||||
response_type: 'code',
|
||||
resource,
|
||||
})
|
||||
setValidation(result)
|
||||
|
||||
@@ -99,6 +101,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: ccMethod,
|
||||
approved,
|
||||
resource,
|
||||
})
|
||||
setPageState('done')
|
||||
window.location.href = result.redirect
|
||||
@@ -124,7 +127,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
}
|
||||
|
||||
function handleLoginRedirect() {
|
||||
const next = '/oauth/authorize?' + params.toString() + window.location.hash
|
||||
const next = '/oauth/consent?' + params.toString() + window.location.hash
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(next)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useTranslation } from '../i18n'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client'
|
||||
import { accommodationRepo } from '../repo/accommodationRepo'
|
||||
import { offlineDb } from '../db/offlineDb'
|
||||
import { tripSyncManager } from '../sync/tripSyncManager'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import { useResizablePanels } from '../hooks/useResizablePanels'
|
||||
@@ -328,6 +329,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
// Load trip + files (needed for place inspector file section)
|
||||
useEffect(() => {
|
||||
if (tripId) {
|
||||
// Stop background sync so its bundle requests don't compete with loadTrip
|
||||
tripSyncManager.interrupt()
|
||||
tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
|
||||
tripActions.loadFiles(tripId)
|
||||
loadAccommodations()
|
||||
@@ -726,12 +729,18 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
|
||||
// Splash screen — show for initial load + a brief moment for photos to start loading
|
||||
const [splashDone, setSplashDone] = useState(false)
|
||||
const [slowLoad, setSlowLoad] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!isLoading && trip) {
|
||||
const timer = setTimeout(() => setSplashDone(true), 1500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isLoading, trip])
|
||||
}, [isLoading, trip?.id])
|
||||
// Show escape hatch after 12 seconds on splash (covers slow first-load scenarios)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSlowLoad(true), 12000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
if (isLoading || !splashDone) {
|
||||
return (
|
||||
@@ -771,6 +780,18 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
{slowLoad && (
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
style={{
|
||||
marginTop: 24, appearance: 'none', border: 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', background: 'transparent',
|
||||
color: 'var(--text-faint)', fontSize: 13, textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
{t('trip.splash.goBack')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>): Promise<{ accommodation: Accommodation }> {
|
||||
const tempId = -(Date.now())
|
||||
const tempAccommodation: Accommodation = {
|
||||
...(data as Partial<Accommodation>),
|
||||
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<string, unknown>): Promise<{ accommodation: Accommodation }> {
|
||||
const existing = await offlineDb.accommodations.get(id)
|
||||
const optimistic: Accommodation = { ...(existing ?? {} as Accommodation), ...(data as Partial<Accommodation>), 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<unknown> {
|
||||
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 }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>): Promise<{ item: BudgetItem }> {
|
||||
const tempId = -(Date.now())
|
||||
const tempItem: BudgetItem = {
|
||||
...(data as Partial<BudgetItem>),
|
||||
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<string, unknown>): Promise<{ item: BudgetItem }> {
|
||||
const existing = await offlineDb.budgetItems.get(id)
|
||||
const optimistic: BudgetItem = { ...(existing ?? {} as BudgetItem), ...(data as Partial<BudgetItem>), 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<unknown> {
|
||||
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 }
|
||||
},
|
||||
}
|
||||
|
||||
+38
-11
@@ -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<string, unknown>): 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 }
|
||||
},
|
||||
}
|
||||
|
||||
+68
-10
@@ -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<string, unknown>): Promise<{ file: TripFile }> {
|
||||
const existing = await offlineDb.tripFiles.get(id)
|
||||
const optimistic: TripFile = { ...(existing ?? {} as TripFile), ...(data as Partial<TripFile>), 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<unknown> {
|
||||
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<unknown> {
|
||||
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 }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>): Promise<{ item: PackingItem }> {
|
||||
if (!navigator.onLine) {
|
||||
const tempId = -(Date.now())
|
||||
const tempItem: PackingItem = {
|
||||
...(data as Partial<PackingItem>),
|
||||
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<PackingItem>),
|
||||
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<string, unknown>): Promise<{ item: PackingItem }> {
|
||||
if (!navigator.onLine) {
|
||||
const existing = await offlineDb.packingItems.get(id)
|
||||
const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial<PackingItem>), 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<PackingItem>), 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<unknown> {
|
||||
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 }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown>): Promise<{ place: Place }> {
|
||||
if (!navigator.onLine) {
|
||||
const tempId = -(Date.now())
|
||||
const tempPlace: Place = {
|
||||
...(data as Partial<Place>),
|
||||
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<Place>),
|
||||
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<string, unknown>): Promise<{ place: Place }> {
|
||||
if (!navigator.onLine) {
|
||||
const existing = await offlineDb.places.get(Number(id))
|
||||
const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial<Place>), 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<Place>), 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<unknown> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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 }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>): Promise<{ reservation: Reservation }> {
|
||||
const tempId = -(Date.now())
|
||||
const tempReservation: Reservation = {
|
||||
...(data as Partial<Reservation>),
|
||||
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<string, unknown>): Promise<{ reservation: Reservation }> {
|
||||
const existing = await offlineDb.reservations.get(id)
|
||||
const optimistic: Reservation = { ...(existing ?? {} as Reservation), ...(data as Partial<Reservation>), 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<unknown> {
|
||||
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 }
|
||||
},
|
||||
}
|
||||
|
||||
+81
-11
@@ -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<string, unknown>): Promise<{ item: TodoItem }> {
|
||||
const tempId = -(Date.now())
|
||||
const tempItem: TodoItem = {
|
||||
...(data as Partial<TodoItem>),
|
||||
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<string, unknown>): Promise<{ item: TodoItem }> {
|
||||
const existing = await offlineDb.todoItems.get(id)
|
||||
const optimistic: TodoItem = { ...(existing ?? {} as TodoItem), ...(data as Partial<TodoItem>), 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<unknown> {
|
||||
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 }
|
||||
},
|
||||
}
|
||||
|
||||
+74
-19
@@ -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<Trip[]>(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<undefined>(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<Trip>): Promise<{ trip: Trip }> {
|
||||
const existing = await offlineDb.trips.get(Number(tripId))
|
||||
const optimistic: Trip = { ...(existing ?? {} as Trip), ...(data as Partial<Trip>), 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<string, unknown>,
|
||||
resource: 'trips',
|
||||
})
|
||||
mutationQueue.flush().catch(() => {})
|
||||
return { trip: optimistic }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<AuthState>()(
|
||||
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<AuthState>()(
|
||||
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<AuthState>()(
|
||||
error: null,
|
||||
})
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
useSystemNoticeStore.getState().fetch()
|
||||
return data
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<void>(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 () => {
|
||||
|
||||
@@ -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<string, unknown>)
|
||||
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<string, unknown>)
|
||||
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'))
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
})
|
||||
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<DayNote>), 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<string, unknown>,
|
||||
})
|
||||
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) {
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>)
|
||||
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<string, unknown>)
|
||||
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'))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<string, unknown>)
|
||||
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<string, unknown>)
|
||||
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 =>
|
||||
|
||||
@@ -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<TripStoreState>((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<TripStoreState>((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<TripStoreState> = {}
|
||||
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<TripStoreState>((set, get) => ({
|
||||
|
||||
updateTrip: async (tripId: number | string, data: Partial<Trip>) => {
|
||||
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'))
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
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<Request> {
|
||||
return new Request(request, { redirect: 'manual' });
|
||||
},
|
||||
async fetchDidSucceed({ response }: { response: Response }): Promise<Response> {
|
||||
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<SwCacheConfig>);
|
||||
applyConfig(validated);
|
||||
|
||||
// Acknowledge back to the sending client
|
||||
(event.source as WindowClient | null)?.postMessage({ type: 'CACHE_CONFIG_APPLIED' });
|
||||
});
|
||||
@@ -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<string, Table> = {
|
||||
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' })
|
||||
|
||||
|
||||
@@ -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>): 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<SwConfigRow, 'singleton'>;
|
||||
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<SwCacheConfig | null> {
|
||||
try {
|
||||
const row = await getDb().config.get('singleton');
|
||||
return row ? validateSwConfig(row) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSwConfig(cfg: SwCacheConfig): Promise<void> {
|
||||
const validated = validateSwConfig(cfg);
|
||||
await getDb().config.put({ id: 'singleton', ...validated, updatedAt: Date.now() });
|
||||
}
|
||||
|
||||
export async function loadSwConfig(): Promise<SwCacheConfig> {
|
||||
return (await readSwConfigFromIDB()) ?? { ...DEFAULT_SW_CONFIG };
|
||||
}
|
||||
@@ -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<typeof setInterval> | 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. */
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
// ── 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<void> {
|
||||
if (_syncing || !navigator.onLine) return
|
||||
async syncAll(opts?: { onProgress?: (p: SyncProgress) => void }): Promise<void> {
|
||||
// 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<never>((_, 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<void> {
|
||||
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<never>((_, 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
|
||||
},
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<void>(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<string, unknown>;
|
||||
// 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<string, unknown>);
|
||||
const result = await useTripStore.getState().updateBudgetItem(1, 10, { amount: 200 } as Record<string, unknown>);
|
||||
|
||||
// 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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void>(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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<void>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void>(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 () => {
|
||||
|
||||
@@ -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<void>(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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+29
-59
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+116
-5
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<OAuthClientInformationFull | undefined> {
|
||||
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<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>,
|
||||
): Promise<OAuthClientInformationFull> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
throw new ServerError('PKCE validation is handled by the provider directly');
|
||||
},
|
||||
|
||||
async exchangeAuthorizationCode(
|
||||
client: OAuthClientInformationFull,
|
||||
code: string,
|
||||
codeVerifier?: string,
|
||||
redirectUri?: string,
|
||||
resource?: URL,
|
||||
): Promise<OAuthTokens> {
|
||||
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<OAuthTokens> {
|
||||
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<AuthInfo> {
|
||||
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<void> {
|
||||
serviceRevokeToken(request.token, client.client_id, undefined, null);
|
||||
},
|
||||
};
|
||||
+23
-127
@@ -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<string, unknown> = 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<string, string> = typeof req.body === 'object' ? req.body : {};
|
||||
const { token, client_id, client_secret } = body;
|
||||
const ip = getClientIp(req);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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://<your-trek-instance>/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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
<!-- TODO: screenshot: Offline tab showing cached trips -->
|
||||
|
||||
**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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user