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
+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)
}
}