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
@@ -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)))