fix(client): give the desktop halves the same fixes their mobile twins got

Several of the fixes in this branch landed on a mobile screen while the
desktop component with the identical bug sat in another package and was left
alone, which leaves the two disagreeing about the same handler.

So: the appearance tab flushes its debounced write on unmount, the journey
share copy guards navigator.clipboard (undefined on a plain-HTTP self-hosted
install), the admin collab toggle rolls back from a scoped snapshot instead
of a stale one, the reservation modal parses metadata through the guarded
helper rather than a bare JSON.parse that takes the route down, and the
notification toggle surfaces its failures and stops racing concurrent
toggles.

The Journey components reached the memories provider-status endpoint with a
raw fetch(), past every apiClient interceptor; they go through a small
memoriesApi now. And the server half of the OSM menu_url finding: the client
renders it through safeHttpUrl, so place-enrichment should not be handing a
javascript: value out in the first place.

The share section hides itself when the server says the link is not this
user's to manage, rather than offering a create button that 403s.
This commit is contained in:
Maurice
2026-08-23 10:38:23 +02:00
committed by Maurice
parent e72338bbbc
commit 3e0bfbbb29
22 changed files with 435 additions and 76 deletions
+16 -2
View File
@@ -1,4 +1,4 @@
// FE-APISURF-001 to FE-APISURF-052 // FE-APISURF-001 to FE-APISURF-054
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { AxiosResponse } from 'axios' import type { AxiosResponse } from 'axios'
import { http, HttpResponse } from 'msw' import { http, HttpResponse } from 'msw'
@@ -9,7 +9,7 @@ import {
tagsApi, categoriesApi, adminApi, addonsApi, pluginsApi, airtrailApi, journeyApi, tagsApi, categoriesApi, adminApi, addonsApi, pluginsApi, airtrailApi, journeyApi,
mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi, mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi,
configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi, configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi,
shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi, shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi, memoriesApi,
} from './client' } from './client'
interface Recorded { method: string; url: string; body: unknown } interface Recorded { method: string; url: string; body: unknown }
@@ -396,6 +396,20 @@ describe('client > endpoint wiring', () => {
]) ])
}) })
it('FE-APISURF-053: memoriesApi maps the photo-provider endpoints', async () => {
await assertCalls([
{ n: 'status', r: () => memoriesApi.status('immich'), e: 'GET /api/integrations/memories/immich/status' },
{ n: 'search', r: () => memoriesApi.search('immich', { from: '2026-01-01', to: '2026-01-02', page: 1, size: 50 }), e: 'POST /api/integrations/memories/immich/search' },
{ n: 'albums', r: () => memoriesApi.albums('immich'), e: 'GET /api/integrations/memories/immich/albums' },
{ n: 'albumPhotos', r: () => memoriesApi.albumPhotos('immich', 'alb-1'), e: 'GET /api/integrations/memories/immich/albums/alb-1/photos' },
])
})
it('FE-APISURF-054: memoriesApi passes the album passphrase as a query parameter', async () => {
const rec = await traceOne(() => memoriesApi.albumPhotos('synologyphotos', 'alb-2', 'p/w?'))
expect(rec.url).toBe('/api/integrations/memories/synologyphotos/albums/alb-2/photos?passphrase=p%2Fw%3F')
})
it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => { it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => {
await assertCalls([ await assertCalls([
{ n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' }, { n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' },
+23 -7
View File
@@ -55,6 +55,7 @@ import {
} from '@trek/shared' } from '@trek/shared'
import { getSocketId } from './websocket' import { getSocketId } from './websocket'
import { probeNow } from '../sync/connectivity' import { probeNow } from '../sync/connectivity'
import { downloadBlob } from '../utils/fileDownload'
/** /**
* Validate a response payload against its @trek/shared Zod schema — but only in * Validate a response payload against its @trek/shared Zod schema — but only in
@@ -968,6 +969,27 @@ export const journeyApi = {
getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data), getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data),
} }
// Photo providers (Immich, Synology Photos, …) behind /api/integrations/memories.
// The provider sits on the user's own hardware and the server proxies through to
// it, so these get a longer leash than the 8s default — a cold Immich answering a
// date-range search regularly needs more.
const MEMORIES_TIMEOUT = 20000
export const memoriesApi = {
status: (provider: string): Promise<{ connected: boolean }> =>
apiClient.get(`/integrations/memories/${provider}/status`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data),
search: (provider: string, body: { from: string; to: string; page: number; size: number }, signal?: AbortSignal) =>
apiClient.post(`/integrations/memories/${provider}/search`, body, { timeout: MEMORIES_TIMEOUT, signal }).then(r => r.data),
albums: (provider: string) =>
apiClient.get(`/integrations/memories/${provider}/albums`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data),
albumPhotos: (provider: string, albumId: string, passphrase?: string, signal?: AbortSignal) =>
apiClient.get(`/integrations/memories/${provider}/albums/${albumId}/photos`, {
timeout: MEMORIES_TIMEOUT,
params: passphrase ? { passphrase } : undefined,
signal,
}).then(r => r.data),
}
export const mapsApi = { export const mapsApi = {
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')), search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) => autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
@@ -1144,13 +1166,7 @@ export const backupApi = {
credentials: 'include', credentials: 'include',
}) })
if (!res.ok) throw new Error('Download failed') if (!res.ok) throw new Error('Download failed')
const blob = await res.blob() downloadBlob(await res.blob(), filename)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}, },
delete: (filename: string) => apiClient.delete(`/backup/${filename}`).then(r => r.data), delete: (filename: string) => apiClient.delete(`/backup/${filename}`).then(r => r.data),
restore: (filename: string) => apiClient.post(`/backup/restore/${filename}`).then(r => r.data), restore: (filename: string) => apiClient.post(`/backup/restore/${filename}`).then(r => r.data),
@@ -586,11 +586,11 @@ describe('EntryEditor', () => {
expect(stripImg.getAttribute('src')).toBe('/api/photos/100/original') expect(stripImg.getAttribute('src')).toBe('/api/photos/100/original')
}) })
it('FE-JRN-EDITOR-029: reorders photos locally even when persisting the order fails', async () => { it('FE-JRN-EDITOR-029: rolls the order back when persisting it fails', async () => {
let attempts = 0 let attempts = 0
server.use(http.patch('/api/journeys/photos/:id', () => { server.use(http.patch('/api/journeys/photos/:id', () => {
attempts += 1 attempts += 1
return new HttpResponse(null, { status: 500 }) return HttpResponse.json({ error: 'sort rejected' }, { status: 500 })
})) }))
const user = userEvent.setup() const user = userEvent.setup()
const { container } = mountEditor(buildEntry({ id: 10, photos: [buildPhoto(100), buildPhoto(101)] })) const { container } = mountEditor(buildEntry({ id: 10, photos: [buildPhoto(100), buildPhoto(101)] }))
@@ -598,8 +598,9 @@ describe('EntryEditor', () => {
await user.click(screen.getByRole('button', { name: 'Make 1st' })) await user.click(screen.getByRole('button', { name: 'Make 1st' }))
await waitFor(() => expect(attempts).toBe(2)) await waitFor(() => expect(attempts).toBe(2))
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('sort rejected', 'error', undefined))
const order = Array.from(container.querySelectorAll('.w-20.h-20 img')).map(i => i.getAttribute('src')) const order = Array.from(container.querySelectorAll('.w-20.h-20 img')).map(i => i.getAttribute('src'))
expect(order).toEqual(['/api/photos/101/thumbnail', '/api/photos/100/thumbnail']) expect(order).toEqual(['/api/photos/100/thumbnail', '/api/photos/101/thumbnail'])
}) })
it('FE-JRN-EDITOR-030: dropping an unsaved gallery pick cancels its link', async () => { it('FE-JRN-EDITOR-030: dropping an unsaved gallery pick cancels its link', async () => {
@@ -865,6 +866,32 @@ describe('EntryEditor', () => {
expect(onSave.mock.calls[0][0]).toEqual(expect.objectContaining({ entry_time: null })) expect(onSave.mock.calls[0][0]).toEqual(expect.objectContaining({ entry_time: null }))
}) })
it('FE-JRN-EDITOR-044: reports a rejected save and keeps the editor open', async () => {
const user = userEvent.setup()
const { onSave, onDone } = mountEditor(buildEntry({ id: 10, title: 'Old' }))
onSave.mockRejectedValueOnce(new Error('server said no'))
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('server said no', 'error', undefined))
expect(onDone).not.toHaveBeenCalled()
expect(screen.getByRole('heading', { name: 'Edit Entry' })).toBeInTheDocument()
})
it('FE-JRN-EDITOR-046: gives the preview blob URLs back when the editor closes', async () => {
const revoke = vi.spyOn(URL, 'revokeObjectURL')
const { container, unmount } = mountEditor(buildEntry())
fireEvent.change(container.querySelector('input[type="file"]') as HTMLInputElement, {
target: { files: [new File(['a'], 'a.jpg', { type: 'image/jpeg' })] },
})
await waitFor(() => expect(container.querySelector('img[src="blob:preview"]')).toBeInTheDocument())
unmount()
expect(revoke).toHaveBeenCalledWith('blob:preview')
revoke.mockRestore()
})
it('FE-JRN-EDITOR-043: the camera route is its own input, so the library picker survives', () => { it('FE-JRN-EDITOR-043: the camera route is its own input, so the library picker survives', () => {
const { container } = mountEditor(buildEntry({ id: 10 })) const { container } = mountEditor(buildEntry({ id: 10 }))
@@ -1,9 +1,9 @@
import { useEffect, useState, useRef } from 'react' import { useEffect, useMemo, useState, useRef } from 'react'
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react' import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic' import { normalizeImageFiles } from '../../utils/convertHeic'
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue' import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { journeyApi, mapsApi, addonsApi } from '../../api/client' import { journeyApi, mapsApi, addonsApi, memoriesApi } from '../../api/client'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { getCurrentPositionOnce } from '../../hooks/useGeolocation' import { getCurrentPositionOnce } from '../../hooks/useGeolocation'
import { getApiErrorMessage } from '../../types' import { getApiErrorMessage } from '../../types'
@@ -52,6 +52,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null) const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null)
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || []) const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
const [pendingFiles, setPendingFiles] = useState<File[]>([]) const [pendingFiles, setPendingFiles] = useState<File[]>([])
// Minting the preview URL inline in the JSX would hand out a fresh blob on
// every keystroke in the story field and never give one back.
const pendingUrls = useMemo(() => pendingFiles.map(f => URL.createObjectURL(f)), [pendingFiles])
useEffect(() => () => { pendingUrls.forEach(u => URL.revokeObjectURL(u)) }, [pendingUrls])
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([]) const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
const [showGalleryPick, setShowGalleryPick] = useState(false) const [showGalleryPick, setShowGalleryPick] = useState(false)
const [photoTab, setPhotoTab] = useState<'upload' | 'gallery' | 'external'>('upload') const [photoTab, setPhotoTab] = useState<'upload' | 'gallery' | 'external'>('upload')
@@ -103,8 +107,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
const connected: { id: string; name: string }[] = [] const connected: { id: string; name: string }[] = []
for (const provider of enabled) { for (const provider of enabled) {
try { try {
const response = await fetch(`/api/integrations/memories/${provider.id}/status`, { credentials: 'include' }) if ((await memoriesApi.status(provider.id)).connected) connected.push({ id: provider.id, name: provider.name })
if (response.ok && (await response.json()).connected) connected.push({ id: provider.id, name: provider.name })
} catch {} } catch {}
} }
setAvailableProviders(connected) setAvailableProviders(connected)
@@ -186,6 +189,11 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
setPendingProviderGroups([]) setPendingProviderGroups([])
} }
onDone() onDone()
} catch (err) {
// Neither the page callback nor journeyStore toasts, so without this the
// whole entry just fails to save with no sign of it.
toast.error(getApiErrorMessage(err, t('journey.settings.saveFailed')))
return
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -436,13 +444,18 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<button <button
onClick={e => { onClick={e => {
e.stopPropagation() e.stopPropagation()
setPhotos(prev => { const prevOrder = photos
const next = [...prev] const next = [...photos]
const [moved] = next.splice(idx, 1) const [moved] = next.splice(idx, 1)
next.unshift(moved) next.unshift(moved)
next.forEach((ph, i) => { journeyApi.updatePhoto(ph.id, { sort_order: i }).catch(() => {}) }) setPhotos(next)
return next // The order is shared: other members, the share view and the
}) // PDF all read it, so a rejected write has to go back.
Promise.all(next.map((ph, i) => journeyApi.updatePhoto(ph.id, { sort_order: i })))
.catch(err => {
setPhotos(prevOrder)
toast.error(getApiErrorMessage(err, t('common.error')))
})
}} }}
className="absolute bottom-0.5 left-0.5 px-1.5 py-0.5 rounded bg-black/60 text-white text-[8px] font-semibold opacity-0 group-hover:opacity-100 transition-opacity" className="absolute bottom-0.5 left-0.5 px-1.5 py-0.5 rounded bg-black/60 text-white text-[8px] font-semibold opacity-0 group-hover:opacity-100 transition-opacity"
> >
@@ -468,7 +481,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
))} ))}
{pendingFiles.map((f, i) => ( {pendingFiles.map((f, i) => (
<div key={`pending-${i}`} className="w-20 h-20 rounded-xl overflow-hidden relative group"> <div key={`pending-${i}`} className="w-20 h-20 rounded-xl overflow-hidden relative group">
<img src={URL.createObjectURL(f)} className="w-full h-full object-cover" alt="" /> <img src={pendingUrls[i]} className="w-full h-full object-cover" alt="" />
<button <button
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))} onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
@@ -4,7 +4,7 @@ import { normalizeImageFiles } from '../../utils/convertHeic'
import { isVideoFile } from '../../utils/videoPoster' import { isVideoFile } from '../../utils/videoPoster'
import { useJourneyStore } from '../../store/journeyStore' import { useJourneyStore } from '../../store/journeyStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { journeyApi, addonsApi } from '../../api/client' import { journeyApi, addonsApi, memoriesApi } from '../../api/client'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { getApiErrorMessage } from '../../types' import { getApiErrorMessage } from '../../types'
import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore' import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
@@ -42,11 +42,8 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
const connected: { id: string; name: string }[] = [] const connected: { id: string; name: string }[] = []
for (const p of enabledProviders) { for (const p of enabledProviders) {
try { try {
const res = await fetch(`/api/integrations/memories/${p.id}/status`, { credentials: 'include' }) const status = await memoriesApi.status(p.id)
if (res.ok) { if (status.connected) connected.push({ id: p.id, name: p.name })
const status = await res.json()
if (status.connected) connected.push({ id: p.id, name: p.name })
}
} catch {} } catch {}
} }
setAvailableProviders(connected) setAvailableProviders(connected)
@@ -1,6 +1,7 @@
import { useEffect, useState, useRef, useMemo } from 'react' import { useEffect, useState, useRef, useMemo } from 'react'
import { X, Check, Calendar, ChevronRight, Camera } from 'lucide-react' import { X, Check, Calendar, ChevronRight, Camera } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { memoriesApi } from '../../api/client'
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore' import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore'
import { groupPhotosByDate, sortProviderPhotos, type GeoPoint } from '../../pages/journeyDetail/JourneyDetailPage.helpers' import { groupPhotosByDate, sortProviderPhotos, type GeoPoint } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger' import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
@@ -64,21 +65,14 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
setSearchTo(to) setSearchTo(to)
setSearchPage(page) setSearchPage(page)
try { try {
const res = await fetch(`/api/integrations/memories/${provider}/search`, { const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal)
method: 'POST', credentials: 'include', signal, const assets = data.assets || []
headers: { 'Content-Type': 'application/json' }, setPhotos(prev => append ? [...prev, ...assets] : assets)
body: JSON.stringify({ from, to, page, size: 50 }), setHasMore(!!data.hasMore)
}) } catch {
if (res.ok) { // A cancelled request is about to be replaced by the next one, so leave
const data = await res.json() // its paging state alone.
const assets = data.assets || [] if (!signal.aborted) setHasMore(false)
setPhotos(prev => append ? [...prev, ...assets] : assets)
setHasMore(!!data.hasMore)
} else {
setHasMore(false)
}
} catch (e: any) {
if (e.name !== 'AbortError') setHasMore(false)
} }
if (!signal.aborted) { setLoading(false); setLoadingMore(false) } if (!signal.aborted) { setLoading(false); setLoadingMore(false) }
} }
@@ -94,17 +88,14 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
setPhotos([]) setPhotos([])
setHasMore(false) setHasMore(false)
try { try {
const qs = album.passphrase ? `?passphrase=${encodeURIComponent(album.passphrase)}` : '' setPhotos((await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || [])
const res = await fetch(`/api/integrations/memories/${provider}/albums/${album.id}/photos${qs}`, { credentials: 'include', signal }) } catch { /* ignore */ }
if (res.ok) setPhotos((await res.json()).assets || [])
} catch (e: any) { if (e.name !== 'AbortError') {} }
if (!signal.aborted) setLoading(false) if (!signal.aborted) setLoading(false)
} }
const loadAlbums = async () => { const loadAlbums = async () => {
try { try {
const res = await fetch(`/api/integrations/memories/${provider}/albums`, { credentials: 'include' }) setAlbums((await memoriesApi.albums(provider)).albums || [])
if (res.ok) setAlbums((await res.json()).albums || [])
} catch {} } catch {}
} }
@@ -22,6 +22,7 @@ beforeEach(() => {
writeText.mockClear() writeText.mockClear()
window.__addToast = toastSpy window.__addToast = toastSpy
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true }) Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true })
Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true, writable: true })
}) })
afterEach(() => { afterEach(() => {
@@ -37,6 +38,18 @@ describe('JourneyShareSection', () => {
expect(screen.getByText('Public Share')).toBeInTheDocument() expect(screen.getByText('Public Share')).toBeInTheDocument()
}) })
it('FE-JRN-SHARE-001b: renders nothing at all when the link is not this user to manage', async () => {
// Anyone but the owner gets 403 on the read, and would be refused on the
// create too - so the section stays out of the dialog rather than offering
// a button that cannot work.
server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ error: 'Not allowed' }, { status: 403 })))
const { container } = render(<JourneyShareSection journeyId={JOURNEY_ID} />)
await waitFor(() => expect(container).toBeEmptyDOMElement())
expect(screen.queryByText('Public Share')).not.toBeInTheDocument()
})
it('FE-JRN-SHARE-002: renders nothing while the share link is still loading', async () => { it('FE-JRN-SHARE-002: renders nothing while the share link is still loading', async () => {
server.use(http.get('/api/journeys/7/share-link', async () => { server.use(http.get('/api/journeys/7/share-link', async () => {
await delay(30) await delay(30)
@@ -99,7 +112,7 @@ describe('JourneyShareSection', () => {
act(() => { copyBtn.click() }) act(() => { copyBtn.click() })
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/public/journey/tok-abc`) expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/public/journey/tok-abc`)
expect(screen.getByRole('button', { name: 'Copied!' })).toBeInTheDocument() expect(await screen.findByRole('button', { name: 'Copied!' })).toBeInTheDocument()
act(() => { vi.advanceTimersByTime(2100) }) act(() => { vi.advanceTimersByTime(2100) })
expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument()
@@ -179,4 +192,23 @@ describe('JourneyShareSection', () => {
expect(await screen.findByRole('button', { name: /create share link/i })).toBeInTheDocument() expect(await screen.findByRole('button', { name: /create share link/i })).toBeInTheDocument()
}) })
// A self-hosted install served over plain HTTP has no navigator.clipboard, and the
// unguarded call used to throw before the button ever showed feedback.
it('FE-JRN-SHARE-012: copies through execCommand when the clipboard API is unavailable', async () => {
server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: existingLink() })))
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true, writable: true })
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true, writable: true })
const execCommand = vi.fn(() => true)
Object.defineProperty(document, 'execCommand', { value: execCommand, configurable: true, writable: true })
render(<JourneyShareSection journeyId={JOURNEY_ID} />)
const copyBtn = await screen.findByRole('button', { name: 'Copy' })
act(() => { copyBtn.click() })
await waitFor(() => expect(execCommand).toHaveBeenCalledWith('copy'))
expect(await screen.findByRole('button', { name: 'Copied!' })).toBeInTheDocument()
// The temporary textarea is removed again.
expect(document.querySelector('textarea')).toBeNull()
})
}) })
@@ -9,10 +9,20 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
const [link, setLink] = useState<{ token: string; share_timeline: boolean; share_gallery: boolean; share_map: boolean } | null>(null) const [link, setLink] = useState<{ token: string; share_timeline: boolean; share_gallery: boolean; share_map: boolean } | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
/** The share token is the owner's to manage; a contributor is refused. */
const [manageable, setManageable] = useState(true)
const toast = useToast() const toast = useToast()
useEffect(() => { useEffect(() => {
journeyApi.getShareLink(journeyId).then(d => setLink(d.link || null)).catch(() => {}).finally(() => setLoading(false)) journeyApi.getShareLink(journeyId)
.then(d => setLink(d.link || null))
.catch((err: { response?: { status?: number } }) => {
// A 403 is the server saying this is not yours to manage. Showing the
// section anyway would offer an editor a "create link" button that is
// refused the moment they press it.
if (err?.response?.status === 403) setManageable(false)
})
.finally(() => setLoading(false))
}, [journeyId]) }, [journeyId])
const createLink = async () => { const createLink = async () => {
@@ -42,13 +52,28 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
const shareUrl = link ? `${window.location.origin}/public/journey/${link.token}` : '' const shareUrl = link ? `${window.location.origin}/public/journey/${link.token}` : ''
const copyLink = () => { const copyLink = async () => {
navigator.clipboard.writeText(shareUrl) try {
setCopied(true) if (navigator.clipboard && window.isSecureContext) {
setTimeout(() => setCopied(false), 2000) await navigator.clipboard.writeText(shareUrl)
} else {
// Fallback for non-secure contexts (plain HTTP) where navigator.clipboard is unavailable
const ta = document.createElement('textarea')
ta.value = shareUrl
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.focus()
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch { /* ignore */ }
} }
if (loading) return null if (loading || !manageable) return null
return ( return (
<div> <div>
@@ -1423,4 +1423,27 @@ describe('ReservationModal', () => {
// Hotel place picker still shows its placeholder — no crash on the missing record. // Hotel place picker still shows its placeholder — no crash on the missing record.
expect(screen.getByText('—')).toBeInTheDocument(); expect(screen.getByText('—')).toBeInTheDocument();
}); });
// A legacy or corrupted metadata column used to throw straight out of the open
// effect and take the whole planner route down.
it('FE-PLANNER-RESMODAL-087: unparseable metadata opens the modal with empty check-in times', () => {
const res = buildReservation({ id: 17, title: 'Grand Hotel', type: 'hotel' });
(res as unknown as { metadata: string }).metadata = '{not json';
render(<ReservationModal {...defaultProps} reservation={res} days={reviewDays()} />);
expect(screen.getByDisplayValue('Grand Hotel')).toBeInTheDocument();
const times = screen.getAllByTestId('time-picker') as HTMLInputElement[];
expect(times.map(i => i.value)).toEqual(['', '', '']);
});
it('FE-PLANNER-RESMODAL-088: double-encoded metadata still fills the check-in times', () => {
const res = buildReservation({ id: 18, title: 'Grand Hotel', type: 'hotel' });
(res as unknown as { metadata: string }).metadata =
JSON.stringify(JSON.stringify({ check_in_time: '16:00', check_out_time: '10:30' }));
render(<ReservationModal {...defaultProps} reservation={res} days={reviewDays()} />);
const times = screen.getAllByTestId('time-picker') as HTMLInputElement[];
expect(times[0].value).toBe('16:00');
expect(times[2].value).toBe('10:30');
});
}); });
@@ -12,6 +12,7 @@ import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker' import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker' import CustomTimePicker from '../shared/CustomTimePicker'
import { openFile } from '../../utils/fileDownload' import { openFile } from '../../utils/fileDownload'
import { parseReservationMetadata } from '../../utils/flightLegs'
import { resolveDayId } from '../../utils/formatters' import { resolveDayId } from '../../utils/formatters'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types' import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
import { BookingCostsSection } from './BookingCostsSection' import { BookingCostsSection } from './BookingCostsSection'
@@ -133,7 +134,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
setTravelerIds(new Set((reservation?.travelers || []).map(tv => tv.user_id))) setTravelerIds(new Set((reservation?.travelers || []).map(tv => tv.user_id)))
if (reservation) { if (reservation) {
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {}) const meta = parseReservationMetadata(reservation)
const rawEnd = reservation.reservation_end_time || '' const rawEnd = reservation.reservation_end_time || ''
let endDate = '' let endDate = ''
let endTime = rawEnd let endTime = rawEnd
@@ -298,4 +298,35 @@ describe('AppearanceSettingsTab – dashboard widgets', () => {
PERSIST, PERSIST,
); );
}); });
// The scheme is already on the DOM by then, so dropping the write would revert the
// choice on the next load without ever telling the user.
it('FE-COMP-APPEARANCE-023: leaving the tab before the debounce fires flushes the pending save once', async () => {
const user = userEvent.setup();
const updateSetting = seedAppearance();
const { unmount } = render(<AppearanceSettingsTab />);
await user.click(screen.getByText('Indigo'));
unmount();
const calls = updateSetting.mock.calls.filter(c => c[0] === 'appearance');
expect(calls).toHaveLength(1);
expect(calls[0][1]).toMatchObject({ schemeId: 'indigo' });
// The timer is cancelled, so nothing lands a second time.
await new Promise(resolve => setTimeout(resolve, 500));
expect(updateSetting.mock.calls.filter(c => c[0] === 'appearance')).toHaveLength(1);
});
it('FE-COMP-APPEARANCE-024: a debounce that already fired is not written again on unmount', async () => {
const user = userEvent.setup();
const updateSetting = seedAppearance();
const { unmount } = render(<AppearanceSettingsTab />);
await user.click(screen.getByText('Teal'));
await waitFor(() => expect(updateSetting.mock.calls.filter(c => c[0] === 'appearance')).toHaveLength(1), PERSIST);
unmount();
expect(updateSetting.mock.calls.filter(c => c[0] === 'appearance')).toHaveLength(1);
});
}); });
@@ -81,6 +81,9 @@ export default function AppearanceSettingsTab(): React.ReactElement {
const [cfg, setCfg] = useState<AppearanceConfig>(() => normalizeAppearance(settings.appearance)) const [cfg, setCfg] = useState<AppearanceConfig>(() => normalizeAppearance(settings.appearance))
const persistTimer = useRef<number | undefined>(undefined) const persistTimer = useRef<number | undefined>(undefined)
// What the pending timer would have written, so leaving the tab inside the
// debounce window still saves instead of silently dropping the change.
const pendingWrite = useRef<AppearanceConfig | null>(null)
// Re-sync when settings change elsewhere (e.g. server reconcile / another tab). // Re-sync when settings change elsewhere (e.g. server reconcile / another tab).
useEffect(() => { useEffect(() => {
@@ -89,8 +92,11 @@ export default function AppearanceSettingsTab(): React.ReactElement {
// Flush any pending persist on unmount. // Flush any pending persist on unmount.
useEffect(() => () => { useEffect(() => () => {
if (persistTimer.current) window.clearTimeout(persistTimer.current) if (!persistTimer.current) return
}, []) window.clearTimeout(persistTimer.current)
// The component is gone, so a failure has nowhere to be shown.
if (pendingWrite.current) updateSetting('appearance', pendingWrite.current).catch(() => {})
}, [updateSetting])
const isDark = const isDark =
settings.dark_mode === true || settings.dark_mode === true ||
@@ -103,7 +109,9 @@ export default function AppearanceSettingsTab(): React.ReactElement {
setCfg(next) setCfg(next)
applyAppearance({ darkMode: settings.dark_mode, appearance: next, isSharedPage: false }) applyAppearance({ darkMode: settings.dark_mode, appearance: next, isSharedPage: false })
if (persistTimer.current) window.clearTimeout(persistTimer.current) if (persistTimer.current) window.clearTimeout(persistTimer.current)
pendingWrite.current = next
persistTimer.current = window.setTimeout(() => { persistTimer.current = window.setTimeout(() => {
pendingWrite.current = null
updateSetting('appearance', next).catch((e: unknown) => updateSetting('appearance', next).catch((e: unknown) =>
toast.error(e instanceof Error ? e.message : t('common.error')) toast.error(e instanceof Error ? e.message : t('common.error'))
) )
@@ -755,6 +755,54 @@ describe('NotificationsTab', () => {
expect(screen.getByText('Connection refused')).toBeInTheDocument(); expect(screen.getByText('Connection refused')).toBeInTheDocument();
}); });
}); });
it('FE-COMP-NOTIFICATIONS-030: a failing toggle surfaces the generic error', async () => {
const user = userEvent.setup();
server.use(
http.put('/api/notifications/preferences', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
);
render(<><NotificationsTab /><ToastContainer /></>);
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
const toggles = await screen.findAllByRole('button');
await user.click(toggles[0]);
expect(await screen.findByText('Error')).toBeInTheDocument();
});
it('FE-COMP-NOTIFICATIONS-031: a failing toggle leaves a later toggle alone', async () => {
const user = userEvent.setup();
let rejectEmail!: () => void;
server.use(
http.put('/api/notifications/preferences', async ({ request }) => {
const body = (await request.json()) as Record<string, Record<string, boolean>>;
// The first write is the email flip; in-app is still untouched there.
if (body.trip_invite.inapp === true) {
return new Promise<Response>(resolve => {
rejectEmail = () => resolve(HttpResponse.json({ error: 'nope' }, { status: 500 }) as unknown as Response);
});
}
return HttpResponse.json({ success: true });
}),
);
render(<><NotificationsTab /><ToastContainer /></>);
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
// Email is off in this matrix, in-app is on.
const [email, inapp] = await screen.findAllByRole('button');
await user.click(email);
await waitFor(() => expect(screen.getAllByRole('button')[0]).toHaveAttribute('aria-pressed', 'true'));
// Flipped while the email write is still hanging.
await user.click(inapp);
await waitFor(() => expect(screen.getAllByRole('button')[1]).toHaveAttribute('aria-pressed', 'false'));
rejectEmail();
await waitFor(() => expect(screen.getAllByRole('button')[0]).toHaveAttribute('aria-pressed', 'false'));
// The in-app toggle the user made meanwhile is not undone.
expect(screen.getAllByRole('button')[1]).toHaveAttribute('aria-pressed', 'false');
});
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -125,7 +125,13 @@ export default function NotificationsTab(): React.ReactElement {
try { try {
await notificationsApi.updatePreferences(updated) await notificationsApi.updatePreferences(updated)
} catch { } catch {
setMatrix(m => m ? { ...m, preferences: matrix.preferences } : m) // Only this cell rolls back — restoring the whole snapshot would also undo a
// toggle the user made while this request was in flight.
setMatrix(m => m ? {
...m,
preferences: { ...m.preferences, [eventType]: { ...m.preferences[eventType], [channel]: current } },
} : m)
toast.error(t('common.error'))
} finally { } finally {
setSaving(false) setSaving(false)
} }
+38 -3
View File
@@ -1,4 +1,4 @@
import { http, HttpResponse } from 'msw'; import { delay, http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
import { buildAdmin } from '../../tests/helpers/factories'; import { buildAdmin } from '../../tests/helpers/factories';
import { server } from '../../tests/helpers/msw/server'; import { server } from '../../tests/helpers/msw/server';
@@ -27,16 +27,21 @@ vi.mock('../components/Admin/AddonManager', () => ({
default: ({ default: ({
bagTrackingEnabled, bagTrackingEnabled,
onToggleBagTracking, onToggleBagTracking,
collabFeatures,
onToggleCollabFeature, onToggleCollabFeature,
}: { }: {
bagTrackingEnabled: boolean; bagTrackingEnabled: boolean;
onToggleBagTracking: () => void; onToggleBagTracking: () => void;
collabFeatures: Record<string, boolean>;
onToggleCollabFeature: (key: string) => void; onToggleCollabFeature: (key: string) => void;
}) => ( }) => (
<div data-testid="addon-manager"> <div data-testid="addon-manager">
<span data-testid="bag-tracking-state">{String(bagTrackingEnabled)}</span> <span data-testid="bag-tracking-state">{String(bagTrackingEnabled)}</span>
<span data-testid="collab-chat-state">{String(collabFeatures?.chat)}</span>
<span data-testid="collab-notes-state">{String(collabFeatures?.notes)}</span>
<button onClick={() => onToggleBagTracking()}>toggle bag tracking</button> <button onClick={() => onToggleBagTracking()}>toggle bag tracking</button>
<button onClick={() => onToggleCollabFeature('chat')}>toggle chat</button> <button onClick={() => onToggleCollabFeature('chat')}>toggle chat</button>
<button onClick={() => onToggleCollabFeature('notes')}>toggle notes</button>
</div> </div>
), ),
})); }));
@@ -1569,8 +1574,38 @@ describe('AdminPage', () => {
fireEvent.click(screen.getByRole('button', { name: /toggle chat/i })); fireEvent.click(screen.getByRole('button', { name: /toggle chat/i }));
// The rollback re-renders the stub; the addon panel stays mounted either way await waitFor(() => expect(screen.getByTestId('collab-chat-state')).toHaveTextContent('true'));
await waitFor(() => expect(screen.getByTestId('addon-manager')).toBeInTheDocument()); });
// A snapshot-wide rollback would undo the second toggle as well, leaving the
// panel disagreeing with the server until a reload.
it('a failing collab toggle keeps a second toggle made while it was in flight', async () => {
server.use(
http.get('/api/admin/collab-features', () =>
HttpResponse.json({ chat: true, notes: true, polls: true, whatsnext: true })
),
http.put('/api/admin/collab-features', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
if ('chat' in body) {
await delay(30);
return HttpResponse.json({}, { status: 500 });
}
return HttpResponse.json({ success: true });
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
render(<AdminPage />);
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /^addons$/i }));
await waitFor(() => expect(screen.getByTestId('collab-chat-state')).toHaveTextContent('true'));
fireEvent.click(screen.getByRole('button', { name: /toggle chat/i }));
fireEvent.click(screen.getByRole('button', { name: /toggle notes/i }));
await waitFor(() => expect(screen.getByTestId('collab-chat-state')).toHaveTextContent('true'));
expect(screen.getByTestId('collab-notes-state')).toHaveTextContent('false');
}); });
}); });
+9 -3
View File
@@ -161,9 +161,15 @@ function AdminPageDesktop(): React.ReactElement {
setBagTrackingEnabled(next) setBagTrackingEnabled(next)
try { await adminApi.updateBagTracking(next) } catch { setBagTrackingEnabled(!next) } try { await adminApi.updateBagTracking(next) } catch { setBagTrackingEnabled(!next) }
}} collabFeatures={collabFeatures} onToggleCollabFeature={async (key: string) => { }} collabFeatures={collabFeatures} onToggleCollabFeature={async (key: string) => {
const next = { ...collabFeatures, [key]: !collabFeatures[key] } const previous = collabFeatures[key]
setCollabFeatures(next) setCollabFeatures({ ...collabFeatures, [key]: !previous })
try { await adminApi.updateCollabFeatures({ [key]: next[key] }) } catch { setCollabFeatures(collabFeatures) } try {
await adminApi.updateCollabFeatures({ [key]: !previous })
} catch {
// Only this key rolls back — a slower request must not undo a toggle
// the admin made in the meantime.
setCollabFeatures(prev => ({ ...prev, [key]: previous }))
}
}} /> }} />
</div> </div>
)} )}
+3 -1
View File
@@ -3720,13 +3720,15 @@ describe('JourneyDetailPage', () => {
it('clicking Copy on share link copies to clipboard', async () => { it('clicking Copy on share link copies to clipboard', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
// Mock clipboard // Mock clipboard. jsdom leaves isSecureContext undefined, which the copy
// handler reads as plain HTTP and answers with its execCommand fallback.
const mockWriteText = vi.fn().mockResolvedValue(undefined); const mockWriteText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { Object.defineProperty(navigator, 'clipboard', {
value: { writeText: mockWriteText }, value: { writeText: mockWriteText },
writable: true, writable: true,
configurable: true, configurable: true,
}); });
Object.defineProperty(window, 'isSecureContext', { value: true, writable: true, configurable: true });
server.use( server.use(
http.get('/api/journeys/1/share-link', () => { http.get('/api/journeys/1/share-link', () => {
@@ -98,7 +98,7 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
unlinkTrip: null, setUnlinkTrip: vi.fn(), unlinkTrip: null, setUnlinkTrip: vi.fn(),
showSettings: false, setShowSettings: vi.fn(), showSettings: false, setShowSettings: vi.fn(),
hideSkeletons: false, setHideSkeletons: vi.fn(), hideSkeletons: false, setHideSkeletons: vi.fn(),
mapRef: { current: null }, fullMapRef: { current: null }, mapRef: { current: null }, fullMapRef: { current: null }, galleryUploadRef: { current: null },
activeLocationId: null, handleMarkerClick: vi.fn(), handleLocationClick: vi.fn(), activeLocationId: null, handleMarkerClick: vi.fn(), handleLocationClick: vi.fn(),
mapEntries: [], sidebarMapItems: [], tripDates: new Set<string>(), isMobile: false, mapEntries: [], sidebarMapItems: [], tripDates: new Set<string>(), isMobile: false,
feedEdge: { atTop: true, atBottom: true }, scrollFeedTo: vi.fn(), feedEdge: { atTop: true, atBottom: true }, scrollFeedTo: vi.fn(),
+38 -3
View File
@@ -1,4 +1,4 @@
// FE-ADMHOOK-001 to FE-ADMHOOK-045 // FE-ADMHOOK-001 to FE-ADMHOOK-049
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import React from 'react'; import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -32,8 +32,13 @@ function wrapper({ children }: { children: React.ReactNode }) {
} }
/** Mounts the hook and waits until the initial parallel loads have settled. */ /** Mounts the hook and waits until the initial parallel loads have settled. */
async function mountAdmin() { async function mountAdmin(url = '/admin') {
const view = renderHook(() => useAdmin(), { wrapper }); const at = ({ children }: { children: React.ReactNode }) => (
<MemoryRouter initialEntries={[url]}>
<TranslationProvider>{children}</TranslationProvider>
</MemoryRouter>
);
const view = renderHook(() => useAdmin(), { wrapper: url === '/admin' ? wrapper : at });
await waitFor(() => expect(view.result.current.isLoading).toBe(false)); await waitFor(() => expect(view.result.current.isLoading).toBe(false));
return view; return view;
} }
@@ -732,6 +737,36 @@ describe('useAdmin', () => {
expect(result.current.users).toHaveLength(2); expect(result.current.users).toHaveLength(2);
}); });
it('FE-ADMHOOK-046: opens on the tab the URL asks for', async () => {
const { result } = await mountAdmin('/admin?tab=audit');
expect(result.current.activeTab).toBe('audit');
});
/*
* A tab id with no panel behind it — a typo, or a link from an install that
* has tabs this one does not — used to open the page on an empty content
* area with nothing selected in the sidebar.
*/
it('FE-ADMHOOK-047: falls back to users when the URL names a tab that does not exist', async () => {
const { result } = await mountAdmin('/admin?tab=nonsense');
expect(result.current.activeTab).toBe('users');
});
/*
* The sidebar entries are built conditionally but the panels are not, so a
* hosted install had its hidden panels one URL away.
*/
it('FE-ADMHOOK-048: refuses a tab a managed install hides', async () => {
seedStore(useAuthStore, { isAuthenticated: true, user: me, managed: true });
const { result } = await mountAdmin('/admin?tab=backup');
await waitFor(() => expect(result.current.activeTab).toBe('users'));
});
it('FE-ADMHOOK-049: keeps that same tab on an install that runs itself', async () => {
const { result } = await mountAdmin('/admin?tab=backup');
expect(result.current.activeTab).toBe('backup');
});
it('FE-ADMHOOK-044: mcpEnabled follows the addon store', async () => { it('FE-ADMHOOK-044: mcpEnabled follows the addon store', async () => {
const { result } = await mountAdmin(); const { result } = await mountAdmin();
+33 -1
View File
@@ -7,8 +7,32 @@ import { useAddonStore } from '../../store/addonStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { getApiErrorMessage } from '../../types' import { getApiErrorMessage } from '../../types'
import { useToast } from '../../components/shared/Toast' import { useToast } from '../../components/shared/Toast'
import { managedAdminTabs } from '../../managed'
import type { AdminUser, AdminStats, OidcConfig, UpdateInfo } from './adminModel' import type { AdminUser, AdminStats, OidcConfig, UpdateInfo } from './adminModel'
/**
* Every tab id AdminPage can render a panel for, whatever this install offers.
*
* ?tab= is user input and was taken as read: an id with no panel behind it —
* a typo, or a link from an install that has tabs this one does not — opened
* the admin page on an empty content area with nothing selected in the
* sidebar. Labels and icons stay in AdminPage; this is only the vocabulary.
*/
const ADMIN_TAB_IDS = [
'users', 'defaults', 'config', 'settings', 'addons', 'plugins', 'storage',
'notifications', 'mcp-tokens', 'github', 'backup', 'audit', 'dev-notifications',
]
/**
* The ones a managed install deliberately does not offer.
*
* The sidebar entries are built conditionally, but the panels are not, so
* ?tab=backup opened a backup schedule that competes with the real one on a
* hosted instance. Checked in an effect rather than in the initial state
* because `managed` arrives with /app-config, after the first render.
*/
const MANAGED_HIDDEN = ['storage', 'github', 'backup']
/** /**
* Admin page logic — owns every admin data slice (users, stats, invites, auth * Admin page logic — owns every admin data slice (users, stats, invites, auth
* toggles, OIDC, feature flags, API keys, SMTP, version/update) plus the CRUD * toggles, OIDC, feature flags, API keys, SMTP, version/update) plus the CRUD
@@ -29,7 +53,11 @@ export function useAdmin() {
// page with eleven of them. Read once for the initial value and written back // page with eleven of them. Read once for the initial value and written back
// on every change, so the address bar keeps saying where the reader is. // on every change, so the address bar keeps saying where the reader is.
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const [activeTab, setActiveTabState] = useState<string>(() => searchParams.get('tab') || 'users') const [activeTab, setActiveTabState] = useState<string>(() => {
const asked = searchParams.get('tab')
const known = [...ADMIN_TAB_IDS, ...managedAdminTabs.map(tab => tab.id)]
return asked && known.includes(asked) ? asked : 'users'
})
const setActiveTab = useCallback((tab: string) => { const setActiveTab = useCallback((tab: string) => {
setActiveTabState(tab) setActiveTabState(tab)
// replace, not push: eleven tabs would otherwise fill the back button with // replace, not push: eleven tabs would otherwise fill the back button with
@@ -41,6 +69,10 @@ export function useAdmin() {
return next return next
}, { replace: true }) }, { replace: true })
}, [setSearchParams]) }, [setSearchParams])
useEffect(() => {
if (managed && MANAGED_HIDDEN.includes(activeTab)) setActiveTab('users')
}, [managed, activeTab, setActiveTab])
const [users, setUsers] = useState<AdminUser[]>([]) const [users, setUsers] = useState<AdminUser[]>([])
const [stats, setStats] = useState<AdminStats | null>(null) const [stats, setStats] = useState<AdminStats | null>(null)
const [isLoading, setIsLoading] = useState<boolean>(true) const [isLoading, setIsLoading] = useState<boolean>(true)
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { placeWebsiteSchema } from '@trek/shared';
import type { import type {
MapsPlaceEnrichmentRequest, MapsPlaceEnrichmentRequest,
MapsPlaceEnrichmentResult, MapsPlaceEnrichmentResult,
@@ -173,8 +174,11 @@ export function collectFacts(details: Record<string, unknown> | null): PlaceFact
// OSM writes several cuisines semicolon-separated and underscored. // OSM writes several cuisines semicolon-separated and underscored.
if (cuisine) push('cuisine', cuisine.split(';').map((c) => c.replace(/_/g, ' ').trim()).filter(Boolean).join(', ')); if (cuisine) push('cuisine', cuisine.split(';').map((c) => c.replace(/_/g, ' ').trim()).filter(Boolean).join(', '));
const menu = typeof details.menu_url === 'string' ? details.menu_url : null; // `menu_url` is a community-editable OSM tag that becomes an href on the
if (menu) push('menu', null, menu); // client, so it goes through the same allow-list as a place's website —
// anything but http(s) is dropped rather than rendered as a link.
const menu = typeof details.menu_url === 'string' ? details.menu_url.trim() : '';
if (placeWebsiteSchema.safeParse(menu).success) push('menu', null, menu);
if (yesNo(details.outdoor_seating) === 'yes') push('outdoorSeating', null); if (yesNo(details.outdoor_seating) === 'yes') push('outdoorSeating', null);
if (yesNo(details.takeaway) === 'yes') push('takeaway', null); if (yesNo(details.takeaway) === 'yes') push('takeaway', null);
@@ -729,6 +729,19 @@ describe('collectFacts', () => {
expect(facts.find((f) => f.kind === 'internetAccess')).toEqual({ kind: 'internetAccess', value: null, url: null }); expect(facts.find((f) => f.kind === 'internetAccess')).toEqual({ kind: 'internetAccess', value: null, url: null });
}); });
it('ENRICH-052c: drops a menu_url that is not http(s)', () => {
// Anyone can edit the OSM tag and the client turns it into an href.
for (const menu_url of ['javascript:alert(1)', 'data:text/html,<script>1</script>', 'ftp://host/menu', '']) {
expect(collectFacts(osm({ menu_url })).map((f) => f.kind)).toEqual([]);
}
});
it('ENRICH-052d: keeps an http menu_url and trims the surrounding whitespace', () => {
expect(collectFacts(osm({ menu_url: ' http://example.org/menu ' }))).toEqual([
{ kind: 'menu', value: null, url: 'http://example.org/menu' },
]);
});
it('ENRICH-053: takes no chips at all from a Google place', () => { it('ENRICH-053: takes no chips at all from a Google place', () => {
expect(collectFacts(null)).toEqual([]); expect(collectFacts(null)).toEqual([]);
// cuisine/outdoor_seating are OSM tagging and never appear on a Google blob; // cuisine/outdoor_seating are OSM tagging and never appear on a Google blob;