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

+ Changes apply immediately to the service worker and persist across reloads. + Existing cached entries follow their original TTL; new entries use the updated settings. +

+ +
+ + + + +
+ +
+ + + {configApplied && ( + + + Applied at {configApplied.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })} + + )} +
+
+ {/* Cached trip list */} {loading ? (

Loading…

@@ -139,24 +286,32 @@ export default function OfflineTab(): React.ReactElement { display: 'flex', flexDirection: 'column', gap: 2, }} > -
- - {trip.name} - - +
+
+ + {trip.title || 'Unnamed trip'} + + {trip.description ? ( + + {trip.description.length > 72 ? trip.description.slice(0, 72) + '…' : trip.description} + + ) : null} + + {trip.start_date + ? `${formatDate(trip.start_date)} – ${formatDate(trip.end_date)}` + : 'No dates set'} + {' · '} + {placeCount} place{placeCount !== 1 ? 's' : ''} + {fileCount > 0 ? ` · ${fileCount} file${fileCount !== 1 ? 's' : ''}` : null} + +
+ {meta.lastSyncedAt ? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : '—'}
- - {formatDate(trip.start_date)} – {formatDate(trip.end_date)} - {' · '} - {placeCount} place{placeCount !== 1 ? 's' : ''} - {' · '} - {fileCount} file{fileCount !== 1 ? 's' : ''} -
))} @@ -178,3 +333,32 @@ function Stat({ label, value }: { label: string; value: number }) { ) } + +function CacheField({ + label, value, min, max, onChange, +}: { + label: string + value: number + min: number + max: number + onChange: (e: React.ChangeEvent) => void +}) { + return ( + + ) +} diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 390b05c0..99295939 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -464,6 +464,8 @@ const ar: Record = { 'login.mfaVerify': 'تحقق', 'login.invalidInviteLink': 'رابط الدعوة غير صالح أو منتهي الصلاحية', 'login.oidcFailed': 'فشل تسجيل الدخول عبر OIDC', + 'login.configLoadError': 'تعذّر تحميل خيارات تسجيل الدخول.', + 'login.configLoadRetry': 'تحديث', 'login.usernameRequired': 'اسم المستخدم مطلوب', 'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل', 'login.forgotPassword': 'نسيت كلمة المرور؟', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 0757c3d2..e23ca5a1 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -459,6 +459,8 @@ const br: Record = { 'login.mfaVerify': 'Verificar', 'login.invalidInviteLink': 'Link de convite inválido ou expirado', 'login.oidcFailed': 'Falha no login OIDC', + 'login.configLoadError': 'Não foi possível carregar as opções de login.', + 'login.configLoadRetry': 'Atualizar', 'login.usernameRequired': 'Nome de usuário é obrigatório', 'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres', 'login.forgotPassword': 'Esqueceu a senha?', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index a14b633d..cc951bce 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -459,6 +459,8 @@ const cs: Record = { 'login.mfaVerify': 'Ověřit', 'login.invalidInviteLink': 'Neplatný nebo vypršelý odkaz s pozvánkou', 'login.oidcFailed': 'Přihlášení přes OIDC se nezdařilo', + 'login.configLoadError': 'Nepodařilo se načíst možnosti přihlášení.', + 'login.configLoadRetry': 'Obnovit', 'login.usernameRequired': 'Uživatelské jméno je povinné', 'login.passwordMinLength': 'Heslo musí mít alespoň 8 znaků', 'login.forgotPassword': 'Zapomenuté heslo?', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index cbb6d153..1aab8743 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -464,6 +464,8 @@ const de: Record = { 'login.mfaVerify': 'Bestätigen', 'login.invalidInviteLink': 'Ungültiger oder abgelaufener Einladungslink', 'login.oidcFailed': 'OIDC-Anmeldung fehlgeschlagen', + 'login.configLoadError': 'Anmeldeoptionen konnten nicht geladen werden.', + 'login.configLoadRetry': 'Aktualisieren', 'login.usernameRequired': 'Benutzername ist erforderlich', 'login.passwordMinLength': 'Das Passwort muss mindestens 8 Zeichen lang sein', 'login.forgotPassword': 'Passwort vergessen?', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index ce8321a6..145c6c5b 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -537,6 +537,8 @@ const en: Record = { 'login.mfaVerify': 'Verify', 'login.invalidInviteLink': 'Invalid or expired invite link', 'login.oidcFailed': 'OIDC login failed', + 'login.configLoadError': 'Could not load login options.', + 'login.configLoadRetry': 'Refresh', 'login.usernameRequired': 'Username is required', 'login.passwordMinLength': 'Password must be at least 8 characters', 'login.forgotPassword': 'Forgot password?', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index a66bdfb6..a4562409 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -451,6 +451,8 @@ const es: Record = { 'login.mfaVerify': 'Verificar', 'login.invalidInviteLink': 'Enlace de invitación inválido o expirado', 'login.oidcFailed': 'Error de inicio de sesión OIDC', + 'login.configLoadError': 'No se pudieron cargar las opciones de inicio de sesión.', + 'login.configLoadRetry': 'Actualizar', 'login.usernameRequired': 'El nombre de usuario es obligatorio', 'login.passwordMinLength': 'La contraseña debe tener al menos 8 caracteres', 'login.forgotPassword': '¿Olvidaste tu contraseña?', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index c7cd1605..ef26c1df 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -452,6 +452,8 @@ const fr: Record = { 'login.mfaVerify': 'Vérifier', 'login.invalidInviteLink': 'Lien d\'invitation invalide ou expiré', 'login.oidcFailed': 'Échec de connexion OIDC', + 'login.configLoadError': 'Impossible de charger les options de connexion.', + 'login.configLoadRetry': 'Actualiser', 'login.usernameRequired': 'Le nom d\'utilisateur est obligatoire', 'login.passwordMinLength': 'Le mot de passe doit comporter au moins 8 caractères', 'login.forgotPassword': 'Mot de passe oublié ?', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index f8046fab..a07397d5 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -459,6 +459,8 @@ const hu: Record = { 'login.mfaVerify': 'Ellenőrzés', 'login.invalidInviteLink': 'Érvénytelen vagy lejárt meghívólink', 'login.oidcFailed': 'OIDC bejelentkezés sikertelen', + 'login.configLoadError': 'A bejelentkezési lehetőségek betöltése nem sikerült.', + 'login.configLoadRetry': 'Frissítés', 'login.usernameRequired': 'A felhasználónév kötelező', 'login.passwordMinLength': 'A jelszónak legalább 8 karakter hosszúnak kell lennie', 'login.forgotPassword': 'Elfelejtetted a jelszavad?', diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index 112d17fc..f9e51bb3 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -521,6 +521,8 @@ const id: Record = { 'login.mfaVerify': 'Verifikasi', 'login.invalidInviteLink': 'Tautan undangan tidak valid atau sudah kedaluwarsa', 'login.oidcFailed': 'Login OIDC gagal', + 'login.configLoadError': 'Gagal memuat opsi login.', + 'login.configLoadRetry': 'Segarkan', 'login.usernameRequired': 'Nama pengguna wajib diisi', 'login.passwordMinLength': 'Kata sandi minimal 8 karakter', 'login.forgotPassword': 'Lupa kata sandi?', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 2ac5424f..44bdcfe8 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -459,6 +459,8 @@ const it: Record = { 'login.mfaVerify': 'Verifica', 'login.invalidInviteLink': 'Link di invito non valido o scaduto', 'login.oidcFailed': 'Accesso OIDC non riuscito', + 'login.configLoadError': 'Impossibile caricare le opzioni di accesso.', + 'login.configLoadRetry': 'Aggiorna', 'login.usernameRequired': 'Il nome utente è obbligatorio', 'login.passwordMinLength': 'La password deve contenere almeno 8 caratteri', 'login.forgotPassword': 'Password dimenticata?', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 0cb55bc1..c16c68cb 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -452,6 +452,8 @@ const nl: Record = { 'login.mfaVerify': 'Verifiëren', 'login.invalidInviteLink': 'Ongeldige of verlopen uitnodigingslink', 'login.oidcFailed': 'OIDC-aanmelding mislukt', + 'login.configLoadError': 'Kan aanmeldingsopties niet laden.', + 'login.configLoadRetry': 'Vernieuwen', 'login.usernameRequired': 'Gebruikersnaam is vereist', 'login.passwordMinLength': 'Wachtwoord moet minimaal 8 tekens bevatten', 'login.forgotPassword': 'Wachtwoord vergeten?', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 87f768a9..005541ae 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -426,6 +426,8 @@ const pl: Record = { 'login.mfaVerify': 'Weryfikuj', 'login.invalidInviteLink': 'Nieprawidłowy lub wygasły link zaproszenia', 'login.oidcFailed': 'Logowanie OIDC nie powiodło się', + 'login.configLoadError': 'Nie można załadować opcji logowania.', + 'login.configLoadRetry': 'Odśwież', 'login.usernameRequired': 'Nazwa użytkownika jest wymagana', 'login.passwordMinLength': 'Hasło musi mieć co najmniej 8 znaków', 'login.forgotPassword': 'Nie pamiętasz hasła?', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index f4f23fb8..0f82d22a 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -452,6 +452,8 @@ const ru: Record = { 'login.mfaVerify': 'Подтвердить', 'login.invalidInviteLink': 'Недействительная или истёкшая ссылка-приглашение', 'login.oidcFailed': 'Ошибка входа через OIDC', + 'login.configLoadError': 'Не удалось загрузить параметры входа.', + 'login.configLoadRetry': 'Обновить', 'login.usernameRequired': 'Имя пользователя обязательно', 'login.passwordMinLength': 'Пароль должен содержать не менее 8 символов', 'login.forgotPassword': 'Забыли пароль?', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index ffa564b6..72e9e9d2 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -452,6 +452,8 @@ const zh: Record = { 'login.mfaVerify': '验证', 'login.invalidInviteLink': '邀请链接无效或已过期', 'login.oidcFailed': 'OIDC 登录失败', + 'login.configLoadError': '无法加载登录选项。', + 'login.configLoadRetry': '刷新', 'login.usernameRequired': '用户名为必填项', 'login.passwordMinLength': '密码至少需要8个字符', 'login.forgotPassword': '忘记密码?', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 331596c5..276a76f6 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -511,6 +511,8 @@ const zhTw: Record = { 'login.mfaVerify': '驗證', 'login.invalidInviteLink': '邀請連結無效或已過期', 'login.oidcFailed': 'OIDC 登入失敗', + 'login.configLoadError': '無法載入登入選項。', + 'login.configLoadRetry': '重新整理', 'login.usernameRequired': '使用者名稱為必填', 'login.passwordMinLength': '密碼至少需要8個字元', 'login.forgotPassword': '忘記密碼?', diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index c3e4fa85..dfffdcc0 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -744,12 +744,17 @@ export default function DashboardPage(): React.ReactElement { const loadTrips = async () => { setIsLoading(true) try { - const { trips, archivedTrips } = await tripRepo.list() + const { trips, archivedTrips, refresh } = await tripRepo.list() setTrips(sortTrips(trips)) setArchivedTrips(sortTrips(archivedTrips)) + setIsLoading(false) + refresh.then(fresh => { + if (!fresh) return + setTrips(sortTrips(fresh.trips)) + setArchivedTrips(sortTrips(fresh.archivedTrips)) + }).catch(() => {}) } catch { toast.error(t('dashboard.toast.loadError')) - } finally { setIsLoading(false) } } @@ -791,7 +796,7 @@ export default function DashboardPage(): React.ReactElement { const handleArchive = async (id) => { try { - const data = await tripsApi.archive(id) + const data = await tripRepo.update(id, { is_archived: true }) setTrips(prev => prev.filter(t => t.id !== id)) setArchivedTrips(prev => sortTrips([data.trip, ...prev])) toast.success(t('dashboard.toast.archived')) @@ -802,7 +807,7 @@ export default function DashboardPage(): React.ReactElement { const handleUnarchive = async (id) => { try { - const data = await tripsApi.unarchive(id) + const data = await tripRepo.update(id, { is_archived: false }) setArchivedTrips(prev => prev.filter(t => t.id !== id)) setTrips(prev => sortTrips([data.trip, ...prev])) toast.success(t('dashboard.toast.restored')) diff --git a/client/src/pages/FilesPage.test.tsx b/client/src/pages/FilesPage.test.tsx index 22298b8c..b6e18177 100644 --- a/client/src/pages/FilesPage.test.tsx +++ b/client/src/pages/FilesPage.test.tsx @@ -9,6 +9,7 @@ import { buildUser, buildTrip, buildTripFile } from '../../tests/helpers/factori import { useAuthStore } from '../store/authStore'; import { useTripStore } from '../store/tripStore'; import FilesPage from './FilesPage'; +import { offlineDb } from '../db/offlineDb'; vi.mock('../components/Files/FileManager', () => ({ default: ({ files }: { files: unknown[]; onUpload: unknown; onDelete: unknown }) => @@ -29,7 +30,9 @@ function renderFilesPage(tripId: number | string = 1) { ); } -beforeEach(() => { +beforeEach(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + await Promise.all(offlineDb.tables.map(t => t.clear())); vi.clearAllMocks(); resetAllStores(); seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() }); diff --git a/client/src/pages/LoginPage.oidc-redirect.test.tsx b/client/src/pages/LoginPage.oidc-redirect.test.tsx index c14e0d96..bcddda56 100644 --- a/client/src/pages/LoginPage.oidc-redirect.test.tsx +++ b/client/src/pages/LoginPage.oidc-redirect.test.tsx @@ -39,11 +39,11 @@ describe('LoginPage — OIDC redirect preservation', () => { describe('FE-PAGE-LOGIN-022: redirect param stashed in sessionStorage on mount', () => { it('saves decoded redirect to sessionStorage when ?redirect= is present', async () => { - setSearch('?redirect=%2Foauth%2Fauthorize%3Fclient_id%3Dfoo'); + setSearch('?redirect=%2Foauth%2Fconsent%3Fclient_id%3Dfoo'); render(); await waitFor(() => { - expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/authorize?client_id=foo'); + expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/consent?client_id=foo'); }); }); @@ -67,13 +67,13 @@ describe('LoginPage — OIDC redirect preservation', () => { }); it('navigates to the saved sessionStorage redirect after successful OIDC exchange', async () => { - sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo&state=xyz'); + sessionStorage.setItem('oidc_redirect', '/oauth/consent?client_id=foo&state=xyz'); setSearch('?oidc_code=testcode123'); render(); await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith( - '/oauth/authorize?client_id=foo&state=xyz', + '/oauth/consent?client_id=foo&state=xyz', { replace: true }, ); }); @@ -93,7 +93,7 @@ describe('LoginPage — OIDC redirect preservation', () => { describe('FE-PAGE-LOGIN-024: OIDC error clears sessionStorage redirect', () => { it('removes oidc_redirect from sessionStorage on OIDC error', async () => { - sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo'); + sessionStorage.setItem('oidc_redirect', '/oauth/consent?client_id=foo'); setSearch('?oidc_error=token_failed'); render(); diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 6fa2c192..0104f97e 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -33,6 +33,7 @@ export default function LoginPage(): React.ReactElement { const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState('') const [appConfig, setAppConfig] = useState(null) + const [configError, setConfigError] = useState(false) const [inviteToken, setInviteToken] = useState('') const [inviteValid, setInviteValid] = useState(false) const exchangeInitiated = useRef(false) @@ -117,15 +118,15 @@ export default function LoginPage(): React.ReactElement { return } - authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => { - if (config) { + authApi.getAppConfig?.() + .then((config: AppConfig) => { setAppConfig(config) if (!config.has_users) setMode('register') if (!config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) { window.location.href = '/api/auth/oidc/login' } - } - }) + }) + .catch(() => setConfigError(true)) }, [navigate, t, noRedirect]) // Language detection chain (runs once on mount, only if user has no saved preference): @@ -860,6 +861,20 @@ export default function LoginPage(): React.ReactElement { )} + {/* Config load error — shown when /api/auth/app-config fails (e.g. ZT redirect, + network blip). Hides the SSO button; prompt user to refresh. */} + {configError && !appConfig && ( +
+ {t('login.configLoadError')} + +
+ )} + {/* Demo login button */} {appConfig?.demo_mode && (