fix(share): only call a share link expired when the server says so (#2505)

The public share page turned every failed GET /api/shared/:token into
"Link expired or invalid". A 5xx during a restart, a proxy 502, a rate
limit, a timeout or a viewer who is briefly offline all told the viewer
the link was dead and sent them back to the owner for a new one, while
the same link loaded fine on the next try.

The endpoint answers 404 with its JSON { error } body for an unknown,
expired or revoked token, so that is now the only answer that shows the
expired screen. A 404 without that body comes from a proxy in front of
TREK (Traefik answers "404 page not found" while the container is
stopped or still starting) and counts as a failed load, as does a 200
that is not the share payload, such as an auth wall's login page, which
used to crash the page. Any failed load shows "This trip could not be
loaded" with a retry button that refetches in place and stays busy
while it runs. The load also ignores a response that lands after the
page moved on to another token.

Both screens use the theme tokens instead of hex literals. New keys are
translated in all 23 locales.
This commit is contained in:
Maurice
2026-09-25 22:00:19 +02:00
parent bea3af4b45
commit 0a3a993dfc
30 changed files with 472 additions and 26 deletions
+175
View File
@@ -2,7 +2,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor, fireEvent } from '../../tests/helpers/render';
import { Routes, Route } from 'react-router';
import { http, HttpResponse } from 'msw';
import { AxiosError } from 'axios';
import { server } from '../../tests/helpers/msw/server';
import { shareApi } from '../api/client';
import { resetAllStores, seedStore } from '../../tests/helpers/store';
import { buildSettings } from '../../tests/helpers/factories';
import { useSettingsStore } from '../store/settingsStore';
@@ -1544,4 +1546,177 @@ describe('SharedTripPage', () => {
expect(header?.style.overflow).toBe('hidden');
});
});
// ── #2505: only a 404 means the link is dead ────────────────────────────
describe('FE-PAGE-SHARED-043: a failed load is not an expired link (#2505)', () => {
const failWith = (status: number) =>
server.use(http.get('/api/shared/:token', () => HttpResponse.json({ error: 'nope' }, { status })));
it.each([500, 502, 503, 429])('keeps the link when the server answers %i', async (status) => {
failWith(status);
renderSharedTrip('test-token');
await waitFor(() => expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument());
expect(screen.queryByText(/link expired or invalid/i)).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
});
// A request with no response makes the api client probe /api/health for a
// proxy wall first; answer it the way a healthy server does.
const healthy = () => http.get('/api/health', () => HttpResponse.json({ status: 'ok' }));
it('keeps the link when the request never reaches the server', async () => {
server.use(healthy(), http.get('/api/shared/:token', () => HttpResponse.error()));
renderSharedTrip('test-token');
await waitFor(() => expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument());
expect(screen.queryByText(/link expired or invalid/i)).not.toBeInTheDocument();
});
it('keeps the link when the request times out', async () => {
// What axios rejects with once its 8s deadline passes: no response at all.
const timeout = new AxiosError('timeout of 8000ms exceeded', AxiosError.ECONNABORTED);
const spy = vi.spyOn(shareApi, 'getSharedTrip').mockRejectedValueOnce(timeout);
renderSharedTrip('test-token');
await waitFor(() => expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument());
expect(screen.queryByText(/link expired or invalid/i)).not.toBeInTheDocument();
spy.mockRestore();
});
it('still calls a 404 from the share endpoint an expired link, without a retry', async () => {
server.use(
http.get('/api/shared/:token', () => HttpResponse.json({ error: 'Invalid or expired link' }, { status: 404 })),
);
renderSharedTrip('gone-token');
await waitFor(() => expect(screen.getByText(/link expired or invalid/i)).toBeInTheDocument());
expect(screen.queryByText(/could not be loaded/i)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument();
});
// A reverse proxy that has no upstream for TREK (Traefik while the
// container is stopped or still in its start period, nginx with a missing
// location) answers 404 on its own. That is the restart window of the
// report, not a dead link.
it.each([
['a plain text 404 from Traefik', '404 page not found\n', 'text/plain; charset=utf-8'],
['an HTML 404 from nginx', '<html><head><title>404 Not Found</title></head><body></body></html>', 'text/html'],
])('keeps the link on %s', async (_label, body, contentType) => {
server.use(
http.get('/api/shared/:token', () => new HttpResponse(body, { status: 404, headers: { 'content-type': contentType } })),
);
renderSharedTrip('test-token');
await waitFor(() => expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument());
expect(screen.queryByText(/link expired or invalid/i)).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
});
it('shows the failed screen instead of crashing when a 200 is not the share payload', async () => {
// An auth wall or a captive portal answering the API call with its own page.
let calls = 0;
server.use(
http.get('/api/shared/:token', () => {
calls += 1;
if (calls === 1) {
return new HttpResponse('<!doctype html><html><body>Sign in</body></html>', {
status: 200,
headers: { 'content-type': 'text/html' },
});
}
return HttpResponse.json({
trip: { id: 1, title: 'Past The Wall', start_date: '2026-07-01', end_date: '2026-07-02' },
days: [],
assignments: {},
dayNotes: {},
places: [],
reservations: [],
accommodations: [],
permissions: { share_map: true },
});
}),
);
renderSharedTrip('test-token');
fireEvent.click(await screen.findByRole('button', { name: /try again/i }));
await waitFor(() => expect(screen.getByText('Past The Wall')).toBeInTheDocument());
expect(calls).toBe(2);
});
it('loads the trip when the retry gets through', async () => {
let calls = 0;
server.use(
http.get('/api/shared/:token', () => {
calls += 1;
if (calls === 1) return new HttpResponse(null, { status: 503 });
return HttpResponse.json({
trip: { id: 1, title: 'Back Online Trip', start_date: '2026-07-01', end_date: '2026-07-02' },
days: [],
assignments: {},
dayNotes: {},
places: [],
reservations: [],
accommodations: [],
permissions: { share_map: true },
});
}),
);
renderSharedTrip('test-token');
const retry = await screen.findByRole('button', { name: /try again/i });
fireEvent.click(retry);
await waitFor(() => expect(screen.getByText('Back Online Trip')).toBeInTheDocument());
expect(calls).toBe(2);
});
it('keeps the failed screen up with a busy button while the retry runs, and sends it once', async () => {
let calls = 0;
let release: () => void = () => {};
server.use(
http.get('/api/shared/:token', async () => {
calls += 1;
if (calls === 1) return new HttpResponse(null, { status: 500 });
await new Promise<void>((resolve) => { release = resolve; });
return new HttpResponse(null, { status: 500 });
}),
);
renderSharedTrip('test-token');
const retry = await screen.findByRole('button', { name: /try again/i });
fireEvent.click(retry);
await waitFor(() => expect(retry).toBeDisabled());
expect(retry).toHaveAttribute('aria-busy', 'true');
expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument();
fireEvent.click(retry);
await waitFor(() => expect(calls).toBe(2));
release();
await waitFor(() => expect(retry).not.toBeDisabled());
expect(calls).toBe(2);
expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument();
});
it('lands on the expired screen when the retry finds the link gone', async () => {
let calls = 0;
server.use(
healthy(),
http.get('/api/shared/:token', () => {
calls += 1;
return calls === 1
? HttpResponse.error()
: HttpResponse.json({ error: 'Invalid or expired link' }, { status: 404 });
}),
);
renderSharedTrip('test-token');
fireEvent.click(await screen.findByRole('button', { name: /try again/i }));
await waitFor(() => expect(screen.getByText(/link expired or invalid/i)).toBeInTheDocument());
expect(calls).toBe(2);
});
});
});
+4 -17
View File
@@ -39,6 +39,7 @@ import { resolveBasemap } from '../utils/tileUrl';
import { useSharedTrip } from './sharedTrip/useSharedTrip';
import { SharedPlaceDetails } from './sharedTrip/SharedPlaceDetails';
import { SharedBookingDetails } from './sharedTrip/SharedBookingDetails';
import { SharedTripErrorScreen } from './sharedTrip/SharedTripErrorScreen';
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship };
@@ -98,6 +99,8 @@ export default function SharedTripPage() {
const {
data,
error,
retry,
retrying,
base,
convert,
selectedDay,
@@ -108,23 +111,7 @@ export default function SharedTripPage() {
setShowLangPicker,
} = useSharedTrip();
if (error)
return (
<div
className="bg-[#f3f4f6]"
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}
>
<div style={{ textAlign: 'center', padding: 40 }}>
<div style={{ fontSize: 'calc(48px * var(--fs-scale-title, 1))', marginBottom: 16 }}>🔒</div>
<h1 className="text-[#111827]" style={{ fontSize: 'calc(20px * var(--fs-scale-title, 1))', fontWeight: 700 }}>
{t('shared.expired')}
</h1>
<p className="text-[#6b7280]" style={{ marginTop: 8 }}>
{t('shared.expiredHint')}
</p>
</div>
</div>
);
if (error) return <SharedTripErrorScreen reason={error} retrying={retrying} onRetry={retry} />;
if (!data)
return (
@@ -0,0 +1,54 @@
import { CloudOff, Loader2, RotateCcw } from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { SharedTripLoadError } from './sharedTripModel'
interface SharedTripErrorScreenProps {
reason: SharedTripLoadError
retrying: boolean
onRetry: () => void
}
/**
* The full-page state of a share link whose payload did not arrive. Only
* 'expired' tells the viewer the link is dead; 'unavailable' says the load
* failed and offers another go, since the same link usually works on the next
* request (#2505).
*/
export function SharedTripErrorScreen({ reason, retrying, onRetry }: SharedTripErrorScreenProps) {
const { t } = useTranslation()
const expired = reason === 'expired'
return (
<div className="flex min-h-screen items-center justify-center bg-surface-secondary">
<div className="flex max-w-md flex-col items-center p-10 text-center" role={expired ? undefined : 'alert'}>
{expired ? (
<div aria-hidden style={{ fontSize: 'calc(48px * var(--fs-scale-title, 1))', marginBottom: 16 }}>
🔒
</div>
) : (
<CloudOff size={44} strokeWidth={1.75} className="mb-4 text-content-muted" aria-hidden />
)}
<h1 className="text-balance text-subtitle font-bold text-content">{t(expired ? 'shared.expired' : 'shared.loadFailed')}</h1>
<p className="mt-2 text-balance text-body text-content-muted">
{t(expired ? 'shared.expiredHint' : 'shared.loadFailedHint')}
</p>
{!expired && (
<button
type="button"
onClick={onRetry}
disabled={retrying}
aria-busy={retrying}
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-body font-medium text-accent-text transition-colors hover:bg-accent-hover disabled:cursor-wait disabled:opacity-70"
>
{retrying ? (
<Loader2 size={15} className="animate-spin" aria-hidden />
) : (
<RotateCcw size={15} aria-hidden />
)}
{t('shared.retry')}
</button>
)}
</div>
</div>
)
}
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { formatDurationMinutes, isHttpUrl, linkHost } from './sharedTripModel'
import { AxiosError, AxiosHeaders } from 'axios'
import { formatDurationMinutes, isHttpUrl, isSharedTripPayload, linkHost, sharedTripLoadError } from './sharedTripModel'
describe('sharedTripModel (#2320)', () => {
it('accepts http and https and nothing else', () => {
@@ -35,3 +36,65 @@ describe('sharedTripModel (#2320)', () => {
expect(linkHost('nonsense')).toBe('nonsense')
})
})
describe('sharedTripLoadError (#2505)', () => {
// What the share endpoint sends for an unknown, expired or revoked token.
const ENDPOINT_404 = { error: 'Invalid or expired link' }
const answered = (status: number, data: unknown = ENDPOINT_404) =>
new AxiosError('Request failed', 'ERR_BAD_RESPONSE', undefined, undefined, {
status,
statusText: '',
data,
headers: {},
config: { headers: new AxiosHeaders() },
})
it('calls only the endpoint 404 an expired link', () => {
expect(sharedTripLoadError(answered(404))).toBe('expired')
})
it('treats every other answer as a failed load', () => {
for (const status of [400, 401, 403, 408, 429, 500, 502, 503, 504]) {
expect(sharedTripLoadError(answered(status))).toBe('unavailable')
}
})
it('does not take a 404 without the TREK error body for an expired link', () => {
// Traefik with the container stopped or still starting, nginx, an empty
// answer: a 404 from something in front of TREK says nothing about the token.
expect(sharedTripLoadError(answered(404, '404 page not found\n'))).toBe('unavailable')
expect(sharedTripLoadError(answered(404, '<html><body><h1>404 Not Found</h1></body></html>'))).toBe('unavailable')
expect(sharedTripLoadError(answered(404, ''))).toBe('unavailable')
expect(sharedTripLoadError(answered(404, null))).toBe('unavailable')
expect(sharedTripLoadError(answered(404, {}))).toBe('unavailable')
expect(sharedTripLoadError(answered(404, [{ error: 'x' }]))).toBe('unavailable')
expect(sharedTripLoadError(answered(404, { error: 404 }))).toBe('unavailable')
})
it('treats a request without any answer as a failed load', () => {
expect(sharedTripLoadError(new AxiosError('Network Error', AxiosError.ERR_NETWORK))).toBe('unavailable')
expect(sharedTripLoadError(new AxiosError('timeout of 8000ms exceeded', AxiosError.ECONNABORTED))).toBe('unavailable')
expect(sharedTripLoadError(new TypeError('Failed to fetch'))).toBe('unavailable')
expect(sharedTripLoadError(undefined)).toBe('unavailable')
expect(sharedTripLoadError(null)).toBe('unavailable')
})
})
describe('isSharedTripPayload (#2505)', () => {
it('accepts what the share endpoint sends', () => {
expect(isSharedTripPayload({ trip: { id: 1, title: 'Lisbon' }, days: [], permissions: {} })).toBe(true)
})
it('rejects a 200 that is not the share payload', () => {
// An auth wall or a captive portal answering the API call with its own page.
expect(isSharedTripPayload('<!doctype html><html><body>Sign in</body></html>')).toBe(false)
expect(isSharedTripPayload('')).toBe(false)
expect(isSharedTripPayload(null)).toBe(false)
expect(isSharedTripPayload(undefined)).toBe(false)
expect(isSharedTripPayload([])).toBe(false)
expect(isSharedTripPayload({})).toBe(false)
expect(isSharedTripPayload({ trip: null })).toBe(false)
expect(isSharedTripPayload({ trip: 'Lisbon' })).toBe(false)
})
})
@@ -46,3 +46,39 @@ export function linkHost(url: string): string {
return url
}
}
/**
* Why the share payload did not arrive (#2505).
*
* The share endpoint answers 404 with its JSON `{ error }` body for a token
* that is unknown, expired or revoked, and that is the only answer that says
* anything about the link. A 5xx while the server restarts, a rate limit, a
* timeout or no response at all because the viewer is offline says the request
* failed, not the link, so the page offers a retry instead of sending the
* viewer back to the owner for a new link that would not have been needed.
*
* A 404 without that body is not TREK talking: a reverse proxy with no
* upstream answers one on its own (Traefik while the container is stopped or
* still starting, nginx with a missing location), so it counts as a failed
* load too.
*/
export type SharedTripLoadError = 'expired' | 'unavailable'
export function sharedTripLoadError(err: unknown): SharedTripLoadError {
const response = (err as { response?: { status?: number; data?: unknown } } | null | undefined)?.response
if (response?.status !== 404) return 'unavailable'
return isPlainObject(response.data) && typeof response.data.error === 'string' ? 'expired' : 'unavailable'
}
/**
* Whether a 200 carried the share payload. An auth wall or a captive portal
* can answer the API call with its own HTML page, which axios hands over as a
* string; the page would crash on it, so the hook treats it as a failed load.
*/
export function isSharedTripPayload(payload: unknown): boolean {
return isPlainObject(payload) && isPlainObject(payload.trip)
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
+53 -7
View File
@@ -2,28 +2,61 @@ import { useState, useEffect } from 'react'
import { useParams } from 'react-router'
import { shareApi } from '../../api/client'
import { useExchangeRates } from '../../hooks/useExchangeRates'
import { isSharedTripPayload, sharedTripLoadError, type SharedTripLoadError } from './sharedTripModel'
/**
* Shared-trip (public) data hook — owns the token lookup, the read-only share
* fetch and the view state (selected day, active tab, language picker).
* SharedTripPage is a pure wiring container; the post-load derivations
* fetch with its retry, and the view state (selected day, active tab, language
* picker). SharedTripPage is a pure wiring container; the post-load derivations
* (sortedDays, map places, …) stay in the page next to the JSX that uses them.
* Behaviour is identical to the previous in-component logic.
*/
export function useSharedTrip() {
const { token } = useParams<{ token: string }>()
// The shared payload is an open-ended snapshot (trip, days, assignments, …),
// matched 1:1 from the public share endpoint — kept loosely typed as before.
const [data, setData] = useState<any>(null)
const [error, setError] = useState(false)
// 'expired' only for the endpoint's own 404; any other failure, a proxy's
// 404 or a 200 that is not the payload included, is 'unavailable' and keeps
// the viewer on a retry (#2505).
const [error, setError] = useState<SharedTripLoadError | null>(null)
const [attempt, setAttempt] = useState(0)
const [retrying, setRetrying] = useState(false)
const [selectedDay, setSelectedDay] = useState<number | null>(null)
const [activeTab, setActiveTab] = useState('plan')
const [showLangPicker, setShowLangPicker] = useState(false)
useEffect(() => {
if (!token) return
shareApi.getSharedTrip(token).then(setData).catch(() => setError(true))
}, [token])
let cancelled = false
shareApi
.getSharedTrip(token)
.then((payload) => {
if (cancelled) return
if (!isSharedTripPayload(payload)) {
setError('unavailable')
return
}
setData(payload)
setError(null)
})
.catch((err: unknown) => {
if (!cancelled) setError(sharedTripLoadError(err))
})
.finally(() => {
if (!cancelled) setRetrying(false)
})
return () => {
cancelled = true
}
}, [token, attempt])
// The failed screen stays up while the next attempt runs, so a request that
// fails again straight away does not flash the spinner.
const retry = () => {
if (retrying) return
setRetrying(true)
setAttempt((n) => n + 1)
}
// The server now withholds the whole itinerary when the owner disabled the map
// (share_map=false), so the Plan tab has nothing to show — land on the first
@@ -46,5 +79,18 @@ export function useSharedTrip() {
const base = String(data?.baseCurrency || data?.trip?.currency || 'EUR').toUpperCase()
const { convert } = useExchangeRates(base)
return { data, error, base, convert, selectedDay, setSelectedDay, activeTab, setActiveTab, showLangPicker, setShowLangPicker }
return {
data,
error,
retry,
retrying,
base,
convert,
selectedDay,
setSelectedDay,
activeTab,
setActiveTab,
showLangPicker,
setShowLangPicker,
}
}
+2 -1
View File
@@ -5,8 +5,9 @@ export const sharedHandlers = [
http.get('/api/shared/:token', ({ params }) => {
const { token } = params;
// What the share endpoint answers for an unknown, expired or revoked token.
if (token === 'invalid-token' || token === 'expired-token') {
return new HttpResponse(null, { status: 404 });
return HttpResponse.json({ error: 'Invalid or expired link' }, { status: 404 });
}
const trip = { ...buildTrip({ start_date: '2026-07-01', end_date: '2026-07-05' }), title: 'Shared Paris Trip' };