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)
}
@@ -1,4 +1,4 @@
// FE-MOB-MDUS-001 to FE-MOB-MDUS-024
// FE-MOB-MDUS-001 to FE-MOB-MDUS-027
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor, within, fireEvent } from '../../../helpers/render';
import userEvent from '@testing-library/user-event';
@@ -48,6 +48,11 @@ function resetLink(label: string): HTMLElement {
return within(holder).getByRole('button', { name: 'reset' });
}
/** The CARTO key input sits under its label inside the field's own wrapper. */
function cartoInput(): HTMLInputElement {
return within(screen.getByText('Shared CARTO key').parentElement as HTMLElement).getByRole('textbox');
}
describe('MAdminDefaultUserSettings', () => {
beforeEach(() => {
resetAllStores();
@@ -376,4 +381,41 @@ describe('MAdminDefaultUserSettings', () => {
expect(screen.queryByText('Reset to built-in default')).not.toBeInTheDocument();
expect(screen.getByRole('tab', { name: '°C Celsius' })).toHaveAttribute('aria-selected', 'true');
});
it('FE-MOB-MDUS-025: 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(<MAdminDefaultUserSettings />);
await screen.findByText('Shared CARTO key');
const input = cartoInput();
expect(input).toHaveValue('ck.old');
await user.clear(input);
await user.type(input, 'ck.new');
input.blur();
await waitFor(() => expect(puts).toEqual([{ carto_api_key: 'ck.new' }]));
await user.click(resetLink('Shared CARTO key'));
await waitFor(() => expect(puts).toHaveLength(2));
expect(puts[1]).toEqual({ carto_api_key: null });
await waitFor(() => expect(cartoInput()).toHaveValue(''));
});
it('FE-MOB-MDUS-026: a managed instance brings its own CARTO key and hides the field', async () => {
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), managed: true });
stubDefaults({ carto_api_key: 'ck.hoster' });
render(<MAdminDefaultUserSettings />);
await screen.findByText('Default User Settings');
expect(screen.queryByText('Shared CARTO key')).not.toBeInTheDocument();
});
it('FE-MOB-MDUS-027: a managed instance hides the shared Mapbox token as well', async () => {
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), managed: true });
stubDefaults({ map_provider: 'mapbox-gl', mapbox_access_token: 'pk.hoster' });
render(<MAdminDefaultUserSettings />);
await screen.findByText('Map style');
expect(screen.queryByText('Shared Mapbox token')).not.toBeInTheDocument();
});
});
@@ -5,6 +5,7 @@ import { render, screen, waitFor } from '../../../helpers/render';
import { resetAllStores, seedStore } from '../../../helpers/store';
import { buildSettings } from '../../../helpers/factories';
import { useSettingsStore } from '../../../../src/store/settingsStore';
import { useAuthStore } from '../../../../src/store/authStore';
import { ToastContainer } from '../../../../src/components/shared/Toast';
import type { Settings } from '../../../../src/types';
import MSettingsMap from '../../../../src/mobile/screens/settings/MSettingsMap';
@@ -30,6 +31,7 @@ vi.mock('../../../../src/components/Settings/MapboxPreview', () => ({
}));
const OSM_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
const CARTO_DARK_TILES = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
function seedMap(over: Partial<Settings> = {}, updateSettings = vi.fn().mockResolvedValue(undefined)) {
seedStore(useSettingsStore, {
@@ -39,6 +41,11 @@ function seedMap(over: Partial<Settings> = {}, updateSettings = vi.fn().mockReso
return updateSettings;
}
/** The CARTO key input follows its eyebrow label in the card's flat layout. */
function cartoInput(): HTMLInputElement {
return screen.getByText('CARTO API key').nextElementSibling as HTMLInputElement;
}
function renderMap() {
return render(
<>
@@ -188,6 +195,7 @@ describe('MSettingsMap', () => {
map_provider: 'leaflet',
map_tile_url: OSM_URL,
mapbox_access_token: '',
carto_api_key: '',
mapbox_style: 'mapbox://styles/mapbox/dark-v11',
mapbox_3d_enabled: true,
mapbox_quality_mode: false,
@@ -272,4 +280,54 @@ describe('MSettingsMap', () => {
expect(await screen.findByTestId('gl-preview')).toHaveAttribute('data-style', 'https://tiles.openfreemap.org/styles/liberty');
});
it('FE-MOB-SETMAP-020: the Leaflet branch offers the CARTO key field', async () => {
const user = userEvent.setup();
renderMap();
expect(screen.getByText('CARTO API key')).toBeInTheDocument();
await user.click(screen.getByText('Mapbox GL'));
expect(screen.queryByText('CARTO API key')).not.toBeInTheDocument();
});
it('FE-MOB-SETMAP-021: a managed instance brings its own key and hides the field', () => {
seedStore(useAuthStore, { managed: true });
renderMap();
expect(screen.queryByText('CARTO API key')).not.toBeInTheDocument();
});
it('FE-MOB-SETMAP-022: a managed instance hides the Mapbox token field too', () => {
seedStore(useAuthStore, { managed: true });
seedMap({ map_provider: 'mapbox-gl' });
renderMap();
expect(screen.queryByPlaceholderText('pk.eyJ1Ijoi...')).not.toBeInTheDocument();
expect(screen.getByText('Mapbox Standard')).toBeInTheDocument();
});
it('FE-MOB-SETMAP-023: the typed CARTO key reaches the save payload', async () => {
const user = userEvent.setup();
const updateSettings = seedMap();
renderMap();
await user.type(cartoInput(), 'demo-key');
await user.click(screen.getByRole('button', { name: 'Save Map' }));
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ carto_api_key: 'demo-key' }));
});
it('FE-MOB-SETMAP-024: a CARTO template without a key explains the watermark until one is typed', async () => {
const user = userEvent.setup();
seedMap({ map_tile_url: CARTO_DARK_TILES });
renderMap();
expect(screen.getByText(/API KEY REQUIRED/)).toBeInTheDocument();
await user.type(cartoInput(), 'demo-key');
expect(screen.queryByText(/API KEY REQUIRED/)).not.toBeInTheDocument();
});
});
+4 -1
View File
@@ -66,7 +66,10 @@ export default defineConfig(({ mode }) => ({
// Carto map tiles (default provider)
// maxEntries MUST stay >= MAX_TILES in src/sync/tilePrefetcher.ts
// (both are 12288) so prefetched tiles aren't evicted on arrival.
urlPattern: /^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i,
// The apex host counts too: a template without {s} points straight at
// basemaps.cartocdn.com, and matching only the shards left those tiles
// uncached, so the map went blank offline.
urlPattern: /^https:\/\/(?:[a-d]\.)?basemaps\.cartocdn\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'map-tiles',
+1 -1
View File
@@ -404,7 +404,7 @@ async function main() {
}
// --- settings: per-user encrypted keys ---
const encryptedSettingKeys = ['webhook_url', 'ntfy_token', 'mapbox_access_token', 'llm_api_key'];
const encryptedSettingKeys = ['webhook_url', 'ntfy_token', 'mapbox_access_token', 'carto_api_key', 'llm_api_key'];
const settingRows = db.prepare(
`SELECT user_id, key, value FROM settings WHERE key IN (${encryptedSettingKeys.map(() => '?').join(', ')})`
).all(...encryptedSettingKeys) as { user_id: number; key: string; value: string }[];
+1 -1
View File
@@ -77,7 +77,7 @@ below.)
**Runtime-toggled** (read live on every access via `readEnv()` /
`RuntimeEnvService`; tests mutate these mid-lifetime):
TREK_MANAGED, PLACES_API_BASE, PLACES_API_KEY, MAPBOX_ACCESS_TOKEN, DEMO_MODE, NODE_ENV, APP_VERSION, APP_URL, TREK_API_DOCS_ENABLED,
TREK_MANAGED, PLACES_API_BASE, PLACES_API_KEY, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY, DEMO_MODE, NODE_ENV, APP_VERSION, APP_URL, TREK_API_DOCS_ENABLED,
TREK_PLUGINS_ENABLED / _DEV_LINK / _DIR / _DATA_DIR / TREK_PLUGIN_PERMISSIONS,
OIDC_*, SMTP_*, FORCE_HTTPS, COOKIE_SECURE, HSTS_INCLUDE_SUBDOMAINS,
ALLOWED_ORIGINS, UNSPLASH_ACCESS_KEY, WEBAUTHN_*, TZ, ADMIN_EMAIL,
+2
View File
@@ -137,6 +137,8 @@ export function deriveMaps(raw: RawEnv) {
placesApiKey: raw.PLACES_API_KEY || undefined,
/** Public pk.* token shipped with a managed instance; reaches the browser by design. */
mapboxToken: raw.MAPBOX_ACCESS_TOKEN || undefined,
/** CARTO basemap key; without one the tiles come back watermarked (#2054). Public too. */
cartoKey: raw.CARTO_API_KEY || undefined,
};
}
+1
View File
@@ -140,6 +140,7 @@ export const envSchema = z.object({
PLACES_API_BASE: url,
PLACES_API_KEY: anyString,
MAPBOX_ACCESS_TOKEN: anyString,
CARTO_API_KEY: anyString,
DEMO_MODE: boolStr,
DEMO_ADMIN_USER: anyString,
DEMO_ADMIN_EMAIL: anyString,
+16 -5
View File
@@ -38,15 +38,23 @@ export const SENSITIVE_KEYS = new Set([
*/
const SENSITIVE_SUFFIXES = ['_token', '_key', '_secret', '_password', '_pass', '_verifier'];
function isSensitiveName(name: string): boolean {
const lower = name.toLowerCase();
return SENSITIVE_KEYS.has(lower) || SENSITIVE_SUFFIXES.some((suffix) => lower.endsWith(suffix));
}
/** Deep-redacts every key in `SENSITIVE_KEYS` (case-insensitive) from a request-log value. */
export function redact(value: unknown): unknown {
if (!value || typeof value !== 'object') return value;
if (Array.isArray(value)) return (value as unknown[]).map(redact);
const entries = value as Record<string, unknown>;
// PUT /api/settings names the setting instead of using it as the field:
// {key: 'carto_api_key', value: '<secret>'}. Neither field name is sensitive
// on its own, so without this the secret goes into the debug log in cleartext.
const namedSecret = typeof entries.key === 'string' && isSensitiveName(entries.key);
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
const lower = k.toLowerCase();
const sensitive = SENSITIVE_KEYS.has(lower) || SENSITIVE_SUFFIXES.some((suffix) => lower.endsWith(suffix));
out[k] = sensitive ? '[REDACTED]' : redact(v);
for (const [k, v] of Object.entries(entries)) {
out[k] = isSensitiveName(k) || (namedSecret && k === 'value') ? '[REDACTED]' : redact(v);
}
return out;
}
@@ -181,7 +189,10 @@ export function applyGlobalMiddleware(
"https://nominatim.openstreetmap.org", "https://overpass-api.de",
"https://places.googleapis.com", "https://api.openweathermap.org",
"https://en.wikipedia.org", "https://commons.wikimedia.org",
"https://*.basemaps.cartocdn.com",
// Both forms here too: CARTO documents the apex host on its key page,
// so that is the template users paste in, and the {s} sharded form is
// what TREK ships (#2054).
"https://basemaps.cartocdn.com", "https://*.basemaps.cartocdn.com",
// Both forms: a CSP wildcard host never matches the apex, and OSM
// serves everything from the bare tile.openstreetmap.org since it
// retired the a/b/c/d shards (#1733). The sharded hosts stay listed
+6
View File
@@ -70,8 +70,14 @@ export function isManagedBlocked(env: RuntimeEnvService): boolean {
* the one that reaches the browser: a managed instance ships with the operator's
* public pk.* token, injected when the settings are read, and a per-user value
* saved over it would only break the map for that user.
*
* carto_api_key is the same shape, injected on read and public in the browser,
* and it is the operator's for one more reason: the key is registered to
* whoever runs the instance, and CARTO's terms hold that account answerable for
* the tiles it fetches.
*/
export const MANAGED_LOCKED_SETTING_KEYS = [
'carto_api_key',
'llm_api_key',
'llm_base_url',
'llm_model',
@@ -5,6 +5,7 @@ import { JourneyDomainService } from './journey-domain.service';
import { JournalRpc } from './journal.rpc';
import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
import { JourneyShareService } from './journey-share.service';
import { SettingsModule } from '../settings/settings.module';
/**
* Leaf module holding the journey domain itself, without the controllers.
@@ -16,9 +17,14 @@ import { JourneyShareService } from './journey-share.service';
*
* RealtimeModule is imported explicitly even though it is @Global, so a
* single-domain e2e TestingModule can still resolve the broadcast.
*
* SettingsModule is the one addition to that shape: the public journey payload
* carries the owner's CARTO tile key, and resolving that (per-user value, admin
* instance default, managed-instance key, all decrypted) is SettingsService's
* job rather than a second copy of its SQL here.
*/
@Module({
imports: [RealtimeModule, TrekPhotosModule, PluginGuardsModule],
imports: [RealtimeModule, TrekPhotosModule, PluginGuardsModule, SettingsModule],
providers: [JourneyDomainService, JourneyShareService, JournalRpc],
exports: [JourneyDomainService, JourneyShareService],
})
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import crypto from 'crypto';
import { DatabaseService } from '../database/database.service';
import { JourneyDomainService } from './journey-domain.service';
import { SettingsService } from '../settings/settings.service';
interface JourneySharePermissions {
share_timeline?: boolean;
@@ -33,6 +34,7 @@ export class JourneyShareService {
constructor(
private readonly db: DatabaseService,
private readonly journey: JourneyDomainService,
private readonly settings: SettingsService,
) {}
createOrUpdateJourneyShareLink(
@@ -238,6 +240,16 @@ export class JourneyShareService {
}));
}
// Same reason as the trip share payload: CARTO watermarks every tile fetched
// without a key (#2054) and the public journey map has no logged-in user to
// read one from, so the owner's key travels with it. Only a valid share token
// gets this far. getUserSettings composes the owner's own value, the admin
// instance default and the managed-instance key in that order; carto_api_key is
// encrypted at rest but deliberately unmasked, since it is useless until it
// reaches a browser.
const ownerCartoKey = this.settings.getUserSettings(journey.user_id)['carto_api_key'];
const cartoApiKey = typeof ownerCartoKey === 'string' ? ownerCartoKey.trim() : '';
return {
journey: {
title: journey.title,
@@ -252,6 +264,7 @@ export class JourneyShareService {
// would hand out places the map was deliberately turned off for.
gallery: shareGallery ? (shareMap ? gallery : stripPhotoGps(gallery)) : [],
stats,
cartoApiKey,
permissions: {
share_timeline: shareTimeline,
share_gallery: shareGallery,
+17 -1
View File
@@ -4,7 +4,13 @@ import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyC
import { MASKED_SETTING_VALUE, normalizeAppearance } from '@trek/shared';
import { readEnv } from '../../app-config';
const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token', 'mapbox_access_token', 'llm_api_key']);
const ENCRYPTED_SETTING_KEYS = new Set([
'webhook_url',
'ntfy_token',
'mapbox_access_token',
'carto_api_key',
'llm_api_key',
]);
// Encrypted keys that are masked (••••••••) when returned to the client.
// Keys not in this set but in ENCRYPTED_SETTING_KEYS are decrypted and returned.
const MASKED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token', 'llm_api_key']);
@@ -19,6 +25,10 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
'default_currency',
'blur_booking_codes',
'map_tile_url',
// CARTO stamps an "API KEY REQUIRED" watermark into keyless tiles (#2054), and
// the key is per-instance rather than per-person: defaultable so one admin
// value clears the watermark for everybody at once.
'carto_api_key',
// Instance-wide GL map defaults: admins can set Mapbox token/style or
// tokenless MapLibre/OpenFreeMap style defaults for new users (#920).
'map_provider',
@@ -216,6 +226,12 @@ export class SettingsService {
merged.map_provider = 'mapbox-gl';
}
}
// Injected the same way, but without touching map_provider: a CARTO key only
// says whose basemap account the tiles are billed to, nothing about which
// renderer the user wants.
if (readEnv().managed.enabled && managedMaps.cartoKey) {
merged.carto_api_key = managedMaps.cartoKey;
}
return merged;
}
+18 -6
View File
@@ -280,15 +280,27 @@ export class ShareService {
// honours per-user → admin-default; we then fall back to trip currency → EUR
// (`||` on purpose: an empty-string trip currency also falls back).
let baseCurrency = (trip as { currency?: string }).currency || 'EUR';
if (shareRow.created_by != null) {
const ownerDefault = this.settings.getUserSettings(shareRow.created_by)['default_currency'];
if (typeof ownerDefault === 'string' && ownerDefault.trim()) {
baseCurrency = ownerDefault.trim();
}
const ownerSettings: Record<string, unknown> = shareRow.created_by != null
? this.settings.getUserSettings(shareRow.created_by)
: {};
const ownerDefault = ownerSettings['default_currency'];
if (typeof ownerDefault === 'string' && ownerDefault.trim()) {
baseCurrency = ownerDefault.trim();
}
// CARTO stamps an "API KEY REQUIRED" watermark into every tile fetched without
// a key (#2054), and a public viewer has no settings of their own to hold one,
// so the owner's key travels in this payload. Nothing without a valid share
// token reaches it, and the key is public in the browser anyway. getUserSettings
// is the right accessor here even though it is the client-facing one:
// carto_api_key is encrypted at rest but deliberately unmasked (same as the
// Mapbox token, both have to reach a browser), and it is the only accessor that
// composes per-user value → admin instance default → managed-instance key.
const ownerCartoKey = ownerSettings['carto_api_key'];
const cartoApiKey = typeof ownerCartoKey === 'string' ? ownerCartoKey.trim() : '';
return {
trip, baseCurrency, categories, permissions,
trip, baseCurrency, cartoApiKey, categories, permissions,
days, assignments, dayNotes, places,
reservations, accommodations,
packing, budget,
+1 -1
View File
@@ -183,7 +183,7 @@ export function createMcpTestRegistry(): McpRegistry {
new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage), dbService, authService, addonsService),
new TransitMcp(new TransitService(), daysService, reservationsService, dbService, authService, guards),
new AtlasMcp(new AtlasService(dbService), addonsService, authService),
new JourneyMcp(journeyDomain, new JourneyShareService(dbService, journeyDomain), addonsService, authService),
new JourneyMcp(journeyDomain, new JourneyShareService(dbService, journeyDomain, new SettingsService(dbService)), addonsService, authService),
new NotificationsMcp(makeNotificationsService(dbService, realtimeService), authService),
],
{ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess },
+21
View File
@@ -524,6 +524,27 @@ describe('Shared trip — display currency (issue #1361)', () => {
});
});
describe('Shared trip: CARTO tile key (issue #2054)', () => {
it('SHARE-029: the payload carries the owner\'s carto_api_key, behind it the admin instance default', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_carto_api_key', 'instance-key')").run();
const { body: { token } } = await request(app)
.post(`/api/trips/${trip.id}/share-link`)
.set('Cookie', authCookie(user.id))
.send({ share_map: true });
const inherited = await request(app).get(`/api/shared/${token}`);
expect(inherited.status).toBe(200);
expect(inherited.body.cartoApiKey).toBe('instance-key');
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', 'owner-key')").run(user.id);
const own = await request(app).get(`/api/shared/${token}`);
expect(own.body.cartoApiKey).toBe('owner-key');
});
});
describe('Shared trip — place photos in shared links (issue #1100)', () => {
const PLACE_ID = 'ChIJsharedPhoto1100';
const PROXY_URL = `/api/maps/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`;
@@ -60,6 +60,22 @@ describe('globalMiddleware request-log redaction', () => {
expect(out.grant_type).toBe('refresh_token');
});
it('redacts the value of a {key, value} settings body when the key names a secret', () => {
expect(redact({ key: 'carto_api_key', value: 'carto-secret' })).toEqual({
key: 'carto_api_key',
value: '[REDACTED]',
});
expect(redact({ key: 'mapbox_access_token', value: 'pk-1' })).toEqual({
key: 'mapbox_access_token',
value: '[REDACTED]',
});
// A harmless setting keeps its value: the name is what decides.
expect(redact({ key: 'map_tile_url', value: 'https://tiles.example/{z}/{x}/{y}.png' })).toEqual({
key: 'map_tile_url',
value: 'https://tiles.example/{z}/{x}/{y}.png',
});
});
it('matches the suffix on the end of the key only, so identifiers stay readable', () => {
expect(redact({ accessKeyId: 'ak-1', tokenCount: 5 })).toEqual({ accessKeyId: 'ak-1', tokenCount: 5 });
});
@@ -39,10 +39,15 @@ import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { JourneyShareService } from '../../../src/nest/journey/journey-share.service';
import { SettingsService } from '../../../src/nest/settings/settings.service';
import { db as dbConn } from '../../../src/db/database';
const dbs = new DatabaseService(dbConn);
const svc = new JourneyShareService(dbs, new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)));
const svc = new JourneyShareService(
dbs,
new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)),
new SettingsService(dbs),
);
beforeAll(() => {
createTables(testDb);
@@ -629,4 +634,16 @@ describe('getPublicJourney', () => {
expect(result.entries).toHaveLength(1);
expect((result.entries[0] as Record<string, unknown>).photos).toEqual([]); // inline photos withheld too
});
it('JOURNEY-SHARE-030: cartoApiKey resolves owner setting → admin instance default → empty (#2054)', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_map: true });
expect(svc.getPublicJourney(token)!.cartoApiKey).toBe('');
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_carto_api_key', 'instance-key')").run();
expect(svc.getPublicJourney(token)!.cartoApiKey).toBe('instance-key');
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', ' owner-key ')").run(user.id);
expect(svc.getPublicJourney(token)!.cartoApiKey).toBe('owner-key');
});
});
@@ -51,6 +51,7 @@ describe('managed key assignment', () => {
it('MANAGED-KEYS-005: the locked list is pinned verbatim', () => {
expect([...MANAGED_LOCKED_SETTING_KEYS]).toEqual([
'carto_api_key',
'llm_api_key',
'llm_base_url',
'llm_model',
@@ -1,8 +1,9 @@
/**
* Unit tests for the DI-native SettingsService — SET-SVC-001 through
* SET-SVC-030 (001–026 moved 1:1 from the legacy
* SET-SVC-034 (001–026 moved 1:1 from the legacy
* tests/unit/services/settingsService.test.ts; 027–030 pin the post-migration
* quirk fixes: null-serializes-as-'' and the bulk masked-sentinel skip).
* quirk fixes: null-serializes-as-'' and the bulk masked-sentinel skip;
* 031–034 cover the CARTO basemap key).
* Uses a real in-memory SQLite DB; apiKeyCrypto is mocked to a passthrough
* so we don't need real encryption for most tests.
*/
@@ -173,6 +174,60 @@ describe('getUserSettings', () => {
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'llm_api_key', '')").run(user.id);
expect(svc.getUserSettings(user.id).llm_api_key).toBe('••••••••');
});
// carto_api_key (#2054): encrypted at rest like the other credentials, but
// Leaflet builds the tile URL in the browser, so it must come back readable.
it('SET-SVC-031 — carto_api_key is stored encrypted and read back in cleartext, never masked', () => {
const { user } = createUser(testDb);
svc.upsertSetting(user.id, 'carto_api_key', 'carto-user');
// Passthrough crypto mock: the stored row is whatever maybe_encrypt_api_key returned.
const raw = testDb
.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'carto_api_key'")
.get(user.id) as { value: string };
expect(raw.value).toBe('carto-user');
expect(svc.getUserSettings(user.id).carto_api_key).toBe('carto-user');
// A digits-only key proves it takes the decrypt branch: a non-encrypted key
// would come back JSON-parsed, as the number 12345.
svc.upsertSetting(user.id, 'carto_api_key', '12345');
expect(svc.getUserSettings(user.id).carto_api_key).toBe('12345');
});
it('SET-SVC-032 — an empty user carto_api_key falls back to the admin default', () => {
const { user } = createUser(testDb);
setAdminDefault('carto_api_key', 'carto-admin');
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', '')").run(user.id);
expect(svc.getUserSettings(user.id).carto_api_key).toBe('carto-admin');
});
it('SET-SVC-033 — a managed instance injects the operator key over both the user and the admin value', () => {
const { user } = createUser(testDb);
setAdminDefault('carto_api_key', 'carto-admin');
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', 'carto-user')").run(user.id);
vi.stubEnv('TREK_MANAGED', 'true');
vi.stubEnv('CARTO_API_KEY', 'carto-operator');
try {
expect(svc.getUserSettings(user.id).carto_api_key).toBe('carto-operator');
} finally {
vi.unstubAllEnvs();
}
});
it('SET-SVC-034 — the injected CARTO key leaves map_provider alone', () => {
const { user } = createUser(testDb);
vi.stubEnv('TREK_MANAGED', 'true');
vi.stubEnv('CARTO_API_KEY', 'carto-operator');
vi.stubEnv('MAPBOX_ACCESS_TOKEN', '');
try {
// The Mapbox token flips a managed instance to GL because a token means a
// renderer. A basemap key says nothing about which renderer the user wants.
const s = svc.getUserSettings(user.id);
expect(s.carto_api_key).toBe('carto-operator');
expect(s.map_provider).toBeUndefined();
} finally {
vi.unstubAllEnvs();
}
});
});
// ── upsertSetting ─────────────────────────────────────────────────────────────
@@ -375,6 +375,15 @@ describe('getSharedTripData', () => {
expect(byName['Uploaded']).toBe('/uploads/pic.jpg');
expect(byName['Bare']).toBeNull();
});
it('SHARE-SVC-027: cartoApiKey resolves owner setting → admin instance default → empty (#2054)', () => {
const { user, token } = seedSharedTrip();
expect(svc.getSharedTripData(token)!.cartoApiKey).toBe('');
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_carto_api_key', 'instance-key')").run();
expect(svc.getSharedTripData(token)!.cartoApiKey).toBe('instance-key');
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', ' owner-key ')").run(user.id);
expect(svc.getSharedTripData(token)!.cartoApiKey).toBe('owner-key');
});
});
// ── getSharedPlacePhotoKey ───────────────────────────────────────────────────
+3
View File
@@ -669,6 +669,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'اختر نمطًا…',
'admin.defaultSettings.mapbox3d': 'المباني والتضاريس ثلاثية الأبعاد',
'admin.defaultSettings.mapboxQuality': 'وضع الجودة العالية',
'admin.defaultSettings.cartoKey': 'مفتاح CARTO المشترك',
'admin.defaultSettings.cartoKeyHint':
'يُستخدم لكل مستخدم لم يُدخل مفتاحه الخاص، حتى يحصل الخادم بأكمله على بلاطات CARTO بدون علامة مائية. يُخزَّن مشفّرًا.',
'admin.group.users': 'Users',
'admin.group.config': 'Configuration',
'admin.group.integration': 'Integrations',
+5
View File
@@ -32,6 +32,11 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'رمز وصول Mapbox',
'settings.mapMapboxTokenHint': 'الرمز العام (pk.*) من',
'settings.mapMapboxTokenLink': 'mapbox.com ← رموز الوصول',
'settings.mapCartoKey': 'مفتاح CARTO API',
'settings.mapCartoKeyHint': 'تعرض خرائط CARTO الأساسية علامة مائية بدون مفتاح. مجاني وبدون حساب، من',
'settings.mapCartoKeyLink': 'مفتاح API لخرائط carto.com الأساسية',
'settings.mapCartoKeyMissing':
'هذا القالب خريطة أساسية من CARTO. بدون مفتاح تطبع CARTO عبارة "API KEY REQUIRED" على كل بلاطة.',
'settings.mapStyle': 'نمط الخريطة',
'settings.mapStylePlaceholder': 'اختر نمط Mapbox',
'settings.mapStyleHint': 'إعداد مسبق أو عنوان URL mapbox://styles/USER/ID خاص بك',
+3
View File
@@ -686,6 +686,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'Escolha um estilo…',
'admin.defaultSettings.mapbox3d': 'Edifícios & relevo em 3D',
'admin.defaultSettings.mapboxQuality': 'Modo de alta qualidade',
'admin.defaultSettings.cartoKey': 'Chave CARTO compartilhada',
'admin.defaultSettings.cartoKeyHint':
"Usada para todos os usuários que não inseriram a própria chave, assim toda a instância recebe blocos do CARTO sem marca d'água. Armazenada de forma criptografada.",
'admin.group.users': 'Users',
'admin.group.config': 'Configuration',
'admin.group.integration': 'Integrations',
+5
View File
@@ -34,6 +34,11 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'Token de acesso Mapbox',
'settings.mapMapboxTokenHint': 'Token público (pk.*) de',
'settings.mapMapboxTokenLink': 'mapbox.com → Tokens de acesso',
'settings.mapCartoKey': 'Chave de API do CARTO',
'settings.mapCartoKeyHint': "Os mapas base do CARTO exibem uma marca d'água sem chave. Gratuita e sem conta, em",
'settings.mapCartoKeyLink': 'chave de API de mapas base do carto.com',
'settings.mapCartoKeyMissing':
'Este modelo é um mapa base do CARTO. Sem uma chave, o CARTO estampa "API KEY REQUIRED" em cada bloco.',
'settings.mapStyle': 'Estilo do mapa',
'settings.mapStylePlaceholder': 'Selecionar um estilo Mapbox',
'settings.mapStyleHint': 'Preset ou sua própria URL mapbox://styles/USER/ID',
+3
View File
@@ -374,6 +374,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'Tria un estil…',
'admin.defaultSettings.mapbox3d': 'Edificis i terreny en 3D',
'admin.defaultSettings.mapboxQuality': "Mode d'alta qualitat",
'admin.defaultSettings.cartoKey': 'Clau CARTO compartida',
'admin.defaultSettings.cartoKeyHint':
"S'utilitza per a cada usuari que no hagi introduït la seva pròpia clau, de manera que tota la instància obtingui tessel·les de CARTO sense marca d'aigua. S'emmagatzema xifrada.",
'admin.unsplashKey': "Clau API d'Unsplash",
'admin.unsplashKeyHint': "Per a la cerca d'imatges. Gratuït a unsplash.com/developers",
'admin.tabs.plugins': 'Connectors',
+6
View File
@@ -23,6 +23,12 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': "Token d'accés de Mapbox",
'settings.mapMapboxTokenHint': 'Token públic (pk.*) de',
'settings.mapMapboxTokenLink': "mapbox.com → Tokens d'accés",
'settings.mapCartoKey': "Clau d'API de CARTO",
'settings.mapCartoKeyHint':
"Els mapes base de CARTO mostren una marca d'aigua sense clau. Gratuïta i sense compte, des de",
'settings.mapCartoKeyLink': "clau d'API de mapes base de carto.com",
'settings.mapCartoKeyMissing':
'Aquesta plantilla és un mapa base de CARTO. Sense clau, CARTO estampa "API KEY REQUIRED" a cada tessel·la.',
'settings.mapStyle': 'Estil de mapa',
'settings.mapStylePlaceholder': 'Selecciona un estil de Mapbox',
'settings.mapStyleHint': 'Predefinit o la teva pròpia URL mapbox://styles/USUARI/ID',
+3
View File
@@ -681,6 +681,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'Vyberte styl…',
'admin.defaultSettings.mapbox3d': '3D budovy & terén',
'admin.defaultSettings.mapboxQuality': 'Režim vysoké kvality',
'admin.defaultSettings.cartoKey': 'Sdílený klíč CARTO',
'admin.defaultSettings.cartoKeyHint':
'Použije se pro každého uživatele, který nezadal vlastní klíč, takže celá instance získá dlaždice CARTO bez vodoznaku. Ukládá se šifrovaně.',
'admin.group.users': 'Users',
'admin.group.config': 'Configuration',
'admin.group.integration': 'Integrations',
+5
View File
@@ -33,6 +33,11 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'Mapbox přístupový token',
'settings.mapMapboxTokenHint': 'Veřejný token (pk.*) z',
'settings.mapMapboxTokenLink': 'mapbox.com → Přístupové tokeny',
'settings.mapCartoKey': 'CARTO API klíč',
'settings.mapCartoKeyHint': 'Podkladové mapy CARTO se bez klíče zobrazují s vodoznakem. Zdarma a bez účtu, na',
'settings.mapCartoKeyLink': 'carto.com API klíč pro podkladové mapy',
'settings.mapCartoKeyMissing':
'Tato šablona je podkladová mapa CARTO. Bez klíče CARTO vypálí do každé dlaždice nápis "API KEY REQUIRED".',
'settings.mapStyle': 'Styl mapy',
'settings.mapStylePlaceholder': 'Vyberte styl Mapbox',
'settings.mapStyleHint': 'Preset nebo vaše vlastní URL mapbox://styles/USER/ID',
+3
View File
@@ -693,6 +693,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'Stil auswählen…',
'admin.defaultSettings.mapbox3d': '3D-Gebäude & Gelände',
'admin.defaultSettings.mapboxQuality': 'Hochqualitätsmodus',
'admin.defaultSettings.cartoKey': 'Gemeinsamer CARTO-Key',
'admin.defaultSettings.cartoKeyHint':
'Wird für jeden Nutzer verwendet, der keinen eigenen Key eingetragen hat, damit die ganze Instanz CARTO-Kacheln ohne Wasserzeichen bekommt. Verschlüsselt gespeichert.',
'admin.group.users': 'Benutzer',
'admin.group.config': 'Konfiguration',
'admin.group.integration': 'Integrationen',
+5
View File
@@ -34,6 +34,11 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'Mapbox Access Token',
'settings.mapMapboxTokenHint': 'Öffentliches Token (pk.*) von',
'settings.mapMapboxTokenLink': 'mapbox.com → Access Tokens',
'settings.mapCartoKey': 'CARTO-API-Key',
'settings.mapCartoKeyHint': 'CARTO-Basiskarten zeigen ohne Key ein Wasserzeichen. Kostenlos und ohne Konto, unter',
'settings.mapCartoKeyLink': 'carto.com Basemap-API-Key',
'settings.mapCartoKeyMissing':
'Diese Vorlage ist eine CARTO-Basiskarte. Ohne Key brennt CARTO "API KEY REQUIRED" in jede Kachel.',
'settings.mapStyle': 'Kartenstil',
'settings.mapStylePlaceholder': 'Mapbox-Stil wählen',
'settings.mapStyleHint': 'Preset oder eigene mapbox://styles/USER/ID URL',
+3
View File
@@ -197,6 +197,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'Choose a style…',
'admin.defaultSettings.mapbox3d': '3D buildings & terrain',
'admin.defaultSettings.mapboxQuality': 'High-quality mode',
'admin.defaultSettings.cartoKey': 'Shared CARTO key',
'admin.defaultSettings.cartoKeyHint':
'Used for every user who has not entered their own key, so the whole instance gets CARTO tiles without a watermark. Stored encrypted.',
'admin.tabs.templates': 'Packing Templates',
'admin.packingTemplates.title': 'Packing Templates',
'admin.packingTemplates.subtitle': 'Create reusable packing lists for your trips',
+5
View File
@@ -42,6 +42,11 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'Mapbox Access Token',
'settings.mapMapboxTokenHint': 'Public token (pk.*) from',
'settings.mapMapboxTokenLink': 'mapbox.com → Access tokens',
'settings.mapCartoKey': 'CARTO API key',
'settings.mapCartoKeyHint': 'CARTO basemaps show a watermark without a key. Free, no account needed, from',
'settings.mapCartoKeyLink': 'carto.com basemap API key',
'settings.mapCartoKeyMissing':
'This template is a CARTO basemap. Without a key CARTO stamps "API KEY REQUIRED" onto every tile.',
'settings.mapStyle': 'Map Style',
'settings.mapStylePlaceholder': 'Select a Mapbox style',
'settings.mapStyleHint': 'Preset or your own mapbox://styles/USER/ID URL',
+3
View File
@@ -702,6 +702,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'Elige un estilo…',
'admin.defaultSettings.mapbox3d': 'Edificios y terreno en 3D',
'admin.defaultSettings.mapboxQuality': 'Modo de alta calidad',
'admin.defaultSettings.cartoKey': 'Clave de CARTO compartida',
'admin.defaultSettings.cartoKeyHint':
'Se usa para cada usuario que no haya introducido su propia clave, de modo que toda la instancia obtenga teselas de CARTO sin marca de agua. Se almacena cifrada.',
'admin.group.users': 'Users',
'admin.group.config': 'Configuration',
'admin.group.integration': 'Integrations',
+5
View File
@@ -33,6 +33,11 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'Token de acceso de Mapbox',
'settings.mapMapboxTokenHint': 'Token público (pk.*) de',
'settings.mapMapboxTokenLink': 'mapbox.com → Tokens de acceso',
'settings.mapCartoKey': 'Clave de API de CARTO',
'settings.mapCartoKeyHint': 'Los mapas base de CARTO muestran una marca de agua sin clave. Gratuita y sin cuenta, en',
'settings.mapCartoKeyLink': 'clave de API de mapas base de carto.com',
'settings.mapCartoKeyMissing':
'Esta plantilla es un mapa base de CARTO. Sin clave, CARTO estampa "API KEY REQUIRED" en cada tesela.',
'settings.mapStyle': 'Estilo de mapa',
'settings.mapStylePlaceholder': 'Seleccionar un estilo de Mapbox',
'settings.mapStyleHint': 'Preset o tu propia URL mapbox://styles/USER/ID',
+3
View File
@@ -700,6 +700,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'Choisissez un style…',
'admin.defaultSettings.mapbox3d': 'Bâtiments & terrain en 3D',
'admin.defaultSettings.mapboxQuality': 'Mode haute qualité',
'admin.defaultSettings.cartoKey': 'Clé CARTO partagée',
'admin.defaultSettings.cartoKeyHint':
"Utilisée pour chaque utilisateur n'ayant pas saisi sa propre clé, ainsi toute l'instance reçoit les tuiles CARTO sans filigrane. Stockée de façon chiffrée.",
'admin.group.users': 'Users',
'admin.group.config': 'Configuration',
'admin.group.integration': 'Integrations',
+6
View File
@@ -33,6 +33,12 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': "Jeton d'accès Mapbox",
'settings.mapMapboxTokenHint': 'Jeton public (pk.*) depuis',
'settings.mapMapboxTokenLink': "mapbox.com → Jetons d'accès",
'settings.mapCartoKey': "Clé d'API CARTO",
'settings.mapCartoKeyHint':
'Les fonds de carte CARTO affichent un filigrane sans clé. Gratuite et sans compte, depuis',
'settings.mapCartoKeyLink': "clé d'API de fonds de carte carto.com",
'settings.mapCartoKeyMissing':
'Ce modèle est un fond de carte CARTO. Sans clé, CARTO appose "API KEY REQUIRED" sur chaque tuile.',
'settings.mapStyle': 'Style de carte',
'settings.mapStylePlaceholder': 'Sélectionner un style Mapbox',
'settings.mapStyleHint': 'Preset ou votre propre URL mapbox://styles/USER/ID',
+3
View File
@@ -710,6 +710,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'Επιλέξτε ένα στυλ…',
'admin.defaultSettings.mapbox3d': 'Κτίρια & ανάγλυφο 3D',
'admin.defaultSettings.mapboxQuality': 'Λειτουργία υψηλής ποιότητας',
'admin.defaultSettings.cartoKey': 'Κοινόχρηστο κλειδί CARTO',
'admin.defaultSettings.cartoKeyHint':
'Χρησιμοποιείται για κάθε χρήστη που δεν έχει εισαγάγει το δικό του κλειδί, έτσι ολόκληρη η εγκατάσταση λαμβάνει πλακίδια CARTO χωρίς υδατογράφημα. Αποθηκεύεται κρυπτογραφημένο.',
'admin.group.users': 'Users',
'admin.group.config': 'Configuration',
'admin.group.integration': 'Integrations',
+6
View File
@@ -35,6 +35,12 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'Mapbox Access Token',
'settings.mapMapboxTokenHint': 'Δημόσιο token (pk.*) από',
'settings.mapMapboxTokenLink': 'mapbox.com → Access tokens',
'settings.mapCartoKey': 'Κλειδί API CARTO',
'settings.mapCartoKeyHint':
'Οι βασικοί χάρτες CARTO εμφανίζουν υδατογράφημα χωρίς κλειδί. Δωρεάν, χωρίς λογαριασμό, από',
'settings.mapCartoKeyLink': 'κλειδί API βασικών χαρτών carto.com',
'settings.mapCartoKeyMissing':
'Αυτό το πρότυπο είναι βασικός χάρτης CARTO. Χωρίς κλειδί, η CARTO τυπώνει "API KEY REQUIRED" σε κάθε πλακίδιο.',
'settings.mapStyle': 'Στυλ Χάρτη',
'settings.mapStylePlaceholder': 'Επιλέξτε ένα στυλ Mapbox',
'settings.mapStyleHint': 'Προκαθορισμένο ή δικό σας mapbox://styles/USER/ID URL',
+3
View File
@@ -700,6 +700,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'Válassz stílust…',
'admin.defaultSettings.mapbox3d': '3D épületek & domborzat',
'admin.defaultSettings.mapboxQuality': 'Kiváló minőségű mód',
'admin.defaultSettings.cartoKey': 'Megosztott CARTO-kulcs',
'admin.defaultSettings.cartoKeyHint':
'Minden olyan felhasználóhoz használatos, aki nem adta meg a saját kulcsát, így az egész példány vízjel nélküli CARTO-csempéket kap. Titkosítva tárolódik.',
'admin.group.users': 'Users',
'admin.group.config': 'Configuration',
'admin.group.integration': 'Integrations',
+6
View File
@@ -33,6 +33,12 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'Mapbox hozzáférési token',
'settings.mapMapboxTokenHint': 'Publikus token (pk.*) innen:',
'settings.mapMapboxTokenLink': 'mapbox.com → Hozzáférési tokenek',
'settings.mapCartoKey': 'CARTO API-kulcs',
'settings.mapCartoKeyHint':
'A CARTO alaptérképek kulcs nélkül vízjelet jelenítenek meg. Ingyenes, fiók nélkül, innen:',
'settings.mapCartoKeyLink': 'carto.com alaptérkép API-kulcs',
'settings.mapCartoKeyMissing':
'Ez a sablon CARTO alaptérkép. Kulcs nélkül a CARTO minden csempére ráírja: "API KEY REQUIRED".',
'settings.mapStyle': 'Térkép stílus',
'settings.mapStylePlaceholder': 'Válassz Mapbox stílust',
'settings.mapStyleHint': 'Preset vagy saját mapbox://styles/USER/ID URL',
+3
View File
@@ -696,6 +696,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'Pilih gaya…',
'admin.defaultSettings.mapbox3d': 'Bangunan & medan 3D',
'admin.defaultSettings.mapboxQuality': 'Mode kualitas tinggi',
'admin.defaultSettings.cartoKey': 'Kunci CARTO bersama',
'admin.defaultSettings.cartoKeyHint':
'Digunakan untuk setiap pengguna yang belum memasukkan kunci mereka sendiri, sehingga seluruh instance mendapatkan ubin CARTO tanpa tanda air. Disimpan dalam bentuk terenkripsi.',
'admin.group.users': 'Users',
'admin.group.config': 'Configuration',
'admin.group.integration': 'Integrations',
+5
View File
@@ -33,6 +33,11 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'Token akses Mapbox',
'settings.mapMapboxTokenHint': 'Token publik (pk.*) dari',
'settings.mapMapboxTokenLink': 'mapbox.com → Token akses',
'settings.mapCartoKey': 'Kunci API CARTO',
'settings.mapCartoKeyHint': 'Peta dasar CARTO menampilkan tanda air tanpa kunci. Gratis, tanpa akun, dari',
'settings.mapCartoKeyLink': 'kunci API peta dasar carto.com',
'settings.mapCartoKeyMissing':
'Templat ini adalah peta dasar CARTO. Tanpa kunci, CARTO mencetak "API KEY REQUIRED" di setiap ubin.',
'settings.mapStyle': 'Gaya peta',
'settings.mapStylePlaceholder': 'Pilih gaya Mapbox',
'settings.mapStyleHint': 'Preset atau URL mapbox://styles/USER/ID milikmu',
+3
View File
@@ -695,6 +695,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'Scegli uno stile…',
'admin.defaultSettings.mapbox3d': 'Edifici & terreno in 3D',
'admin.defaultSettings.mapboxQuality': 'Modalità alta qualità',
'admin.defaultSettings.cartoKey': 'Chiave CARTO condivisa',
'admin.defaultSettings.cartoKeyHint':
"Usata per ogni utente che non ha inserito una propria chiave, così tutta l'istanza ottiene le tessere CARTO senza filigrana. Archiviata in forma crittografata.",
'admin.group.users': 'Users',
'admin.group.config': 'Configuration',
'admin.group.integration': 'Integrations',
+5
View File
@@ -33,6 +33,11 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'Token di accesso Mapbox',
'settings.mapMapboxTokenHint': 'Token pubblico (pk.*) da',
'settings.mapMapboxTokenLink': 'mapbox.com → Token di accesso',
'settings.mapCartoKey': 'Chiave API CARTO',
'settings.mapCartoKeyHint': 'Le mappe base CARTO mostrano una filigrana senza chiave. Gratuita e senza account, da',
'settings.mapCartoKeyLink': 'chiave API mappe base di carto.com',
'settings.mapCartoKeyMissing':
'Questo modello è una mappa base CARTO. Senza chiave, CARTO stampa "API KEY REQUIRED" su ogni tassello.',
'settings.mapStyle': 'Stile mappa',
'settings.mapStylePlaceholder': 'Seleziona uno stile Mapbox',
'settings.mapStyleHint': 'Preset o il tuo URL mapbox://styles/USER/ID',
+3
View File
@@ -660,6 +660,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'スタイルを選択…',
'admin.defaultSettings.mapbox3d': '3D の建物と地形',
'admin.defaultSettings.mapboxQuality': '高品質モード',
'admin.defaultSettings.cartoKey': '共有 CARTO キー',
'admin.defaultSettings.cartoKeyHint':
'自分のキーを入力していないすべてのユーザーに使用されます。これによりインスタンス全体で透かしのない CARTO タイルを利用できます。暗号化して保存されます。',
'admin.group.users': 'Users',
'admin.group.config': 'Configuration',
'admin.group.integration': 'Integrations',
+5
View File
@@ -33,6 +33,11 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'Mapbox アクセストークン',
'settings.mapMapboxTokenHint': 'mapbox.com の公開トークン(pk.*)',
'settings.mapMapboxTokenLink': 'mapbox.com → Access tokens',
'settings.mapCartoKey': 'CARTO API キー',
'settings.mapCartoKeyHint': 'キーがないと CARTO のベースマップに透かしが入ります。無料でアカウントも不要、取得先:',
'settings.mapCartoKeyLink': 'carto.com ベースマップ API キー',
'settings.mapCartoKeyMissing':
'このテンプレートは CARTO のベースマップです。キーがないと CARTO はすべてのタイルに "API KEY REQUIRED" を焼き込みます。',
'settings.mapStyle': '地図スタイル',
'settings.mapStylePlaceholder': 'Mapboxスタイルを選択',
'settings.mapStyleHint': 'プリセットまたは mapbox://styles/USER/ID のURL',
+3
View File
@@ -657,6 +657,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': '스타일을 선택하세요…',
'admin.defaultSettings.mapbox3d': '3D 건물 & 지형',
'admin.defaultSettings.mapboxQuality': '고품질 모드',
'admin.defaultSettings.cartoKey': '공유 CARTO 키',
'admin.defaultSettings.cartoKeyHint':
'자신의 키를 입력하지 않은 모든 사용자에게 사용됩니다. 이렇게 하면 인스턴스 전체에서 워터마크 없는 CARTO 타일을 사용할 수 있습니다. 암호화하여 저장됩니다.',
'admin.group.users': 'Users',
'admin.group.config': 'Configuration',
'admin.group.integration': 'Integrations',
+6
View File
@@ -33,6 +33,12 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'Mapbox 액세스 토큰',
'settings.mapMapboxTokenHint': '공개 토큰 (pk.*) 출처',
'settings.mapMapboxTokenLink': 'mapbox.com → 액세스 토큰',
'settings.mapCartoKey': 'CARTO API 키',
'settings.mapCartoKeyHint':
'CARTO 배경 지도는 키가 없으면 워터마크가 표시됩니다. 무료이며 계정도 필요 없습니다. 출처',
'settings.mapCartoKeyLink': 'carto.com 배경 지도 API 키',
'settings.mapCartoKeyMissing':
'이 템플릿은 CARTO 배경 지도입니다. 키가 없으면 CARTO가 모든 타일에 "API KEY REQUIRED"를 새깁니다.',
'settings.mapStyle': '지도 스타일',
'settings.mapStylePlaceholder': 'Mapbox 스타일 선택',
'settings.mapStyleHint': '프리셋 또는 mapbox://styles/USER/ID URL 직접 입력',
+3
View File
@@ -693,6 +693,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'Kies een stijl…',
'admin.defaultSettings.mapbox3d': '3D-gebouwen & terrein',
'admin.defaultSettings.mapboxQuality': 'Hogekwaliteitsmodus',
'admin.defaultSettings.cartoKey': 'Gedeelde CARTO-sleutel',
'admin.defaultSettings.cartoKeyHint':
'Wordt gebruikt voor elke gebruiker die nog geen eigen sleutel heeft ingevoerd, zo krijgt de hele instantie CARTO-tegels zonder watermerk. Versleuteld opgeslagen.',
'admin.group.users': 'Users',
'admin.group.config': 'Configuration',
'admin.group.integration': 'Integrations',
+5
View File
@@ -34,6 +34,11 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'Mapbox Access Token',
'settings.mapMapboxTokenHint': 'Openbaar token (pk.*) van',
'settings.mapMapboxTokenLink': 'mapbox.com → Access tokens',
'settings.mapCartoKey': 'CARTO API-sleutel',
'settings.mapCartoKeyHint': 'CARTO-basiskaarten tonen zonder sleutel een watermerk. Gratis en zonder account, via',
'settings.mapCartoKeyLink': 'carto.com basemap API-sleutel',
'settings.mapCartoKeyMissing':
'Deze sjabloon is een CARTO-basiskaart. Zonder sleutel drukt CARTO "API KEY REQUIRED" op elke tegel.',
'settings.mapStyle': 'Kaartstijl',
'settings.mapStylePlaceholder': 'Kies een Mapbox-stijl',
'settings.mapStyleHint': 'Preset of eigen mapbox://styles/USER/ID URL',
+3
View File
@@ -702,6 +702,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'Wybierz styl…',
'admin.defaultSettings.mapbox3d': 'Budynki i teren 3D',
'admin.defaultSettings.mapboxQuality': 'Tryb wysokiej jakości',
'admin.defaultSettings.cartoKey': 'Współdzielony klucz CARTO',
'admin.defaultSettings.cartoKeyHint':
'Używany dla każdego użytkownika, który nie wprowadził własnego klucza, dzięki czemu cała instancja otrzymuje kafelki CARTO bez znaku wodnego. Przechowywany w postaci zaszyfrowanej.',
'admin.group.users': 'Users',
'admin.group.config': 'Configuration',
'admin.group.integration': 'Integrations',
+5
View File
@@ -33,6 +33,11 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'Token dostępu Mapbox',
'settings.mapMapboxTokenHint': 'Token publiczny (pk.*) z',
'settings.mapMapboxTokenLink': 'mapbox.com → Tokeny dostępu',
'settings.mapCartoKey': 'Klucz API CARTO',
'settings.mapCartoKeyHint': 'Mapy podkładowe CARTO bez klucza pokazują znak wodny. Bezpłatny, bez konta, z',
'settings.mapCartoKeyLink': 'klucz API map podkładowych carto.com',
'settings.mapCartoKeyMissing':
'Ten szablon to mapa podkładowa CARTO. Bez klucza CARTO nanosi "API KEY REQUIRED" na każdy kafelek.',
'settings.mapStyle': 'Styl mapy',
'settings.mapStylePlaceholder': 'Wybierz styl Mapbox',
'settings.mapStyleHint': 'Preset lub własny URL mapbox://styles/USER/ID',
+3
View File
@@ -694,6 +694,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'Выберите стиль…',
'admin.defaultSettings.mapbox3d': '3D-здания и рельеф',
'admin.defaultSettings.mapboxQuality': 'Режим высокого качества',
'admin.defaultSettings.cartoKey': 'Общий ключ CARTO',
'admin.defaultSettings.cartoKeyHint':
'Используется для каждого пользователя, который не ввёл собственный ключ, так весь сервер получает тайлы CARTO без водяного знака. Хранится в зашифрованном виде.',
'admin.group.users': 'Users',
'admin.group.config': 'Configuration',
'admin.group.integration': 'Integrations',
+6
View File
@@ -33,6 +33,12 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'Токен доступа Mapbox',
'settings.mapMapboxTokenHint': 'Публичный токен (pk.*) с',
'settings.mapMapboxTokenLink': 'mapbox.com → Токены доступа',
'settings.mapCartoKey': 'Ключ API CARTO',
'settings.mapCartoKeyHint':
'Без ключа на базовых картах CARTO появляется водяной знак. Бесплатно и без учётной записи, на',
'settings.mapCartoKeyLink': 'ключ API базовых карт carto.com',
'settings.mapCartoKeyMissing':
'Этот шаблон является базовой картой CARTO. Без ключа CARTO наносит "API KEY REQUIRED" на каждый тайл.',
'settings.mapStyle': 'Стиль карты',
'settings.mapStylePlaceholder': 'Выберите стиль Mapbox',
'settings.mapStyleHint': 'Preset или собственный URL mapbox://styles/USER/ID',
+3
View File
@@ -201,6 +201,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'Välj en stil…',
'admin.defaultSettings.mapbox3d': '3D-byggnader och terräng',
'admin.defaultSettings.mapboxQuality': 'Högkvalitetsläge',
'admin.defaultSettings.cartoKey': 'Delad CARTO-nyckel',
'admin.defaultSettings.cartoKeyHint':
'Används för alla användare som inte har angett en egen nyckel, på så sätt får hela instansen CARTO-rutor utan vattenstämpel. Lagras i krypterad form.',
'admin.tabs.templates': 'Packningsmallar',
'admin.packingTemplates.title': 'Packningsmallar',
'admin.packingTemplates.subtitle': 'Skapa återanvändbara packlistor för dina resor',
+5
View File
@@ -36,6 +36,11 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'Mapbox-åtkomsttoken',
'settings.mapMapboxTokenHint': 'Offentlig token (pk.*) från',
'settings.mapMapboxTokenLink': 'mapbox.com → Åtkomsttoken',
'settings.mapCartoKey': 'CARTO API-nyckel',
'settings.mapCartoKeyHint': 'CARTO-bakgrundskartor visar en vattenstämpel utan nyckel. Gratis och utan konto, från',
'settings.mapCartoKeyLink': 'API-nyckel för bakgrundskartor på carto.com',
'settings.mapCartoKeyMissing':
'Den här mallen är en CARTO-bakgrundskarta. Utan nyckel stämplar CARTO "API KEY REQUIRED" på varje ruta.',
'settings.mapStyle': 'Kartstil',
'settings.mapStylePlaceholder': 'Välj en Mapbox-stil',
'settings.mapStyleHint': 'Förinställda eller egna mapbox://styles/USER/ID länk',
+3
View File
@@ -697,6 +697,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'Bir stil seçin…',
'admin.defaultSettings.mapbox3d': '3D binalar & arazi',
'admin.defaultSettings.mapboxQuality': 'Yüksek kalite modu',
'admin.defaultSettings.cartoKey': 'Paylaşılan CARTO anahtarı',
'admin.defaultSettings.cartoKeyHint':
'Kendi anahtarını girmemiş her kullanıcı için kullanılır, böylece tüm örnek filigransız CARTO döşemeleri alır. Şifrelenmiş olarak saklanır.',
'admin.group.users': 'Users',
'admin.group.config': 'Configuration',
'admin.group.integration': 'Integrations',
+6
View File
@@ -33,6 +33,12 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'Mapbox Erişim Anahtarı',
'settings.mapMapboxTokenHint': 'Genel anahtar (pk.*) kaynağı:',
'settings.mapMapboxTokenLink': 'Mapbox.com → Jetonlara erişim',
'settings.mapCartoKey': 'CARTO API anahtarı',
'settings.mapCartoKeyHint':
'CARTO temel haritaları anahtar olmadan filigran gösterir. Ücretsiz, hesap gerekmez, kaynağı:',
'settings.mapCartoKeyLink': 'carto.com temel harita API anahtarı',
'settings.mapCartoKeyMissing':
'Bu şablon bir CARTO temel haritası. Anahtar olmadan CARTO her karoya "API KEY REQUIRED" damgası basar.',
'settings.mapStyle': 'Harita Stili',
'settings.mapStylePlaceholder': 'Bir Mapbox stili seçin',
'settings.mapStyleHint': 'Ön ayar veya kendi mapbox://styles/KULLANICI/ID adresiniz',
+3
View File
@@ -691,6 +691,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'Виберіть стиль…',
'admin.defaultSettings.mapbox3d': '3D-будівлі та рельєф',
'admin.defaultSettings.mapboxQuality': 'Режим високої якості',
'admin.defaultSettings.cartoKey': 'Спільний ключ CARTO',
'admin.defaultSettings.cartoKeyHint':
'Використовується для кожного користувача, який не ввів власний ключ, щоб увесь екземпляр отримував плитки CARTO без водяного знака. Зберігається в зашифрованому вигляді.',
'admin.group.users': 'Users',
'admin.group.config': 'Configuration',
'admin.group.integration': 'Integrations',
+6
View File
@@ -34,6 +34,12 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'Токен доступу Mapbox',
'settings.mapMapboxTokenHint': 'Публічний токен (pk.*) з',
'settings.mapMapboxTokenLink': 'mapbox.com → Токени доступу',
'settings.mapCartoKey': 'Ключ API CARTO',
'settings.mapCartoKeyHint':
'Без ключа базові карти CARTO показують водяний знак. Безкоштовно та без облікового запису, на',
'settings.mapCartoKeyLink': 'ключ API базових карт carto.com',
'settings.mapCartoKeyMissing':
'Цей шаблон є базовою картою CARTO. Без ключа CARTO наносить "API KEY REQUIRED" на кожен тайл.',
'settings.mapStyle': 'Стиль карти',
'settings.mapStylePlaceholder': 'Виберіть стиль Mapbox',
'settings.mapStyleHint': 'Preset або власний URL mapbox://styles/USER/ID',
+3
View File
@@ -200,6 +200,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'Chọn một phong cách…',
'admin.defaultSettings.mapbox3d': 'Tòa nhà và địa hình 3D',
'admin.defaultSettings.mapboxQuality': 'Chế độ chất lượng cao',
'admin.defaultSettings.cartoKey': 'Khóa CARTO được chia sẻ',
'admin.defaultSettings.cartoKeyHint':
'Được sử dụng cho mọi người dùng chưa nhập khóa của riêng họ, vì vậy toàn bộ phiên bản nhận được ô bản đồ CARTO không có hình mờ. Được lưu trữ dưới dạng mã hóa.',
'admin.tabs.templates': 'Mẫu đóng gói',
'admin.packingTemplates.title': 'Mẫu đóng gói',
'admin.packingTemplates.subtitle': 'Tạo danh sách đóng gói có thể tái sử dụng cho chuyến đi của bạn',
+5
View File
@@ -34,6 +34,11 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'Mapbox Mã thông báo truy cập',
'settings.mapMapboxTokenHint': 'Mã thông báo công khai (pk.*) từ',
'settings.mapMapboxTokenLink': 'mapbox.com → Mã thông báo truy cập',
'settings.mapCartoKey': 'Khóa API CARTO',
'settings.mapCartoKeyHint': 'Bản đồ nền CARTO hiển thị hình mờ nếu không có khóa. Miễn phí, không cần tài khoản, từ',
'settings.mapCartoKeyLink': 'khóa API bản đồ nền carto.com',
'settings.mapCartoKeyMissing':
'Mẫu này là bản đồ nền CARTO. Không có khóa, CARTO in "API KEY REQUIRED" lên mọi ô bản đồ.',
'settings.mapStyle': 'Kiểu bản đồ',
'settings.mapStylePlaceholder': 'Chọn kiểu Mapbox',
'settings.mapStyleHint': 'Đặt trước hoặc của riêng bạn mapbox://styles/USER/ID URL',
+3
View File
@@ -635,6 +635,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': '選擇樣式…',
'admin.defaultSettings.mapbox3d': '3D 建築物與地形',
'admin.defaultSettings.mapboxQuality': '高品質模式',
'admin.defaultSettings.cartoKey': '共用的 CARTO 金鑰',
'admin.defaultSettings.cartoKeyHint':
'用於每一位尚未輸入自己金鑰的使用者,如此整個執行個體都能取得沒有浮水印的 CARTO 圖磚。以加密方式儲存。',
'admin.group.users': 'Users',
'admin.group.config': 'Configuration',
'admin.group.integration': 'Integrations',
+4
View File
@@ -33,6 +33,10 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'Mapbox 存取權杖',
'settings.mapMapboxTokenHint': '公開權杖 (pk.*) 來自',
'settings.mapMapboxTokenLink': 'mapbox.com → 存取權杖',
'settings.mapCartoKey': 'CARTO API 金鑰',
'settings.mapCartoKeyHint': '沒有金鑰時 CARTO 底圖會顯示浮水印。免費且無需帳戶,來自',
'settings.mapCartoKeyLink': 'carto.com 底圖 API 金鑰',
'settings.mapCartoKeyMissing': '此範本是 CARTO 底圖。沒有金鑰時,CARTO 會在每個圖磚上印上 "API KEY REQUIRED"。',
'settings.mapStyle': '地圖樣式',
'settings.mapStylePlaceholder': '選擇 Mapbox 樣式',
'settings.mapStyleHint': '預設或您自己的 mapbox://styles/USER/ID URL',
+3
View File
@@ -633,6 +633,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': '选择一种样式…',
'admin.defaultSettings.mapbox3d': '3D 建筑与地形',
'admin.defaultSettings.mapboxQuality': '高质量模式',
'admin.defaultSettings.cartoKey': '共享 CARTO 密钥',
'admin.defaultSettings.cartoKeyHint':
'用于所有未输入自己密钥的用户,这样整个实例都能获得无水印的 CARTO 瓦片。以加密方式存储。',
'admin.group.users': '用户',
'admin.group.config': '配置',
'admin.group.integration': '集成',
+4
View File
@@ -33,6 +33,10 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'Mapbox 访问令牌',
'settings.mapMapboxTokenHint': '公共令牌 (pk.*) 来自',
'settings.mapMapboxTokenLink': 'mapbox.com → 访问令牌',
'settings.mapCartoKey': 'CARTO API 密钥',
'settings.mapCartoKeyHint': '没有密钥时 CARTO 底图会显示水印。免费且无需账户,来自',
'settings.mapCartoKeyLink': 'carto.com 底图 API 密钥',
'settings.mapCartoKeyMissing': '此模板是 CARTO 底图。没有密钥时,CARTO 会在每个瓦片上打上 "API KEY REQUIRED"。',
'settings.mapStyle': '地图样式',
'settings.mapStylePlaceholder': '选择 Mapbox 样式',
'settings.mapStyleHint': '预设或您自己的 mapbox://styles/USER/ID URL',
+19
View File
@@ -40,6 +40,25 @@ You can also type any XYZ tile URL directly into the text field.
> **Admin:** The admin can set a default map tile URL for all new users via the **User Defaults** tab in the Admin Panel. See [Admin-Panel-Overview](Admin-Panel-Overview).
## CARTO API key
Since 26 August 2026 CARTO stamps an **API KEY REQUIRED** watermark onto every basemap tile fetched without a key, so
both CartoDB presets need one. The key is free and no CARTO account is required: request it at
[carto.com/basemaps/apikey](https://carto.com/basemaps/apikey/) with an email address, the domain you run TREK on and
a one-line description of your project. It arrives by mail, there is no approval queue. The free allowance is 5
million tile requests per calendar month.
Paste it into **CARTO API key** in Settings, Map. TREK appends it as `?key=...` to every CARTO tile request, which
covers the Trip Planner, Journey maps, Collections, Atlas, Studio and the offline tile download in one go. The key is
never stored inside the tile URL itself, so switching keys later does not break a saved template.
> **Admin:** **User Defaults** in the Admin Panel has the same field as **Shared CARTO key**. It applies to every user
> who has not entered one of their own, which clears the watermark for the whole instance at once. It is stored
> encrypted at rest.
Other providers are unaffected. If you would rather not register at all, switch the template to OpenStreetMap, which
needs no key. Note that the OSM tile servers do not permit bulk pre-downloading, so offline maps stay a CARTO feature.
## Mapbox GL — access token and style
Enter your **public token** (`pk.*`) from [mapbox.com → Access tokens](https://console.mapbox.com/account/access-tokens/).