This commit is contained in:
Julien G.
2026-05-05 14:28:09 +00:00
committed by GitHub
73 changed files with 2109 additions and 705 deletions
+9 -10
View File
@@ -27,6 +27,12 @@
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0", "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" "zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
@@ -6471,7 +6477,6 @@
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/indent-string": { "node_modules/indent-string": {
@@ -7538,9 +7543,9 @@
} }
}, },
"node_modules/marked": { "node_modules/marked": {
"version": "18.0.0", "version": "18.0.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz", "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz",
"integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==", "integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"marked": "bin/marked.js" "marked": "bin/marked.js"
@@ -12032,7 +12037,6 @@
"version": "7.4.0", "version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz", "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz",
"integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==", "integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"workbox-core": "7.4.0" "workbox-core": "7.4.0"
@@ -12042,14 +12046,12 @@
"version": "7.4.0", "version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz", "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz",
"integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==", "integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/workbox-expiration": { "node_modules/workbox-expiration": {
"version": "7.4.0", "version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz", "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz",
"integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==", "integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"idb": "^7.0.1", "idb": "^7.0.1",
@@ -12083,7 +12085,6 @@
"version": "7.4.0", "version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz", "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz",
"integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==", "integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"workbox-core": "7.4.0", "workbox-core": "7.4.0",
@@ -12120,7 +12121,6 @@
"version": "7.4.0", "version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz", "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz",
"integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==", "integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"workbox-core": "7.4.0" "workbox-core": "7.4.0"
@@ -12130,7 +12130,6 @@
"version": "7.4.0", "version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz", "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz",
"integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==", "integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"workbox-core": "7.4.0" "workbox-core": "7.4.0"
+6
View File
@@ -18,6 +18,12 @@
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7", "axios": "^1.6.7",
"dexie": "^4.4.2", "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", "leaflet": "^1.9.4",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0", "mapbox-gl": "^3.22.0",
+1 -1
View File
@@ -218,7 +218,7 @@ export default function App() {
<Route path="/forgot-password" element={<ForgotPasswordPage />} /> <Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} /> <Route path="/reset-password" element={<ResetPasswordPage />} />
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */} {/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} /> <Route path="/oauth/consent" element={<OAuthAuthorizePage />} />
<Route <Route
path="/dashboard" path="/dashboard"
element={ element={
+3
View File
@@ -33,6 +33,7 @@ function translateRateLimit(): string {
export const apiClient: AxiosInstance = axios.create({ export const apiClient: AxiosInstance = axios.create({
baseURL: '/api', baseURL: '/api',
withCredentials: true, withCredentials: true,
timeout: 8000,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@@ -142,6 +143,7 @@ export const oauthApi = {
state?: string state?: string
code_challenge: string code_challenge: string
code_challenge_method: string code_challenge_method: string
resource?: string
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data), }) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
/** Submit user consent (approve or deny) */ /** Submit user consent (approve or deny) */
@@ -153,6 +155,7 @@ export const oauthApi = {
code_challenge: string code_challenge: string
code_challenge_method: string code_challenge_method: string
approved: boolean approved: boolean
resource?: string
}) => apiClient.post('/oauth/authorize', body).then(r => r.data), }) => apiClient.post('/oauth/authorize', body).then(r => r.data),
clients: { clients: {
@@ -10,8 +10,11 @@ import { usePermissionsStore } from '../../store/permissionsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories'; import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories';
import BudgetPanel from './BudgetPanel'; 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(); resetAllStores();
// Settlement and per-person APIs needed by BudgetPanel // Settlement and per-person APIs needed by BudgetPanel
server.use( server.use(
@@ -35,6 +35,7 @@ vi.mock('../../api/client', async (importOriginal) => {
}); });
import { filesApi } from '../../api/client'; import { filesApi } from '../../api/client';
import { offlineDb } from '../../db/offlineDb';
const buildFile = (overrides = {}) => ({ const buildFile = (overrides = {}) => ({
id: 1, id: 1,
@@ -66,7 +67,9 @@ const defaultProps = {
allowedFileTypes: null, allowedFileTypes: null,
}; };
beforeEach(() => { beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores(); resetAllStores();
vi.clearAllMocks(); vi.clearAllMocks();
// Seed auth as admin so useCanDo() returns true for all permissions // Seed auth as admin so useCanDo() returns true for all permissions
@@ -130,15 +133,21 @@ describe('FileManager', () => {
expect(screen.queryByText('doc.pdf')).not.toBeInTheDocument(); 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()]} />); render(<FileManager {...defaultProps} files={[buildFile()]} />);
const user = userEvent.setup(); const user = userEvent.setup();
// Find the star button by its title
const starBtn = screen.getByTitle(/star/i); const starBtn = screen.getByTitle(/star/i);
await user.click(starBtn); 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 () => { it('FE-COMP-FILEMANAGER-006: trash toggle loads and displays trashed files', async () => {
@@ -398,39 +407,47 @@ describe('FileManager', () => {
await screen.findByText('Hotel Paris'); 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 { buildPlace } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Louvre Museum' }); const place = buildPlace({ id: 10, name: 'Louvre Museum' });
const file = buildFile({ id: 1 }); const file = buildFile({ id: 1 });
const onUpdate = vi.fn().mockResolvedValue(undefined); 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} />); render(<FileManager {...defaultProps} files={[file]} places={[place]} onUpdate={onUpdate} />);
const user = userEvent.setup(); const user = userEvent.setup();
// Open assign modal
await user.click(screen.getByTitle(/assign/i)); await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Louvre Museum'); await screen.findByText('Louvre Museum');
// Click on the place button to link it
await user.click(screen.getByText('Louvre Museum')); 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 { buildReservation } = await import('../../../tests/helpers/factories');
const reservation = buildReservation({ id: 20, name: 'Train Ticket' }); const reservation = buildReservation({ id: 20, name: 'Train Ticket' });
const file = buildFile({ id: 1 }); 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]} />); render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
const user = userEvent.setup(); const user = userEvent.setup();
// Open assign modal
await user.click(screen.getByTitle(/assign/i)); await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Train Ticket'); await screen.findByText('Train Ticket');
// Click on the reservation button to link it
await user.click(screen.getByText('Train Ticket')); 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 () => { 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/); 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 { buildPlace } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Venice Beach' }); 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 }); 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]} />); render(<FileManager {...defaultProps} files={[file]} places={[place]} />);
const user = userEvent.setup(); const user = userEvent.setup();
// Open assign modal
await user.click(screen.getByTitle(/assign/i)); await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Venice Beach'); await screen.findByText('Venice Beach');
// Clicking the linked place should unlink it
await user.click(screen.getByText('Venice Beach')); 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 { buildReservation } = await import('../../../tests/helpers/factories');
const reservation = buildReservation({ id: 20, name: 'Museum Pass' }); const reservation = buildReservation({ id: 20, name: 'Museum Pass' });
// File already has reservation_id set to 20
const file = buildFile({ id: 1, reservation_id: 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]} />); render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
const user = userEvent.setup(); const user = userEvent.setup();
await user.click(screen.getByTitle(/assign/i)); await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Museum Pass'); await screen.findByText('Museum Pass');
// Clicking the linked reservation should unlink it
await user.click(screen.getByText('Museum Pass')); 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 () => { it('FE-COMP-FILEMANAGER-033: opening PDF preview and closing via backdrop', async () => {
+3 -2
View File
@@ -5,6 +5,7 @@ import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, M
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { filesApi } from '../../api/client' import { filesApi } from '../../api/client'
import { fileRepo } from '../../repo/fileRepo'
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types' import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
@@ -290,7 +291,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
const handleStar = async (fileId: number) => { const handleStar = async (fileId: number) => {
try { try {
await filesApi.toggleStar(tripId, fileId) await fileRepo.toggleStar(tripId, fileId)
refreshFiles() refreshFiles()
} catch { /* */ } } 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 }) => { const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
try { try {
await filesApi.update(tripId, fileId, data) await fileRepo.update(tripId, fileId, data as Record<string, unknown>)
refreshFiles() refreshFiles()
} catch { } catch {
toast.error(t('files.toast.assignError')) toast.error(t('files.toast.assignError'))
@@ -9,8 +9,11 @@ import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories'; import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
import PackingListPanel from './PackingListPanel'; 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(); resetAllStores();
// Side-effect APIs PackingListPanel calls on mount // Side-effect APIs PackingListPanel calls on mount
server.use( server.use(
@@ -11,6 +11,7 @@ import { usePermissionsStore } from '../../store/permissionsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildAdmin, buildTrip, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories'; import { buildUser, buildAdmin, buildTrip, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories';
import DayDetailPanel from './DayDetailPanel'; 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' }); 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(), onAccommodationChange: vi.fn(),
}; };
beforeEach(() => { beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores(); resetAllStores();
vi.clearAllMocks(); vi.clearAllMocks();
server.use( 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_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' } 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 { weatherApi, accommodationsApi } from '../../api/client'
import { accommodationRepo } from '../../repo/accommodationRepo'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
@@ -117,8 +118,10 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const handleSaveAccommodation = async () => { const handleSaveAccommodation = async () => {
if (!hotelForm.place_id) return if (!hotelForm.place_id) return
try { 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_id: hotelForm.place_id,
place_name: selectedPlace?.name,
start_day_id: hotelDayRange.start, start_day_id: hotelDayRange.start,
end_day_id: hotelDayRange.end, end_day_id: hotelDayRange.end,
check_in: hotelForm.check_in || null, check_in: hotelForm.check_in || null,
@@ -142,7 +145,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const updateAccommodationField = async (field, value) => { const updateAccommodationField = async (field, value) => {
if (!accommodation) return if (!accommodation) return
try { 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) setAccommodation(data.accommodation)
onAccommodationChange?.() onAccommodationChange?.()
} catch {} } catch {}
@@ -151,7 +154,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const handleRemoveAccommodation = async () => { const handleRemoveAccommodation = async () => {
if (!accommodation) return if (!accommodation) return
try { try {
await accommodationsApi.delete(tripId, accommodation.id) await accommodationRepo.delete(tripId, accommodation.id)
const updated = accommodations.filter(a => a.id !== accommodation.id) const updated = accommodations.filter(a => a.id !== accommodation.id)
setAccommodations(updated) setAccommodations(updated)
setDayAccommodations(updated.filter(a => setDayAccommodations(updated.filter(a =>
@@ -583,7 +586,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<button onClick={async () => { <button onClick={async () => {
if (showHotelPicker === 'edit' && accommodation) { if (showHotelPicker === 'edit' && accommodation) {
// Update existing // Update existing
await accommodationsApi.update(tripId, accommodation.id, { await accommodationRepo.update(tripId, accommodation.id, {
place_id: hotelForm.place_id, place_id: hotelForm.place_id,
start_day_id: hotelDayRange.start, start_day_id: hotelDayRange.start,
end_day_id: hotelDayRange.end, end_day_id: hotelDayRange.end,
+199 -15
View File
@@ -1,13 +1,21 @@
/** /**
* Offline settings tab — shows cached trips, storage info, and controls * 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 React, { useState, useEffect, useCallback, useRef } from 'react'
import { Wifi, RefreshCw, Trash2, Database } from 'lucide-react' import { Wifi, RefreshCw, Trash2, Database, Settings2, RotateCcw, CheckCircle } from 'lucide-react'
import Section from './Section' import Section from './Section'
import { offlineDb, clearAll } from '../../db/offlineDb' import { offlineDb, clearAll } from '../../db/offlineDb'
import { tripSyncManager } from '../../sync/tripSyncManager' import { tripSyncManager } from '../../sync/tripSyncManager'
import { mutationQueue } from '../../sync/mutationQueue' 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 { SyncMeta } from '../../db/offlineDb'
import type { Trip } from '../../types' import type { Trip } from '../../types'
@@ -25,6 +33,12 @@ export default function OfflineTab(): React.ReactElement {
const [clearing, setClearing] = useState(false) const [clearing, setClearing] = useState(false)
const [loading, setLoading] = useState(true) 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 () => { const load = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
@@ -53,6 +67,59 @@ export default function OfflineTab(): React.ReactElement {
useEffect(() => { load() }, [load]) 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)
}
}, [])
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() { async function handleResync() {
setSyncing(true) setSyncing(true)
try { try {
@@ -120,6 +187,86 @@ export default function OfflineTab(): React.ReactElement {
</button> </button>
</div> </div>
{/* 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 */} {/* Cached trip list */}
{loading ? ( {loading ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading</p> <p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading</p>
@@ -139,24 +286,32 @@ export default function OfflineTab(): React.ReactElement {
display: 'flex', flexDirection: 'column', gap: 2, display: 'flex', flexDirection: 'column', gap: 2,
}} }}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
{trip.name} <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' }}>
</span> {trip.title || 'Unnamed trip'}
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}> </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 }} /> <Wifi size={10} style={{ display: 'inline', marginRight: 3 }} />
{meta.lastSyncedAt {meta.lastSyncedAt
? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) ? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
: '—'} : '—'}
</span> </span>
</div> </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>
))} ))}
</div> </div>
@@ -178,3 +333,32 @@ function Stat({ label, value }: { label: string; value: number }) {
</div> </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>
)
}
+2
View File
@@ -464,6 +464,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'تحقق', 'login.mfaVerify': 'تحقق',
'login.invalidInviteLink': 'رابط الدعوة غير صالح أو منتهي الصلاحية', 'login.invalidInviteLink': 'رابط الدعوة غير صالح أو منتهي الصلاحية',
'login.oidcFailed': 'فشل تسجيل الدخول عبر OIDC', 'login.oidcFailed': 'فشل تسجيل الدخول عبر OIDC',
'login.configLoadError': 'تعذّر تحميل خيارات تسجيل الدخول.',
'login.configLoadRetry': 'تحديث',
'login.usernameRequired': 'اسم المستخدم مطلوب', 'login.usernameRequired': 'اسم المستخدم مطلوب',
'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل', 'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل',
'login.forgotPassword': 'نسيت كلمة المرور؟', 'login.forgotPassword': 'نسيت كلمة المرور؟',
+2
View File
@@ -459,6 +459,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Verificar', 'login.mfaVerify': 'Verificar',
'login.invalidInviteLink': 'Link de convite inválido ou expirado', 'login.invalidInviteLink': 'Link de convite inválido ou expirado',
'login.oidcFailed': 'Falha no login OIDC', '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.usernameRequired': 'Nome de usuário é obrigatório',
'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres', 'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres',
'login.forgotPassword': 'Esqueceu a senha?', 'login.forgotPassword': 'Esqueceu a senha?',
+2
View File
@@ -459,6 +459,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Ověřit', 'login.mfaVerify': 'Ověřit',
'login.invalidInviteLink': 'Neplatný nebo vypršelý odkaz s pozvánkou', 'login.invalidInviteLink': 'Neplatný nebo vypršelý odkaz s pozvánkou',
'login.oidcFailed': 'Přihlášení přes OIDC se nezdařilo', '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.usernameRequired': 'Uživatelské jméno je povinné',
'login.passwordMinLength': 'Heslo musí mít alespoň 8 znaků', 'login.passwordMinLength': 'Heslo musí mít alespoň 8 znaků',
'login.forgotPassword': 'Zapomenuté heslo?', 'login.forgotPassword': 'Zapomenuté heslo?',
+2
View File
@@ -464,6 +464,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Bestätigen', 'login.mfaVerify': 'Bestätigen',
'login.invalidInviteLink': 'Ungültiger oder abgelaufener Einladungslink', 'login.invalidInviteLink': 'Ungültiger oder abgelaufener Einladungslink',
'login.oidcFailed': 'OIDC-Anmeldung fehlgeschlagen', 'login.oidcFailed': 'OIDC-Anmeldung fehlgeschlagen',
'login.configLoadError': 'Anmeldeoptionen konnten nicht geladen werden.',
'login.configLoadRetry': 'Aktualisieren',
'login.usernameRequired': 'Benutzername ist erforderlich', 'login.usernameRequired': 'Benutzername ist erforderlich',
'login.passwordMinLength': 'Das Passwort muss mindestens 8 Zeichen lang sein', 'login.passwordMinLength': 'Das Passwort muss mindestens 8 Zeichen lang sein',
'login.forgotPassword': 'Passwort vergessen?', 'login.forgotPassword': 'Passwort vergessen?',
+2
View File
@@ -537,6 +537,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Verify', 'login.mfaVerify': 'Verify',
'login.invalidInviteLink': 'Invalid or expired invite link', 'login.invalidInviteLink': 'Invalid or expired invite link',
'login.oidcFailed': 'OIDC login failed', 'login.oidcFailed': 'OIDC login failed',
'login.configLoadError': 'Could not load login options.',
'login.configLoadRetry': 'Refresh',
'login.usernameRequired': 'Username is required', 'login.usernameRequired': 'Username is required',
'login.passwordMinLength': 'Password must be at least 8 characters', 'login.passwordMinLength': 'Password must be at least 8 characters',
'login.forgotPassword': 'Forgot password?', 'login.forgotPassword': 'Forgot password?',
+2
View File
@@ -451,6 +451,8 @@ const es: Record<string, string> = {
'login.mfaVerify': 'Verificar', 'login.mfaVerify': 'Verificar',
'login.invalidInviteLink': 'Enlace de invitación inválido o expirado', 'login.invalidInviteLink': 'Enlace de invitación inválido o expirado',
'login.oidcFailed': 'Error de inicio de sesión OIDC', '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.usernameRequired': 'El nombre de usuario es obligatorio',
'login.passwordMinLength': 'La contraseña debe tener al menos 8 caracteres', 'login.passwordMinLength': 'La contraseña debe tener al menos 8 caracteres',
'login.forgotPassword': '¿Olvidaste tu contraseña?', 'login.forgotPassword': '¿Olvidaste tu contraseña?',
+2
View File
@@ -452,6 +452,8 @@ const fr: Record<string, string> = {
'login.mfaVerify': 'Vérifier', 'login.mfaVerify': 'Vérifier',
'login.invalidInviteLink': 'Lien d\'invitation invalide ou expiré', 'login.invalidInviteLink': 'Lien d\'invitation invalide ou expiré',
'login.oidcFailed': 'Échec de connexion OIDC', '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.usernameRequired': 'Le nom d\'utilisateur est obligatoire',
'login.passwordMinLength': 'Le mot de passe doit comporter au moins 8 caractères', 'login.passwordMinLength': 'Le mot de passe doit comporter au moins 8 caractères',
'login.forgotPassword': 'Mot de passe oublié ?', 'login.forgotPassword': 'Mot de passe oublié ?',
+2
View File
@@ -459,6 +459,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Ellenőrzés', 'login.mfaVerify': 'Ellenőrzés',
'login.invalidInviteLink': 'Érvénytelen vagy lejárt meghívólink', 'login.invalidInviteLink': 'Érvénytelen vagy lejárt meghívólink',
'login.oidcFailed': 'OIDC bejelentkezés sikertelen', '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.usernameRequired': 'A felhasználónév kötelező',
'login.passwordMinLength': 'A jelszónak legalább 8 karakter hosszúnak kell lennie', 'login.passwordMinLength': 'A jelszónak legalább 8 karakter hosszúnak kell lennie',
'login.forgotPassword': 'Elfelejtetted a jelszavad?', 'login.forgotPassword': 'Elfelejtetted a jelszavad?',
+2
View File
@@ -521,6 +521,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Verifikasi', 'login.mfaVerify': 'Verifikasi',
'login.invalidInviteLink': 'Tautan undangan tidak valid atau sudah kedaluwarsa', 'login.invalidInviteLink': 'Tautan undangan tidak valid atau sudah kedaluwarsa',
'login.oidcFailed': 'Login OIDC gagal', 'login.oidcFailed': 'Login OIDC gagal',
'login.configLoadError': 'Gagal memuat opsi login.',
'login.configLoadRetry': 'Segarkan',
'login.usernameRequired': 'Nama pengguna wajib diisi', 'login.usernameRequired': 'Nama pengguna wajib diisi',
'login.passwordMinLength': 'Kata sandi minimal 8 karakter', 'login.passwordMinLength': 'Kata sandi minimal 8 karakter',
'login.forgotPassword': 'Lupa kata sandi?', 'login.forgotPassword': 'Lupa kata sandi?',
+2
View File
@@ -459,6 +459,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Verifica', 'login.mfaVerify': 'Verifica',
'login.invalidInviteLink': 'Link di invito non valido o scaduto', 'login.invalidInviteLink': 'Link di invito non valido o scaduto',
'login.oidcFailed': 'Accesso OIDC non riuscito', 'login.oidcFailed': 'Accesso OIDC non riuscito',
'login.configLoadError': 'Impossibile caricare le opzioni di accesso.',
'login.configLoadRetry': 'Aggiorna',
'login.usernameRequired': 'Il nome utente è obbligatorio', 'login.usernameRequired': 'Il nome utente è obbligatorio',
'login.passwordMinLength': 'La password deve contenere almeno 8 caratteri', 'login.passwordMinLength': 'La password deve contenere almeno 8 caratteri',
'login.forgotPassword': 'Password dimenticata?', 'login.forgotPassword': 'Password dimenticata?',
+2
View File
@@ -452,6 +452,8 @@ const nl: Record<string, string> = {
'login.mfaVerify': 'Verifiëren', 'login.mfaVerify': 'Verifiëren',
'login.invalidInviteLink': 'Ongeldige of verlopen uitnodigingslink', 'login.invalidInviteLink': 'Ongeldige of verlopen uitnodigingslink',
'login.oidcFailed': 'OIDC-aanmelding mislukt', 'login.oidcFailed': 'OIDC-aanmelding mislukt',
'login.configLoadError': 'Kan aanmeldingsopties niet laden.',
'login.configLoadRetry': 'Vernieuwen',
'login.usernameRequired': 'Gebruikersnaam is vereist', 'login.usernameRequired': 'Gebruikersnaam is vereist',
'login.passwordMinLength': 'Wachtwoord moet minimaal 8 tekens bevatten', 'login.passwordMinLength': 'Wachtwoord moet minimaal 8 tekens bevatten',
'login.forgotPassword': 'Wachtwoord vergeten?', 'login.forgotPassword': 'Wachtwoord vergeten?',
+2
View File
@@ -426,6 +426,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Weryfikuj', 'login.mfaVerify': 'Weryfikuj',
'login.invalidInviteLink': 'Nieprawidłowy lub wygasły link zaproszenia', 'login.invalidInviteLink': 'Nieprawidłowy lub wygasły link zaproszenia',
'login.oidcFailed': 'Logowanie OIDC nie powiodło się', '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.usernameRequired': 'Nazwa użytkownika jest wymagana',
'login.passwordMinLength': 'Hasło musi mieć co najmniej 8 znaków', 'login.passwordMinLength': 'Hasło musi mieć co najmniej 8 znaków',
'login.forgotPassword': 'Nie pamiętasz hasła?', 'login.forgotPassword': 'Nie pamiętasz hasła?',
+2
View File
@@ -452,6 +452,8 @@ const ru: Record<string, string> = {
'login.mfaVerify': 'Подтвердить', 'login.mfaVerify': 'Подтвердить',
'login.invalidInviteLink': 'Недействительная или истёкшая ссылка-приглашение', 'login.invalidInviteLink': 'Недействительная или истёкшая ссылка-приглашение',
'login.oidcFailed': 'Ошибка входа через OIDC', 'login.oidcFailed': 'Ошибка входа через OIDC',
'login.configLoadError': 'Не удалось загрузить параметры входа.',
'login.configLoadRetry': 'Обновить',
'login.usernameRequired': 'Имя пользователя обязательно', 'login.usernameRequired': 'Имя пользователя обязательно',
'login.passwordMinLength': 'Пароль должен содержать не менее 8 символов', 'login.passwordMinLength': 'Пароль должен содержать не менее 8 символов',
'login.forgotPassword': 'Забыли пароль?', 'login.forgotPassword': 'Забыли пароль?',
+2
View File
@@ -452,6 +452,8 @@ const zh: Record<string, string> = {
'login.mfaVerify': '验证', 'login.mfaVerify': '验证',
'login.invalidInviteLink': '邀请链接无效或已过期', 'login.invalidInviteLink': '邀请链接无效或已过期',
'login.oidcFailed': 'OIDC 登录失败', 'login.oidcFailed': 'OIDC 登录失败',
'login.configLoadError': '无法加载登录选项。',
'login.configLoadRetry': '刷新',
'login.usernameRequired': '用户名为必填项', 'login.usernameRequired': '用户名为必填项',
'login.passwordMinLength': '密码至少需要8个字符', 'login.passwordMinLength': '密码至少需要8个字符',
'login.forgotPassword': '忘记密码?', 'login.forgotPassword': '忘记密码?',
+2
View File
@@ -511,6 +511,8 @@ const zhTw: Record<string, string> = {
'login.mfaVerify': '驗證', 'login.mfaVerify': '驗證',
'login.invalidInviteLink': '邀請連結無效或已過期', 'login.invalidInviteLink': '邀請連結無效或已過期',
'login.oidcFailed': 'OIDC 登入失敗', 'login.oidcFailed': 'OIDC 登入失敗',
'login.configLoadError': '無法載入登入選項。',
'login.configLoadRetry': '重新整理',
'login.usernameRequired': '使用者名稱為必填', 'login.usernameRequired': '使用者名稱為必填',
'login.passwordMinLength': '密碼至少需要8個字元', 'login.passwordMinLength': '密碼至少需要8個字元',
'login.forgotPassword': '忘記密碼?', 'login.forgotPassword': '忘記密碼?',
+9 -4
View File
@@ -744,12 +744,17 @@ export default function DashboardPage(): React.ReactElement {
const loadTrips = async () => { const loadTrips = async () => {
setIsLoading(true) setIsLoading(true)
try { try {
const { trips, archivedTrips } = await tripRepo.list() const { trips, archivedTrips, refresh } = await tripRepo.list()
setTrips(sortTrips(trips)) setTrips(sortTrips(trips))
setArchivedTrips(sortTrips(archivedTrips)) setArchivedTrips(sortTrips(archivedTrips))
setIsLoading(false)
refresh.then(fresh => {
if (!fresh) return
setTrips(sortTrips(fresh.trips))
setArchivedTrips(sortTrips(fresh.archivedTrips))
}).catch(() => {})
} catch { } catch {
toast.error(t('dashboard.toast.loadError')) toast.error(t('dashboard.toast.loadError'))
} finally {
setIsLoading(false) setIsLoading(false)
} }
} }
@@ -791,7 +796,7 @@ export default function DashboardPage(): React.ReactElement {
const handleArchive = async (id) => { const handleArchive = async (id) => {
try { try {
const data = await tripsApi.archive(id) const data = await tripRepo.update(id, { is_archived: true })
setTrips(prev => prev.filter(t => t.id !== id)) setTrips(prev => prev.filter(t => t.id !== id))
setArchivedTrips(prev => sortTrips([data.trip, ...prev])) setArchivedTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.archived')) toast.success(t('dashboard.toast.archived'))
@@ -802,7 +807,7 @@ export default function DashboardPage(): React.ReactElement {
const handleUnarchive = async (id) => { const handleUnarchive = async (id) => {
try { try {
const data = await tripsApi.unarchive(id) const data = await tripRepo.update(id, { is_archived: false })
setArchivedTrips(prev => prev.filter(t => t.id !== id)) setArchivedTrips(prev => prev.filter(t => t.id !== id))
setTrips(prev => sortTrips([data.trip, ...prev])) setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.restored')) toast.success(t('dashboard.toast.restored'))
+4 -1
View File
@@ -9,6 +9,7 @@ import { buildUser, buildTrip, buildTripFile } from '../../tests/helpers/factori
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useTripStore } from '../store/tripStore'; import { useTripStore } from '../store/tripStore';
import FilesPage from './FilesPage'; import FilesPage from './FilesPage';
import { offlineDb } from '../db/offlineDb';
vi.mock('../components/Files/FileManager', () => ({ vi.mock('../components/Files/FileManager', () => ({
default: ({ files }: { files: unknown[]; onUpload: unknown; onDelete: unknown }) => 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(); vi.clearAllMocks();
resetAllStores(); resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() }); 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', () => { describe('FE-PAGE-LOGIN-022: redirect param stashed in sessionStorage on mount', () => {
it('saves decoded redirect to sessionStorage when ?redirect= is present', async () => { 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 />); render(<LoginPage />);
await waitFor(() => { 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 () => { 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'); setSearch('?oidc_code=testcode123');
render(<LoginPage />); render(<LoginPage />);
await waitFor(() => { await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith( expect(mockNavigate).toHaveBeenCalledWith(
'/oauth/authorize?client_id=foo&state=xyz', '/oauth/consent?client_id=foo&state=xyz',
{ replace: true }, { replace: true },
); );
}); });
@@ -93,7 +93,7 @@ describe('LoginPage — OIDC redirect preservation', () => {
describe('FE-PAGE-LOGIN-024: OIDC error clears sessionStorage redirect', () => { describe('FE-PAGE-LOGIN-024: OIDC error clears sessionStorage redirect', () => {
it('removes oidc_redirect from sessionStorage on OIDC error', async () => { 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'); setSearch('?oidc_error=token_failed');
render(<LoginPage />); render(<LoginPage />);
+19 -4
View File
@@ -33,6 +33,7 @@ export default function LoginPage(): React.ReactElement {
const [isLoading, setIsLoading] = useState<boolean>(false) const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string>('') const [error, setError] = useState<string>('')
const [appConfig, setAppConfig] = useState<AppConfig | null>(null) const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
const [configError, setConfigError] = useState<boolean>(false)
const [inviteToken, setInviteToken] = useState<string>('') const [inviteToken, setInviteToken] = useState<string>('')
const [inviteValid, setInviteValid] = useState<boolean>(false) const [inviteValid, setInviteValid] = useState<boolean>(false)
const exchangeInitiated = useRef(false) const exchangeInitiated = useRef(false)
@@ -117,15 +118,15 @@ export default function LoginPage(): React.ReactElement {
return return
} }
authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => { authApi.getAppConfig?.()
if (config) { .then((config: AppConfig) => {
setAppConfig(config) setAppConfig(config)
if (!config.has_users) setMode('register') if (!config.has_users) setMode('register')
if (!config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) { if (!config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) {
window.location.href = '/api/auth/oidc/login' window.location.href = '/api/auth/oidc/login'
} }
} })
}) .catch(() => setConfigError(true))
}, [navigate, t, noRedirect]) }, [navigate, t, noRedirect])
// Language detection chain (runs once on mount, only if user has no saved preference): // 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 */} {/* Demo login button */}
{appConfig?.demo_mode && ( {appConfig?.demo_mode && (
<button onClick={handleDemoLogin} disabled={isLoading} <button onClick={handleDemoLogin} disabled={isLoading}
+1 -1
View File
@@ -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'; 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) { function setSearchParams(search: string) {
window.history.pushState({}, '', '/oauth/authorize' + search); window.history.pushState({}, '', '/oauth/consent' + search);
} }
const VALIDATE_OK = { const VALIDATE_OK = {
+4 -1
View File
@@ -34,6 +34,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
const state = params.get('state') || '' const state = params.get('state') || ''
const codeChallenge = params.get('code_challenge') || '' const codeChallenge = params.get('code_challenge') || ''
const ccMethod = params.get('code_challenge_method') || '' const ccMethod = params.get('code_challenge_method') || ''
const resource = params.get('resource') || undefined
// Load auth state once, then validate // Load auth state once, then validate
useEffect(() => { useEffect(() => {
@@ -57,6 +58,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
code_challenge: codeChallenge, code_challenge: codeChallenge,
code_challenge_method: ccMethod, code_challenge_method: ccMethod,
response_type: 'code', response_type: 'code',
resource,
}) })
setValidation(result) setValidation(result)
@@ -99,6 +101,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
code_challenge: codeChallenge, code_challenge: codeChallenge,
code_challenge_method: ccMethod, code_challenge_method: ccMethod,
approved, approved,
resource,
}) })
setPageState('done') setPageState('done')
window.location.href = result.redirect window.location.href = result.redirect
@@ -124,7 +127,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
} }
function handleLoginRedirect() { 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) window.location.href = '/login?redirect=' + encodeURIComponent(next)
} }
+82 -9
View File
@@ -1,16 +1,89 @@
import { accommodationsApi } from '../api/client' import { accommodationsApi } from '../api/client'
import { offlineDb, upsertAccommodations } from '../db/offlineDb' import { offlineDb, upsertAccommodations } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { Accommodation } from '../types' import type { Accommodation } from '../types'
export const accommodationRepo = { export const accommodationRepo = {
async list(tripId: number | string): Promise<{ accommodations: Accommodation[] }> { async list(tripId: number | string): Promise<{ accommodations: Accommodation[]; refresh: Promise<{ accommodations: Accommodation[] } | null> }> {
if (!navigator.onLine) { const cached = await offlineDb.accommodations
const accommodations = await offlineDb.accommodations .where('trip_id').equals(Number(tripId)).toArray()
.where('trip_id').equals(Number(tripId)).toArray()
return { accommodations } const refresh = (async () => {
} if (!navigator.onLine) return null
const result = await accommodationsApi.list(tripId) try {
upsertAccommodations(result.accommodations || []).catch(() => {}) const result = await accommodationsApi.list(tripId)
return result 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(fresh) }
},
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 }
}, },
} }
+79 -11
View File
@@ -1,18 +1,86 @@
import { budgetApi } from '../api/client' import { budgetApi } from '../api/client'
import { offlineDb, upsertBudgetItems } from '../db/offlineDb' import { offlineDb, upsertBudgetItems } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { BudgetItem } from '../types' import type { BudgetItem } from '../types'
export const budgetRepo = { export const budgetRepo = {
async list(tripId: number | string): Promise<{ items: BudgetItem[] }> { async list(tripId: number | string): Promise<{ items: BudgetItem[]; refresh: Promise<{ items: BudgetItem[] } | null> }> {
if (!navigator.onLine) { const cached = await offlineDb.budgetItems
const cached = await offlineDb.budgetItems .where('trip_id')
.where('trip_id') .equals(Number(tripId))
.equals(Number(tripId)) .toArray()
.toArray()
return { items: cached } const refresh = (async () => {
} if (!navigator.onLine) return null
const result = await budgetApi.list(tripId) try {
upsertBudgetItems(result.items) const result = await budgetApi.list(tripId)
return result upsertBudgetItems(result.items)
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(fresh) }
},
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 }
}, },
} }
+39 -11
View File
@@ -1,18 +1,46 @@
import { daysApi } from '../api/client' import { daysApi } from '../api/client'
import { offlineDb, upsertDays } from '../db/offlineDb' import { offlineDb, upsertDays } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { Day } from '../types' import type { Day } from '../types'
export const dayRepo = { export const dayRepo = {
async list(tripId: number | string): Promise<{ days: Day[] }> { async list(tripId: number | string): Promise<{ days: Day[]; refresh: Promise<{ days: Day[] } | null> }> {
if (!navigator.onLine) { const cached = (await offlineDb.days
const cached = await offlineDb.days .where('trip_id')
.where('trip_id') .equals(Number(tripId))
.equals(Number(tripId)) .sortBy('day_number' as keyof Day)) as Day[]
.sortBy('day_number' as keyof Day)
return { days: cached as Day[] } const refresh = (async () => {
} if (!navigator.onLine) return null
const result = await daysApi.list(tripId) try {
upsertDays(result.days) const result = await daysApi.list(tripId)
return result upsertDays(result.days)
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(fresh) }
},
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 }
}, },
} }
+69 -10
View File
@@ -1,18 +1,77 @@
import { filesApi } from '../api/client' import { filesApi } from '../api/client'
import { offlineDb, upsertTripFiles } from '../db/offlineDb' import { offlineDb, upsertTripFiles } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { TripFile } from '../types' import type { TripFile } from '../types'
export const fileRepo = { export const fileRepo = {
async list(tripId: number | string): Promise<{ files: TripFile[] }> { async list(tripId: number | string): Promise<{ files: TripFile[]; refresh: Promise<{ files: TripFile[] } | null> }> {
if (!navigator.onLine) { const cached = await offlineDb.tripFiles
const cached = await offlineDb.tripFiles .where('trip_id')
.where('trip_id') .equals(Number(tripId))
.equals(Number(tripId)) .toArray()
.toArray()
return { files: cached } const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await filesApi.list(tripId)
upsertTripFiles(result.files)
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(fresh) }
},
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) await mutationQueue.enqueue({
upsertTripFiles(result.files) id: generateUUID(),
return result 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 }
}, },
} }
+67 -71
View File
@@ -4,85 +4,81 @@ import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { PackingItem } from '../types' import type { PackingItem } from '../types'
export const packingRepo = { export const packingRepo = {
async list(tripId: number | string): Promise<{ items: PackingItem[] }> { async list(tripId: number | string): Promise<{ items: PackingItem[]; refresh: Promise<{ items: PackingItem[] } | null> }> {
if (!navigator.onLine) { const cached = await offlineDb.packingItems
const cached = await offlineDb.packingItems .where('trip_id')
.where('trip_id') .equals(Number(tripId))
.equals(Number(tripId)) .toArray()
.toArray()
return { items: cached } const refresh = (async () => {
} if (!navigator.onLine) return null
const result = await packingApi.list(tripId) try {
upsertPackingItems(result.items) const result = await packingApi.list(tripId)
return result upsertPackingItems(result.items)
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(fresh) }
}, },
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ item: PackingItem }> { async create(tripId: number | string, data: Record<string, unknown>): Promise<{ item: PackingItem }> {
if (!navigator.onLine) { const tempId = -(Date.now())
const tempId = -(Date.now()) const tempItem: PackingItem = {
const tempItem: PackingItem = { ...(data as Partial<PackingItem>),
...(data as Partial<PackingItem>), id: tempId,
id: tempId, trip_id: Number(tripId),
trip_id: Number(tripId), name: (data.name as string) ?? 'New item',
name: (data.name as string) ?? 'New item', checked: 0,
checked: 0, } as PackingItem
} as PackingItem await offlineDb.packingItems.put(tempItem)
await offlineDb.packingItems.put(tempItem) await mutationQueue.enqueue({
const id = generateUUID() id: generateUUID(),
await mutationQueue.enqueue({ tripId: Number(tripId),
id, method: 'POST',
tripId: Number(tripId), url: `/trips/${tripId}/packing`,
method: 'POST', body: data,
url: `/trips/${tripId}/packing`, resource: 'packingItems',
body: data, tempId,
resource: 'packingItems', })
tempId, mutationQueue.flush().catch(() => {})
}) return { item: tempItem }
return { item: tempItem }
}
const result = await packingApi.create(tripId, data)
offlineDb.packingItems.put(result.item)
return result
}, },
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ item: PackingItem }> { 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 existing = await offlineDb.packingItems.get(id) const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial<PackingItem>), id }
const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial<PackingItem>), id } await offlineDb.packingItems.put(optimistic)
await offlineDb.packingItems.put(optimistic) await mutationQueue.enqueue({
const mutId = generateUUID() id: generateUUID(),
await mutationQueue.enqueue({ tripId: Number(tripId),
id: mutId, method: 'PUT',
tripId: Number(tripId), url: `/trips/${tripId}/packing/${id}`,
method: 'PUT', body: data,
url: `/trips/${tripId}/packing/${id}`, resource: 'packingItems',
body: data, })
resource: 'packingItems', mutationQueue.flush().catch(() => {})
}) return { item: optimistic }
return { item: optimistic }
}
const result = await packingApi.update(tripId, id, data)
offlineDb.packingItems.put(result.item)
return result
}, },
async delete(tripId: number | string, id: number): Promise<unknown> { async delete(tripId: number | string, id: number): Promise<unknown> {
if (!navigator.onLine) { await offlineDb.packingItems.delete(id)
await offlineDb.packingItems.delete(id) await mutationQueue.enqueue({
const mutId = generateUUID() id: generateUUID(),
await mutationQueue.enqueue({ tripId: Number(tripId),
id: mutId, method: 'DELETE',
tripId: Number(tripId), url: `/trips/${tripId}/packing/${id}`,
method: 'DELETE', body: undefined,
url: `/trips/${tripId}/packing/${id}`, resource: 'packingItems',
body: undefined, entityId: id,
resource: 'packingItems', })
entityId: id, mutationQueue.flush().catch(() => {})
}) return { success: true }
return { success: true }
}
const result = await packingApi.delete(tripId, id)
offlineDb.packingItems.delete(id)
return result
}, },
} }
+75 -84
View File
@@ -4,106 +4,97 @@ import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { Place } from '../types' import type { Place } from '../types'
export const placeRepo = { export const placeRepo = {
async list(tripId: number | string, params?: Record<string, unknown>): Promise<{ places: Place[] }> { async list(tripId: number | string, params?: Record<string, unknown>): Promise<{ places: Place[]; refresh: Promise<{ places: Place[] } | null> }> {
if (!navigator.onLine) { const cached = await offlineDb.places
const cached = await offlineDb.places .where('trip_id')
.where('trip_id') .equals(Number(tripId))
.equals(Number(tripId)) .toArray()
.toArray()
return { places: cached } const refresh = (async () => {
} if (!navigator.onLine) return null
const result = await placesApi.list(tripId, params) try {
upsertPlaces(result.places) const result = await placesApi.list(tripId, params)
return result upsertPlaces(result.places)
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(fresh) }
}, },
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ place: Place }> { async create(tripId: number | string, data: Record<string, unknown>): Promise<{ place: Place }> {
if (!navigator.onLine) { const tempId = -(Date.now())
const tempId = -(Date.now()) const tempPlace: Place = {
const tempPlace: Place = { ...(data as Partial<Place>),
...(data as Partial<Place>), id: tempId,
id: tempId, trip_id: Number(tripId),
trip_id: Number(tripId), name: (data.name as string) ?? 'New place',
name: (data.name as string) ?? 'New place', } as Place
} as Place await offlineDb.places.put(tempPlace)
await offlineDb.places.put(tempPlace) await mutationQueue.enqueue({
const id = generateUUID() id: generateUUID(),
await mutationQueue.enqueue({ tripId: Number(tripId),
id, method: 'POST',
tripId: Number(tripId), url: `/trips/${tripId}/places`,
method: 'POST', body: data,
url: `/trips/${tripId}/places`, resource: 'places',
body: data, tempId,
resource: 'places', })
tempId, mutationQueue.flush().catch(() => {})
}) return { place: tempPlace }
return { place: tempPlace }
}
const result = await placesApi.create(tripId, data)
offlineDb.places.put(result.place)
return result
}, },
async update(tripId: number | string, id: number | string, data: Record<string, unknown>): Promise<{ place: Place }> { 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 existing = await offlineDb.places.get(Number(id)) const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial<Place>), id: Number(id) }
const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial<Place>), id: Number(id) } await offlineDb.places.put(optimistic)
await offlineDb.places.put(optimistic) await mutationQueue.enqueue({
const mutId = generateUUID() id: generateUUID(),
await mutationQueue.enqueue({ tripId: Number(tripId),
id: mutId, method: 'PUT',
tripId: Number(tripId), url: `/trips/${tripId}/places/${id}`,
method: 'PUT', body: data,
url: `/trips/${tripId}/places/${id}`, resource: 'places',
body: data, })
resource: 'places', mutationQueue.flush().catch(() => {})
}) return { place: optimistic }
return { place: optimistic }
}
const result = await placesApi.update(tripId, id, data)
offlineDb.places.put(result.place)
return result
}, },
async delete(tripId: number | string, id: number | string): Promise<unknown> { async delete(tripId: number | string, id: number | string): Promise<unknown> {
if (!navigator.onLine) { await offlineDb.places.delete(Number(id))
await offlineDb.places.delete(Number(id)) await mutationQueue.enqueue({
const mutId = generateUUID() 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({ await mutationQueue.enqueue({
id: mutId, id: generateUUID(),
tripId: Number(tripId), tripId: Number(tripId),
method: 'DELETE', method: 'DELETE',
url: `/trips/${tripId}/places/${id}`, url: `/trips/${tripId}/places/${id}`,
body: undefined, body: undefined,
resource: 'places', resource: 'places',
entityId: Number(id), entityId: id,
}) })
return { success: true }
} }
const result = await placesApi.delete(tripId, id) mutationQueue.flush().catch(() => {})
offlineDb.places.delete(Number(id)) return { deleted: ids, count: ids.length }
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
}, },
} }
+84 -11
View File
@@ -1,18 +1,91 @@
import { reservationsApi } from '../api/client' import { reservationsApi } from '../api/client'
import { offlineDb, upsertReservations } from '../db/offlineDb' import { offlineDb, upsertReservations } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { Reservation } from '../types' import type { Reservation } from '../types'
export const reservationRepo = { export const reservationRepo = {
async list(tripId: number | string): Promise<{ reservations: Reservation[] }> { async list(tripId: number | string): Promise<{ reservations: Reservation[]; refresh: Promise<{ reservations: Reservation[] } | null> }> {
if (!navigator.onLine) { const cached = await offlineDb.reservations
const cached = await offlineDb.reservations .where('trip_id')
.where('trip_id') .equals(Number(tripId))
.equals(Number(tripId)) .toArray()
.toArray()
return { reservations: cached } const refresh = (async () => {
} if (!navigator.onLine) return null
const result = await reservationsApi.list(tripId) try {
upsertReservations(result.reservations) const result = await reservationsApi.list(tripId)
return result upsertReservations(result.reservations)
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(fresh) }
},
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 }
}, },
} }
+82 -11
View File
@@ -1,18 +1,89 @@
import { todoApi } from '../api/client' import { todoApi } from '../api/client'
import { offlineDb, upsertTodoItems } from '../db/offlineDb' import { offlineDb, upsertTodoItems } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { TodoItem } from '../types' import type { TodoItem } from '../types'
export const todoRepo = { export const todoRepo = {
async list(tripId: number | string): Promise<{ items: TodoItem[] }> { async list(tripId: number | string): Promise<{ items: TodoItem[]; refresh: Promise<{ items: TodoItem[] } | null> }> {
if (!navigator.onLine) { const cached = await offlineDb.todoItems
const cached = await offlineDb.todoItems .where('trip_id')
.where('trip_id') .equals(Number(tripId))
.equals(Number(tripId)) .toArray()
.toArray()
return { items: cached } const refresh = (async () => {
} if (!navigator.onLine) return null
const result = await todoApi.list(tripId) try {
upsertTodoItems(result.items) const result = await todoApi.list(tripId)
return result upsertTodoItems(result.items)
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(fresh) }
},
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 }
}, },
} }
+63 -19
View File
@@ -1,33 +1,77 @@
import { tripsApi } from '../api/client' import { tripsApi } from '../api/client'
import { offlineDb, upsertTrip } from '../db/offlineDb' import { offlineDb, upsertTrip } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { Trip } from '../types' import type { Trip } from '../types'
type TripsRefresh = Promise<{ trips: Trip[]; archivedTrips: Trip[] } | null>
type TripRefresh = Promise<{ trip: Trip } | null>
export const tripRepo = { export const tripRepo = {
async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[] }> { async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[]; refresh: TripsRefresh }> {
if (!navigator.onLine) { const all = await offlineDb.trips.toArray()
const all = await offlineDb.trips.toArray()
const refresh: TripsRefresh = (async () => {
if (!navigator.onLine) return null
try {
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 }
} catch {
return null
}
})()
if (all.length > 0) {
return { return {
trips: all.filter(t => !t.is_archived), trips: all.filter(t => !t.is_archived),
archivedTrips: all.filter(t => t.is_archived), archivedTrips: all.filter(t => t.is_archived),
refresh,
} }
} }
const [active, archived] = await Promise.all([
tripsApi.list(), const fresh = await refresh
tripsApi.list({ archived: 1 }), if (!fresh) return { trips: [], archivedTrips: [], refresh: Promise.resolve(null) }
]) return { ...fresh, refresh: Promise.resolve(fresh) }
active.trips.forEach(t => upsertTrip(t))
archived.trips.forEach(t => upsertTrip(t))
return { trips: active.trips, archivedTrips: archived.trips }
}, },
async get(tripId: number | string): Promise<{ trip: Trip }> { async get(tripId: number | string): Promise<{ trip: Trip; refresh: TripRefresh }> {
if (!navigator.onLine) { const cached = await offlineDb.trips.get(Number(tripId))
const cached = await offlineDb.trips.get(Number(tripId))
if (cached) return { trip: cached } const refresh: TripRefresh = (async () => {
throw new Error('No cached trip data available offline') if (!navigator.onLine) return null
} try {
const result = await tripsApi.get(tripId) const result = await tripsApi.get(tripId)
upsertTrip(result.trip) upsertTrip(result.trip)
return result 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(fresh) }
},
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 }
}, },
} }
@@ -1,4 +1,6 @@
import { assignmentsApi } from '../../api/client' import { assignmentsApi } from '../../api/client'
import { offlineDb } from '../../db/offlineDb'
import { mutationQueue, generateUUID } from '../../sync/mutationQueue'
import type { StoreApi } from 'zustand' import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore' import type { TripStoreState } from '../tripStore'
import type { Assignment, AssignmentsMap } from '../../types' 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 { try {
const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId }) const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId })
const newAssignment: Assignment = { 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 { try {
await assignmentsApi.delete(tripId, dayId, assignmentId) await assignmentsApi.delete(tripId, dayId, assignmentId)
} catch (err: unknown) { } catch (err: unknown) {
+24 -23
View File
@@ -4,8 +4,11 @@ import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildBudgetItem } from '../../../tests/helpers/factories'; import { buildBudgetItem } from '../../../tests/helpers/factories';
import { useTripStore } from '../tripStore'; 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(); resetAllStores();
server.resetHandlers(); server.resetHandlers();
}); });
@@ -34,25 +37,28 @@ describe('budgetSlice', () => {
expect(useTripStore.getState().budgetItems).toEqual([]); expect(useTripStore.getState().budgetItems).toEqual([]);
}); });
it('FE-STORE-BUDGET-003: addBudgetItem appends to store and returns item', async () => { it('FE-STORE-BUDGET-003: addBudgetItem appends to store optimistically', async () => {
const newItem = buildBudgetItem({ name: 'Hotel', trip_id: 1 });
server.use( server.use(
http.post('/api/trips/1/budget', () => 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' }); const result = await useTripStore.getState().addBudgetItem(1, { name: 'Hotel' });
expect(result.id).toBe(newItem.id); expect(result.name).toBe('Hotel');
expect(useTripStore.getState().budgetItems).toContainEqual(newItem); 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( server.use(
http.post('/api/trips/1/budget', () => http.post('/api/trips/1/budget', () =>
HttpResponse.json({ error: 'Validation failed' }, { status: 422 }) 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 () => { it('FE-STORE-BUDGET-005: updateBudgetItem replaces item in store', async () => {
@@ -71,24 +77,21 @@ describe('budgetSlice', () => {
expect(items[0].name).toBe('New'); expect(items[0].name).toBe('New');
}); });
it('FE-STORE-BUDGET-006: updateBudgetItem calls loadReservations when reservation_id + total_price provided', async () => { it('FE-STORE-BUDGET-006: updateBudgetItem resolves and updates store optimistically', async () => {
const existing = buildBudgetItem({ id: 20, trip_id: 1 }); const existing = buildBudgetItem({ id: 20, trip_id: 1, amount: 100 });
seedStore(useTripStore, { budgetItems: [existing] }); seedStore(useTripStore, { budgetItems: [existing] });
const loadReservations = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, { loadReservations });
const itemWithReservation = { ...existing, reservation_id: 99 };
server.use( server.use(
http.put('/api/trips/1/budget/20', () => 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 }); const result = await useTripStore.getState().updateBudgetItem(1, 20, { amount: 50 });
expect(loadReservations).toHaveBeenCalledWith(1); 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 }); const item = buildBudgetItem({ id: 5, trip_id: 1 });
seedStore(useTripStore, { budgetItems: [item] }); seedStore(useTripStore, { budgetItems: [item] });
@@ -97,11 +100,9 @@ describe('budgetSlice', () => {
HttpResponse.json({ error: 'forbidden' }, { status: 403 }) HttpResponse.json({ error: 'forbidden' }, { status: 403 })
) )
); );
// The item is removed immediately (optimistic), then restored on error await useTripStore.getState().deleteBudgetItem(1, 5);
const deletePromise = useTripStore.getState().deleteBudgetItem(1, 5); // Permanently removed (queued for sync, no rollback)
await expect(deletePromise).rejects.toThrow(); expect(useTripStore.getState().budgetItems).toHaveLength(0);
// After rollback, item is back
expect(useTripStore.getState().budgetItems).toContainEqual(item);
}); });
it('FE-STORE-BUDGET-008: setBudgetItemMembers updates members on matching item', async () => { it('FE-STORE-BUDGET-008: setBudgetItemMembers updates members on matching item', async () => {
+6 -3
View File
@@ -24,6 +24,9 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
try { try {
const data = await budgetRepo.list(tripId) const data = await budgetRepo.list(tripId)
set({ budgetItems: data.items }) set({ budgetItems: data.items })
data.refresh.then(fresh => {
if (fresh) set({ budgetItems: fresh.items })
}).catch(() => {})
} catch (err: unknown) { } catch (err: unknown) {
console.error('Failed to load budget items:', err) console.error('Failed to load budget items:', err)
} }
@@ -31,7 +34,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
addBudgetItem: async (tripId, data) => { addBudgetItem: async (tripId, data) => {
try { 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] })) set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
return result.item return result.item
} catch (err: unknown) { } catch (err: unknown) {
@@ -41,7 +44,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
updateBudgetItem: async (tripId, id, data) => { updateBudgetItem: async (tripId, id, data) => {
try { try {
const result = await budgetApi.update(tripId, id, data) const result = await budgetRepo.update(tripId, id, data as Record<string, unknown>)
set(state => ({ set(state => ({
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item) 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 const prev = get().budgetItems
set(state => ({ budgetItems: state.budgetItems.filter(item => item.id !== id) })) set(state => ({ budgetItems: state.budgetItems.filter(item => item.id !== id) }))
try { try {
await budgetApi.delete(tripId, id) await budgetRepo.delete(tripId, id)
} catch (err: unknown) { } catch (err: unknown) {
set({ budgetItems: prev }) set({ budgetItems: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting budget item')) throw new Error(getApiErrorMessage(err, 'Error deleting budget item'))
+67 -3
View File
@@ -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 { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore' import type { TripStoreState } from '../tripStore'
import type { DayNote } from '../../types' import type { DayNote } from '../../types'
@@ -19,7 +22,7 @@ export interface DayNotesSlice {
export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice => ({ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice => ({
updateDayNotes: async (tripId, dayId, notes) => { updateDayNotes: async (tripId, dayId, notes) => {
try { try {
await daysApi.update(tripId, dayId, { notes }) await dayRepo.update(tripId, dayId, { notes })
set(state => ({ set(state => ({
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, notes } : d) 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) => { updateDayTitle: async (tripId, dayId, title) => {
try { try {
await daysApi.update(tripId, dayId, { title }) await dayRepo.update(tripId, dayId, { title })
set(state => ({ set(state => ({
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, title } : d) 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], [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 { try {
const result = await dayNotesApi.create(tripId, dayId, data) const result = await dayNotesApi.create(tripId, dayId, data)
set(state => ({ set(state => ({
@@ -69,6 +88,32 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
}, },
updateDayNote: async (tripId, dayId, id, data) => { 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 { try {
const result = await dayNotesApi.update(tripId, dayId, id, data) const result = await dayNotesApi.update(tripId, dayId, id, data)
set(state => ({ 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), [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 { try {
await dayNotesApi.delete(tripId, dayId, id) await dayNotesApi.delete(tripId, dayId, id)
} catch (err: unknown) { } catch (err: unknown) {
+4 -2
View File
@@ -35,10 +35,12 @@ export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({
}, },
deleteFile: async (tripId, id) => { deleteFile: async (tripId, id) => {
const prev = get().files
set(state => ({ files: state.files.filter(f => f.id !== id) }))
try { try {
await filesApi.delete(tripId, id) await fileRepo.delete(tripId, id)
set(state => ({ files: state.files.filter(f => f.id !== id) }))
} catch (err: unknown) { } catch (err: unknown) {
set({ files: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting file')) throw new Error(getApiErrorMessage(err, 'Error deleting file'))
} }
}, },
+3
View File
@@ -20,6 +20,9 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
try { try {
const data = await placeRepo.list(tripId) const data = await placeRepo.list(tripId)
set({ places: data.places }) set({ places: data.places })
data.refresh.then(fresh => {
if (fresh) set({ places: fresh.places })
}).catch(() => {})
} catch (err: unknown) { } catch (err: unknown) {
console.error('Failed to refresh places:', err) console.error('Failed to refresh places:', err)
} }
+7 -6
View File
@@ -1,4 +1,3 @@
import { reservationsApi } from '../../api/client'
import { reservationRepo } from '../../repo/reservationRepo' import { reservationRepo } from '../../repo/reservationRepo'
import type { StoreApi } from 'zustand' import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore' import type { TripStoreState } from '../tripStore'
@@ -28,7 +27,7 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
addReservation: async (tripId, data) => { addReservation: async (tripId, data) => {
try { 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] })) set(state => ({ reservations: [result.reservation, ...state.reservations] }))
return result.reservation return result.reservation
} catch (err: unknown) { } catch (err: unknown) {
@@ -38,7 +37,7 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
updateReservation: async (tripId, id, data) => { updateReservation: async (tripId, id, data) => {
try { try {
const result = await reservationsApi.update(tripId, id, data) const result = await reservationRepo.update(tripId, id, data as Record<string, unknown>)
set(state => ({ set(state => ({
reservations: state.reservations.map(r => r.id === id ? result.reservation : r) 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) reservations: state.reservations.map(r => r.id === id ? { ...r, status: newStatus } : r)
})) }))
try { try {
await reservationsApi.update(tripId, id, { status: newStatus }) await reservationRepo.update(tripId, id, { status: newStatus })
} catch { } catch {
set({ reservations: prev }) set({ reservations: prev })
} }
}, },
deleteReservation: async (tripId, id) => { deleteReservation: async (tripId, id) => {
const prev = get().reservations
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
try { try {
await reservationsApi.delete(tripId, id) await reservationRepo.delete(tripId, id)
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
} catch (err: unknown) { } catch (err: unknown) {
set({ reservations: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting reservation')) throw new Error(getApiErrorMessage(err, 'Error deleting reservation'))
} }
}, },
+5 -5
View File
@@ -1,4 +1,4 @@
import { todoApi } from '../../api/client' import { todoRepo } from '../../repo/todoRepo'
import type { StoreApi } from 'zustand' import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore' import type { TripStoreState } from '../tripStore'
import type { TodoItem } from '../../types' import type { TodoItem } from '../../types'
@@ -17,7 +17,7 @@ export interface TodoSlice {
export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
addTodoItem: async (tripId, data) => { addTodoItem: async (tripId, data) => {
try { 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] })) set(state => ({ todoItems: [...state.todoItems, result.item] }))
return result.item return result.item
} catch (err: unknown) { } catch (err: unknown) {
@@ -27,7 +27,7 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
updateTodoItem: async (tripId, id, data) => { updateTodoItem: async (tripId, id, data) => {
try { try {
const result = await todoApi.update(tripId, id, data) const result = await todoRepo.update(tripId, id, data as Record<string, unknown>)
set(state => ({ set(state => ({
todoItems: state.todoItems.map(item => item.id === id ? result.item : item) 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 const prev = get().todoItems
set(state => ({ todoItems: state.todoItems.filter(item => item.id !== id) })) set(state => ({ todoItems: state.todoItems.filter(item => item.id !== id) }))
try { try {
await todoApi.delete(tripId, id) await todoRepo.delete(tripId, id)
} catch (err: unknown) { } catch (err: unknown) {
set({ todoItems: prev }) set({ todoItems: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting todo')) throw new Error(getApiErrorMessage(err, 'Error deleting todo'))
@@ -55,7 +55,7 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
) )
})) }))
try { try {
await todoApi.update(tripId, id, { checked }) await todoRepo.update(tripId, id, { checked })
} catch { } catch {
set(state => ({ set(state => ({
todoItems: state.todoItems.map(item => todoItems: state.todoItems.map(item =>
+63 -24
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { StoreApi } from 'zustand' import type { StoreApi } from 'zustand'
import { tripsApi, tagsApi, categoriesApi } from '../api/client' import { tagsApi, categoriesApi } from '../api/client'
import { offlineDb } from '../db/offlineDb' import { offlineDb, upsertTags, upsertCategories } from '../db/offlineDb'
import { tripRepo } from '../repo/tripRepo' import { tripRepo } from '../repo/tripRepo'
import { dayRepo } from '../repo/dayRepo' import { dayRepo } from '../repo/dayRepo'
import { placeRepo } from '../repo/placeRepo' import { placeRepo } from '../repo/placeRepo'
@@ -89,27 +89,38 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
loadTrip: async (tripId: number | string) => { loadTrip: async (tripId: number | string) => {
set({ isLoading: true, error: null }) set({ isLoading: true, error: null })
try { 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), tripRepo.get(tripId),
dayRepo.list(tripId), dayRepo.list(tripId),
placeRepo.list(tripId), placeRepo.list(tripId),
packingRepo.list(tripId), packingRepo.list(tripId),
todoRepo.list(tripId), todoRepo.list(tripId),
navigator.onLine offlineDb.tags.toArray(),
? tagsApi.list().catch(() => offlineDb.tags.toArray().then(tags => ({ tags }))) offlineDb.categories.toArray(),
: offlineDb.tags.toArray().then(tags => ({ tags })),
navigator.onLine
? categoriesApi.list().catch(() => offlineDb.categories.toArray().then(categories => ({ categories })))
: offlineDb.categories.toArray().then(categories => ({ categories })),
]) ])
const assignmentsMap: AssignmentsMap = {} const buildMaps = (days: Day[]) => {
const dayNotesMap: DayNotesMap = {} const assignmentsMap: AssignmentsMap = {}
for (const day of daysData.days) { const dayNotesMap: DayNotesMap = {}
assignmentsMap[String(day.id)] = day.assignments || [] for (const day of days) {
dayNotesMap[String(day.id)] = day.notes_items || [] assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
}
return { assignmentsMap, dayNotesMap }
} }
const { assignmentsMap, dayNotesMap } = buildMaps(daysData.days)
set({ set({
trip: tripData.trip, trip: tripData.trip,
days: daysData.days, days: daysData.days,
@@ -118,10 +129,36 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
dayNotes: dayNotesMap, dayNotes: dayNotesMap,
packingItems: packingData.items, packingItems: packingData.items,
todoItems: todoData.items, todoItems: todoData.items,
tags: tagsData.tags, tags: cachedTags,
categories: categoriesData.categories, categories: cachedCategories,
isLoading: false, 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) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error' const message = err instanceof Error ? err.message : 'Unknown error'
set({ isLoading: false, error: message }) set({ isLoading: false, error: message })
@@ -146,16 +183,18 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
updateTrip: async (tripId: number | string, data: Partial<Trip>) => { updateTrip: async (tripId: number | string, data: Partial<Trip>) => {
try { try {
const result = await tripsApi.update(tripId, data) const result = await tripRepo.update(tripId, data)
set({ trip: result.trip }) set({ trip: result.trip })
const daysData = await dayRepo.list(tripId) if (navigator.onLine) {
const assignmentsMap: AssignmentsMap = {} const daysData = await dayRepo.list(tripId)
const dayNotesMap: DayNotesMap = {} const assignmentsMap: AssignmentsMap = {}
for (const day of daysData.days) { const dayNotesMap: DayNotesMap = {}
assignmentsMap[String(day.id)] = day.assignments || [] for (const day of daysData.days) {
dayNotesMap[String(day.id)] = day.notes_items || [] 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 return result.trip
} catch (err: unknown) { } catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating trip')) throw new Error(getApiErrorMessage(err, 'Error updating trip'))
+170
View File
@@ -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' });
});
+16 -11
View File
@@ -13,12 +13,15 @@ import type { Table } from 'dexie'
// Map Dexie table names used in `resource` field → actual Dexie tables. // Map Dexie table names used in `resource` field → actual Dexie tables.
function getTable(resource: string): Table | undefined { function getTable(resource: string): Table | undefined {
const map: Record<string, Table> = { const map: Record<string, Table> = {
places: offlineDb.places, trips: offlineDb.trips,
packingItems: offlineDb.packingItems, days: offlineDb.days,
todoItems: offlineDb.todoItems, places: offlineDb.places,
budgetItems: offlineDb.budgetItems, packingItems: offlineDb.packingItems,
reservations: offlineDb.reservations, todoItems: offlineDb.todoItems,
tripFiles: offlineDb.tripFiles, budgetItems: offlineDb.budgetItems,
reservations: offlineDb.reservations,
accommodations: offlineDb.accommodations,
tripFiles: offlineDb.tripFiles,
} }
return map[resource] return map[resource]
} }
@@ -70,12 +73,14 @@ export const mutationQueue = {
if (_flushing || !navigator.onLine) return if (_flushing || !navigator.onLine) return
_flushing = true _flushing = true
try { try {
const pending = await offlineDb.mutationQueue while (true) {
.where('status') const pending = await offlineDb.mutationQueue
.equals('pending') .where('status')
.sortBy('createdAt') .equals('pending')
.sortBy('createdAt')
const mutation = pending[0]
if (!mutation) break
for (const mutation of pending) {
// Mark as syncing so UI can show progress // Mark as syncing so UI can show progress
await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' }) await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' })
+80
View File
@@ -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 -1
View File
@@ -16,7 +16,7 @@ export interface User {
export interface Trip { export interface Trip {
id: number id: number
name: string title: string
description: string | null description: string | null
start_date: string start_date: string
end_date: string end_date: string
+9 -19
View File
@@ -66,38 +66,28 @@ describe('packingRepo.list', () => {
}); });
describe('packingRepo.create', () => { describe('packingRepo.create', () => {
it('calls REST and caches created item in Dexie', async () => { it('writes item optimistically to Dexie immediately', async () => {
const item = buildPackingItem({ trip_id: 1, name: 'Sunscreen' });
server.use(
http.post('/api/trips/1/packing', () => HttpResponse.json({ item })),
);
const result = await packingRepo.create(1, { name: 'Sunscreen' }); const result = await packingRepo.create(1, { name: 'Sunscreen' });
expect(result.item.name).toBe('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.where('trip_id').equals(1).toArray();
const cached = await offlineDb.packingItems.get(item.id); expect(cached).toHaveLength(1);
expect(cached).toBeDefined(); expect(cached[0].name).toBe('Sunscreen');
expect(cached!.name).toBe('Sunscreen');
}); });
}); });
describe('packingRepo.update', () => { 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 }); const original = buildPackingItem({ trip_id: 1, name: 'Jacket', checked: 0 });
await offlineDb.packingItems.put(original); 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 }); 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); const cached = await offlineDb.packingItems.get(original.id);
expect(cached!.checked).toBe(1); expect(cached!.checked).toBeTruthy();
}); });
}); });
+6 -10
View File
@@ -67,19 +67,15 @@ describe('placeRepo.list', () => {
}); });
describe('placeRepo.create', () => { describe('placeRepo.create', () => {
it('calls REST and caches created place in Dexie', async () => { it('writes place optimistically to Dexie immediately', async () => {
const place = buildPlace({ trip_id: 1, name: 'Eiffel Tower' });
server.use(
http.post('/api/trips/1/places', () => HttpResponse.json({ place })),
);
const result = await placeRepo.create(1, { name: 'Eiffel Tower' }); const result = await placeRepo.create(1, { name: 'Eiffel Tower' });
expect(result.place.name).toBe('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.where('trip_id').equals(1).toArray();
const cached = await offlineDb.places.get(place.id); expect(cached).toHaveLength(1);
expect(cached).toBeDefined(); expect(cached[0].name).toBe('Eiffel Tower');
expect(cached!.name).toBe('Eiffel Tower');
}); });
}); });
+21 -28
View File
@@ -2,8 +2,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { useTripStore } from '../../../src/store/tripStore'; import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores, seedStore } from '../../helpers/store'; import { resetAllStores, seedStore } from '../../helpers/store';
import { buildBudgetItem, buildReservation } from '../../helpers/factories'; import { buildBudgetItem } from '../../helpers/factories';
import { server } from '../../helpers/msw/server'; import { server } from '../../helpers/msw/server';
import { offlineDb } from '../../../src/db/offlineDb';
vi.mock('../../../src/api/websocket', () => ({ vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(), connect: vi.fn(),
@@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(), setPreReconnectHook: vi.fn(),
})); }));
beforeEach(() => { beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores(); resetAllStores();
}); });
@@ -49,16 +52,18 @@ describe('budgetSlice', () => {
expect(useTripStore.getState().budgetItems).toHaveLength(2); 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( server.use(
http.post('/api/trips/1/budget', () => http.post('/api/trips/1/budget', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 }) HttpResponse.json({ message: 'Error' }, { status: 500 })
), ),
); );
await expect( const result = await useTripStore.getState().addBudgetItem(1, { name: 'Fail' });
useTripStore.getState().addBudgetItem(1, { name: 'Fail' })
).rejects.toThrow(); 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'); expect(useTripStore.getState().budgetItems[0].name).toBe('Updated');
}); });
it('FE-BUDGET-005: updateBudgetItem with total_price triggers loadReservations when reservation_id present', async () => { it('FE-BUDGET-005: updateBudgetItem resolves and updates store optimistically', async () => {
const item = buildBudgetItem({ id: 10, trip_id: 1, amount: 100 }); const item = buildBudgetItem({ id: 10, trip_id: 1, name: 'Old', amount: 100 });
const initialReservation = buildReservation({ trip_id: 1 }); seedStore(useTripStore, { budgetItems: [item] });
const newReservation = buildReservation({ trip_id: 1, name: 'Refreshed Reservation' });
seedStore(useTripStore, {
budgetItems: [item],
reservations: [initialReservation],
});
server.use( server.use(
http.put('/api/trips/1/budget/10', async ({ request }) => { http.put('/api/trips/1/budget/10', async ({ request }) => {
const body = await request.json() as Record<string, unknown>; 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 } }); 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 expect(result.amount).toBe(200);
await new Promise(resolve => setTimeout(resolve, 50)); expect(useTripStore.getState().budgetItems[0].amount).toBe(200);
expect(useTripStore.getState().reservations).toHaveLength(1);
expect(useTripStore.getState().reservations[0].name).toBe('Refreshed Reservation');
}); });
}); });
describe('deleteBudgetItem', () => { 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 }); const item = buildBudgetItem({ id: 10, trip_id: 1 });
seedStore(useTripStore, { budgetItems: [item] }); 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); // Permanently removed (queued for sync, no rollback)
expect(useTripStore.getState().budgetItems[0].id).toBe(10); expect(useTripStore.getState().budgetItems).toHaveLength(0);
}); });
it('FE-BUDGET-006b: deleteBudgetItem success removes item', async () => { it('FE-BUDGET-006b: deleteBudgetItem success removes item', async () => {
+4 -4
View File
@@ -100,7 +100,7 @@ describe('filesSlice', () => {
expect(files[0].id).toBe(20); 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 }); const file = buildTripFile({ id: 10, trip_id: 1 });
seedStore(useTripStore, { files: [file] }); 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) // Permanently removed (queued for sync, no rollback)
expect(useTripStore.getState().files).toHaveLength(1); expect(useTripStore.getState().files).toHaveLength(0);
}); });
}); });
}); });
+16 -13
View File
@@ -4,6 +4,7 @@ import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores, seedStore } from '../../helpers/store'; import { resetAllStores, seedStore } from '../../helpers/store';
import { buildPackingItem } from '../../helpers/factories'; import { buildPackingItem } from '../../helpers/factories';
import { server } from '../../helpers/msw/server'; import { server } from '../../helpers/msw/server';
import { offlineDb } from '../../../src/db/offlineDb';
vi.mock('../../../src/api/websocket', () => ({ vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(), connect: vi.fn(),
@@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(), setPreReconnectHook: vi.fn(),
})); }));
beforeEach(() => { beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores(); resetAllStores();
}); });
@@ -36,16 +39,18 @@ describe('packingSlice', () => {
expect(items[items.length - 1].name).toBe('Toothbrush'); 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( server.use(
http.post('/api/trips/1/packing', () => http.post('/api/trips/1/packing', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 }) HttpResponse.json({ message: 'Error' }, { status: 500 })
), ),
); );
await expect( const result = await useTripStore.getState().addPackingItem(1, { name: 'Fail item' });
useTripStore.getState().addPackingItem(1, { name: 'Fail item' })
).rejects.toThrow(); 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', () => { 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 }); const item = buildPackingItem({ id: 10, trip_id: 1 });
seedStore(useTripStore, { packingItems: [item] }); 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).toHaveLength(0);
expect(useTripStore.getState().packingItems[0].id).toBe(10);
}); });
it('FE-PACKING-004b: deletePackingItem success removes item', async () => { it('FE-PACKING-004b: deletePackingItem success removes item', async () => {
@@ -115,7 +119,7 @@ describe('packingSlice', () => {
expect(useTripStore.getState().packingItems[0].checked).toBe(1); 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 }); const item = buildPackingItem({ id: 10, trip_id: 1, checked: 0 });
seedStore(useTripStore, { packingItems: [item] }); 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); await useTripStore.getState().togglePackingItem(1, 10, true);
// Should be rolled back to original value // Optimistic state preserved — no rollback (queued for sync)
expect(useTripStore.getState().packingItems[0].checked).toBe(0); expect(useTripStore.getState().packingItems[0].checked).toBe(1);
}); });
}); });
}); });
+10 -4
View File
@@ -4,6 +4,7 @@ import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores, seedStore } from '../../helpers/store'; import { resetAllStores, seedStore } from '../../helpers/store';
import { buildPlace, buildAssignment } from '../../helpers/factories'; import { buildPlace, buildAssignment } from '../../helpers/factories';
import { server } from '../../helpers/msw/server'; import { server } from '../../helpers/msw/server';
import { offlineDb } from '../../../src/db/offlineDb';
vi.mock('../../../src/api/websocket', () => ({ vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(), connect: vi.fn(),
@@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(), setPreReconnectHook: vi.fn(),
})); }));
beforeEach(() => { beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores(); resetAllStores();
}); });
@@ -35,7 +38,7 @@ describe('placesSlice', () => {
expect(places[0].name).toBe('New Place'); // prepended 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 }); const existing = buildPlace({ trip_id: 1 });
seedStore(useTripStore, { places: [existing] }); seedStore(useTripStore, { places: [existing] });
@@ -45,8 +48,11 @@ describe('placesSlice', () => {
), ),
); );
await expect(useTripStore.getState().addPlace(1, { name: 'Fail' })).rejects.toThrow(); const result = await useTripStore.getState().addPlace(1, { name: 'Fail' });
expect(useTripStore.getState().places).toEqual([existing]);
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 { resetAllStores, seedStore } from '../../helpers/store';
import { buildReservation } from '../../helpers/factories'; import { buildReservation } from '../../helpers/factories';
import { server } from '../../helpers/msw/server'; import { server } from '../../helpers/msw/server';
import { offlineDb } from '../../../src/db/offlineDb';
vi.mock('../../../src/api/websocket', () => ({ vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(), connect: vi.fn(),
@@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(), setPreReconnectHook: vi.fn(),
})); }));
beforeEach(() => { beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores(); resetAllStores();
}); });
@@ -58,16 +61,18 @@ describe('reservationsSlice', () => {
expect(reservations[0].name).toBe('New Hotel'); 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( server.use(
http.post('/api/trips/1/reservations', () => http.post('/api/trips/1/reservations', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 }) HttpResponse.json({ message: 'Error' }, { status: 500 })
), ),
); );
await expect( const result = await useTripStore.getState().addReservation(1, { name: 'Fail' });
useTripStore.getState().addReservation(1, { name: 'Fail' })
).rejects.toThrow(); 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'); 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' }); const reservation = buildReservation({ id: 10, trip_id: 1, status: 'confirmed' });
seedStore(useTripStore, { reservations: [reservation] }); seedStore(useTripStore, { reservations: [reservation] });
@@ -133,10 +138,10 @@ describe('reservationsSlice', () => {
), ),
); );
// Does NOT throw (silent rollback)
await useTripStore.getState().toggleReservationStatus(1, 10); 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 () => { it('FE-RESERV-008: toggleReservationStatus does nothing if reservation not found', async () => {
@@ -162,7 +167,7 @@ describe('reservationsSlice', () => {
expect(reservations[0].id).toBe(20); 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 }); const reservation = buildReservation({ id: 10, trip_id: 1 });
seedStore(useTripStore, { reservations: [reservation] }); 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) // Permanently removed (queued for sync, no rollback)
expect(useTripStore.getState().reservations).toHaveLength(1); expect(useTripStore.getState().reservations).toHaveLength(0);
}); });
}); });
}); });
+16 -12
View File
@@ -4,6 +4,7 @@ import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores, seedStore } from '../../helpers/store'; import { resetAllStores, seedStore } from '../../helpers/store';
import { buildTodoItem } from '../../helpers/factories'; import { buildTodoItem } from '../../helpers/factories';
import { server } from '../../helpers/msw/server'; import { server } from '../../helpers/msw/server';
import { offlineDb } from '../../../src/db/offlineDb';
vi.mock('../../../src/api/websocket', () => ({ vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(), connect: vi.fn(),
@@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(), setPreReconnectHook: vi.fn(),
})); }));
beforeEach(() => { beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores(); resetAllStores();
}); });
@@ -34,16 +37,18 @@ describe('todoSlice', () => {
expect(items).toHaveLength(2); 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( server.use(
http.post('/api/trips/1/todo', () => http.post('/api/trips/1/todo', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 }) HttpResponse.json({ message: 'Error' }, { status: 500 })
), ),
); );
await expect( const result = await useTripStore.getState().addTodoItem(1, { name: 'Fail' });
useTripStore.getState().addTodoItem(1, { name: 'Fail' })
).rejects.toThrow(); 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', () => { 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 }); const item = buildTodoItem({ id: 10, trip_id: 1 });
seedStore(useTripStore, { todoItems: [item] }); 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).toHaveLength(0);
expect(useTripStore.getState().todoItems[0].id).toBe(10);
}); });
it('FE-TODO-004b: deleteTodoItem success removes item from array', async () => { 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); 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 }); const item = buildTodoItem({ id: 10, trip_id: 1, checked: 0 });
seedStore(useTripStore, { todoItems: [item] }); seedStore(useTripStore, { todoItems: [item] });
@@ -125,10 +129,10 @@ describe('todoSlice', () => {
), ),
); );
// Does NOT throw
await useTripStore.getState().toggleTodoItem(1, 10, true); 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 () => { it('FE-TODO-007: toggleTodoItem preserves sort_order field', async () => {
+12 -3
View File
@@ -4,6 +4,7 @@ import { useTripStore } from '../../src/store/tripStore';
import { resetAllStores } from '../helpers/store'; import { resetAllStores } from '../helpers/store';
import { buildTrip, buildDay, buildPlace, buildPackingItem, buildTodoItem, buildTag, buildCategory, buildAssignment, buildDayNote } from '../helpers/factories'; import { buildTrip, buildDay, buildPlace, buildPackingItem, buildTodoItem, buildTag, buildCategory, buildAssignment, buildDayNote } from '../helpers/factories';
import { server } from '../helpers/msw/server'; import { server } from '../helpers/msw/server';
import { offlineDb } from '../../src/db/offlineDb';
vi.mock('../../src/api/websocket', () => ({ vi.mock('../../src/api/websocket', () => ({
connect: vi.fn(), connect: vi.fn(),
@@ -17,7 +18,11 @@ vi.mock('../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(), 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(); resetAllStores();
}); });
@@ -75,6 +80,10 @@ describe('tripStore', () => {
const tag = buildTag(); const tag = buildTag();
const category = buildCategory(); 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( server.use(
http.get('/api/trips/1', () => HttpResponse.json({ trip })), http.get('/api/trips/1', () => HttpResponse.json({ trip })),
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })), 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' }); const result = await useTripStore.getState().updateTrip(1, { name: 'Updated Trip' });
expect(result).toEqual(updatedTrip); expect(result.name).toBe('Updated Trip');
expect(useTripStore.getState().trip).toEqual(updatedTrip); expect(useTripStore.getState().trip?.name).toBe('Updated Trip');
}); });
}); });
+29 -59
View File
@@ -7,65 +7,12 @@ export default defineConfig({
react(), react(),
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',
workbox: { strategies: 'injectManifest',
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, srcDir: 'src',
filename: 'sw.ts',
injectManifest: {
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'], globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
navigateFallback: 'index.html', maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
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] },
},
},
],
}, },
manifest: { manifest: {
name: 'TREK \u2014 Travel Planner', name: 'TREK \u2014 Travel Planner',
@@ -110,7 +57,30 @@ export default defineConfig({
'/mcp': { '/mcp': {
target: 'http://localhost:3001', target: 'http://localhost:3001',
changeOrigin: true, 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
View File
@@ -43,11 +43,18 @@ import journeyPublicRoutes from './routes/journeyPublic';
import publicConfigRoutes from './routes/publicConfig'; import publicConfigRoutes from './routes/publicConfig';
import systemNoticesRoutes from './routes/systemNotices'; import systemNoticesRoutes from './routes/systemNotices';
import { mcpHandler } from './mcp'; import { mcpHandler } from './mcp';
import { trekOAuthProvider, trekClientsStore } from './mcp/oauthProvider';
import { Addon } from './types'; import { Addon } from './types';
import { getPhotoProviderConfig } from './services/memories/helpersService'; import { getPhotoProviderConfig } from './services/memories/helpersService';
import { getCollabFeatures } from './services/adminService'; import { getCollabFeatures } from './services/adminService';
import { isAddonEnabled } from './services/adminService'; import { isAddonEnabled } from './services/adminService';
import { ADDON_IDS } from './addons'; 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 { export function createApp(): express.Application {
const app = express(); const app = express();
@@ -88,10 +95,27 @@ export function createApp(): express.Application {
const hstsActive = shouldForceHttps || process.env.NODE_ENV === 'production'; const hstsActive = shouldForceHttps || process.env.NODE_ENV === 'production';
const hstsIncludeSubdomains = process.env.HSTS_INCLUDE_SUBDOMAINS === 'true'; 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( app.use(
['/.well-known/oauth-authorization-server', '/.well-known/oauth-protected-resource'], (req: Request, _res: Response, next: NextFunction) => {
cors({ origin: '*', credentials: false }), 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(cors({ origin: corsOrigin, credentials: true }));
app.use(helmet({ app.use(helmet({
@@ -340,16 +364,103 @@ export function createApp(): express.Application {
app.use('/api/notifications', notificationRoutes); app.use('/api/notifications', notificationRoutes);
app.use('/api', shareRoutes); app.use('/api', shareRoutes);
// OAuth 2.1 — public endpoints (/.well-known, /oauth/token, /oauth/revoke) // OAuth 2.1 — public endpoints
app.use('/', oauthPublicRouter); // 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/*) // 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); 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 // MCP endpoint
app.post('/mcp', mcpHandler); app.post('/mcp', mcpHandler);
app.get('/mcp', mcpHandler); app.get('/mcp', mcpHandler);
app.delete('/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 // Production static file serving
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
const publicPath = path.join(__dirname, '../public'); const publicPath = path.join(__dirname, '../public');
+2 -1
View File
@@ -154,8 +154,9 @@ sessionSweepInterval.unref();
function setAuthChallenge(res: Response, error = 'invalid_token'): void { function setAuthChallenge(res: Response, error = 'invalid_token'): void {
const base = (getAppUrl() || '').replace(/\/+$/, ''); const base = (getAppUrl() || '').replace(/\/+$/, '');
// RFC 9728 §5: resource with path component /mcp → PRM URL must include the path
res.set('WWW-Authenticate', 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 { interface VerifyTokenResult {
+220
View File
@@ -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
View File
@@ -2,7 +2,7 @@ import express, { Request, Response } from 'express';
import { authenticate, requireCookieAuth, optionalAuth } from '../middleware/auth'; import { authenticate, requireCookieAuth, optionalAuth } from '../middleware/auth';
import { AuthRequest, OptionalAuthRequest } from '../types'; import { AuthRequest, OptionalAuthRequest } from '../types';
import { isAddonEnabled } from '../services/adminService'; 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 { ADDON_IDS } from '../addons';
import { import {
validateAuthorizeRequest, validateAuthorizeRequest,
@@ -14,16 +14,15 @@ import {
revokeToken, revokeToken,
verifyPKCE, verifyPKCE,
authenticateClient, authenticateClient,
isValidRedirectUri,
listOAuthClients, listOAuthClients,
createOAuthClient, createOAuthClient,
deleteOAuthClient, deleteOAuthClient,
rotateOAuthClientSecret, rotateOAuthClientSecret,
listOAuthSessions, listOAuthSessions,
revokeSession, revokeSession,
getUserByAccessToken,
AuthorizeParams, AuthorizeParams,
} from '../services/oauthService'; } from '../services/oauthService';
import { getAppUrl } from '../services/oidcService';
import { writeAudit, getClientIp, logWarn } from '../services/auditLog'; 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 tokenLimiter = makeRateLimiter(30, 60_000, (req) => `${req.ip}|${req.body?.client_id ?? ''}`);
const validateLimiter = makeRateLimiter(30, 60_000, (req) => req.ip ?? 'unknown'); const validateLimiter = makeRateLimiter(30, 60_000, (req) => req.ip ?? 'unknown');
const revokeLimiter = makeRateLimiter(10, 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(); 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 // Token endpoint — handles authorization_code and refresh_token grants
oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Response) => { 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 // M1: RFC 6749 §5.1 — token responses must not be cached
res.set('Cache-Control', 'no-store'); res.set('Cache-Control', 'no-store');
res.set('Pragma', 'no-cache'); 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 { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token, resource } = body;
const ip = getClientIp(req); 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) { if (!client_id) {
return res.status(401).json({ error: 'invalid_client', error_description: 'client_id is required' }); 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}` }); return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
}); });
// RFC 7591 Dynamic Client Registration endpoint // OIDC UserInfo endpoint (RFC 9068 / OpenID Connect Core §5.3)
oauthPublicRouter.post('/oauth/register', dcrLimiter, (req: Request, res: Response) => { // 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(); if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const auth = req.headers['authorization'];
const body: Record<string, unknown> = typeof req.body === 'object' && req.body !== null ? req.body : {}; if (!auth || !auth.toLowerCase().startsWith('bearer ')) {
const ip = getClientIp(req); res.set('WWW-Authenticate', 'Bearer realm="TREK MCP"');
return res.status(401).json({ error: 'invalid_token' });
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' });
} }
// OAuth 2.1 + RFC 8252: confidential web apps need HTTPS; public const token = auth.slice(7);
// clients (MCP, native) are limited to loopback or a reverse-DNS const info = getUserByAccessToken(token);
// private-use scheme. This rejects `http://evil.example` DCR payloads if (!info) {
// that today would otherwise be accepted since we previously only res.set('WWW-Authenticate', 'Bearer realm="TREK MCP", error="invalid_token"');
// checked shape. Dangerous URL schemes (`javascript:`, `data:` etc.) return res.status(401).json({ error: 'invalid_token' });
// 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' });
} }
return res.json({
const rawName = typeof body.client_name === 'string' ? body.client_name.trim().slice(0, 100) : ''; sub: String(info.user.id),
const clientName = rawName || 'MCP Client'; email: info.user.email,
email_verified: true,
// Determine if the client wants to be public (no secret) — MCP clients typically use PKCE only preferred_username: info.user.username,
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',
}); });
}); });
// Token revocation endpoint (RFC 7009) // Token revocation endpoint (RFC 7009)
oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Response) => { 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(); if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {}; const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
const { token, client_id, client_secret } = body; const { token, client_id, client_secret } = body;
const ip = getClientIp(req); const ip = getClientIp(req);
+40 -6
View File
@@ -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 // POST /oauth/token — authorization_code grant
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
describe('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) const res = await request(app)
.post('/oauth/token') .post('/oauth/token')
.send({ grant_type: 'authorization_code', code: 'x', redirect_uri: 'https://example.com/cb', code_verifier: 'y' }); .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'); 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); isAddonEnabledMock.mockReturnValue(false);
const res = await request(app) const res = await request(app)
.post('/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' }); .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.status).toBe(404);
expect(res.body.error).toBe('mcp_disabled');
}); });
it('OAUTH-004 — missing code/redirect_uri/code_verifier returns 400 invalid_request', async () => { 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'); 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 { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
const { verifier, challenge } = makePkce(); const { verifier, challenge } = makePkce();
@@ -909,7 +944,6 @@ describe('M1 — Cache-Control headers on /oauth/token', () => {
.post('/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' }); .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['cache-control']).toBe('no-store');
expect(res.headers['pragma']).toBe('no-cache');
}); });
}); });
+9 -1
View File
@@ -20,7 +20,15 @@
// These paths manually redirect to the CJS dist until the SDK fixes its exports map. // These paths manually redirect to the CJS dist until the SDK fixes its exports map.
"paths": { "paths": {
"@modelcontextprotocol/sdk/server/mcp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp"], "@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"], "include": ["src"],
+10
View File
@@ -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. 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 ### Cursor, VS Code, Windsurf, and Zed
Clients that support `mcp-remote` can connect in one of two ways. Clients that support `mcp-remote` can connect in one of two ways.
+32 -8
View File
@@ -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. 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)** **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 | | CartoDB / OpenStreetMap map tiles | `map-tiles` | CacheFirst | 30 days | 1 000 |
| Leaflet / CDN assets (unpkg) | `cdn-libs` | CacheFirst | 365 days | 30 | | 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 | | Cover images and avatars (`/uploads/covers`, `/uploads/avatars`) | `user-uploads` | CacheFirst | 7 days | 300 |
| App shell (HTML / JS / CSS) | precache | Precached | Until next deploy | — | | 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. > **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** **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. - 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. - 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. The **Offline Cache** section under Settings → Offline shows the current state of the local cache.
<!-- TODO: screenshot: Offline tab showing cached trips -->
**Stats panel:** **Stats panel:**
- **Cached trips** — number of trips stored in IndexedDB (Dexie). - **Cached trips** — number of trips stored in IndexedDB (Dexie).
- **Pending changes** — number of actions taken offline that are queued to sync. - **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. 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 | 1365 | How long API responses stay in the `api-data` cache |
| API max entries | 500 | 105 000 | Maximum number of API responses cached |
| Map tiles TTL (days) | 30 | 1365 | How long map tiles stay in the `map-tiles` cache |
| Map tiles max entries | 1 000 | 105 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 ## Limitations
- New trips created while offline are queued and synced when connectivity is restored. - 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. - Photo uploads require connectivity; non-photo file attachments are pre-cached automatically during sync.
- Real-time collaboration features require an active WebSocket connection. - 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). - 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 ## See also
+31
View File
@@ -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" ## 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. **Cause:** Each user is limited to 300 MCP requests per minute and 20 concurrent sessions by default. Exceeding either limit returns a `429` response.