fix(maps): let users and admins enter a CARTO API key (#2060)

CARTO stamps an "API KEY REQUIRED" watermark onto every basemap tile
fetched without a key since 26.08.2026, and both CartoDB presets plus the
built-in fallback are CARTO. The key is free and needs no account, so add
it as a setting rather than dropping the provider.

carto_api_key sits next to mapbox_access_token: per user in Settings/Map,
instance-wide under Admin/User Defaults, encrypted at rest, injected from
CARTO_API_KEY on managed instances. Unlike Mapbox it never switches the
map provider, since a raster key says nothing about the renderer.

The key is appended as ?key= at render time instead of being stored in the
template, so a saved URL stays portable and survives a rotation. That is
also what reaches the four maps that pinned CARTO and ignored the user's
tile setting: atlas, collections, the public trip share and the public
journey. The two share pages read it from their token-gated payload, the
same way baseCurrency already travels.

Also lifts the release notice above page chrome. Its overlay sat at
z-index 50, under the Vacay mode toolbar at 61, so the dialog was neither
on top nor modal. Adds a six-step layer scale to index.css and moves the
three notice shells onto it.

Closes #2054
Closes #2052
This commit is contained in:
Maurice
2026-08-27 12:06:55 +02:00
committed by GitHub
parent 521e52c853
commit 1f66ef1d32
99 changed files with 1258 additions and 67 deletions
@@ -1,4 +1,4 @@
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-025
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-027
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -63,6 +63,11 @@ function hasResetLink(label: string): boolean {
return !!el && within(el).queryByRole('button') !== null;
}
/** The CARTO key input sits under its label inside the field's own wrapper. */
function cartoInput(): HTMLInputElement {
return within(screen.getByText('Shared CARTO key').closest('div') as HTMLElement).getByRole('textbox');
}
/** Opens a CustomSelect by its trigger label and picks an option from the portal. */
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string) {
await user.click(screen.getByRole('button', { name: trigger }));
@@ -419,4 +424,32 @@ describe('DefaultUserSettingsTab', () => {
expect(await screen.findByText(/Request failed with status code 503/)).toBeInTheDocument();
expect(screen.queryByText('Reset to built-in default')).not.toBeInTheDocument();
});
it('FE-ADMIN-DUS-026: the shared CARTO key is stored on blur and cleared by its reset link', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ carto_api_key: 'ck.old' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Shared CARTO key');
const input = cartoInput();
expect(input).toHaveValue('ck.old');
await user.clear(input);
await user.type(input, 'ck.new');
fireEvent.blur(input);
await waitFor(() => expect(puts).toEqual([{ carto_api_key: 'ck.new' }]));
// Clicking the reset link also blurs the field again, so only the last PUT is checked
await user.click(resetLink('Shared CARTO key'));
await waitFor(() => expect(puts[puts.length - 1]).toEqual({ carto_api_key: null }));
await waitFor(() => expect(cartoInput()).toHaveValue(''));
});
it('FE-ADMIN-DUS-027: a managed instance hides the shared CARTO key field', async () => {
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), managed: true });
stubDefaults({ carto_api_key: 'ck.hoster' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
expect(screen.queryByText('Shared CARTO key')).not.toBeInTheDocument();
});
});
@@ -36,6 +36,7 @@ type Defaults = {
default_currency?: string
blur_booking_codes?: boolean
map_tile_url?: string
carto_api_key?: string
map_provider?: string
mapbox_access_token?: string
mapbox_style?: string
@@ -111,6 +112,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const [mapTileUrl, setMapTileUrl] = useState('')
const managed = useAuthStore((s) => s.managed)
const [mapboxToken, setMapboxToken] = useState('')
const [cartoKey, setCartoKey] = useState('')
const [mapboxStyle, setMapboxStyle] = useState('')
useEffect(() => {
@@ -119,6 +121,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
setDefaults(data)
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
setMapboxToken(data.mapbox_access_token || '')
setCartoKey(data.carto_api_key || '')
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
setLoaded(true)
}).catch(() => setLoaded(true))
@@ -140,6 +143,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
setDefaults(updated)
if (key === 'map_tile_url') setMapTileUrl('')
if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'carto_api_key') setCartoKey('')
if (key === 'mapbox_style' || key === 'maplibre_style') {
const provider = normalizeProvider(defaults.map_provider)
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
@@ -335,6 +339,26 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs mt-1 text-content-faint">{t('settings.mapDefaultHint')}</p>
{/* The key comes with the instance on a managed install, injected when the
settings are read. A field here would only let somebody save a worse one. */}
{!managed && (
<div style={{ marginTop: 14 }}>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.cartoKey')}
<ResetButton field="carto_api_key" />
</label>
<input
type="text"
value={cartoKey}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCartoKey(e.target.value)}
onBlur={() => save({ carto_api_key: cartoKey })}
spellCheck={false}
autoComplete="off"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
</div>
)}
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{React.createElement(MapView as any, {
@@ -2,6 +2,8 @@ import React from 'react'
import { MapViewAuto } from '../Map/MapViewAuto'
import type { CollectionPlace } from '@trek/shared'
import { mappablePlaces } from '../../pages/collections/collectionsModel'
import { useTileUrl } from '../../hooks/useTileUrl'
import { CARTO_DARK, CARTO_LIGHT } from '../../constants/mapDefaults'
interface CollectionMapProps {
places: CollectionPlace[]
@@ -20,9 +22,7 @@ interface CollectionMapProps {
*/
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark }: CollectionMapProps): React.ReactElement {
const pts = mappablePlaces(places)
const tileUrl = dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
const tileUrl = useTileUrl(dark ? CARTO_DARK : CARTO_LIGHT)
return (
<div style={{ width: '100%', height: '100%' }}>
+11 -7
View File
@@ -1,6 +1,9 @@
import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react'
import L from 'leaflet'
import { useSettingsStore } from '../../store/settingsStore'
import { useCartoApiKey } from '../../hooks/useTileUrl'
import { resolveTileUrl } from '../../utils/tileUrl'
import { CARTO_DARK, CARTO_VOYAGER } from '../../constants/mapDefaults'
import { escapeHtml, type JourneyTrack } from '@trek/shared'
export interface MapMarkerItem {
@@ -94,6 +97,8 @@ interface Props {
onMarkerClick?: (id: string, type?: string) => void
fullScreen?: boolean
paddingBottom?: number
/** CARTO key from the share payload: the public journey has no settings store to read. */
cartoApiKey?: string
}
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
@@ -142,11 +147,13 @@ const EMPTY_TRACKS: JourneyTrack[] = []
const TRACK_FALLBACK_COLOR = '#4f46e5'
function JourneyMap(
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, ref }: Props,
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, ref }: Props,
) {
const stableTrail = trail || EMPTY_TRAIL
const stableTracks = tracks || EMPTY_TRACKS
const mapTileUrl = useSettingsStore(s => s.settings.map_tile_url)
const storedCartoKey = useCartoApiKey()
const cartoKey = cartoApiKey || storedCartoKey
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
const markersRef = useRef<Map<string, L.Marker>>(new Map())
@@ -225,10 +232,7 @@ function JourneyMap(
})
mapRef.current = map
const defaultTile = dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'
L.tileLayer(mapTileUrl || defaultTile, {
L.tileLayer(resolveTileUrl(mapTileUrl, dark ? CARTO_DARK : CARTO_VOYAGER, cartoKey), {
maxZoom: 18,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
referrerPolicy: 'strict-origin-when-cross-origin',
@@ -335,7 +339,7 @@ function JourneyMap(
mapRef.current = null
markersRef.current.clear()
}
}, [entries, stableTrail, stableTracks, dark, mapTileUrl, fullScreen, paddingBottom])
}, [entries, stableTrail, stableTracks, dark, mapTileUrl, cartoKey, fullScreen, paddingBottom])
// Photo layer (#1614). Its own effect on purpose: photos arriving must not tear
// down and rebuild the map the way the entry effect does. Redrawn on zoom and
@@ -378,7 +382,7 @@ function JourneyMap(
photoLayerRef.current?.remove()
photoLayerRef.current = null
}
}, [photos, entries, stableTrail, stableTracks, dark, mapTileUrl, fullScreen, paddingBottom])
}, [photos, entries, stableTrail, stableTracks, dark, mapTileUrl, cartoKey, fullScreen, paddingBottom])
// react to activeMarkerId prop changes — runs after map is built
useEffect(() => {
@@ -27,6 +27,8 @@ interface Props {
onAddEntry?: () => void
publicPhotoUrl?: (photoId: number) => string
carouselBottom?: string
/** CARTO key from the share payload, forwarded to the map (#2054). */
cartoApiKey?: string
}
export default function MobileMapTimeline({
@@ -40,6 +42,7 @@ export default function MobileMapTimeline({
onAddEntry,
publicPhotoUrl,
carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)',
cartoApiKey,
}: Props) {
const mapRef = useRef<JourneyMapHandle>(null)
const carouselRef = useRef<HTMLDivElement>(null)
@@ -168,6 +171,7 @@ export default function MobileMapTimeline({
dark={dark}
onMarkerClick={handleMarkerClick}
fullScreen
cartoApiKey={cartoApiKey}
/>
{!readOnly && onAddEntry && (
<div className="fixed right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 16px)' }}>
@@ -201,6 +205,7 @@ export default function MobileMapTimeline({
onMarkerClick={handleMarkerClick}
fullScreen
paddingBottom={200}
cartoApiKey={cartoApiKey}
/>
{/* Bottom carousel */}
+4 -2
View File
@@ -19,7 +19,7 @@ import { escapeHtml } from '@trek/shared'
import type { Day, Reservation, RouteVia } from '../../types'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
import { CARTO_LIGHT, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
import { useSettingsStore } from '../../store/settingsStore'
import { MapLayerSwitcher } from './MapLayerSwitcher'
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
@@ -515,7 +515,9 @@ export const MapView = memo(function MapView({
onMapContextMenu = null,
center = DEFAULT_MAP_CENTER,
zoom = DEFAULT_MAP_ZOOM,
tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
// Callers hand down a URL that already carries the CARTO key; this is only
// the shape a caller without one gets.
tileUrl = CARTO_LIGHT,
fitKey = 0,
dayOrderMap = {},
leftWidth = 0,
@@ -1,4 +1,4 @@
// FE-COMP-MAP-001 to FE-COMP-MAP-030
// FE-COMP-MAP-001 to FE-COMP-MAP-035
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { useAuthStore } from '../../store/authStore';
@@ -350,3 +350,72 @@ describe('MapSettingsTab – GL providers', () => {
expect(screen.getByDisplayValue('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png')).toBeInTheDocument();
});
});
// ── CARTO key (031–035) ─────────────────────────────────────────────
const CARTO_DARK_TILES = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
/** The key input sits under its label inside the field's own wrapper. */
function cartoInput(): HTMLInputElement {
return within(screen.getByText('CARTO API key').closest('div') as HTMLElement).getByRole('textbox');
}
describe('MapSettingsTab – CARTO key', () => {
it('FE-COMP-MAP-031: the key field belongs to Leaflet and goes away with the GL providers', async () => {
const user = userEvent.setup();
render(<MapSettingsTab />);
expect(screen.getByText('CARTO API key')).toBeInTheDocument();
await user.click(screen.getByText('Mapbox GL'));
expect(screen.queryByText('CARTO API key')).not.toBeInTheDocument();
});
it('FE-COMP-MAP-032: a managed instance brings its own key, so the field is hidden', () => {
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, managed: true });
render(<MapSettingsTab />);
expect(screen.queryByText('CARTO API key')).not.toBeInTheDocument();
});
it('FE-COMP-MAP-033: the typed key is part of the save patch', async () => {
const user = userEvent.setup();
const updateSettings = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, {
settings: buildSettings({ map_tile_url: CARTO_DARK_TILES }),
updateSettings,
});
render(<MapSettingsTab />);
await user.type(cartoInput(), 'demo-key');
await user.click(screen.getByText('Save Map'));
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ carto_api_key: 'demo-key' }));
});
it('FE-COMP-MAP-034: a CARTO template without a key explains the watermark until one is typed', async () => {
const user = userEvent.setup();
seedStore(useSettingsStore, {
settings: buildSettings({ map_tile_url: CARTO_DARK_TILES }),
updateSettings: vi.fn().mockResolvedValue(undefined),
});
render(<MapSettingsTab />);
expect(screen.getByText(/API KEY REQUIRED/)).toBeInTheDocument();
await user.type(cartoInput(), 'demo-key');
expect(screen.queryByText(/API KEY REQUIRED/)).not.toBeInTheDocument();
});
it('FE-COMP-MAP-035: a template on another host never gets that notice', () => {
seedStore(useSettingsStore, {
settings: buildSettings({ map_tile_url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' }),
updateSettings: vi.fn().mockResolvedValue(undefined),
});
render(<MapSettingsTab />);
expect(screen.queryByText(/API KEY REQUIRED/)).not.toBeInTheDocument();
});
});
@@ -164,6 +164,7 @@ export default function MapSettingsTab(): React.ReactElement {
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
const managed = useAuthStore((s) => s.managed)
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '')
const [cartoKey, setCartoKey] = useState<string>(settings.carto_api_key || '')
const [mapboxStyle, setMapboxStyle] = useState<string>(styleForProvider(initialProvider, slotStyle(initialProvider, settings)))
const [mapbox3d, setMapbox3d] = useState<boolean>(settings.mapbox_3d_enabled !== false)
const [mapboxQuality, setMapboxQuality] = useState<boolean>(settings.mapbox_quality_mode === true)
@@ -175,6 +176,7 @@ export default function MapSettingsTab(): React.ReactElement {
setProvider(nextProvider)
setMapTileUrl(settings.map_tile_url || '')
setMapboxToken(settings.mapbox_access_token || '')
setCartoKey(settings.carto_api_key || '')
setMapboxStyle(styleForProvider(nextProvider, slotStyle(nextProvider, settings)))
setMapbox3d(settings.mapbox_3d_enabled !== false)
setMapboxQuality(settings.mapbox_quality_mode === true)
@@ -209,6 +211,7 @@ export default function MapSettingsTab(): React.ReactElement {
map_provider: provider,
map_tile_url: mapTileUrl,
mapbox_access_token: mapboxToken,
carto_api_key: cartoKey,
...stylePatch,
mapbox_3d_enabled: mapbox3d,
mapbox_quality_mode: mapboxQuality,
@@ -230,6 +233,8 @@ export default function MapSettingsTab(): React.ReactElement {
setProvider(nextProvider)
if (nextProvider !== 'leaflet') setMapboxStyle(styleForProvider(nextProvider, mapboxStyle))
}
// Only CARTO burns a watermark into keyless tiles, so the nudge is scoped to its hosts.
const cartoNeedsKey = mapTileUrl.includes('basemaps.cartocdn.com') && !cartoKey.trim()
return (
<Section title={t('settings.map')} icon={Map}>
@@ -321,6 +326,30 @@ export default function MapSettingsTab(): React.ReactElement {
</div>
)}
{/* Same deal as the Mapbox token: a managed install brings its own key. */}
{provider === 'leaflet' && !managed && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapCartoKey')}</label>
<input
type="text"
value={cartoKey}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCartoKey(e.target.value)}
spellCheck={false}
autoComplete="off"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs text-slate-400 mt-1">
{t('settings.mapCartoKeyHint')}{' '}
<a href="https://carto.com/basemaps/apikey/" target="_blank" rel="noreferrer" className="underline">
{t('settings.mapCartoKeyLink')}
</a>
</p>
{cartoNeedsKey && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1">{t('settings.mapCartoKeyMissing')}</p>
)}
</div>
)}
{/* GL settings */}
{provider !== 'leaflet' && (
<div className="space-y-3">
@@ -9,6 +9,7 @@ import { Swatches } from './StudioSwatches'
import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts'
import { sourceIdOf, useMapSources } from './mapSources'
import { printDpi, tileView } from './mapTiles'
import { useCartoApiKey } from '../../hooks/useTileUrl'
import { routeFor } from './travelRefresh'
import { Choice, Line, Picks, Section, Switch } from './StudioControls'
import { fetchRoads } from './roadRoute'
@@ -217,6 +218,7 @@ function ListProps({ el, set, t }: Props<BookListElement>) {
}
function MapProps({ el, set, t, stats }: Props<BookMapElement> & { stats: JourneyStats | null }) {
const cartoKey = useCartoApiKey()
const [roadState, setRoadState] = useState<'idle' | 'busy'>('idle')
const [roadDone, setRoadDone] = useState(0)
const hasRoads = (el.roads ?? []).some(r => r && r.length > 1)
@@ -248,7 +250,7 @@ function MapProps({ el, set, t, stats }: Props<BookMapElement> & { stats: Journe
* say what it will print at. Same call the renderer makes, same answer.
*/
const view = el.source === 'tiles' && el.tileUrl
? tileView(el.points, { w: el.frame.w, h: el.frame.h }, el.tileUrl, el.zoom)
? tileView(el.points, { w: el.frame.w, h: el.frame.h }, el.tileUrl, el.zoom, undefined, 'print', cartoKey)
: null
const dpi = view ? printDpi(view, el.tileUrl) : null
@@ -11,6 +11,7 @@ import { fontStack } from './bookFonts'
import { brightness } from './folioColour'
import { COUNTRY_SHAPES, countryParts, countryWorldPath, projectMercator, unprojectMercator } from './countryShapes'
import { projectOntoTiles, tileView, usableStaticUrl } from './mapTiles'
import { useCartoApiKey } from '../../hooks/useTileUrl'
import { FLAG_H, FLAG_W, flagBands, flagDisc, flagSpec } from './flags'
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
import { useTranslation } from '../../i18n'
@@ -200,6 +201,7 @@ function MapView({ el, frameStyle, big = false }: {
/** False in a thumbnail, where a page of imagery would be a page of waste. */
big?: boolean
}) {
const cartoKey = useCartoApiKey()
const palette = MAP_PALETTES[el.style]
// Per element: two cut maps on one spread sharing a stencil would both be cut
// to whichever of them rendered last.
@@ -591,7 +593,7 @@ function MapView({ el, frameStyle, big = false }: {
maxLat: topLeft.lat,
maxLng: bottomRight.lng,
minLat: bottomRight.lat,
}, big ? 'print' : 'preview')
}, big ? 'print' : 'preview', cartoKey)
: null
/*
+12 -1
View File
@@ -21,6 +21,8 @@
* placed so the renderer cannot lose it.
*/
import { withTileApiKey } from '../../utils/tileUrl'
/** A slippy-map tile, in the standard XYZ scheme. */
export interface Tile {
x: number
@@ -168,6 +170,14 @@ export function tileView(
* tiles, deliberately soft, because nobody prints the pages rail.
*/
quality: 'print' | 'preview' = 'print',
/**
* The instance's CARTO key, for a book still on a CARTO template.
*
* Applied here rather than stored: keyless CARTO tiles carry a watermark
* since 26.08.2026 (#2054), and a key frozen into the document would be
* printed into every export and would outlive the key itself.
*/
cartoKey?: string,
): TileView | null {
if (!template || (!points.length && !extent)) return null
@@ -290,6 +300,7 @@ export function tileView(
const originY = frame.h / 2 - (cy * scale - y0) * size
const tiles: Tile[] = []
const source = withTileApiKey(template, cartoKey)
const wrap = (n: number) => ((n % scale) + scale) % scale
for (let y = y0; y <= y1; y++) {
// Past the poles there is no tile — the row simply does not exist.
@@ -299,7 +310,7 @@ export function tileView(
x: x - x0,
y: y - y0,
z: zoom,
url: template
url: source
.replace('{z}', String(zoom))
// Longitude wraps, so a route across the antimeridian keeps its tiles.
.replace('{x}', String(wrap(x)))
@@ -670,7 +670,7 @@ function MobileNoticeSheet(S: NoticeState) {
const nextNotice = notices[idx + 1] ?? null;
return (
<div className="fixed inset-0 z-50" role="presentation">
<div className="fixed inset-0 z-[var(--z-notice)]" role="presentation">
{/* Screen-reader page announcements */}
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{pageAnnouncement}</span>
{/* Backdrop */}
@@ -842,7 +842,7 @@ function DesktopNoticeModal(S: NoticeState) {
return (
<div
className={`fixed inset-0 z-50 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
className={`fixed inset-0 z-[var(--z-notice)] bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
role="presentation"
>
{/* Screen-reader page announcements */}
@@ -19,7 +19,9 @@
position: fixed;
inset: 0;
z-index: 50;
/* Above the navbar, the page-bound sticky bars and the page-owned
overlays: the notice is app-wide, so nothing a page draws may cover it. */
z-index: var(--z-notice, 99998);
display: flex;
align-items: center;
justify-content: center;
@@ -0,0 +1,39 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, it, expect } from 'vitest'
// Nothing in a rendered test can see the notice's stacking order: jsdom applies
// no author stylesheet and resolves no custom properties, so the two real files
// are the only place the answer lives. Issue #2052 is exactly that gap, the
// overlay sat at a bare 50 and the Vacay mode bar covered it.
describe('release notice css', () => {
// Vitest runs with the client package as its root, so cwd is stable here.
const read = (rel: string): string => readFileSync(resolve(process.cwd(), rel), 'utf8')
const notice = read('src/components/SystemNotices/releaseNotice.css')
const tokens = read('src/index.css')
const block = (selector: string): string => {
const at = notice.indexOf(selector)
expect(at, `${selector} missing from releaseNotice.css`).toBeGreaterThan(-1)
return notice.slice(at, notice.indexOf('}', at) + 1)
}
const step = (name: string): number => {
const found = new RegExp(`${name}:\\s*(\\d+);`).exec(tokens)
expect(found, `${name} missing from index.css`).not.toBeNull()
return Number(found![1])
}
it('FE-COMP-RELEASENOTICECSS-001: the overlay stacks from the token, not a bare 50', () => {
const overlay = block('.rn-overlay {')
expect(overlay).toMatch(/z-index:\s*var\(--z-notice/)
expect(overlay).not.toMatch(/z-index:\s*50\b/)
})
it('FE-COMP-RELEASENOTICECSS-002: the notice step clears the navbar and the Vacay toolbar', () => {
// The Vacay calendar's mode bar is sticky at 61 and no stacking context
// separates the two, so it comes down to a plain number comparison.
expect(step('--z-notice')).toBeGreaterThan(step('--z-nav'))
expect(step('--z-notice')).toBeGreaterThan(61)
})
})
+8
View File
@@ -9,3 +9,11 @@ export const SATELLITE_TILE_URL =
export const SATELLITE_TILE_ATTRIBUTION =
'Imagery &copy; <a href="https://www.esri.com">Esri</a>, Maxar, Earthstar Geographics'
export const SATELLITE_TILE_MAXZOOM = 19
// CARTO basemaps. Keyless tiles carry an "API KEY REQUIRED" watermark since
// 26.08.2026, so these are always passed through withTileApiKey() (#2054).
export const CARTO_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
export const CARTO_DARK = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
export const CARTO_LIGHT_NOLABELS = 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png'
export const CARTO_DARK_NOLABELS = 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png'
export const CARTO_VOYAGER = 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useTileUrl, useCartoApiKey } from './useTileUrl'
import { useSettingsStore } from '../store/settingsStore'
import { CARTO_LIGHT, CARTO_DARK } from '../constants/mapDefaults'
import type { Settings } from '../types'
const OSM = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
const SELF_HOSTED = 'https://tiles.example.com/{z}/{x}/{y}.png'
const initialState = useSettingsStore.getState()
function setSettings(patch: Partial<Settings>) {
useSettingsStore.setState((s) => ({ settings: { ...s.settings, ...patch } }))
}
describe('useTileUrl', () => {
beforeEach(() => {
useSettingsStore.setState(initialState, true)
})
it('FE-TILEURL-001: falls back to the caller default while nothing is configured', () => {
const { result } = renderHook(() => useTileUrl(OSM))
expect(result.current).toBe(OSM)
})
it('FE-TILEURL-002: the user template wins over the fallback', () => {
setSettings({ map_tile_url: SELF_HOSTED })
const { result } = renderHook(() => useTileUrl(CARTO_LIGHT))
expect(result.current).toBe(SELF_HOSTED)
})
it('FE-TILEURL-003: appends the CARTO key to a CARTO fallback', () => {
setSettings({ carto_api_key: 'abc123' })
const { result } = renderHook(() => useTileUrl(CARTO_LIGHT))
expect(result.current).toBe(`${CARTO_LIGHT}?key=abc123`)
})
it('FE-TILEURL-004: keeps the key away from a self-hosted template', () => {
setSettings({ map_tile_url: SELF_HOSTED, carto_api_key: 'abc123' })
const { result } = renderHook(() => useTileUrl(CARTO_LIGHT))
expect(result.current).toBe(SELF_HOSTED)
})
it('FE-TILEURL-005: ignoreUserTemplate pins the fallback but still gets the key', () => {
// Atlas and collections pin a basemap of their own; they must not lose the
// key just because they ignore map_tile_url.
setSettings({ map_tile_url: SELF_HOSTED, carto_api_key: 'abc123' })
const { result } = renderHook(() => useTileUrl(CARTO_DARK, true))
expect(result.current).toBe(`${CARTO_DARK}?key=abc123`)
})
it('FE-TILEURL-006: normalizes a legacy sharded OSM template', () => {
setSettings({ map_tile_url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' })
const { result } = renderHook(() => useTileUrl(CARTO_LIGHT))
expect(result.current).toBe(OSM)
})
it('FE-TILEURL-007: follows a key change without a remount', () => {
setSettings({ carto_api_key: 'first' })
const { result } = renderHook(() => useTileUrl(CARTO_LIGHT))
expect(result.current).toBe(`${CARTO_LIGHT}?key=first`)
act(() => setSettings({ carto_api_key: 'second' }))
expect(result.current).toBe(`${CARTO_LIGHT}?key=second`)
act(() => setSettings({ carto_api_key: '' }))
expect(result.current).toBe(CARTO_LIGHT)
})
it('FE-TILEURL-008: follows a template change without a remount', () => {
const { result } = renderHook(() => useTileUrl(CARTO_LIGHT))
act(() => setSettings({ map_tile_url: SELF_HOSTED }))
expect(result.current).toBe(SELF_HOSTED)
})
})
describe('useCartoApiKey', () => {
beforeEach(() => {
useSettingsStore.setState(initialState, true)
})
it('FE-TILEURL-009: reports an empty string while no key is stored', () => {
setSettings({ carto_api_key: undefined })
const { result } = renderHook(() => useCartoApiKey())
expect(result.current).toBe('')
})
it('FE-TILEURL-010: reports the stored key', () => {
setSettings({ carto_api_key: 'abc123' })
const { result } = renderHook(() => useCartoApiKey())
expect(result.current).toBe('abc123')
})
})
+21
View File
@@ -0,0 +1,21 @@
import { useMemo } from 'react'
import { useSettingsStore } from '../store/settingsStore'
import { resolveTileUrl } from '../utils/tileUrl'
/**
* Every logged-in map reads its tile template through here so the CARTO key
* reaches all of them, including the ones that ignore map_tile_url and pin a
* basemap of their own (atlas, collections).
*/
export function useTileUrl(fallback: string, ignoreUserTemplate = false): string {
const template = useSettingsStore((s) => s.settings.map_tile_url)
const cartoKey = useSettingsStore((s) => s.settings.carto_api_key)
return useMemo(
() => resolveTileUrl(ignoreUserTemplate ? '' : template, fallback, cartoKey),
[template, fallback, cartoKey, ignoreUserTemplate]
)
}
export function useCartoApiKey(): string {
return useSettingsStore((s) => s.settings.carto_api_key) ?? ''
}
+13
View File
@@ -536,6 +536,19 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
--safe-top: env(safe-area-inset-top, 0px);
--nav-h: 0px;
--bottom-nav-h: 0px;
/* Layer scale. Anything fixed or sticky picks a step from here instead of
inventing a number, so two overlays can no longer land in the wrong order
by accident. The steps are spaced so today's hardcoded z-indexes keep the
position they already have; each of those moves onto its token whenever
its file gets touched for other reasons. */
--z-bar: 60; /* BottomNav, page-bound sticky bars */
--z-nav: 200; /* the desktop navbar */
--z-modal: 10000; /* shared/Modal, ConfirmDialog */
--z-overlay: 99990; /* page-owned full-screen overlays */
--z-notice: 99998; /* app-wide system and release notices */
--z-toast: 100000; /* toasts, tooltips, context menus */
--font-system: 'Poppins', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
/* Secondary "subtext"/caption tier renders in Geist; primary text + headings stay Poppins. */
--font-subtext: 'Geist Sans', 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
@@ -16,6 +16,7 @@ import {
styleSettingKey,
type GlMapProvider,
} from '../../../components/Map/glProviders'
import { useAuthStore } from '../../../store/authStore'
import MToggle from '../../components/MToggle'
import MSegmented from '../../components/MSegmented'
import { MAdminCard, MAdminCardHead, MAdminField, MAdminInput, MAdminRow } from './MAdminUi'
@@ -38,6 +39,7 @@ type Defaults = {
default_currency?: string
blur_booking_codes?: boolean
map_tile_url?: string
carto_api_key?: string
map_provider?: string
mapbox_access_token?: string
mapbox_style?: string
@@ -67,7 +69,9 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
const [defaults, setDefaults] = useState<Defaults>({})
const [loaded, setLoaded] = useState(false)
const [mapTileUrl, setMapTileUrl] = useState('')
const managed = useAuthStore((s) => s.managed)
const [mapboxToken, setMapboxToken] = useState('')
const [cartoKey, setCartoKey] = useState('')
const [mapboxStyle, setMapboxStyle] = useState('')
const [currencyOpen, setCurrencyOpen] = useState(false)
const [presetOpen, setPresetOpen] = useState(false)
@@ -79,6 +83,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
setDefaults(data)
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
setMapboxToken(data.mapbox_access_token || '')
setCartoKey(data.carto_api_key || '')
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
setLoaded(true)
}).catch(() => setLoaded(true))
@@ -100,6 +105,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
setDefaults(updated)
if (key === 'map_tile_url') setMapTileUrl('')
if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'carto_api_key') setCartoKey('')
if (key === 'mapbox_style' || key === 'maplibre_style') {
const provider = normalizeProvider(defaults.map_provider)
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
@@ -287,6 +293,24 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
/>
</MAdminField>
{/* The key comes with the instance on a managed install, injected when the
settings are read. A field here would only let somebody save a worse one. */}
{!managed && (
<MAdminField
label={<>{t('admin.defaultSettings.cartoKey')} <ResetButton field="carto_api_key" /></>}
hint={t('admin.defaultSettings.cartoKeyHint')}
>
<MAdminInput
type="text"
value={cartoKey}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCartoKey(e.target.value)}
onBlur={() => save({ carto_api_key: cartoKey })}
spellCheck={false}
autoComplete="off"
/>
</MAdminField>
)}
{/* Live tile preview */}
<div className="relative h-[200px] w-full overflow-hidden rounded-xl">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
@@ -322,7 +346,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
{mapProvider !== 'leaflet' && (
<div className="space-y-[14px]">
{mapProvider === 'mapbox-gl' && (
{mapProvider === 'mapbox-gl' && !managed && (
<MAdminField
label={<>{t('admin.defaultSettings.mapboxToken')} <ResetButton field="mapbox_access_token" /></>}
hint={t('admin.defaultSettings.mapboxTokenHint')}
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState, Suspense } from 'react'
import { Box, Check, ChevronDown, Globe2, Layers, Map, Save } from 'lucide-react'
import { useTranslation } from '../../../i18n'
import { useSettingsStore } from '../../../store/settingsStore'
import { useAuthStore } from '../../../store/authStore'
import { useToast } from '../../../components/shared/Toast'
import { MapView } from '../../../components/Map/MapView'
// Same as the desktop map settings tab: on demand, and paired with one engine.
@@ -67,7 +68,9 @@ export default function MSettingsMap() {
const [saving, setSaving] = useState(false)
const [provider, setProvider] = useState<Provider>(initialProvider)
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
const managed = useAuthStore((s) => s.managed)
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '')
const [cartoKey, setCartoKey] = useState<string>(settings.carto_api_key || '')
const [mapboxStyle, setMapboxStyle] = useState<string>(styleForProvider(initialProvider, slotStyle(initialProvider, settings)))
const [mapbox3d, setMapbox3d] = useState<boolean>(settings.mapbox_3d_enabled !== false)
const [mapboxQuality, setMapboxQuality] = useState<boolean>(settings.mapbox_quality_mode === true)
@@ -79,6 +82,7 @@ export default function MSettingsMap() {
setProvider(nextProvider)
setMapTileUrl(settings.map_tile_url || '')
setMapboxToken(settings.mapbox_access_token || '')
setCartoKey(settings.carto_api_key || '')
setMapboxStyle(styleForProvider(nextProvider, slotStyle(nextProvider, settings)))
setMapbox3d(settings.mapbox_3d_enabled !== false)
setMapboxQuality(settings.mapbox_quality_mode === true)
@@ -118,6 +122,7 @@ export default function MSettingsMap() {
map_provider: provider,
map_tile_url: mapTileUrl,
mapbox_access_token: mapboxToken,
carto_api_key: cartoKey,
...stylePatch,
mapbox_3d_enabled: mapbox3d,
mapbox_quality_mode: mapboxQuality,
@@ -134,6 +139,8 @@ export default function MSettingsMap() {
setProvider(nextProvider)
if (nextProvider !== 'leaflet') setMapboxStyle(styleForProvider(nextProvider, mapboxStyle))
}
// Only CARTO burns a watermark into keyless tiles, so the nudge is scoped to its hosts.
const cartoNeedsKey = mapTileUrl.includes('basemaps.cartocdn.com') && !cartoKey.trim()
const providers: { id: Provider; name: string; sub: string; icon: typeof Layers }[] = [
{ id: 'leaflet', name: 'Leaflet', sub: t('settings.mapLeafletSubtitle'), icon: Layers },
@@ -193,12 +200,37 @@ export default function MSettingsMap() {
placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MSetHint>{t('settings.mapDefaultHint')}</MSetHint>
{/* A managed install brings its own key, same as the Mapbox token below. */}
{!managed && (
<>
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.mapCartoKey')}</MSetEyebrow>
<MSetInput
mono
value={cartoKey}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCartoKey(e.target.value)}
spellCheck={false}
autoComplete="off"
/>
<MSetHint>
{t('settings.mapCartoKeyHint')}{' '}
<a href="https://carto.com/basemaps/apikey/" target="_blank" rel="noreferrer" className="underline">
{t('settings.mapCartoKeyLink')}
</a>
</MSetHint>
{cartoNeedsKey && (
<p className="mt-[6px] font-geist text-[0.625rem] leading-relaxed text-[color:var(--m-st-pending)]">
{t('settings.mapCartoKeyMissing')}
</p>
)}
</>
)}
</>
)}
{provider !== 'leaflet' && (
<>
{provider === 'mapbox-gl' && (
{provider === 'mapbox-gl' && !managed && (
<>
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.mapMapboxToken')}</MSetEyebrow>
<MSetInput mono value={mapboxToken} onChange={(e) => setMapboxToken(e.target.value)} placeholder="pk.eyJ1Ijoi..." />
+7 -1
View File
@@ -140,6 +140,10 @@ export default function JourneyPublicPage() {
);
}
// A visitor of a share link has no settings of their own, so the CARTO key
// travels in the payload; without it every tile carries the watermark.
const cartoApiKey: string | undefined = data.cartoApiKey;
// In desktop two-column mode the map is always visible — exclude the standalone 'map' tab
const availableViews = [
perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
@@ -842,6 +846,7 @@ export default function JourneyPublicPage() {
fullScreen
activeMarkerId={activeEntryId ?? undefined}
onMarkerClick={handleMarkerClick}
cartoApiKey={cartoApiKey}
/>
</div>
</aside>
@@ -885,6 +890,7 @@ export default function JourneyPublicPage() {
onEntryClick={(entry) => setViewingEntry(entry as any)}
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
cartoApiKey={cartoApiKey}
/>
)}
@@ -897,7 +903,7 @@ export default function JourneyPublicPage() {
{/* Map (standalone tab — only in single-column mode) */}
{view === 'map' && perms.share_map && (
<div className="overflow-hidden rounded-2xl border border-zinc-200 dark:border-zinc-700">
<JourneyMap checkins={[]} entries={sidebarMapItems as any} photos={photoLayer} height={500} />
<JourneyMap checkins={[]} entries={sidebarMapItems as any} photos={photoLayer} height={500} cartoApiKey={cartoApiKey} />
</div>
)}
</div>
+8 -2
View File
@@ -19,7 +19,7 @@ import { createElement, useEffect, useRef } from 'react';
import { renderIconMarkup } from '../utils/iconMarkup';
import { MapContainer, Marker, Polyline, TileLayer, Tooltip, useMap } from 'react-leaflet';
import { getCategoryIcon } from '../components/shared/categoryIcons';
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../constants/mapDefaults';
import { CARTO_LIGHT, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../constants/mapDefaults';
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n';
import { useSettingsStore } from '../store/settingsStore';
import { avatarSrc } from '../utils/avatarSrc';
@@ -29,6 +29,7 @@ import { isDayInAccommodationRange } from '../utils/dayOrder';
import { getFlightLegs, getTrainLegs } from '../utils/flightLegs';
import { splitReservationDateTime } from '../utils/formatters';
import { computeMapViewport, TILE_SIZE_RASTER } from '../utils/mapViewport';
import { resolveTileUrl } from '../utils/tileUrl';
import { useSharedTrip } from './sharedTrip/useSharedTrip';
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship };
@@ -150,6 +151,7 @@ export default function SharedTripPage() {
categories,
permissions,
collab,
cartoApiKey,
} = data;
const sortedDays = [...(days || [])].sort((a: any, b: any) => a.day_number - b.day_number);
@@ -183,6 +185,10 @@ export default function SharedTripPage() {
});
const initialView = framed ?? { center: DEFAULT_MAP_CENTER, zoom: DEFAULT_MAP_ZOOM };
// A visitor of a share link has no settings of their own, so the key travels in
// the payload; without it CARTO stamps "API KEY REQUIRED" across every tile.
const tileUrl = resolveTileUrl(null, CARTO_LIGHT, cartoApiKey);
return (
<div className="bg-surface-secondary" style={{ minHeight: '100vh', fontFamily: 'var(--font-system)' }}>
{/* Header */}
@@ -482,7 +488,7 @@ export default function SharedTripPage() {
style={{ width: '100%', height: '100%' }}
>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
url={tileUrl}
referrerPolicy="strict-origin-when-cross-origin"
/>
<FitBoundsToPlaces places={mapPlaces} framedOnMount={framed !== null} />
+6 -5
View File
@@ -2,6 +2,8 @@ import React, { useEffect, useMemo, useState, useRef } from 'react'
import { useNavigate } from 'react-router'
import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useTileUrl } from '../../hooks/useTileUrl'
import { CARTO_DARK_NOLABELS, CARTO_LIGHT_NOLABELS } from '../../constants/mapDefaults'
import apiClient, { mapsApi, pluginsApi, type PluginAtlasLayer } from '../../api/client'
import L from 'leaflet'
import type { GeoJsonFeatureCollection } from '../../types'
@@ -79,6 +81,9 @@ export function useAtlas() {
const resolveName = useCountryNames(language)
const dm = settings.dark_mode
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
// Label-free tiles on purpose (the country fills carry the names here), so the
// user's own template is deliberately not read, only their CARTO key.
const tileUrl = useTileUrl(dark ? CARTO_DARK_NOLABELS : CARTO_LIGHT_NOLABELS, true)
const mapRef = useRef<HTMLDivElement>(null)
const mapInstance = useRef<L.Map | null>(null)
const geoLayerRef = useRef<L.GeoJSON | null>(null)
@@ -362,10 +367,6 @@ export function useAtlas() {
L.control.zoom({ position: 'bottomright' }).addTo(map)
const tileUrl = dark
? 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png'
L.tileLayer(tileUrl, {
maxZoom: 10,
keepBuffer: 25,
@@ -443,7 +444,7 @@ export function useAtlas() {
regionLayerRef.current = null
renderedRegionSigRef.current = ''
}
}, [dark, loading])
}, [dark, loading, tileUrl])
// Render GeoJSON countries
useEffect(() => {
@@ -25,7 +25,8 @@ import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
import { useIsTouch } from '../../hooks/useIsTouch'
import { usePluginStore } from '../../store/pluginStore'
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
import { DEFAULT_MAP_LAT, DEFAULT_MAP_LNG, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { CARTO_LIGHT, DEFAULT_MAP_LAT, DEFAULT_MAP_LNG, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { useTileUrl } from '../../hooks/useTileUrl'
import { resolvePoolAssignmentId } from './tripPlannerModel'
import { isDeepLinkableTripTab, TRIP_TAB_LABEL_KEYS } from '../../constants/tripTabs'
import { isRoutableReservation } from '../../utils/reservationRoutes'
@@ -1011,7 +1012,7 @@ export function useTripPlanner() {
return da.map(a => a.place).filter(p => p?.lat && p?.lng)
}, [selectedDayId, assignments])
const mapTileUrl = settings.map_tile_url || 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
const mapTileUrl = useTileUrl(CARTO_LIGHT)
const fontStyle = { fontFamily: "var(--font-system)" }
+72 -1
View File
@@ -1,5 +1,19 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { DEFAULT_SETTINGS, useSettingsStore } from './settingsStore'
import { settingsApi } from '../api/client'
import { clearTileCache } from '../sync/tilePrefetcher'
vi.mock('../api/client', () => ({
settingsApi: {
get: vi.fn(),
set: vi.fn().mockResolvedValue(undefined),
setBulk: vi.fn().mockResolvedValue(undefined),
},
}))
vi.mock('../sync/tilePrefetcher', () => ({
clearTileCache: vi.fn().mockResolvedValue(undefined),
}))
// A fresh instance sends no value for a setting an admin hasn't defaulted, so DEFAULT_SETTINGS
// is what a brand-new user actually sees. These guard against the two regressions in the
@@ -18,4 +32,61 @@ describe('settings defaults', () => {
expect(settings.distance_unit).toBe(DEFAULT_SETTINGS.distance_unit)
expect(settings.time_format).toBe(DEFAULT_SETTINGS.time_format)
})
it('SETTINGS-DEFAULTS-003: no CARTO key is shipped, so the field starts empty instead of undefined', () => {
expect(DEFAULT_SETTINGS.carto_api_key).toBe('')
})
})
// The CARTO key lives in its own setting and is appended at render time (#2054). Two things
// have to hold on the way out to the server: the key never gets frozen into the saved
// template, and a key change invalidates the tile cache, which is keyed by the full URL.
describe('settings tile template hygiene', () => {
const initialState = useSettingsStore.getState()
beforeEach(() => {
vi.clearAllMocks()
useSettingsStore.setState(initialState, true)
})
it('SETTINGS-TILE-001: a key pasted into the template is stripped before the template is stored', async () => {
await useSettingsStore.getState().updateSetting(
'map_tile_url',
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png?key=pasted-secret',
)
const stored = useSettingsStore.getState().settings.map_tile_url
expect(stored).toBe('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png')
expect(stored).not.toContain('pasted-secret')
expect(settingsApi.set).toHaveBeenCalledWith('map_tile_url', stored)
})
it('SETTINGS-TILE-002: a bulk save strips the key and normalizes the retired OSM shard host', async () => {
await useSettingsStore.getState().updateSettings({
map_tile_url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png?key=pasted-secret',
})
const stored = useSettingsStore.getState().settings.map_tile_url
expect(stored).toBe('https://tile.openstreetmap.org/{z}/{x}/{y}.png')
expect(settingsApi.setBulk).toHaveBeenCalledWith({ map_tile_url: stored })
})
it('SETTINGS-TILE-003: changing the CARTO key drops the tile cache', async () => {
await useSettingsStore.getState().updateSetting('carto_api_key', 'fresh-key')
expect(clearTileCache).toHaveBeenCalledTimes(1)
})
it('SETTINGS-TILE-004: re-saving the same key leaves the cache alone', async () => {
await useSettingsStore.getState().updateSetting('carto_api_key', 'fresh-key')
vi.mocked(clearTileCache).mockClear()
await useSettingsStore.getState().updateSetting('carto_api_key', 'fresh-key')
expect(clearTileCache).not.toHaveBeenCalled()
})
it('SETTINGS-TILE-005: a bulk save clears the cache only when the key actually moves', async () => {
await useSettingsStore.getState().updateSettings({ carto_api_key: 'fresh-key' })
expect(clearTileCache).toHaveBeenCalledTimes(1)
vi.mocked(clearTileCache).mockClear()
await useSettingsStore.getState().updateSettings({ map_tile_url: '' })
expect(clearTileCache).not.toHaveBeenCalled()
})
})
+18 -3
View File
@@ -4,7 +4,8 @@ import type { Settings } from '../types'
import { DEFAULT_APPEARANCE } from '@trek/shared'
import { getApiErrorMessage } from '../types'
import { SUPPORTED_LANGUAGE_CODES } from '../i18n/supportedLanguages'
import { normalizeTileUrl } from '../utils/tileUrl'
import { normalizeTileUrl, stripTileApiKey } from '../utils/tileUrl'
import { clearTileCache } from '../sync/tilePrefetcher'
import { rememberStartDestination, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../utils/startDestination'
interface SettingsState {
@@ -43,6 +44,7 @@ export const DEFAULT_SETTINGS: Settings = {
map_provider: 'leaflet',
map_base_layer: 'default',
map_poi_pill_enabled: true,
carto_api_key: '',
mapbox_access_token: '',
mapbox_style: 'mapbox://styles/mapbox/standard',
maplibre_style: '',
@@ -68,7 +70,14 @@ let _loadInFlight: Promise<void> | null = null
// typed by hand survive in the database until the next load put it back (#1733).
function withNormalizedTileUrl<T extends Partial<Settings>>(patch: T): T {
if (typeof patch.map_tile_url !== 'string') return patch
return { ...patch, map_tile_url: normalizeTileUrl(patch.map_tile_url) }
return { ...patch, map_tile_url: cleanTileTemplate(patch.map_tile_url) }
}
// A CARTO key pasted along with a full tile URL is dropped here: the key lives
// in its own setting and is appended at render time, so leaving it in the
// template would freeze it into the saved value and break on the next rotation.
function cleanTileTemplate(url: string): string {
return stripTileApiKey(normalizeTileUrl(url))
}
export const useSettingsStore = create<SettingsState>((set, get) => ({
@@ -105,7 +114,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
updateSetting: async (key: keyof Settings, value: Settings[keyof Settings]) => {
const next =
key === 'map_tile_url' && typeof value === 'string' ? normalizeTileUrl(value) : value
key === 'map_tile_url' && typeof value === 'string' ? cleanTileTemplate(value) : value
if (key === 'carto_api_key' && next !== get().settings.carto_api_key) void clearTileCache()
set((state) => ({
settings: { ...state.settings, [key]: next },
}))
@@ -134,6 +144,11 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
updateSettings: async (settingsObj: Partial<Settings>) => {
const patch = withNormalizedTileUrl(settingsObj)
// Cached tiles are keyed by their full URL, so a new key leaves the whole
// offline cache stranded behind the old one.
if ('carto_api_key' in patch && patch.carto_api_key !== get().settings.carto_api_key) {
void clearTileCache()
}
set((state) => ({
settings: { ...state.settings, ...patch },
}))
+13 -7
View File
@@ -21,7 +21,8 @@
import type { Place } from '../types'
import { offlineDb, upsertSyncMeta } from '../db/offlineDb'
import { isAuthed } from './authGate'
import { normalizeTileUrl } from '../utils/tileUrl'
import { normalizeTileUrl, withTileApiKey } from '../utils/tileUrl'
import { CARTO_LIGHT } from '../constants/mapDefaults'
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -50,8 +51,7 @@ export const TILE_CONCURRENCY = 6
/** Name of the Workbox runtime cache holding map tiles (see vite.config.js). */
const TILE_CACHE = 'map-tiles'
const DEFAULT_TILE_URL =
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
const DEFAULT_TILE_URL = CARTO_LIGHT
/**
* Must stay identical to Leaflet's `subdomains` default ('abc'), because the
@@ -158,9 +158,13 @@ export function countTiles(bbox: TileBbox, minZoom: number, maxZoom: number): nu
* The template is normalized first: this function is also reached with a raw
* admin default rather than the value from the settings store, so the OSM
* sharding rewrite has to happen here too.
*
* The CARTO key is appended here as well, and it has to be: the Workbox cache is
* keyed on the whole URL including the query, so a tile prefetched without the
* key is a tile the map never asks for.
*/
export function buildTileUrl(template: string, z: number, x: number, y: number): string {
return normalizeTileUrl(template)
export function buildTileUrl(template: string, z: number, x: number, y: number, cartoKey?: string): string {
return withTileApiKey(normalizeTileUrl(template), cartoKey)
.replace('{z}', String(z))
.replace('{x}', String(x))
.replace('{y}', String(y))
@@ -225,6 +229,7 @@ export async function prefetchTiles(
tileUrlTemplate: string,
minZoom = 10,
maxZoom = 16,
cartoKey?: string,
): Promise<number> {
if (!navigator.onLine) return 0
if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) return 0
@@ -246,7 +251,7 @@ export async function prefetchTiles(
if (!navigator.onLine || !isAuthed()) return
const [z, x, y] = coords[cursor++]
const url = buildTileUrl(tileUrlTemplate, z, x, y)
const url = buildTileUrl(tileUrlTemplate, z, x, y, cartoKey)
if (cache && (await cache.match(url))) continue
@@ -312,6 +317,7 @@ export async function prefetchTilesForTrip(
places: Place[],
tileUrlTemplate?: string,
force = false,
cartoKey?: string,
): Promise<void> {
const template = tileUrlTemplate || DEFAULT_TILE_URL
const bbox = computeBbox(places)
@@ -333,7 +339,7 @@ export async function prefetchTilesForTrip(
// tile providers that don't send CORS headers. To stop the browser evicting
// these tiles under the inflated quota, we request persistent storage at app
// init instead (sync/persistentStorage.ts).
const fetched = await prefetchTiles(bbox, template, 10, 16)
const fetched = await prefetchTiles(bbox, template, 10, 16, cartoKey)
// Update syncMeta with bbox and tile count
const meta = await offlineDb.syncMeta.get(tripId)
+4 -2
View File
@@ -218,6 +218,7 @@ export const tripSyncManager = {
// Cache file blobs + map tiles in background (don't block syncAll)
const cacheTiles = getOfflinePrefs().cacheTiles
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
const cartoKey = useSettingsStore.getState().settings.carto_api_key || undefined
for (const trip of toSync) {
if (!isAuthed()) return
const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray()
@@ -233,7 +234,7 @@ export const tripSyncManager = {
for (const trip of toSync) {
if (!isAuthed() || !navigator.onLine) return
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
await prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error)
await prefetchTilesForTrip(trip.id, places, tileUrl, undefined, cartoKey).catch(console.error)
}
})
}
@@ -289,12 +290,13 @@ export const tripSyncManager = {
// 3) Map tiles — awaited, and only when the user opted to store them.
if (getOfflinePrefs().cacheTiles) {
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
const cartoKey = useSettingsStore.getState().settings.carto_api_key || undefined
i = 0
for (const trip of toSync) {
if (!isAuthed()) return 0
onProgress?.({ phase: 'tiles', current: ++i, total, label: trip.title })
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
await prefetchTilesForTrip(trip.id, places, tileUrl, true).catch(console.error)
await prefetchTilesForTrip(trip.id, places, tileUrl, true, cartoKey).catch(console.error)
}
}
+19
View File
@@ -32,6 +32,25 @@ free **if they follow the contract below**.
| Overlay / inverse | `--overlay` · `--bg-inverse` / `--text-inverse` · `bg-inverse text-inverse-text` |
| Type tiers | `text-title` / `text-subtitle` / `text-body` / `text-caption` (scale with the user's per-tier multipliers) |
## Layering
Fixed and sticky surfaces read their stacking order from one scale in
`src/index.css`, so two of them cannot end up in the wrong order by accident.
| Token | Value | For |
| --- | --- | --- |
| `--z-bar` | 60 | `BottomNav`, page-bound sticky bars |
| `--z-nav` | 200 | the desktop navbar |
| `--z-modal` | 10000 | `shared/Modal`, `ConfirmDialog` |
| `--z-overlay` | 99990 | page-owned full-screen overlays |
| `--z-notice` | 99998 | app-wide system and release notices |
| `--z-toast` | 100000 | toasts, tooltips, context menus |
A new fixed surface docks onto the step it belongs to (`z-[var(--z-modal)]` as a
class, `z-index: var(--z-modal)` in CSS) instead of inventing a number. Older
files still carry literal values; the steps are far enough apart that those keep
their current position until the file gets converted.
## The contract (enforced by `npm run theme:lint`)
1. **Surfaces/text/borders** use the semantic utilities (`bg-surface*`,
+2
View File
@@ -124,6 +124,8 @@ export interface Settings {
map_provider?: 'leaflet' | 'mapbox-gl' | 'maplibre-gl'
/** Leaflet base layer: default street tiles or a satellite/aerial view. */
map_base_layer?: 'default' | 'satellite'
/** CARTO basemaps watermark keyless tiles; the key is appended as ?key= (#2054). */
carto_api_key?: string
mapbox_access_token?: string
mapbox_style?: string
maplibre_style?: string
+117 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { normalizeTileUrl } from './tileUrl'
import { normalizeTileUrl, withTileApiKey, stripTileApiKey, resolveTileUrl } from './tileUrl'
describe('normalizeTileUrl', () => {
it('drops the {s} placeholder from an OSM template', () => {
@@ -56,3 +56,119 @@ describe('normalizeTileUrl', () => {
expect(normalizeTileUrl('')).toBe('')
})
})
const CARTO = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
const CARTO_APEX = 'https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'
const OSM = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
const SATELLITE = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
const SELF_HOSTED = 'https://tiles.example.com/basemaps/{z}/{x}/{y}.png'
describe('withTileApiKey', () => {
it('appends the key to a sharded CARTO template', () => {
expect(withTileApiKey(CARTO, 'abc123')).toBe(`${CARTO}?key=abc123`)
})
it('appends the key to the apex form and to a fixed shard', () => {
expect(withTileApiKey(CARTO_APEX, 'abc123')).toBe(`${CARTO_APEX}?key=abc123`)
expect(withTileApiKey('https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', 'abc123'))
.toBe('https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png?key=abc123')
})
it('leaves other providers untouched', () => {
for (const url of [OSM, SATELLITE, SELF_HOSTED]) {
expect(withTileApiKey(url, 'abc123')).toBe(url)
}
})
it('is idempotent', () => {
const once = withTileApiKey(CARTO, 'abc123')
expect(withTileApiKey(once, 'abc123')).toBe(once)
// A different key does not stack a second parameter either: the one already
// in the template wins, so a double-wrapped URL stays valid.
expect(withTileApiKey(once, 'other')).toBe(once)
})
it('passes the template through without a usable key', () => {
expect(withTileApiKey(CARTO)).toBe(CARTO)
expect(withTileApiKey(CARTO, '')).toBe(CARTO)
expect(withTileApiKey(CARTO, null)).toBe(CARTO)
expect(withTileApiKey('', 'abc123')).toBe('')
})
it('joins onto an existing query string with &', () => {
const withQuery = 'https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png?attribution=none'
expect(withTileApiKey(withQuery, 'abc123')).toBe(`${withQuery}&key=abc123`)
})
it('encodes the key', () => {
expect(withTileApiKey(CARTO, 'a b&c=d')).toBe(`${CARTO}?key=a%20b%26c%3Dd`)
})
it('does not hand the key to a host that merely contains cartocdn', () => {
// The security promise of the function: a template pointing somewhere else
// must never turn into an outbound copy of the key.
const lookalike = 'https://basemaps.cartocdn.com.example.org/light_all/{z}/{x}/{y}.png'
expect(withTileApiKey(lookalike, 'abc123')).toBe(lookalike)
const proxy = 'https://tiles.example.com/basemaps.cartocdn.com/{z}/{x}/{y}.png'
expect(withTileApiKey(proxy, 'abc123')).toBe(proxy)
})
})
describe('stripTileApiKey', () => {
it('removes the key and keeps the other parameters', () => {
expect(stripTileApiKey('https://basemaps.cartocdn.com/x.png?key=abc&lang=de'))
.toBe('https://basemaps.cartocdn.com/x.png?lang=de')
expect(stripTileApiKey('https://basemaps.cartocdn.com/x.png?lang=de&key=abc'))
.toBe('https://basemaps.cartocdn.com/x.png?lang=de')
})
it('keeps a parameter that only ends in key', () => {
const url = 'https://tiles.example.com/x.png?monkey=1'
expect(stripTileApiKey(url)).toBe(url)
})
it('passes a URL without a key through', () => {
expect(stripTileApiKey(CARTO)).toBe(CARTO)
expect(stripTileApiKey('')).toBe('')
})
it('leaves no dangling ? or &', () => {
expect(stripTileApiKey(`${CARTO}?key=abc`)).toBe(CARTO)
expect(stripTileApiKey('https://basemaps.cartocdn.com/x.png?key=abc'))
.toBe('https://basemaps.cartocdn.com/x.png')
expect(stripTileApiKey('https://basemaps.cartocdn.com/x.png?key=abc&a=1'))
.not.toContain('?&')
})
it('undoes withTileApiKey', () => {
expect(stripTileApiKey(withTileApiKey(CARTO, 'abc123'))).toBe(CARTO)
})
})
describe('resolveTileUrl', () => {
it('falls back on an empty or blank template', () => {
expect(resolveTileUrl('', OSM)).toBe(OSM)
expect(resolveTileUrl(' ', OSM)).toBe(OSM)
expect(resolveTileUrl(null, OSM)).toBe(OSM)
expect(resolveTileUrl(undefined, OSM)).toBe(OSM)
})
it('prefers a configured template over the fallback', () => {
expect(resolveTileUrl(SELF_HOSTED, OSM)).toBe(SELF_HOSTED)
expect(resolveTileUrl(` ${SELF_HOSTED} `, OSM)).toBe(SELF_HOSTED)
})
it('still normalizes the retired OSM shard host', () => {
expect(resolveTileUrl('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', CARTO)).toBe(OSM)
expect(resolveTileUrl('', 'https://d.tile.openstreetmap.org/{z}/{x}/{y}.png')).toBe(OSM)
})
it('appends the key when the result is a CARTO host', () => {
expect(resolveTileUrl('', CARTO, 'abc123')).toBe(`${CARTO}?key=abc123`)
expect(resolveTileUrl(CARTO_APEX, OSM, 'abc123')).toBe(`${CARTO_APEX}?key=abc123`)
// A self-hosted template reaches the network without the key, even when the
// fallback would have been CARTO.
expect(resolveTileUrl(SELF_HOSTED, CARTO, 'abc123')).toBe(SELF_HOSTED)
})
})
+34
View File
@@ -22,3 +22,37 @@ export function normalizeTileUrl(url: string): string {
if (!url) return url
return url.replace(OSM_SHARD, '$1tile.openstreetmap.org')
}
/**
* CARTO started watermarking keyless basemap tiles on 26.08.2026 (#2054). The
* key rides along as a `?key=` query parameter, which every template engine in
* the client passes through untouched, so it is appended here rather than baked
* into the stored template: a saved URL stays portable and survives a key
* change. Only CARTO hosts are touched; OSM and self-hosted templates are not.
*/
const CARTO_HOST = /^(?:\{s\}|[a-d])?\.?basemaps\.cartocdn\.com$/i
function templateHost(url: string): string {
return url.replace(/^\w*:?\/\//, '').split(/[/?#]/)[0]
}
export function withTileApiKey(url: string, key?: string | null): string {
if (!url || !key) return url
if (!CARTO_HOST.test(templateHost(url))) return url
if (/[?&]key=/.test(url)) return url
return `${url}${url.includes('?') ? '&' : '?'}key=${encodeURIComponent(key)}`
}
/** Keeps the key out of anything we persist: stored templates, book documents. */
export function stripTileApiKey(url: string): string {
if (!url || !/[?&]key=/.test(url)) return url
return url.replace(/([?&])key=[^&]*&?/, '$1').replace(/[?&]$/, '')
}
/**
* A blank template means "not configured", not "no tiles": the settings
* previews save an empty string and would otherwise render grey.
*/
export function resolveTileUrl(template: string | null | undefined, fallback: string, cartoKey?: string | null): string {
return withTileApiKey(normalizeTileUrl(template?.trim() || fallback), cartoKey)
}