From 1f66ef1d3266289c9ae2a751bd6092f72a36d2fb Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:06:55 +0200 Subject: [PATCH] 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 --- .../Admin/DefaultUserSettingsTab.test.tsx | 35 +++++- .../Admin/DefaultUserSettingsTab.tsx | 24 ++++ .../components/Collections/CollectionMap.tsx | 6 +- client/src/components/Journey/JourneyMap.tsx | 18 +-- .../components/Journey/MobileMapTimeline.tsx | 5 + client/src/components/Map/MapView.tsx | 6 +- .../Settings/MapSettingsTab.test.tsx | 71 ++++++++++- .../components/Settings/MapSettingsTab.tsx | 29 +++++ .../Studio/StudioTravelInspector.tsx | 4 +- .../src/components/Studio/TravelElements.tsx | 4 +- client/src/components/Studio/mapTiles.ts | 13 +- .../SystemNotices/SystemNoticeModal.tsx | 4 +- .../SystemNotices/releaseNotice.css | 4 +- .../SystemNotices/releaseNoticeCss.test.ts | 39 ++++++ client/src/constants/mapDefaults.ts | 8 ++ client/src/hooks/useTileUrl.test.ts | 94 ++++++++++++++ client/src/hooks/useTileUrl.ts | 21 ++++ client/src/index.css | 13 ++ .../admin/MAdminDefaultUserSettings.tsx | 26 +++- .../mobile/screens/settings/MSettingsMap.tsx | 34 ++++- client/src/pages/JourneyPublicPage.tsx | 8 +- client/src/pages/SharedTripPage.tsx | 10 +- client/src/pages/atlas/useAtlas.ts | 11 +- .../src/pages/tripPlanner/useTripPlanner.ts | 5 +- client/src/store/settingsStore.test.ts | 73 ++++++++++- client/src/store/settingsStore.ts | 21 +++- client/src/sync/tilePrefetcher.ts | 20 +-- client/src/sync/tripSyncManager.ts | 6 +- client/src/theme/README.md | 19 +++ client/src/types.ts | 2 + client/src/utils/tileUrl.test.ts | 118 +++++++++++++++++- client/src/utils/tileUrl.ts | 34 +++++ .../admin/MAdminDefaultUserSettings.test.tsx | 44 ++++++- .../mobile/settings/MSettingsMap.test.tsx | 58 +++++++++ client/vite.config.js | 5 +- server/scripts/migrate-encryption.ts | 2 +- server/src/app-config/README.md | 2 +- server/src/app-config/derive.ts | 2 + server/src/app-config/env.schema.ts | 1 + server/src/middleware/globalMiddleware.ts | 21 +++- server/src/nest/common/managed.ts | 6 + .../src/nest/journey/journey-domain.module.ts | 8 +- .../src/nest/journey/journey-share.service.ts | 13 ++ server/src/nest/settings/settings.service.ts | 18 ++- server/src/nest/share/share.service.ts | 24 +++- server/tests/helpers/mcp-test-controllers.ts | 2 +- server/tests/integration/share.test.ts | 21 ++++ .../globalMiddleware.redaction.test.ts | 16 +++ .../unit/nest/journey-share.service.test.ts | 19 ++- server/tests/unit/nest/managed-keys.test.ts | 1 + .../tests/unit/nest/settings.service.test.ts | 59 ++++++++- server/tests/unit/nest/share.service.test.ts | 9 ++ shared/src/i18n/ar/admin.ts | 3 + shared/src/i18n/ar/settings.ts | 5 + shared/src/i18n/br/admin.ts | 3 + shared/src/i18n/br/settings.ts | 5 + shared/src/i18n/ca/admin.ts | 3 + shared/src/i18n/ca/settings.ts | 6 + shared/src/i18n/cs/admin.ts | 3 + shared/src/i18n/cs/settings.ts | 5 + shared/src/i18n/de/admin.ts | 3 + shared/src/i18n/de/settings.ts | 5 + shared/src/i18n/en/admin.ts | 3 + shared/src/i18n/en/settings.ts | 5 + shared/src/i18n/es/admin.ts | 3 + shared/src/i18n/es/settings.ts | 5 + shared/src/i18n/fr/admin.ts | 3 + shared/src/i18n/fr/settings.ts | 6 + shared/src/i18n/gr/admin.ts | 3 + shared/src/i18n/gr/settings.ts | 6 + shared/src/i18n/hu/admin.ts | 3 + shared/src/i18n/hu/settings.ts | 6 + shared/src/i18n/id/admin.ts | 3 + shared/src/i18n/id/settings.ts | 5 + shared/src/i18n/it/admin.ts | 3 + shared/src/i18n/it/settings.ts | 5 + shared/src/i18n/ja/admin.ts | 3 + shared/src/i18n/ja/settings.ts | 5 + shared/src/i18n/ko/admin.ts | 3 + shared/src/i18n/ko/settings.ts | 6 + shared/src/i18n/nl/admin.ts | 3 + shared/src/i18n/nl/settings.ts | 5 + shared/src/i18n/pl/admin.ts | 3 + shared/src/i18n/pl/settings.ts | 5 + shared/src/i18n/ru/admin.ts | 3 + shared/src/i18n/ru/settings.ts | 6 + shared/src/i18n/sv/admin.ts | 3 + shared/src/i18n/sv/settings.ts | 5 + shared/src/i18n/tr/admin.ts | 3 + shared/src/i18n/tr/settings.ts | 6 + shared/src/i18n/uk/admin.ts | 3 + shared/src/i18n/uk/settings.ts | 6 + shared/src/i18n/vi/admin.ts | 3 + shared/src/i18n/vi/settings.ts | 5 + shared/src/i18n/zh-TW/admin.ts | 3 + shared/src/i18n/zh-TW/settings.ts | 4 + shared/src/i18n/zh/admin.ts | 3 + shared/src/i18n/zh/settings.ts | 4 + wiki/Map-Settings.md | 19 +++ 99 files changed, 1258 insertions(+), 67 deletions(-) create mode 100644 client/src/components/SystemNotices/releaseNoticeCss.test.ts create mode 100644 client/src/hooks/useTileUrl.test.ts create mode 100644 client/src/hooks/useTileUrl.ts diff --git a/client/src/components/Admin/DefaultUserSettingsTab.test.tsx b/client/src/components/Admin/DefaultUserSettingsTab.test.tsx index 8a1342c9e..2ed73a677 100644 --- a/client/src/components/Admin/DefaultUserSettingsTab.test.tsx +++ b/client/src/components/Admin/DefaultUserSettingsTab.test.tsx @@ -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, 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(); + 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(); + await screen.findByText('Default User Settings'); + + expect(screen.queryByText('Shared CARTO key')).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/Admin/DefaultUserSettingsTab.tsx b/client/src/components/Admin/DefaultUserSettingsTab.tsx index e73523c05..e9b098d52 100644 --- a/client/src/components/Admin/DefaultUserSettingsTab.tsx +++ b/client/src/components/Admin/DefaultUserSettingsTab.tsx @@ -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" />

{t('settings.mapDefaultHint')}

+ {/* 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 && ( +
+ + ) => 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" + /> +

{t('admin.defaultSettings.cartoKeyHint')}

+
+ )}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {React.createElement(MapView as any, { diff --git a/client/src/components/Collections/CollectionMap.tsx b/client/src/components/Collections/CollectionMap.tsx index 0c3b56780..d9b9db11e 100644 --- a/client/src/components/Collections/CollectionMap.tsx +++ b/client/src/components/Collections/CollectionMap.tsx @@ -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 (
diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx index 5e5e3f89a..0147a0ed5 100644 --- a/client/src/components/Journey/JourneyMap.tsx +++ b/client/src/components/Journey/JourneyMap.tsx @@ -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(null) const mapRef = useRef(null) const markersRef = useRef>(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: '© OpenStreetMap', 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(() => { diff --git a/client/src/components/Journey/MobileMapTimeline.tsx b/client/src/components/Journey/MobileMapTimeline.tsx index c118c97cf..05cd1a0d7 100644 --- a/client/src/components/Journey/MobileMapTimeline.tsx +++ b/client/src/components/Journey/MobileMapTimeline.tsx @@ -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(null) const carouselRef = useRef(null) @@ -168,6 +171,7 @@ export default function MobileMapTimeline({ dark={dark} onMarkerClick={handleMarkerClick} fullScreen + cartoApiKey={cartoApiKey} /> {!readOnly && onAddEntry && (
@@ -201,6 +205,7 @@ export default function MobileMapTimeline({ onMarkerClick={handleMarkerClick} fullScreen paddingBottom={200} + cartoApiKey={cartoApiKey} /> {/* Bottom carousel */} diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index f1b507b54..cbabfd6a9 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -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, diff --git a/client/src/components/Settings/MapSettingsTab.test.tsx b/client/src/components/Settings/MapSettingsTab.test.tsx index 867465fbf..f1c5c8558 100644 --- a/client/src/components/Settings/MapSettingsTab.test.tsx +++ b/client/src/components/Settings/MapSettingsTab.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + expect(screen.queryByText(/API KEY REQUIRED/)).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Settings/MapSettingsTab.tsx b/client/src/components/Settings/MapSettingsTab.tsx index 1f489e1ea..e22135f3d 100644 --- a/client/src/components/Settings/MapSettingsTab.tsx +++ b/client/src/components/Settings/MapSettingsTab.tsx @@ -164,6 +164,7 @@ export default function MapSettingsTab(): React.ReactElement { const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || '') const managed = useAuthStore((s) => s.managed) const [mapboxToken, setMapboxToken] = useState(settings.mapbox_access_token || '') + const [cartoKey, setCartoKey] = useState(settings.carto_api_key || '') const [mapboxStyle, setMapboxStyle] = useState(styleForProvider(initialProvider, slotStyle(initialProvider, settings))) const [mapbox3d, setMapbox3d] = useState(settings.mapbox_3d_enabled !== false) const [mapboxQuality, setMapboxQuality] = useState(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 (
@@ -321,6 +326,30 @@ export default function MapSettingsTab(): React.ReactElement {
)} + {/* Same deal as the Mapbox token: a managed install brings its own key. */} + {provider === 'leaflet' && !managed && ( +
+ + ) => 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" + /> +

+ {t('settings.mapCartoKeyHint')}{' '} + + {t('settings.mapCartoKeyLink')} + +

+ {cartoNeedsKey && ( +

{t('settings.mapCartoKeyMissing')}

+ )} +
+ )} + {/* GL settings */} {provider !== 'leaflet' && (
diff --git a/client/src/components/Studio/StudioTravelInspector.tsx b/client/src/components/Studio/StudioTravelInspector.tsx index 09be1556f..8754ed238 100644 --- a/client/src/components/Studio/StudioTravelInspector.tsx +++ b/client/src/components/Studio/StudioTravelInspector.tsx @@ -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) { } function MapProps({ el, set, t, stats }: Props & { 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 & { 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 diff --git a/client/src/components/Studio/TravelElements.tsx b/client/src/components/Studio/TravelElements.tsx index 4684fbb4c..181184590 100644 --- a/client/src/components/Studio/TravelElements.tsx +++ b/client/src/components/Studio/TravelElements.tsx @@ -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 /* diff --git a/client/src/components/Studio/mapTiles.ts b/client/src/components/Studio/mapTiles.ts index 34c3d8e5b..4efc8c1f3 100644 --- a/client/src/components/Studio/mapTiles.ts +++ b/client/src/components/Studio/mapTiles.ts @@ -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))) diff --git a/client/src/components/SystemNotices/SystemNoticeModal.tsx b/client/src/components/SystemNotices/SystemNoticeModal.tsx index 7565b62d6..756d17235 100644 --- a/client/src/components/SystemNotices/SystemNoticeModal.tsx +++ b/client/src/components/SystemNotices/SystemNoticeModal.tsx @@ -670,7 +670,7 @@ function MobileNoticeSheet(S: NoticeState) { const nextNotice = notices[idx + 1] ?? null; return ( -
+
{/* Screen-reader page announcements */} {pageAnnouncement} {/* Backdrop */} @@ -842,7 +842,7 @@ function DesktopNoticeModal(S: NoticeState) { return (
{/* Screen-reader page announcements */} diff --git a/client/src/components/SystemNotices/releaseNotice.css b/client/src/components/SystemNotices/releaseNotice.css index 273a93fee..0fe6d3f1a 100644 --- a/client/src/components/SystemNotices/releaseNotice.css +++ b/client/src/components/SystemNotices/releaseNotice.css @@ -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; diff --git a/client/src/components/SystemNotices/releaseNoticeCss.test.ts b/client/src/components/SystemNotices/releaseNoticeCss.test.ts new file mode 100644 index 000000000..19196a18b --- /dev/null +++ b/client/src/components/SystemNotices/releaseNoticeCss.test.ts @@ -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) + }) +}) diff --git a/client/src/constants/mapDefaults.ts b/client/src/constants/mapDefaults.ts index e62c22d64..627dad517 100644 --- a/client/src/constants/mapDefaults.ts +++ b/client/src/constants/mapDefaults.ts @@ -9,3 +9,11 @@ export const SATELLITE_TILE_URL = export const SATELLITE_TILE_ATTRIBUTION = 'Imagery © Esri, 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' diff --git a/client/src/hooks/useTileUrl.test.ts b/client/src/hooks/useTileUrl.test.ts new file mode 100644 index 000000000..28d1c8003 --- /dev/null +++ b/client/src/hooks/useTileUrl.test.ts @@ -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) { + 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') + }) +}) diff --git a/client/src/hooks/useTileUrl.ts b/client/src/hooks/useTileUrl.ts new file mode 100644 index 000000000..67940f903 --- /dev/null +++ b/client/src/hooks/useTileUrl.ts @@ -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) ?? '' +} diff --git a/client/src/index.css b/client/src/index.css index a0416cc5c..bb96789b2 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -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; diff --git a/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx b/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx index 51846fb88..dec74e984 100644 --- a/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx +++ b/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx @@ -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({}) 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 { /> + {/* 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 && ( + {t('admin.defaultSettings.cartoKey')} } + hint={t('admin.defaultSettings.cartoKeyHint')} + > + ) => setCartoKey(e.target.value)} + onBlur={() => save({ carto_api_key: cartoKey })} + spellCheck={false} + autoComplete="off" + /> + + )} + {/* Live tile preview */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} @@ -322,7 +346,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement { {mapProvider !== 'leaflet' && (
- {mapProvider === 'mapbox-gl' && ( + {mapProvider === 'mapbox-gl' && !managed && ( {t('admin.defaultSettings.mapboxToken')} } hint={t('admin.defaultSettings.mapboxTokenHint')} diff --git a/client/src/mobile/screens/settings/MSettingsMap.tsx b/client/src/mobile/screens/settings/MSettingsMap.tsx index ad918188e..8b9e5312a 100644 --- a/client/src/mobile/screens/settings/MSettingsMap.tsx +++ b/client/src/mobile/screens/settings/MSettingsMap.tsx @@ -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(initialProvider) const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || '') + const managed = useAuthStore((s) => s.managed) const [mapboxToken, setMapboxToken] = useState(settings.mapbox_access_token || '') + const [cartoKey, setCartoKey] = useState(settings.carto_api_key || '') const [mapboxStyle, setMapboxStyle] = useState(styleForProvider(initialProvider, slotStyle(initialProvider, settings))) const [mapbox3d, setMapbox3d] = useState(settings.mapbox_3d_enabled !== false) const [mapboxQuality, setMapboxQuality] = useState(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" /> {t('settings.mapDefaultHint')} + + {/* A managed install brings its own key, same as the Mapbox token below. */} + {!managed && ( + <> + {t('settings.mapCartoKey')} + ) => setCartoKey(e.target.value)} + spellCheck={false} + autoComplete="off" + /> + + {t('settings.mapCartoKeyHint')}{' '} + + {t('settings.mapCartoKeyLink')} + + + {cartoNeedsKey && ( +

+ {t('settings.mapCartoKeyMissing')} +

+ )} + + )} )} {provider !== 'leaflet' && ( <> - {provider === 'mapbox-gl' && ( + {provider === 'mapbox-gl' && !managed && ( <> {t('settings.mapMapboxToken')} setMapboxToken(e.target.value)} placeholder="pk.eyJ1Ijoi..." /> diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx index 0f9a4aa2a..42a119b8a 100644 --- a/client/src/pages/JourneyPublicPage.tsx +++ b/client/src/pages/JourneyPublicPage.tsx @@ -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} />
@@ -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 && (
- +
)}
diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index 19fbf87b3..d58e0001b 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -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 (
{/* Header */} @@ -482,7 +488,7 @@ export default function SharedTripPage() { style={{ width: '100%', height: '100%' }} > diff --git a/client/src/pages/atlas/useAtlas.ts b/client/src/pages/atlas/useAtlas.ts index 937b2f1e8..bf870c533 100644 --- a/client/src/pages/atlas/useAtlas.ts +++ b/client/src/pages/atlas/useAtlas.ts @@ -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(null) const mapInstance = useRef(null) const geoLayerRef = useRef(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(() => { diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 723eaca77..93172a5a1 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -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)" } diff --git a/client/src/store/settingsStore.test.ts b/client/src/store/settingsStore.test.ts index 77a20d521..3d9430461 100644 --- a/client/src/store/settingsStore.test.ts +++ b/client/src/store/settingsStore.test.ts @@ -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() + }) }) diff --git a/client/src/store/settingsStore.ts b/client/src/store/settingsStore.ts index 9ec2a5400..e559816b7 100644 --- a/client/src/store/settingsStore.ts +++ b/client/src/store/settingsStore.ts @@ -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 | null = null // typed by hand survive in the database until the next load put it back (#1733). function withNormalizedTileUrl>(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((set, get) => ({ @@ -105,7 +114,8 @@ export const useSettingsStore = create((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((set, get) => ({ updateSettings: async (settingsObj: Partial) => { 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 }, })) diff --git a/client/src/sync/tilePrefetcher.ts b/client/src/sync/tilePrefetcher.ts index a6d676c3d..4e5ecb84e 100644 --- a/client/src/sync/tilePrefetcher.ts +++ b/client/src/sync/tilePrefetcher.ts @@ -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 { 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 { 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) diff --git a/client/src/sync/tripSyncManager.ts b/client/src/sync/tripSyncManager.ts index 64de8af8b..83669a416 100644 --- a/client/src/sync/tripSyncManager.ts +++ b/client/src/sync/tripSyncManager.ts @@ -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) } } diff --git a/client/src/theme/README.md b/client/src/theme/README.md index 4e278cd7d..dd3e17592 100644 --- a/client/src/theme/README.md +++ b/client/src/theme/README.md @@ -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*`, diff --git a/client/src/types.ts b/client/src/types.ts index 9b6385630..5e8bd14c1 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -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 diff --git a/client/src/utils/tileUrl.test.ts b/client/src/utils/tileUrl.test.ts index 108a3a402..d91a10cd9 100644 --- a/client/src/utils/tileUrl.test.ts +++ b/client/src/utils/tileUrl.test.ts @@ -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) + }) +}) diff --git a/client/src/utils/tileUrl.ts b/client/src/utils/tileUrl.ts index c5576734a..108f39e19 100644 --- a/client/src/utils/tileUrl.ts +++ b/client/src/utils/tileUrl.ts @@ -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) +} diff --git a/client/tests/unit/mobile/admin/MAdminDefaultUserSettings.test.tsx b/client/tests/unit/mobile/admin/MAdminDefaultUserSettings.test.tsx index 84bb1c2a1..419ab5561 100644 --- a/client/tests/unit/mobile/admin/MAdminDefaultUserSettings.test.tsx +++ b/client/tests/unit/mobile/admin/MAdminDefaultUserSettings.test.tsx @@ -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(); + 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(); + 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(); + await screen.findByText('Map style'); + + expect(screen.queryByText('Shared Mapbox token')).not.toBeInTheDocument(); + }); }); diff --git a/client/tests/unit/mobile/settings/MSettingsMap.test.tsx b/client/tests/unit/mobile/settings/MSettingsMap.test.tsx index b183128fb..389b2a6b5 100644 --- a/client/tests/unit/mobile/settings/MSettingsMap.test.tsx +++ b/client/tests/unit/mobile/settings/MSettingsMap.test.tsx @@ -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 = {}, updateSettings = vi.fn().mockResolvedValue(undefined)) { seedStore(useSettingsStore, { @@ -39,6 +41,11 @@ function seedMap(over: Partial = {}, 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(); + }); }); diff --git a/client/vite.config.js b/client/vite.config.js index 99504105f..7acf3a4cd 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -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', diff --git a/server/scripts/migrate-encryption.ts b/server/scripts/migrate-encryption.ts index c00191d97..02f4e0c46 100644 --- a/server/scripts/migrate-encryption.ts +++ b/server/scripts/migrate-encryption.ts @@ -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 }[]; diff --git a/server/src/app-config/README.md b/server/src/app-config/README.md index 7244339ff..35412db7b 100644 --- a/server/src/app-config/README.md +++ b/server/src/app-config/README.md @@ -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, diff --git a/server/src/app-config/derive.ts b/server/src/app-config/derive.ts index a4b3d95d8..0f017f265 100644 --- a/server/src/app-config/derive.ts +++ b/server/src/app-config/derive.ts @@ -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, }; } diff --git a/server/src/app-config/env.schema.ts b/server/src/app-config/env.schema.ts index 4bf41ac3d..f2d61adbd 100644 --- a/server/src/app-config/env.schema.ts +++ b/server/src/app-config/env.schema.ts @@ -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, diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts index ec0a4b083..ca8c2bea8 100644 --- a/server/src/middleware/globalMiddleware.ts +++ b/server/src/middleware/globalMiddleware.ts @@ -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; + // PUT /api/settings names the setting instead of using it as the field: + // {key: 'carto_api_key', value: ''}. 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 = {}; - for (const [k, v] of Object.entries(value as Record)) { - 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 diff --git a/server/src/nest/common/managed.ts b/server/src/nest/common/managed.ts index 0f216e9fd..e1c265376 100644 --- a/server/src/nest/common/managed.ts +++ b/server/src/nest/common/managed.ts @@ -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', diff --git a/server/src/nest/journey/journey-domain.module.ts b/server/src/nest/journey/journey-domain.module.ts index de9c2bcbd..0a5f5b8a4 100644 --- a/server/src/nest/journey/journey-domain.module.ts +++ b/server/src/nest/journey/journey-domain.module.ts @@ -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], }) diff --git a/server/src/nest/journey/journey-share.service.ts b/server/src/nest/journey/journey-share.service.ts index e4a406717..e3a6f51e9 100644 --- a/server/src/nest/journey/journey-share.service.ts +++ b/server/src/nest/journey/journey-share.service.ts @@ -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, diff --git a/server/src/nest/settings/settings.service.ts b/server/src/nest/settings/settings.service.ts index fa887957e..135ba679b 100644 --- a/server/src/nest/settings/settings.service.ts +++ b/server/src/nest/settings/settings.service.ts @@ -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; } diff --git a/server/src/nest/share/share.service.ts b/server/src/nest/share/share.service.ts index e975164a7..33089a1e8 100644 --- a/server/src/nest/share/share.service.ts +++ b/server/src/nest/share/share.service.ts @@ -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 = 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, diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 2d61e04f3..4bd624c42 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -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 }, diff --git a/server/tests/integration/share.test.ts b/server/tests/integration/share.test.ts index 6cccb1eb6..fc3cd29cc 100644 --- a/server/tests/integration/share.test.ts +++ b/server/tests/integration/share.test.ts @@ -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`; diff --git a/server/tests/unit/middleware/globalMiddleware.redaction.test.ts b/server/tests/unit/middleware/globalMiddleware.redaction.test.ts index ed4116b43..b092a13f4 100644 --- a/server/tests/unit/middleware/globalMiddleware.redaction.test.ts +++ b/server/tests/unit/middleware/globalMiddleware.redaction.test.ts @@ -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 }); }); diff --git a/server/tests/unit/nest/journey-share.service.test.ts b/server/tests/unit/nest/journey-share.service.test.ts index d883e18ea..fe0acbece 100644 --- a/server/tests/unit/nest/journey-share.service.test.ts +++ b/server/tests/unit/nest/journey-share.service.test.ts @@ -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).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'); + }); }); diff --git a/server/tests/unit/nest/managed-keys.test.ts b/server/tests/unit/nest/managed-keys.test.ts index 6318afd92..8b4bb0986 100644 --- a/server/tests/unit/nest/managed-keys.test.ts +++ b/server/tests/unit/nest/managed-keys.test.ts @@ -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', diff --git a/server/tests/unit/nest/settings.service.test.ts b/server/tests/unit/nest/settings.service.test.ts index efc704c16..8df6e68ed 100644 --- a/server/tests/unit/nest/settings.service.test.ts +++ b/server/tests/unit/nest/settings.service.test.ts @@ -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 ───────────────────────────────────────────────────────────── diff --git a/server/tests/unit/nest/share.service.test.ts b/server/tests/unit/nest/share.service.test.ts index 168e4e371..00409482e 100644 --- a/server/tests/unit/nest/share.service.test.ts +++ b/server/tests/unit/nest/share.service.test.ts @@ -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 ─────────────────────────────────────────────────── diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index 0265093b6..4b622c200 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -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', diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index a3269385a..d3124b829 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -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 خاص بك', diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 32f3ea386..0e89ff2fd 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -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', diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index 1c33ca5ab..e777359cd 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -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', diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index df4084d65..5d5c13a51 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -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', diff --git a/shared/src/i18n/ca/settings.ts b/shared/src/i18n/ca/settings.ts index 7b16a8368..a45c956a2 100644 --- a/shared/src/i18n/ca/settings.ts +++ b/shared/src/i18n/ca/settings.ts @@ -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', diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index f69d9274b..7d0f8e0b9 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -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', diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index bb6a14574..6b6c78623 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -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', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index 075f7d649..b5687b842 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -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', diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index d6c1cdcd9..04035ab1e 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -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', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 0634b2ffd..c172c62ac 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -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', diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index eef9a0df3..6c9335f65 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -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', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index c76db1d92..5cedc9ee4 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -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', diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index 8f6dc2fe3..a973e7a4e 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -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', diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index af857033e..b31d87485 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -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', diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index 3b0a82326..78895bdb9 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -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', diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index 2ae99ca57..53994cd7d 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -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', diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index ee5747dea..35454b4fe 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -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', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index 24e6beaf6..42f8b906c 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -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', diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index 939080548..02ff6a04c 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -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', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index 2b441f399..01692f10a 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -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', diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index 4030048da..a8d4f40e7 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -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', diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index 29fd73d22..a0272b6e8 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -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', diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index 14c8b7869..b79296c4b 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -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', diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index fc54be6c5..ca97b1940 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -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', diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index e80fcff29..f7cc4ca5e 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -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', diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index fe36bad8e..85c764b3a 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -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', diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index b57e391e1..013f1098d 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -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 직접 입력', diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index b803a3e4d..00669739e 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -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', diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index 08b432989..0f26c2f30 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -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', diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index 59cccf9e9..49126c983 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -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', diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index 739cb3b49..498142f87 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -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', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index ef613decd..1fd66e9a1 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -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', diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index d27cf4940..acb6bd029 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -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', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index d222807cf..89185d771 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -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', diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts index 043d4d887..2d5efa1ec 100644 --- a/shared/src/i18n/sv/settings.ts +++ b/shared/src/i18n/sv/settings.ts @@ -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', diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index 21b427b28..842816a45 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -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', diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index 514045868..9afe7f038 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -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', diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index 42441282d..615692ea9 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -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', diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index 56c187931..9856aa90d 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -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', diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index e3c00665d..227c4a53c 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -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', diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts index c3f76fffd..d401e398a 100644 --- a/shared/src/i18n/vi/settings.ts +++ b/shared/src/i18n/vi/settings.ts @@ -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', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index 47d4845f3..989c16efc 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -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', diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index 7dada78e2..966954eb6 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -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', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index 4277fa72b..b6ba03fb1 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -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': '集成', diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index ecc12ad5f..23f544be3 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -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', diff --git a/wiki/Map-Settings.md b/wiki/Map-Settings.md index 0036b9e4e..91df1f237 100644 --- a/wiki/Map-Settings.md +++ b/wiki/Map-Settings.md @@ -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/).