mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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 type { AxiosResponse } from 'axios'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
tagsApi, categoriesApi, adminApi, addonsApi, pluginsApi, airtrailApi, journeyApi,
|
||||
mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi,
|
||||
configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi,
|
||||
shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi,
|
||||
shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi, memoriesApi,
|
||||
} from './client'
|
||||
|
||||
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 () => {
|
||||
await assertCalls([
|
||||
{ n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' },
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
} from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { probeNow } from '../sync/connectivity'
|
||||
import { downloadBlob } from '../utils/fileDownload'
|
||||
|
||||
/**
|
||||
* 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),
|
||||
}
|
||||
|
||||
// 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 = {
|
||||
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) =>
|
||||
@@ -1144,13 +1166,7 @@ export const backupApi = {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error('Download failed')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
downloadBlob(await res.blob(), filename)
|
||||
},
|
||||
delete: (filename: string) => apiClient.delete(`/backup/${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')
|
||||
})
|
||||
|
||||
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
|
||||
server.use(http.patch('/api/journeys/photos/:id', () => {
|
||||
attempts += 1
|
||||
return new HttpResponse(null, { status: 500 })
|
||||
return HttpResponse.json({ error: 'sort rejected' }, { status: 500 })
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
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 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'))
|
||||
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 () => {
|
||||
@@ -865,6 +866,32 @@ describe('EntryEditor', () => {
|
||||
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', () => {
|
||||
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 { normalizeImageFiles } from '../../utils/convertHeic'
|
||||
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
|
||||
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 { getCurrentPositionOnce } from '../../hooks/useGeolocation'
|
||||
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 [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
|
||||
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 [showGalleryPick, setShowGalleryPick] = useState(false)
|
||||
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 }[] = []
|
||||
for (const provider of enabled) {
|
||||
try {
|
||||
const response = await fetch(`/api/integrations/memories/${provider.id}/status`, { credentials: 'include' })
|
||||
if (response.ok && (await response.json()).connected) connected.push({ id: provider.id, name: provider.name })
|
||||
if ((await memoriesApi.status(provider.id)).connected) connected.push({ id: provider.id, name: provider.name })
|
||||
} catch {}
|
||||
}
|
||||
setAvailableProviders(connected)
|
||||
@@ -186,6 +189,11 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
setPendingProviderGroups([])
|
||||
}
|
||||
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 {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -436,13 +444,18 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
setPhotos(prev => {
|
||||
const next = [...prev]
|
||||
const [moved] = next.splice(idx, 1)
|
||||
next.unshift(moved)
|
||||
next.forEach((ph, i) => { journeyApi.updatePhoto(ph.id, { sort_order: i }).catch(() => {}) })
|
||||
return next
|
||||
})
|
||||
const prevOrder = photos
|
||||
const next = [...photos]
|
||||
const [moved] = next.splice(idx, 1)
|
||||
next.unshift(moved)
|
||||
setPhotos(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"
|
||||
>
|
||||
@@ -468,7 +481,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
))}
|
||||
{pendingFiles.map((f, i) => (
|
||||
<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
|
||||
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"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { normalizeImageFiles } from '../../utils/convertHeic'
|
||||
import { isVideoFile } from '../../utils/videoPoster'
|
||||
import { useJourneyStore } from '../../store/journeyStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { journeyApi, addonsApi } from '../../api/client'
|
||||
import { journeyApi, addonsApi, memoriesApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
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 }[] = []
|
||||
for (const p of enabledProviders) {
|
||||
try {
|
||||
const res = await fetch(`/api/integrations/memories/${p.id}/status`, { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const status = await res.json()
|
||||
if (status.connected) connected.push({ id: p.id, name: p.name })
|
||||
}
|
||||
const status = await memoriesApi.status(p.id)
|
||||
if (status.connected) connected.push({ id: p.id, name: p.name })
|
||||
} catch {}
|
||||
}
|
||||
setAvailableProviders(connected)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, useRef, useMemo } from 'react'
|
||||
import { X, Check, Calendar, ChevronRight, Camera } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { memoriesApi } from '../../api/client'
|
||||
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore'
|
||||
import { groupPhotosByDate, sortProviderPhotos, type GeoPoint } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
|
||||
@@ -64,21 +65,14 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
setSearchTo(to)
|
||||
setSearchPage(page)
|
||||
try {
|
||||
const res = await fetch(`/api/integrations/memories/${provider}/search`, {
|
||||
method: 'POST', credentials: 'include', signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from, to, page, size: 50 }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const assets = data.assets || []
|
||||
setPhotos(prev => append ? [...prev, ...assets] : assets)
|
||||
setHasMore(!!data.hasMore)
|
||||
} else {
|
||||
setHasMore(false)
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name !== 'AbortError') setHasMore(false)
|
||||
const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal)
|
||||
const assets = data.assets || []
|
||||
setPhotos(prev => append ? [...prev, ...assets] : assets)
|
||||
setHasMore(!!data.hasMore)
|
||||
} catch {
|
||||
// A cancelled request is about to be replaced by the next one, so leave
|
||||
// its paging state alone.
|
||||
if (!signal.aborted) setHasMore(false)
|
||||
}
|
||||
if (!signal.aborted) { setLoading(false); setLoadingMore(false) }
|
||||
}
|
||||
@@ -94,17 +88,14 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
setPhotos([])
|
||||
setHasMore(false)
|
||||
try {
|
||||
const qs = album.passphrase ? `?passphrase=${encodeURIComponent(album.passphrase)}` : ''
|
||||
const res = await fetch(`/api/integrations/memories/${provider}/albums/${album.id}/photos${qs}`, { credentials: 'include', signal })
|
||||
if (res.ok) setPhotos((await res.json()).assets || [])
|
||||
} catch (e: any) { if (e.name !== 'AbortError') {} }
|
||||
setPhotos((await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || [])
|
||||
} catch { /* ignore */ }
|
||||
if (!signal.aborted) setLoading(false)
|
||||
}
|
||||
|
||||
const loadAlbums = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/integrations/memories/${provider}/albums`, { credentials: 'include' })
|
||||
if (res.ok) setAlbums((await res.json()).albums || [])
|
||||
setAlbums((await memoriesApi.albums(provider)).albums || [])
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ beforeEach(() => {
|
||||
writeText.mockClear()
|
||||
window.__addToast = toastSpy
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true })
|
||||
Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true, writable: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -37,6 +38,18 @@ describe('JourneyShareSection', () => {
|
||||
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 () => {
|
||||
server.use(http.get('/api/journeys/7/share-link', async () => {
|
||||
await delay(30)
|
||||
@@ -99,7 +112,7 @@ describe('JourneyShareSection', () => {
|
||||
act(() => { copyBtn.click() })
|
||||
|
||||
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) })
|
||||
expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument()
|
||||
@@ -179,4 +192,23 @@ describe('JourneyShareSection', () => {
|
||||
|
||||
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 [loading, setLoading] = useState(true)
|
||||
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()
|
||||
|
||||
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])
|
||||
|
||||
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 copyLink = () => {
|
||||
navigator.clipboard.writeText(shareUrl)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
const copyLink = async () => {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
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 (
|
||||
<div>
|
||||
|
||||
@@ -1423,4 +1423,27 @@ describe('ReservationModal', () => {
|
||||
// Hotel place picker still shows its placeholder — no crash on the missing record.
|
||||
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 CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import { parseReservationMetadata } from '../../utils/flightLegs'
|
||||
import { resolveDayId } from '../../utils/formatters'
|
||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
|
||||
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)))
|
||||
if (reservation) {
|
||||
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
|
||||
const meta = parseReservationMetadata(reservation)
|
||||
const rawEnd = reservation.reservation_end_time || ''
|
||||
let endDate = ''
|
||||
let endTime = rawEnd
|
||||
|
||||
@@ -298,4 +298,35 @@ describe('AppearanceSettingsTab – dashboard widgets', () => {
|
||||
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 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).
|
||||
useEffect(() => {
|
||||
@@ -89,8 +92,11 @@ export default function AppearanceSettingsTab(): React.ReactElement {
|
||||
|
||||
// Flush any pending persist on unmount.
|
||||
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 =
|
||||
settings.dark_mode === true ||
|
||||
@@ -103,7 +109,9 @@ export default function AppearanceSettingsTab(): React.ReactElement {
|
||||
setCfg(next)
|
||||
applyAppearance({ darkMode: settings.dark_mode, appearance: next, isSharedPage: false })
|
||||
if (persistTimer.current) window.clearTimeout(persistTimer.current)
|
||||
pendingWrite.current = next
|
||||
persistTimer.current = window.setTimeout(() => {
|
||||
pendingWrite.current = null
|
||||
updateSetting('appearance', next).catch((e: unknown) =>
|
||||
toast.error(e instanceof Error ? e.message : t('common.error'))
|
||||
)
|
||||
|
||||
@@ -755,6 +755,54 @@ describe('NotificationsTab', () => {
|
||||
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 {
|
||||
await notificationsApi.updatePreferences(updated)
|
||||
} 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 {
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { buildAdmin } from '../../tests/helpers/factories';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
@@ -27,16 +27,21 @@ vi.mock('../components/Admin/AddonManager', () => ({
|
||||
default: ({
|
||||
bagTrackingEnabled,
|
||||
onToggleBagTracking,
|
||||
collabFeatures,
|
||||
onToggleCollabFeature,
|
||||
}: {
|
||||
bagTrackingEnabled: boolean;
|
||||
onToggleBagTracking: () => void;
|
||||
collabFeatures: Record<string, boolean>;
|
||||
onToggleCollabFeature: (key: string) => void;
|
||||
}) => (
|
||||
<div data-testid="addon-manager">
|
||||
<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={() => onToggleCollabFeature('chat')}>toggle chat</button>
|
||||
<button onClick={() => onToggleCollabFeature('notes')}>toggle notes</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -1569,8 +1574,38 @@ describe('AdminPage', () => {
|
||||
|
||||
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('addon-manager')).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByTestId('collab-chat-state')).toHaveTextContent('true'));
|
||||
});
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -161,9 +161,15 @@ function AdminPageDesktop(): React.ReactElement {
|
||||
setBagTrackingEnabled(next)
|
||||
try { await adminApi.updateBagTracking(next) } catch { setBagTrackingEnabled(!next) }
|
||||
}} collabFeatures={collabFeatures} onToggleCollabFeature={async (key: string) => {
|
||||
const next = { ...collabFeatures, [key]: !collabFeatures[key] }
|
||||
setCollabFeatures(next)
|
||||
try { await adminApi.updateCollabFeatures({ [key]: next[key] }) } catch { setCollabFeatures(collabFeatures) }
|
||||
const previous = collabFeatures[key]
|
||||
setCollabFeatures({ ...collabFeatures, [key]: !previous })
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -3720,13 +3720,15 @@ describe('JourneyDetailPage', () => {
|
||||
it('clicking Copy on share link copies to clipboard', async () => {
|
||||
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);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: mockWriteText },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, 'isSecureContext', { value: true, writable: true, configurable: true });
|
||||
|
||||
server.use(
|
||||
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(),
|
||||
showSettings: false, setShowSettings: 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(),
|
||||
mapEntries: [], sidebarMapItems: [], tripDates: new Set<string>(), isMobile: false,
|
||||
feedEdge: { atTop: true, atBottom: true }, scrollFeedTo: vi.fn(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMHOOK-001 to FE-ADMHOOK-045
|
||||
// FE-ADMHOOK-001 to FE-ADMHOOK-049
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import React from 'react';
|
||||
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. */
|
||||
async function mountAdmin() {
|
||||
const view = renderHook(() => useAdmin(), { wrapper });
|
||||
async function mountAdmin(url = '/admin') {
|
||||
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));
|
||||
return view;
|
||||
}
|
||||
@@ -732,6 +737,36 @@ describe('useAdmin', () => {
|
||||
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 () => {
|
||||
const { result } = await mountAdmin();
|
||||
|
||||
|
||||
@@ -7,8 +7,32 @@ import { useAddonStore } from '../../store/addonStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { managedAdminTabs } from '../../managed'
|
||||
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
|
||||
* 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
|
||||
// on every change, so the address bar keeps saying where the reader is.
|
||||
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) => {
|
||||
setActiveTabState(tab)
|
||||
// replace, not push: eleven tabs would otherwise fill the back button with
|
||||
@@ -41,6 +69,10 @@ export function useAdmin() {
|
||||
return next
|
||||
}, { replace: true })
|
||||
}, [setSearchParams])
|
||||
useEffect(() => {
|
||||
if (managed && MANAGED_HIDDEN.includes(activeTab)) setActiveTab('users')
|
||||
}, [managed, activeTab, setActiveTab])
|
||||
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { placeWebsiteSchema } from '@trek/shared';
|
||||
import type {
|
||||
MapsPlaceEnrichmentRequest,
|
||||
MapsPlaceEnrichmentResult,
|
||||
@@ -173,8 +174,11 @@ export function collectFacts(details: Record<string, unknown> | null): PlaceFact
|
||||
// OSM writes several cuisines semicolon-separated and underscored.
|
||||
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;
|
||||
if (menu) push('menu', null, menu);
|
||||
// `menu_url` is a community-editable OSM tag that becomes an href on the
|
||||
// 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.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 });
|
||||
});
|
||||
|
||||
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', () => {
|
||||
expect(collectFacts(null)).toEqual([]);
|
||||
// cuisine/outdoor_seating are OSM tagging and never appear on a Google blob;
|
||||
|
||||
Reference in New Issue
Block a user