mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
fix(maps): let users and admins enter a CARTO API key (#2060)
CARTO stamps an "API KEY REQUIRED" watermark onto every basemap tile fetched without a key since 26.08.2026, and both CartoDB presets plus the built-in fallback are CARTO. The key is free and needs no account, so add it as a setting rather than dropping the provider. carto_api_key sits next to mapbox_access_token: per user in Settings/Map, instance-wide under Admin/User Defaults, encrypted at rest, injected from CARTO_API_KEY on managed instances. Unlike Mapbox it never switches the map provider, since a raster key says nothing about the renderer. The key is appended as ?key= at render time instead of being stored in the template, so a saved URL stays portable and survives a rotation. That is also what reaches the four maps that pinned CARTO and ignored the user's tile setting: atlas, collections, the public trip share and the public journey. The two share pages read it from their token-gated payload, the same way baseCurrency already travels. Also lifts the release notice above page chrome. Its overlay sat at z-index 50, under the Vacay mode toolbar at 61, so the dialog was neither on top nor modal. Adds a six-step layer scale to index.css and moves the three notice shells onto it. Closes #2054 Closes #2052
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-025
|
||||
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-027
|
||||
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -63,6 +63,11 @@ function hasResetLink(label: string): boolean {
|
||||
return !!el && within(el).queryByRole('button') !== null;
|
||||
}
|
||||
|
||||
/** The CARTO key input sits under its label inside the field's own wrapper. */
|
||||
function cartoInput(): HTMLInputElement {
|
||||
return within(screen.getByText('Shared CARTO key').closest('div') as HTMLElement).getByRole('textbox');
|
||||
}
|
||||
|
||||
/** Opens a CustomSelect by its trigger label and picks an option from the portal. */
|
||||
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string) {
|
||||
await user.click(screen.getByRole('button', { name: trigger }));
|
||||
@@ -419,4 +424,32 @@ describe('DefaultUserSettingsTab', () => {
|
||||
expect(await screen.findByText(/Request failed with status code 503/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Reset to built-in default')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-026: the shared CARTO key is stored on blur and cleared by its reset link', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ carto_api_key: 'ck.old' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Shared CARTO key');
|
||||
|
||||
const input = cartoInput();
|
||||
expect(input).toHaveValue('ck.old');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'ck.new');
|
||||
fireEvent.blur(input);
|
||||
await waitFor(() => expect(puts).toEqual([{ carto_api_key: 'ck.new' }]));
|
||||
|
||||
// Clicking the reset link also blurs the field again, so only the last PUT is checked
|
||||
await user.click(resetLink('Shared CARTO key'));
|
||||
await waitFor(() => expect(puts[puts.length - 1]).toEqual({ carto_api_key: null }));
|
||||
await waitFor(() => expect(cartoInput()).toHaveValue(''));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-027: a managed instance hides the shared CARTO key field', async () => {
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), managed: true });
|
||||
stubDefaults({ carto_api_key: 'ck.hoster' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
expect(screen.queryByText('Shared CARTO key')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ type Defaults = {
|
||||
default_currency?: string
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
carto_api_key?: string
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
@@ -111,6 +112,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const [mapTileUrl, setMapTileUrl] = useState('')
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [cartoKey, setCartoKey] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -119,6 +121,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
setDefaults(data)
|
||||
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setCartoKey(data.carto_api_key || '')
|
||||
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
@@ -140,6 +143,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
setDefaults(updated)
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'carto_api_key') setCartoKey('')
|
||||
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
||||
const provider = normalizeProvider(defaults.map_provider)
|
||||
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
|
||||
@@ -335,6 +339,26 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.mapDefaultHint')}</p>
|
||||
{/* The key comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{!managed && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.cartoKey')}
|
||||
<ResetButton field="carto_api_key" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cartoKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCartoKey(e.target.value)}
|
||||
onBlur={() => save({ carto_api_key: cartoKey })}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
|
||||
@@ -2,6 +2,8 @@ import React from 'react'
|
||||
import { MapViewAuto } from '../Map/MapViewAuto'
|
||||
import type { CollectionPlace } from '@trek/shared'
|
||||
import { mappablePlaces } from '../../pages/collections/collectionsModel'
|
||||
import { useTileUrl } from '../../hooks/useTileUrl'
|
||||
import { CARTO_DARK, CARTO_LIGHT } from '../../constants/mapDefaults'
|
||||
|
||||
interface CollectionMapProps {
|
||||
places: CollectionPlace[]
|
||||
@@ -20,9 +22,7 @@ interface CollectionMapProps {
|
||||
*/
|
||||
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark }: CollectionMapProps): React.ReactElement {
|
||||
const pts = mappablePlaces(places)
|
||||
const tileUrl = dark
|
||||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||
const tileUrl = useTileUrl(dark ? CARTO_DARK : CARTO_LIGHT)
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%' }}>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react'
|
||||
import L from 'leaflet'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useCartoApiKey } from '../../hooks/useTileUrl'
|
||||
import { resolveTileUrl } from '../../utils/tileUrl'
|
||||
import { CARTO_DARK, CARTO_VOYAGER } from '../../constants/mapDefaults'
|
||||
import { escapeHtml, type JourneyTrack } from '@trek/shared'
|
||||
|
||||
export interface MapMarkerItem {
|
||||
@@ -94,6 +97,8 @@ interface Props {
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
paddingBottom?: number
|
||||
/** CARTO key from the share payload: the public journey has no settings store to read. */
|
||||
cartoApiKey?: string
|
||||
}
|
||||
|
||||
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
||||
@@ -142,11 +147,13 @@ const EMPTY_TRACKS: JourneyTrack[] = []
|
||||
const TRACK_FALLBACK_COLOR = '#4f46e5'
|
||||
|
||||
function JourneyMap(
|
||||
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, ref }: Props,
|
||||
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, ref }: Props,
|
||||
) {
|
||||
const stableTrail = trail || EMPTY_TRAIL
|
||||
const stableTracks = tracks || EMPTY_TRACKS
|
||||
const mapTileUrl = useSettingsStore(s => s.settings.map_tile_url)
|
||||
const storedCartoKey = useCartoApiKey()
|
||||
const cartoKey = cartoApiKey || storedCartoKey
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<L.Map | null>(null)
|
||||
const markersRef = useRef<Map<string, L.Marker>>(new Map())
|
||||
@@ -225,10 +232,7 @@ function JourneyMap(
|
||||
})
|
||||
mapRef.current = map
|
||||
|
||||
const defaultTile = dark
|
||||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'
|
||||
L.tileLayer(mapTileUrl || defaultTile, {
|
||||
L.tileLayer(resolveTileUrl(mapTileUrl, dark ? CARTO_DARK : CARTO_VOYAGER, cartoKey), {
|
||||
maxZoom: 18,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||
@@ -335,7 +339,7 @@ function JourneyMap(
|
||||
mapRef.current = null
|
||||
markersRef.current.clear()
|
||||
}
|
||||
}, [entries, stableTrail, stableTracks, dark, mapTileUrl, fullScreen, paddingBottom])
|
||||
}, [entries, stableTrail, stableTracks, dark, mapTileUrl, cartoKey, fullScreen, paddingBottom])
|
||||
|
||||
// Photo layer (#1614). Its own effect on purpose: photos arriving must not tear
|
||||
// down and rebuild the map the way the entry effect does. Redrawn on zoom and
|
||||
@@ -378,7 +382,7 @@ function JourneyMap(
|
||||
photoLayerRef.current?.remove()
|
||||
photoLayerRef.current = null
|
||||
}
|
||||
}, [photos, entries, stableTrail, stableTracks, dark, mapTileUrl, fullScreen, paddingBottom])
|
||||
}, [photos, entries, stableTrail, stableTracks, dark, mapTileUrl, cartoKey, fullScreen, paddingBottom])
|
||||
|
||||
// react to activeMarkerId prop changes — runs after map is built
|
||||
useEffect(() => {
|
||||
|
||||
@@ -27,6 +27,8 @@ interface Props {
|
||||
onAddEntry?: () => void
|
||||
publicPhotoUrl?: (photoId: number) => string
|
||||
carouselBottom?: string
|
||||
/** CARTO key from the share payload, forwarded to the map (#2054). */
|
||||
cartoApiKey?: string
|
||||
}
|
||||
|
||||
export default function MobileMapTimeline({
|
||||
@@ -40,6 +42,7 @@ export default function MobileMapTimeline({
|
||||
onAddEntry,
|
||||
publicPhotoUrl,
|
||||
carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)',
|
||||
cartoApiKey,
|
||||
}: Props) {
|
||||
const mapRef = useRef<JourneyMapHandle>(null)
|
||||
const carouselRef = useRef<HTMLDivElement>(null)
|
||||
@@ -168,6 +171,7 @@ export default function MobileMapTimeline({
|
||||
dark={dark}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
fullScreen
|
||||
cartoApiKey={cartoApiKey}
|
||||
/>
|
||||
{!readOnly && onAddEntry && (
|
||||
<div className="fixed right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 16px)' }}>
|
||||
@@ -201,6 +205,7 @@ export default function MobileMapTimeline({
|
||||
onMarkerClick={handleMarkerClick}
|
||||
fullScreen
|
||||
paddingBottom={200}
|
||||
cartoApiKey={cartoApiKey}
|
||||
/>
|
||||
|
||||
{/* Bottom carousel */}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { escapeHtml } from '@trek/shared'
|
||||
import type { Day, Reservation, RouteVia } from '../../types'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
|
||||
import { CARTO_LIGHT, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { MapLayerSwitcher } from './MapLayerSwitcher'
|
||||
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
|
||||
@@ -515,7 +515,9 @@ export const MapView = memo(function MapView({
|
||||
onMapContextMenu = null,
|
||||
center = DEFAULT_MAP_CENTER,
|
||||
zoom = DEFAULT_MAP_ZOOM,
|
||||
tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
||||
// Callers hand down a URL that already carries the CARTO key; this is only
|
||||
// the shape a caller without one gets.
|
||||
tileUrl = CARTO_LIGHT,
|
||||
fitKey = 0,
|
||||
dayOrderMap = {},
|
||||
leftWidth = 0,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-MAP-001 to FE-COMP-MAP-030
|
||||
// FE-COMP-MAP-001 to FE-COMP-MAP-035
|
||||
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
@@ -350,3 +350,72 @@ describe('MapSettingsTab – GL providers', () => {
|
||||
expect(screen.getByDisplayValue('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── CARTO key (031–035) ─────────────────────────────────────────────
|
||||
|
||||
const CARTO_DARK_TILES = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
||||
|
||||
/** The key input sits under its label inside the field's own wrapper. */
|
||||
function cartoInput(): HTMLInputElement {
|
||||
return within(screen.getByText('CARTO API key').closest('div') as HTMLElement).getByRole('textbox');
|
||||
}
|
||||
|
||||
describe('MapSettingsTab – CARTO key', () => {
|
||||
it('FE-COMP-MAP-031: the key field belongs to Leaflet and goes away with the GL providers', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MapSettingsTab />);
|
||||
|
||||
expect(screen.getByText('CARTO API key')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText('Mapbox GL'));
|
||||
|
||||
expect(screen.queryByText('CARTO API key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-032: a managed instance brings its own key, so the field is hidden', () => {
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, managed: true });
|
||||
render(<MapSettingsTab />);
|
||||
|
||||
expect(screen.queryByText('CARTO API key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-033: the typed key is part of the save patch', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ map_tile_url: CARTO_DARK_TILES }),
|
||||
updateSettings,
|
||||
});
|
||||
render(<MapSettingsTab />);
|
||||
|
||||
await user.type(cartoInput(), 'demo-key');
|
||||
await user.click(screen.getByText('Save Map'));
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ carto_api_key: 'demo-key' }));
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-034: a CARTO template without a key explains the watermark until one is typed', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ map_tile_url: CARTO_DARK_TILES }),
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
render(<MapSettingsTab />);
|
||||
|
||||
expect(screen.getByText(/API KEY REQUIRED/)).toBeInTheDocument();
|
||||
|
||||
await user.type(cartoInput(), 'demo-key');
|
||||
|
||||
expect(screen.queryByText(/API KEY REQUIRED/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-035: a template on another host never gets that notice', () => {
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ map_tile_url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' }),
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
render(<MapSettingsTab />);
|
||||
|
||||
expect(screen.queryByText(/API KEY REQUIRED/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,6 +164,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '')
|
||||
const [cartoKey, setCartoKey] = useState<string>(settings.carto_api_key || '')
|
||||
const [mapboxStyle, setMapboxStyle] = useState<string>(styleForProvider(initialProvider, slotStyle(initialProvider, settings)))
|
||||
const [mapbox3d, setMapbox3d] = useState<boolean>(settings.mapbox_3d_enabled !== false)
|
||||
const [mapboxQuality, setMapboxQuality] = useState<boolean>(settings.mapbox_quality_mode === true)
|
||||
@@ -175,6 +176,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
setProvider(nextProvider)
|
||||
setMapTileUrl(settings.map_tile_url || '')
|
||||
setMapboxToken(settings.mapbox_access_token || '')
|
||||
setCartoKey(settings.carto_api_key || '')
|
||||
setMapboxStyle(styleForProvider(nextProvider, slotStyle(nextProvider, settings)))
|
||||
setMapbox3d(settings.mapbox_3d_enabled !== false)
|
||||
setMapboxQuality(settings.mapbox_quality_mode === true)
|
||||
@@ -209,6 +211,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
map_provider: provider,
|
||||
map_tile_url: mapTileUrl,
|
||||
mapbox_access_token: mapboxToken,
|
||||
carto_api_key: cartoKey,
|
||||
...stylePatch,
|
||||
mapbox_3d_enabled: mapbox3d,
|
||||
mapbox_quality_mode: mapboxQuality,
|
||||
@@ -230,6 +233,8 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
setProvider(nextProvider)
|
||||
if (nextProvider !== 'leaflet') setMapboxStyle(styleForProvider(nextProvider, mapboxStyle))
|
||||
}
|
||||
// Only CARTO burns a watermark into keyless tiles, so the nudge is scoped to its hosts.
|
||||
const cartoNeedsKey = mapTileUrl.includes('basemaps.cartocdn.com') && !cartoKey.trim()
|
||||
|
||||
return (
|
||||
<Section title={t('settings.map')} icon={Map}>
|
||||
@@ -321,6 +326,30 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Same deal as the Mapbox token: a managed install brings its own key. */}
|
||||
{provider === 'leaflet' && !managed && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapCartoKey')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cartoKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCartoKey(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
{t('settings.mapCartoKeyHint')}{' '}
|
||||
<a href="https://carto.com/basemaps/apikey/" target="_blank" rel="noreferrer" className="underline">
|
||||
{t('settings.mapCartoKeyLink')}
|
||||
</a>
|
||||
</p>
|
||||
{cartoNeedsKey && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1">{t('settings.mapCartoKeyMissing')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GL settings */}
|
||||
{provider !== 'leaflet' && (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Swatches } from './StudioSwatches'
|
||||
import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts'
|
||||
import { sourceIdOf, useMapSources } from './mapSources'
|
||||
import { printDpi, tileView } from './mapTiles'
|
||||
import { useCartoApiKey } from '../../hooks/useTileUrl'
|
||||
import { routeFor } from './travelRefresh'
|
||||
import { Choice, Line, Picks, Section, Switch } from './StudioControls'
|
||||
import { fetchRoads } from './roadRoute'
|
||||
@@ -217,6 +218,7 @@ function ListProps({ el, set, t }: Props<BookListElement>) {
|
||||
}
|
||||
|
||||
function MapProps({ el, set, t, stats }: Props<BookMapElement> & { stats: JourneyStats | null }) {
|
||||
const cartoKey = useCartoApiKey()
|
||||
const [roadState, setRoadState] = useState<'idle' | 'busy'>('idle')
|
||||
const [roadDone, setRoadDone] = useState(0)
|
||||
const hasRoads = (el.roads ?? []).some(r => r && r.length > 1)
|
||||
@@ -248,7 +250,7 @@ function MapProps({ el, set, t, stats }: Props<BookMapElement> & { stats: Journe
|
||||
* say what it will print at. Same call the renderer makes, same answer.
|
||||
*/
|
||||
const view = el.source === 'tiles' && el.tileUrl
|
||||
? tileView(el.points, { w: el.frame.w, h: el.frame.h }, el.tileUrl, el.zoom)
|
||||
? tileView(el.points, { w: el.frame.w, h: el.frame.h }, el.tileUrl, el.zoom, undefined, 'print', cartoKey)
|
||||
: null
|
||||
const dpi = view ? printDpi(view, el.tileUrl) : null
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { fontStack } from './bookFonts'
|
||||
import { brightness } from './folioColour'
|
||||
import { COUNTRY_SHAPES, countryParts, countryWorldPath, projectMercator, unprojectMercator } from './countryShapes'
|
||||
import { projectOntoTiles, tileView, usableStaticUrl } from './mapTiles'
|
||||
import { useCartoApiKey } from '../../hooks/useTileUrl'
|
||||
import { FLAG_H, FLAG_W, flagBands, flagDisc, flagSpec } from './flags'
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -200,6 +201,7 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
/** False in a thumbnail, where a page of imagery would be a page of waste. */
|
||||
big?: boolean
|
||||
}) {
|
||||
const cartoKey = useCartoApiKey()
|
||||
const palette = MAP_PALETTES[el.style]
|
||||
// Per element: two cut maps on one spread sharing a stencil would both be cut
|
||||
// to whichever of them rendered last.
|
||||
@@ -591,7 +593,7 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
maxLat: topLeft.lat,
|
||||
maxLng: bottomRight.lng,
|
||||
minLat: bottomRight.lat,
|
||||
}, big ? 'print' : 'preview')
|
||||
}, big ? 'print' : 'preview', cartoKey)
|
||||
: null
|
||||
|
||||
/*
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
* placed so the renderer cannot lose it.
|
||||
*/
|
||||
|
||||
import { withTileApiKey } from '../../utils/tileUrl'
|
||||
|
||||
/** A slippy-map tile, in the standard XYZ scheme. */
|
||||
export interface Tile {
|
||||
x: number
|
||||
@@ -168,6 +170,14 @@ export function tileView(
|
||||
* tiles, deliberately soft, because nobody prints the pages rail.
|
||||
*/
|
||||
quality: 'print' | 'preview' = 'print',
|
||||
/**
|
||||
* The instance's CARTO key, for a book still on a CARTO template.
|
||||
*
|
||||
* Applied here rather than stored: keyless CARTO tiles carry a watermark
|
||||
* since 26.08.2026 (#2054), and a key frozen into the document would be
|
||||
* printed into every export and would outlive the key itself.
|
||||
*/
|
||||
cartoKey?: string,
|
||||
): TileView | null {
|
||||
if (!template || (!points.length && !extent)) return null
|
||||
|
||||
@@ -290,6 +300,7 @@ export function tileView(
|
||||
const originY = frame.h / 2 - (cy * scale - y0) * size
|
||||
|
||||
const tiles: Tile[] = []
|
||||
const source = withTileApiKey(template, cartoKey)
|
||||
const wrap = (n: number) => ((n % scale) + scale) % scale
|
||||
for (let y = y0; y <= y1; y++) {
|
||||
// Past the poles there is no tile — the row simply does not exist.
|
||||
@@ -299,7 +310,7 @@ export function tileView(
|
||||
x: x - x0,
|
||||
y: y - y0,
|
||||
z: zoom,
|
||||
url: template
|
||||
url: source
|
||||
.replace('{z}', String(zoom))
|
||||
// Longitude wraps, so a route across the antimeridian keeps its tiles.
|
||||
.replace('{x}', String(wrap(x)))
|
||||
|
||||
@@ -670,7 +670,7 @@ function MobileNoticeSheet(S: NoticeState) {
|
||||
const nextNotice = notices[idx + 1] ?? null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" role="presentation">
|
||||
<div className="fixed inset-0 z-[var(--z-notice)]" role="presentation">
|
||||
{/* Screen-reader page announcements */}
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{pageAnnouncement}</span>
|
||||
{/* Backdrop */}
|
||||
@@ -842,7 +842,7 @@ function DesktopNoticeModal(S: NoticeState) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
|
||||
className={`fixed inset-0 z-[var(--z-notice)] bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
|
||||
role="presentation"
|
||||
>
|
||||
{/* Screen-reader page announcements */}
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
/* Above the navbar, the page-bound sticky bars and the page-owned
|
||||
overlays: the notice is app-wide, so nothing a page draws may cover it. */
|
||||
z-index: var(--z-notice, 99998);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
// Nothing in a rendered test can see the notice's stacking order: jsdom applies
|
||||
// no author stylesheet and resolves no custom properties, so the two real files
|
||||
// are the only place the answer lives. Issue #2052 is exactly that gap, the
|
||||
// overlay sat at a bare 50 and the Vacay mode bar covered it.
|
||||
describe('release notice css', () => {
|
||||
// Vitest runs with the client package as its root, so cwd is stable here.
|
||||
const read = (rel: string): string => readFileSync(resolve(process.cwd(), rel), 'utf8')
|
||||
const notice = read('src/components/SystemNotices/releaseNotice.css')
|
||||
const tokens = read('src/index.css')
|
||||
|
||||
const block = (selector: string): string => {
|
||||
const at = notice.indexOf(selector)
|
||||
expect(at, `${selector} missing from releaseNotice.css`).toBeGreaterThan(-1)
|
||||
return notice.slice(at, notice.indexOf('}', at) + 1)
|
||||
}
|
||||
|
||||
const step = (name: string): number => {
|
||||
const found = new RegExp(`${name}:\\s*(\\d+);`).exec(tokens)
|
||||
expect(found, `${name} missing from index.css`).not.toBeNull()
|
||||
return Number(found![1])
|
||||
}
|
||||
|
||||
it('FE-COMP-RELEASENOTICECSS-001: the overlay stacks from the token, not a bare 50', () => {
|
||||
const overlay = block('.rn-overlay {')
|
||||
expect(overlay).toMatch(/z-index:\s*var\(--z-notice/)
|
||||
expect(overlay).not.toMatch(/z-index:\s*50\b/)
|
||||
})
|
||||
|
||||
it('FE-COMP-RELEASENOTICECSS-002: the notice step clears the navbar and the Vacay toolbar', () => {
|
||||
// The Vacay calendar's mode bar is sticky at 61 and no stacking context
|
||||
// separates the two, so it comes down to a plain number comparison.
|
||||
expect(step('--z-notice')).toBeGreaterThan(step('--z-nav'))
|
||||
expect(step('--z-notice')).toBeGreaterThan(61)
|
||||
})
|
||||
})
|
||||
@@ -9,3 +9,11 @@ export const SATELLITE_TILE_URL =
|
||||
export const SATELLITE_TILE_ATTRIBUTION =
|
||||
'Imagery © <a href="https://www.esri.com">Esri</a>, Maxar, Earthstar Geographics'
|
||||
export const SATELLITE_TILE_MAXZOOM = 19
|
||||
|
||||
// CARTO basemaps. Keyless tiles carry an "API KEY REQUIRED" watermark since
|
||||
// 26.08.2026, so these are always passed through withTileApiKey() (#2054).
|
||||
export const CARTO_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||
export const CARTO_DARK = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
export const CARTO_LIGHT_NOLABELS = 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png'
|
||||
export const CARTO_DARK_NOLABELS = 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png'
|
||||
export const CARTO_VOYAGER = 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useTileUrl, useCartoApiKey } from './useTileUrl'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { CARTO_LIGHT, CARTO_DARK } from '../constants/mapDefaults'
|
||||
import type { Settings } from '../types'
|
||||
|
||||
const OSM = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||
const SELF_HOSTED = 'https://tiles.example.com/{z}/{x}/{y}.png'
|
||||
|
||||
const initialState = useSettingsStore.getState()
|
||||
|
||||
function setSettings(patch: Partial<Settings>) {
|
||||
useSettingsStore.setState((s) => ({ settings: { ...s.settings, ...patch } }))
|
||||
}
|
||||
|
||||
describe('useTileUrl', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState(initialState, true)
|
||||
})
|
||||
|
||||
it('FE-TILEURL-001: falls back to the caller default while nothing is configured', () => {
|
||||
const { result } = renderHook(() => useTileUrl(OSM))
|
||||
expect(result.current).toBe(OSM)
|
||||
})
|
||||
|
||||
it('FE-TILEURL-002: the user template wins over the fallback', () => {
|
||||
setSettings({ map_tile_url: SELF_HOSTED })
|
||||
const { result } = renderHook(() => useTileUrl(CARTO_LIGHT))
|
||||
expect(result.current).toBe(SELF_HOSTED)
|
||||
})
|
||||
|
||||
it('FE-TILEURL-003: appends the CARTO key to a CARTO fallback', () => {
|
||||
setSettings({ carto_api_key: 'abc123' })
|
||||
const { result } = renderHook(() => useTileUrl(CARTO_LIGHT))
|
||||
expect(result.current).toBe(`${CARTO_LIGHT}?key=abc123`)
|
||||
})
|
||||
|
||||
it('FE-TILEURL-004: keeps the key away from a self-hosted template', () => {
|
||||
setSettings({ map_tile_url: SELF_HOSTED, carto_api_key: 'abc123' })
|
||||
const { result } = renderHook(() => useTileUrl(CARTO_LIGHT))
|
||||
expect(result.current).toBe(SELF_HOSTED)
|
||||
})
|
||||
|
||||
it('FE-TILEURL-005: ignoreUserTemplate pins the fallback but still gets the key', () => {
|
||||
// Atlas and collections pin a basemap of their own; they must not lose the
|
||||
// key just because they ignore map_tile_url.
|
||||
setSettings({ map_tile_url: SELF_HOSTED, carto_api_key: 'abc123' })
|
||||
const { result } = renderHook(() => useTileUrl(CARTO_DARK, true))
|
||||
expect(result.current).toBe(`${CARTO_DARK}?key=abc123`)
|
||||
})
|
||||
|
||||
it('FE-TILEURL-006: normalizes a legacy sharded OSM template', () => {
|
||||
setSettings({ map_tile_url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' })
|
||||
const { result } = renderHook(() => useTileUrl(CARTO_LIGHT))
|
||||
expect(result.current).toBe(OSM)
|
||||
})
|
||||
|
||||
it('FE-TILEURL-007: follows a key change without a remount', () => {
|
||||
setSettings({ carto_api_key: 'first' })
|
||||
const { result } = renderHook(() => useTileUrl(CARTO_LIGHT))
|
||||
expect(result.current).toBe(`${CARTO_LIGHT}?key=first`)
|
||||
|
||||
act(() => setSettings({ carto_api_key: 'second' }))
|
||||
expect(result.current).toBe(`${CARTO_LIGHT}?key=second`)
|
||||
|
||||
act(() => setSettings({ carto_api_key: '' }))
|
||||
expect(result.current).toBe(CARTO_LIGHT)
|
||||
})
|
||||
|
||||
it('FE-TILEURL-008: follows a template change without a remount', () => {
|
||||
const { result } = renderHook(() => useTileUrl(CARTO_LIGHT))
|
||||
act(() => setSettings({ map_tile_url: SELF_HOSTED }))
|
||||
expect(result.current).toBe(SELF_HOSTED)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCartoApiKey', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState(initialState, true)
|
||||
})
|
||||
|
||||
it('FE-TILEURL-009: reports an empty string while no key is stored', () => {
|
||||
setSettings({ carto_api_key: undefined })
|
||||
const { result } = renderHook(() => useCartoApiKey())
|
||||
expect(result.current).toBe('')
|
||||
})
|
||||
|
||||
it('FE-TILEURL-010: reports the stored key', () => {
|
||||
setSettings({ carto_api_key: 'abc123' })
|
||||
const { result } = renderHook(() => useCartoApiKey())
|
||||
expect(result.current).toBe('abc123')
|
||||
})
|
||||
})
|
||||
@@ -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) ?? ''
|
||||
}
|
||||
@@ -536,6 +536,19 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--nav-h: 0px;
|
||||
--bottom-nav-h: 0px;
|
||||
|
||||
/* Layer scale. Anything fixed or sticky picks a step from here instead of
|
||||
inventing a number, so two overlays can no longer land in the wrong order
|
||||
by accident. The steps are spaced so today's hardcoded z-indexes keep the
|
||||
position they already have; each of those moves onto its token whenever
|
||||
its file gets touched for other reasons. */
|
||||
--z-bar: 60; /* BottomNav, page-bound sticky bars */
|
||||
--z-nav: 200; /* the desktop navbar */
|
||||
--z-modal: 10000; /* shared/Modal, ConfirmDialog */
|
||||
--z-overlay: 99990; /* page-owned full-screen overlays */
|
||||
--z-notice: 99998; /* app-wide system and release notices */
|
||||
--z-toast: 100000; /* toasts, tooltips, context menus */
|
||||
|
||||
--font-system: 'Poppins', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
|
||||
/* Secondary "subtext"/caption tier renders in Geist; primary text + headings stay Poppins. */
|
||||
--font-subtext: 'Geist Sans', 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
styleSettingKey,
|
||||
type GlMapProvider,
|
||||
} from '../../../components/Map/glProviders'
|
||||
import { useAuthStore } from '../../../store/authStore'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import MSegmented from '../../components/MSegmented'
|
||||
import { MAdminCard, MAdminCardHead, MAdminField, MAdminInput, MAdminRow } from './MAdminUi'
|
||||
@@ -38,6 +39,7 @@ type Defaults = {
|
||||
default_currency?: string
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
carto_api_key?: string
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
@@ -67,7 +69,9 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
|
||||
const [defaults, setDefaults] = useState<Defaults>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [mapTileUrl, setMapTileUrl] = useState('')
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [cartoKey, setCartoKey] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
const [currencyOpen, setCurrencyOpen] = useState(false)
|
||||
const [presetOpen, setPresetOpen] = useState(false)
|
||||
@@ -79,6 +83,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
|
||||
setDefaults(data)
|
||||
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setCartoKey(data.carto_api_key || '')
|
||||
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
@@ -100,6 +105,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
|
||||
setDefaults(updated)
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'carto_api_key') setCartoKey('')
|
||||
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
||||
const provider = normalizeProvider(defaults.map_provider)
|
||||
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
|
||||
@@ -287,6 +293,24 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
|
||||
/>
|
||||
</MAdminField>
|
||||
|
||||
{/* The key comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{!managed && (
|
||||
<MAdminField
|
||||
label={<>{t('admin.defaultSettings.cartoKey')} <ResetButton field="carto_api_key" /></>}
|
||||
hint={t('admin.defaultSettings.cartoKeyHint')}
|
||||
>
|
||||
<MAdminInput
|
||||
type="text"
|
||||
value={cartoKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCartoKey(e.target.value)}
|
||||
onBlur={() => save({ carto_api_key: cartoKey })}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</MAdminField>
|
||||
)}
|
||||
|
||||
{/* Live tile preview */}
|
||||
<div className="relative h-[200px] w-full overflow-hidden rounded-xl">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
@@ -322,7 +346,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
|
||||
|
||||
{mapProvider !== 'leaflet' && (
|
||||
<div className="space-y-[14px]">
|
||||
{mapProvider === 'mapbox-gl' && (
|
||||
{mapProvider === 'mapbox-gl' && !managed && (
|
||||
<MAdminField
|
||||
label={<>{t('admin.defaultSettings.mapboxToken')} <ResetButton field="mapbox_access_token" /></>}
|
||||
hint={t('admin.defaultSettings.mapboxTokenHint')}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState, Suspense } from 'react'
|
||||
import { Box, Check, ChevronDown, Globe2, Layers, Map, Save } from 'lucide-react'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { useSettingsStore } from '../../../store/settingsStore'
|
||||
import { useAuthStore } from '../../../store/authStore'
|
||||
import { useToast } from '../../../components/shared/Toast'
|
||||
import { MapView } from '../../../components/Map/MapView'
|
||||
// Same as the desktop map settings tab: on demand, and paired with one engine.
|
||||
@@ -67,7 +68,9 @@ export default function MSettingsMap() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [provider, setProvider] = useState<Provider>(initialProvider)
|
||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '')
|
||||
const [cartoKey, setCartoKey] = useState<string>(settings.carto_api_key || '')
|
||||
const [mapboxStyle, setMapboxStyle] = useState<string>(styleForProvider(initialProvider, slotStyle(initialProvider, settings)))
|
||||
const [mapbox3d, setMapbox3d] = useState<boolean>(settings.mapbox_3d_enabled !== false)
|
||||
const [mapboxQuality, setMapboxQuality] = useState<boolean>(settings.mapbox_quality_mode === true)
|
||||
@@ -79,6 +82,7 @@ export default function MSettingsMap() {
|
||||
setProvider(nextProvider)
|
||||
setMapTileUrl(settings.map_tile_url || '')
|
||||
setMapboxToken(settings.mapbox_access_token || '')
|
||||
setCartoKey(settings.carto_api_key || '')
|
||||
setMapboxStyle(styleForProvider(nextProvider, slotStyle(nextProvider, settings)))
|
||||
setMapbox3d(settings.mapbox_3d_enabled !== false)
|
||||
setMapboxQuality(settings.mapbox_quality_mode === true)
|
||||
@@ -118,6 +122,7 @@ export default function MSettingsMap() {
|
||||
map_provider: provider,
|
||||
map_tile_url: mapTileUrl,
|
||||
mapbox_access_token: mapboxToken,
|
||||
carto_api_key: cartoKey,
|
||||
...stylePatch,
|
||||
mapbox_3d_enabled: mapbox3d,
|
||||
mapbox_quality_mode: mapboxQuality,
|
||||
@@ -134,6 +139,8 @@ export default function MSettingsMap() {
|
||||
setProvider(nextProvider)
|
||||
if (nextProvider !== 'leaflet') setMapboxStyle(styleForProvider(nextProvider, mapboxStyle))
|
||||
}
|
||||
// Only CARTO burns a watermark into keyless tiles, so the nudge is scoped to its hosts.
|
||||
const cartoNeedsKey = mapTileUrl.includes('basemaps.cartocdn.com') && !cartoKey.trim()
|
||||
|
||||
const providers: { id: Provider; name: string; sub: string; icon: typeof Layers }[] = [
|
||||
{ id: 'leaflet', name: 'Leaflet', sub: t('settings.mapLeafletSubtitle'), icon: Layers },
|
||||
@@ -193,12 +200,37 @@ export default function MSettingsMap() {
|
||||
placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<MSetHint>{t('settings.mapDefaultHint')}</MSetHint>
|
||||
|
||||
{/* A managed install brings its own key, same as the Mapbox token below. */}
|
||||
{!managed && (
|
||||
<>
|
||||
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.mapCartoKey')}</MSetEyebrow>
|
||||
<MSetInput
|
||||
mono
|
||||
value={cartoKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCartoKey(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<MSetHint>
|
||||
{t('settings.mapCartoKeyHint')}{' '}
|
||||
<a href="https://carto.com/basemaps/apikey/" target="_blank" rel="noreferrer" className="underline">
|
||||
{t('settings.mapCartoKeyLink')}
|
||||
</a>
|
||||
</MSetHint>
|
||||
{cartoNeedsKey && (
|
||||
<p className="mt-[6px] font-geist text-[0.625rem] leading-relaxed text-[color:var(--m-st-pending)]">
|
||||
{t('settings.mapCartoKeyMissing')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{provider !== 'leaflet' && (
|
||||
<>
|
||||
{provider === 'mapbox-gl' && (
|
||||
{provider === 'mapbox-gl' && !managed && (
|
||||
<>
|
||||
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.mapMapboxToken')}</MSetEyebrow>
|
||||
<MSetInput mono value={mapboxToken} onChange={(e) => setMapboxToken(e.target.value)} placeholder="pk.eyJ1Ijoi..." />
|
||||
|
||||
@@ -140,6 +140,10 @@ export default function JourneyPublicPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// A visitor of a share link has no settings of their own, so the CARTO key
|
||||
// travels in the payload; without it every tile carries the watermark.
|
||||
const cartoApiKey: string | undefined = data.cartoApiKey;
|
||||
|
||||
// In desktop two-column mode the map is always visible — exclude the standalone 'map' tab
|
||||
const availableViews = [
|
||||
perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
|
||||
@@ -842,6 +846,7 @@ export default function JourneyPublicPage() {
|
||||
fullScreen
|
||||
activeMarkerId={activeEntryId ?? undefined}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
cartoApiKey={cartoApiKey}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -885,6 +890,7 @@ export default function JourneyPublicPage() {
|
||||
onEntryClick={(entry) => setViewingEntry(entry as any)}
|
||||
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
|
||||
carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
|
||||
cartoApiKey={cartoApiKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -897,7 +903,7 @@ export default function JourneyPublicPage() {
|
||||
{/* Map (standalone tab — only in single-column mode) */}
|
||||
{view === 'map' && perms.share_map && (
|
||||
<div className="overflow-hidden rounded-2xl border border-zinc-200 dark:border-zinc-700">
|
||||
<JourneyMap checkins={[]} entries={sidebarMapItems as any} photos={photoLayer} height={500} />
|
||||
<JourneyMap checkins={[]} entries={sidebarMapItems as any} photos={photoLayer} height={500} cartoApiKey={cartoApiKey} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ import { createElement, useEffect, useRef } from 'react';
|
||||
import { renderIconMarkup } from '../utils/iconMarkup';
|
||||
import { MapContainer, Marker, Polyline, TileLayer, Tooltip, useMap } from 'react-leaflet';
|
||||
import { getCategoryIcon } from '../components/shared/categoryIcons';
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../constants/mapDefaults';
|
||||
import { CARTO_LIGHT, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../constants/mapDefaults';
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
import { avatarSrc } from '../utils/avatarSrc';
|
||||
@@ -29,6 +29,7 @@ import { isDayInAccommodationRange } from '../utils/dayOrder';
|
||||
import { getFlightLegs, getTrainLegs } from '../utils/flightLegs';
|
||||
import { splitReservationDateTime } from '../utils/formatters';
|
||||
import { computeMapViewport, TILE_SIZE_RASTER } from '../utils/mapViewport';
|
||||
import { resolveTileUrl } from '../utils/tileUrl';
|
||||
import { useSharedTrip } from './sharedTrip/useSharedTrip';
|
||||
|
||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship };
|
||||
@@ -150,6 +151,7 @@ export default function SharedTripPage() {
|
||||
categories,
|
||||
permissions,
|
||||
collab,
|
||||
cartoApiKey,
|
||||
} = data;
|
||||
const sortedDays = [...(days || [])].sort((a: any, b: any) => a.day_number - b.day_number);
|
||||
|
||||
@@ -183,6 +185,10 @@ export default function SharedTripPage() {
|
||||
});
|
||||
const initialView = framed ?? { center: DEFAULT_MAP_CENTER, zoom: DEFAULT_MAP_ZOOM };
|
||||
|
||||
// A visitor of a share link has no settings of their own, so the key travels in
|
||||
// the payload; without it CARTO stamps "API KEY REQUIRED" across every tile.
|
||||
const tileUrl = resolveTileUrl(null, CARTO_LIGHT, cartoApiKey);
|
||||
|
||||
return (
|
||||
<div className="bg-surface-secondary" style={{ minHeight: '100vh', fontFamily: 'var(--font-system)' }}>
|
||||
{/* Header */}
|
||||
@@ -482,7 +488,7 @@ export default function SharedTripPage() {
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||
url={tileUrl}
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
<FitBoundsToPlaces places={mapPlaces} framedOnMount={framed !== null} />
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useEffect, useMemo, useState, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTileUrl } from '../../hooks/useTileUrl'
|
||||
import { CARTO_DARK_NOLABELS, CARTO_LIGHT_NOLABELS } from '../../constants/mapDefaults'
|
||||
import apiClient, { mapsApi, pluginsApi, type PluginAtlasLayer } from '../../api/client'
|
||||
import L from 'leaflet'
|
||||
import type { GeoJsonFeatureCollection } from '../../types'
|
||||
@@ -79,6 +81,9 @@ export function useAtlas() {
|
||||
const resolveName = useCountryNames(language)
|
||||
const dm = settings.dark_mode
|
||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
// Label-free tiles on purpose (the country fills carry the names here), so the
|
||||
// user's own template is deliberately not read, only their CARTO key.
|
||||
const tileUrl = useTileUrl(dark ? CARTO_DARK_NOLABELS : CARTO_LIGHT_NOLABELS, true)
|
||||
const mapRef = useRef<HTMLDivElement>(null)
|
||||
const mapInstance = useRef<L.Map | null>(null)
|
||||
const geoLayerRef = useRef<L.GeoJSON | null>(null)
|
||||
@@ -362,10 +367,6 @@ export function useAtlas() {
|
||||
|
||||
L.control.zoom({ position: 'bottomright' }).addTo(map)
|
||||
|
||||
const tileUrl = dark
|
||||
? 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png'
|
||||
: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png'
|
||||
|
||||
L.tileLayer(tileUrl, {
|
||||
maxZoom: 10,
|
||||
keepBuffer: 25,
|
||||
@@ -443,7 +444,7 @@ export function useAtlas() {
|
||||
regionLayerRef.current = null
|
||||
renderedRegionSigRef.current = ''
|
||||
}
|
||||
}, [dark, loading])
|
||||
}, [dark, loading, tileUrl])
|
||||
|
||||
// Render GeoJSON countries
|
||||
useEffect(() => {
|
||||
|
||||
@@ -25,7 +25,8 @@ import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
||||
import { useIsTouch } from '../../hooks/useIsTouch'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
|
||||
import { DEFAULT_MAP_LAT, DEFAULT_MAP_LNG, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { CARTO_LIGHT, DEFAULT_MAP_LAT, DEFAULT_MAP_LNG, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { useTileUrl } from '../../hooks/useTileUrl'
|
||||
import { resolvePoolAssignmentId } from './tripPlannerModel'
|
||||
import { isDeepLinkableTripTab, TRIP_TAB_LABEL_KEYS } from '../../constants/tripTabs'
|
||||
import { isRoutableReservation } from '../../utils/reservationRoutes'
|
||||
@@ -1011,7 +1012,7 @@ export function useTripPlanner() {
|
||||
return da.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||
}, [selectedDayId, assignments])
|
||||
|
||||
const mapTileUrl = settings.map_tile_url || 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||
const mapTileUrl = useTileUrl(CARTO_LIGHT)
|
||||
|
||||
const fontStyle = { fontFamily: "var(--font-system)" }
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,8 @@ import type { Settings } from '../types'
|
||||
import { DEFAULT_APPEARANCE } from '@trek/shared'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '../i18n/supportedLanguages'
|
||||
import { normalizeTileUrl } from '../utils/tileUrl'
|
||||
import { normalizeTileUrl, stripTileApiKey } from '../utils/tileUrl'
|
||||
import { clearTileCache } from '../sync/tilePrefetcher'
|
||||
import { rememberStartDestination, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../utils/startDestination'
|
||||
|
||||
interface SettingsState {
|
||||
@@ -43,6 +44,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
map_provider: 'leaflet',
|
||||
map_base_layer: 'default',
|
||||
map_poi_pill_enabled: true,
|
||||
carto_api_key: '',
|
||||
mapbox_access_token: '',
|
||||
mapbox_style: 'mapbox://styles/mapbox/standard',
|
||||
maplibre_style: '',
|
||||
@@ -68,7 +70,14 @@ let _loadInFlight: Promise<void> | null = null
|
||||
// typed by hand survive in the database until the next load put it back (#1733).
|
||||
function withNormalizedTileUrl<T extends Partial<Settings>>(patch: T): T {
|
||||
if (typeof patch.map_tile_url !== 'string') return patch
|
||||
return { ...patch, map_tile_url: normalizeTileUrl(patch.map_tile_url) }
|
||||
return { ...patch, map_tile_url: cleanTileTemplate(patch.map_tile_url) }
|
||||
}
|
||||
|
||||
// A CARTO key pasted along with a full tile URL is dropped here: the key lives
|
||||
// in its own setting and is appended at render time, so leaving it in the
|
||||
// template would freeze it into the saved value and break on the next rotation.
|
||||
function cleanTileTemplate(url: string): string {
|
||||
return stripTileApiKey(normalizeTileUrl(url))
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
@@ -105,7 +114,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
|
||||
updateSetting: async (key: keyof Settings, value: Settings[keyof Settings]) => {
|
||||
const next =
|
||||
key === 'map_tile_url' && typeof value === 'string' ? normalizeTileUrl(value) : value
|
||||
key === 'map_tile_url' && typeof value === 'string' ? cleanTileTemplate(value) : value
|
||||
if (key === 'carto_api_key' && next !== get().settings.carto_api_key) void clearTileCache()
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, [key]: next },
|
||||
}))
|
||||
@@ -134,6 +144,11 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
|
||||
updateSettings: async (settingsObj: Partial<Settings>) => {
|
||||
const patch = withNormalizedTileUrl(settingsObj)
|
||||
// Cached tiles are keyed by their full URL, so a new key leaves the whole
|
||||
// offline cache stranded behind the old one.
|
||||
if ('carto_api_key' in patch && patch.carto_api_key !== get().settings.carto_api_key) {
|
||||
void clearTileCache()
|
||||
}
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, ...patch },
|
||||
}))
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
import type { Place } from '../types'
|
||||
import { offlineDb, upsertSyncMeta } from '../db/offlineDb'
|
||||
import { isAuthed } from './authGate'
|
||||
import { normalizeTileUrl } from '../utils/tileUrl'
|
||||
import { normalizeTileUrl, withTileApiKey } from '../utils/tileUrl'
|
||||
import { CARTO_LIGHT } from '../constants/mapDefaults'
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -50,8 +51,7 @@ export const TILE_CONCURRENCY = 6
|
||||
/** Name of the Workbox runtime cache holding map tiles (see vite.config.js). */
|
||||
const TILE_CACHE = 'map-tiles'
|
||||
|
||||
const DEFAULT_TILE_URL =
|
||||
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||
const DEFAULT_TILE_URL = CARTO_LIGHT
|
||||
|
||||
/**
|
||||
* Must stay identical to Leaflet's `subdomains` default ('abc'), because the
|
||||
@@ -158,9 +158,13 @@ export function countTiles(bbox: TileBbox, minZoom: number, maxZoom: number): nu
|
||||
* The template is normalized first: this function is also reached with a raw
|
||||
* admin default rather than the value from the settings store, so the OSM
|
||||
* sharding rewrite has to happen here too.
|
||||
*
|
||||
* The CARTO key is appended here as well, and it has to be: the Workbox cache is
|
||||
* keyed on the whole URL including the query, so a tile prefetched without the
|
||||
* key is a tile the map never asks for.
|
||||
*/
|
||||
export function buildTileUrl(template: string, z: number, x: number, y: number): string {
|
||||
return normalizeTileUrl(template)
|
||||
export function buildTileUrl(template: string, z: number, x: number, y: number, cartoKey?: string): string {
|
||||
return withTileApiKey(normalizeTileUrl(template), cartoKey)
|
||||
.replace('{z}', String(z))
|
||||
.replace('{x}', String(x))
|
||||
.replace('{y}', String(y))
|
||||
@@ -225,6 +229,7 @@ export async function prefetchTiles(
|
||||
tileUrlTemplate: string,
|
||||
minZoom = 10,
|
||||
maxZoom = 16,
|
||||
cartoKey?: string,
|
||||
): Promise<number> {
|
||||
if (!navigator.onLine) return 0
|
||||
if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) return 0
|
||||
@@ -246,7 +251,7 @@ export async function prefetchTiles(
|
||||
if (!navigator.onLine || !isAuthed()) return
|
||||
|
||||
const [z, x, y] = coords[cursor++]
|
||||
const url = buildTileUrl(tileUrlTemplate, z, x, y)
|
||||
const url = buildTileUrl(tileUrlTemplate, z, x, y, cartoKey)
|
||||
|
||||
if (cache && (await cache.match(url))) continue
|
||||
|
||||
@@ -312,6 +317,7 @@ export async function prefetchTilesForTrip(
|
||||
places: Place[],
|
||||
tileUrlTemplate?: string,
|
||||
force = false,
|
||||
cartoKey?: string,
|
||||
): Promise<void> {
|
||||
const template = tileUrlTemplate || DEFAULT_TILE_URL
|
||||
const bbox = computeBbox(places)
|
||||
@@ -333,7 +339,7 @@ export async function prefetchTilesForTrip(
|
||||
// tile providers that don't send CORS headers. To stop the browser evicting
|
||||
// these tiles under the inflated quota, we request persistent storage at app
|
||||
// init instead (sync/persistentStorage.ts).
|
||||
const fetched = await prefetchTiles(bbox, template, 10, 16)
|
||||
const fetched = await prefetchTiles(bbox, template, 10, 16, cartoKey)
|
||||
|
||||
// Update syncMeta with bbox and tile count
|
||||
const meta = await offlineDb.syncMeta.get(tripId)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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*`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,3 +22,37 @@ export function normalizeTileUrl(url: string): string {
|
||||
if (!url) return url
|
||||
return url.replace(OSM_SHARD, '$1tile.openstreetmap.org')
|
||||
}
|
||||
|
||||
/**
|
||||
* CARTO started watermarking keyless basemap tiles on 26.08.2026 (#2054). The
|
||||
* key rides along as a `?key=` query parameter, which every template engine in
|
||||
* the client passes through untouched, so it is appended here rather than baked
|
||||
* into the stored template: a saved URL stays portable and survives a key
|
||||
* change. Only CARTO hosts are touched; OSM and self-hosted templates are not.
|
||||
*/
|
||||
const CARTO_HOST = /^(?:\{s\}|[a-d])?\.?basemaps\.cartocdn\.com$/i
|
||||
|
||||
function templateHost(url: string): string {
|
||||
return url.replace(/^\w*:?\/\//, '').split(/[/?#]/)[0]
|
||||
}
|
||||
|
||||
export function withTileApiKey(url: string, key?: string | null): string {
|
||||
if (!url || !key) return url
|
||||
if (!CARTO_HOST.test(templateHost(url))) return url
|
||||
if (/[?&]key=/.test(url)) return url
|
||||
return `${url}${url.includes('?') ? '&' : '?'}key=${encodeURIComponent(key)}`
|
||||
}
|
||||
|
||||
/** Keeps the key out of anything we persist: stored templates, book documents. */
|
||||
export function stripTileApiKey(url: string): string {
|
||||
if (!url || !/[?&]key=/.test(url)) return url
|
||||
return url.replace(/([?&])key=[^&]*&?/, '$1').replace(/[?&]$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* A blank template means "not configured", not "no tiles": the settings
|
||||
* previews save an empty string and would otherwise render grey.
|
||||
*/
|
||||
export function resolveTileUrl(template: string | null | undefined, fallback: string, cartoKey?: string | null): string {
|
||||
return withTileApiKey(normalizeTileUrl(template?.trim() || fallback), cartoKey)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-MOB-MDUS-001 to FE-MOB-MDUS-024
|
||||
// FE-MOB-MDUS-001 to FE-MOB-MDUS-027
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, waitFor, within, fireEvent } from '../../../helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
@@ -48,6 +48,11 @@ function resetLink(label: string): HTMLElement {
|
||||
return within(holder).getByRole('button', { name: 'reset' });
|
||||
}
|
||||
|
||||
/** The CARTO key input sits under its label inside the field's own wrapper. */
|
||||
function cartoInput(): HTMLInputElement {
|
||||
return within(screen.getByText('Shared CARTO key').parentElement as HTMLElement).getByRole('textbox');
|
||||
}
|
||||
|
||||
describe('MAdminDefaultUserSettings', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
@@ -376,4 +381,41 @@ describe('MAdminDefaultUserSettings', () => {
|
||||
expect(screen.queryByText('Reset to built-in default')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: '°C Celsius' })).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-025: the shared CARTO key is stored on blur and cleared by its reset link', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ carto_api_key: 'ck.old' });
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Shared CARTO key');
|
||||
|
||||
const input = cartoInput();
|
||||
expect(input).toHaveValue('ck.old');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'ck.new');
|
||||
input.blur();
|
||||
await waitFor(() => expect(puts).toEqual([{ carto_api_key: 'ck.new' }]));
|
||||
|
||||
await user.click(resetLink('Shared CARTO key'));
|
||||
await waitFor(() => expect(puts).toHaveLength(2));
|
||||
expect(puts[1]).toEqual({ carto_api_key: null });
|
||||
await waitFor(() => expect(cartoInput()).toHaveValue(''));
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-026: a managed instance brings its own CARTO key and hides the field', async () => {
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), managed: true });
|
||||
stubDefaults({ carto_api_key: 'ck.hoster' });
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
expect(screen.queryByText('Shared CARTO key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MDUS-027: a managed instance hides the shared Mapbox token as well', async () => {
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), managed: true });
|
||||
stubDefaults({ map_provider: 'mapbox-gl', mapbox_access_token: 'pk.hoster' });
|
||||
render(<MAdminDefaultUserSettings />);
|
||||
await screen.findByText('Map style');
|
||||
|
||||
expect(screen.queryByText('Shared Mapbox token')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { render, screen, waitFor } from '../../../helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { buildSettings } from '../../../helpers/factories';
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore';
|
||||
import { useAuthStore } from '../../../../src/store/authStore';
|
||||
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
||||
import type { Settings } from '../../../../src/types';
|
||||
import MSettingsMap from '../../../../src/mobile/screens/settings/MSettingsMap';
|
||||
@@ -30,6 +31,7 @@ vi.mock('../../../../src/components/Settings/MapboxPreview', () => ({
|
||||
}));
|
||||
|
||||
const OSM_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
const CARTO_DARK_TILES = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
||||
|
||||
function seedMap(over: Partial<Settings> = {}, updateSettings = vi.fn().mockResolvedValue(undefined)) {
|
||||
seedStore(useSettingsStore, {
|
||||
@@ -39,6 +41,11 @@ function seedMap(over: Partial<Settings> = {}, updateSettings = vi.fn().mockReso
|
||||
return updateSettings;
|
||||
}
|
||||
|
||||
/** The CARTO key input follows its eyebrow label in the card's flat layout. */
|
||||
function cartoInput(): HTMLInputElement {
|
||||
return screen.getByText('CARTO API key').nextElementSibling as HTMLInputElement;
|
||||
}
|
||||
|
||||
function renderMap() {
|
||||
return render(
|
||||
<>
|
||||
@@ -188,6 +195,7 @@ describe('MSettingsMap', () => {
|
||||
map_provider: 'leaflet',
|
||||
map_tile_url: OSM_URL,
|
||||
mapbox_access_token: '',
|
||||
carto_api_key: '',
|
||||
mapbox_style: 'mapbox://styles/mapbox/dark-v11',
|
||||
mapbox_3d_enabled: true,
|
||||
mapbox_quality_mode: false,
|
||||
@@ -272,4 +280,54 @@ describe('MSettingsMap', () => {
|
||||
|
||||
expect(await screen.findByTestId('gl-preview')).toHaveAttribute('data-style', 'https://tiles.openfreemap.org/styles/liberty');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-020: the Leaflet branch offers the CARTO key field', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMap();
|
||||
|
||||
expect(screen.getByText('CARTO API key')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText('Mapbox GL'));
|
||||
|
||||
expect(screen.queryByText('CARTO API key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-021: a managed instance brings its own key and hides the field', () => {
|
||||
seedStore(useAuthStore, { managed: true });
|
||||
renderMap();
|
||||
|
||||
expect(screen.queryByText('CARTO API key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-022: a managed instance hides the Mapbox token field too', () => {
|
||||
seedStore(useAuthStore, { managed: true });
|
||||
seedMap({ map_provider: 'mapbox-gl' });
|
||||
renderMap();
|
||||
|
||||
expect(screen.queryByPlaceholderText('pk.eyJ1Ijoi...')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Mapbox Standard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-023: the typed CARTO key reaches the save payload', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = seedMap();
|
||||
renderMap();
|
||||
|
||||
await user.type(cartoInput(), 'demo-key');
|
||||
await user.click(screen.getByRole('button', { name: 'Save Map' }));
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ carto_api_key: 'demo-key' }));
|
||||
});
|
||||
|
||||
it('FE-MOB-SETMAP-024: a CARTO template without a key explains the watermark until one is typed', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedMap({ map_tile_url: CARTO_DARK_TILES });
|
||||
renderMap();
|
||||
|
||||
expect(screen.getByText(/API KEY REQUIRED/)).toBeInTheDocument();
|
||||
|
||||
await user.type(cartoInput(), 'demo-key');
|
||||
|
||||
expect(screen.queryByText(/API KEY REQUIRED/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }[];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -38,15 +38,23 @@ export const SENSITIVE_KEYS = new Set([
|
||||
*/
|
||||
const SENSITIVE_SUFFIXES = ['_token', '_key', '_secret', '_password', '_pass', '_verifier'];
|
||||
|
||||
function isSensitiveName(name: string): boolean {
|
||||
const lower = name.toLowerCase();
|
||||
return SENSITIVE_KEYS.has(lower) || SENSITIVE_SUFFIXES.some((suffix) => lower.endsWith(suffix));
|
||||
}
|
||||
|
||||
/** Deep-redacts every key in `SENSITIVE_KEYS` (case-insensitive) from a request-log value. */
|
||||
export function redact(value: unknown): unknown {
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
if (Array.isArray(value)) return (value as unknown[]).map(redact);
|
||||
const entries = value as Record<string, unknown>;
|
||||
// PUT /api/settings names the setting instead of using it as the field:
|
||||
// {key: 'carto_api_key', value: '<secret>'}. Neither field name is sensitive
|
||||
// on its own, so without this the secret goes into the debug log in cleartext.
|
||||
const namedSecret = typeof entries.key === 'string' && isSensitiveName(entries.key);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
const lower = k.toLowerCase();
|
||||
const sensitive = SENSITIVE_KEYS.has(lower) || SENSITIVE_SUFFIXES.some((suffix) => lower.endsWith(suffix));
|
||||
out[k] = sensitive ? '[REDACTED]' : redact(v);
|
||||
for (const [k, v] of Object.entries(entries)) {
|
||||
out[k] = isSensitiveName(k) || (namedSecret && k === 'value') ? '[REDACTED]' : redact(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -181,7 +189,10 @@ export function applyGlobalMiddleware(
|
||||
"https://nominatim.openstreetmap.org", "https://overpass-api.de",
|
||||
"https://places.googleapis.com", "https://api.openweathermap.org",
|
||||
"https://en.wikipedia.org", "https://commons.wikimedia.org",
|
||||
"https://*.basemaps.cartocdn.com",
|
||||
// Both forms here too: CARTO documents the apex host on its key page,
|
||||
// so that is the template users paste in, and the {s} sharded form is
|
||||
// what TREK ships (#2054).
|
||||
"https://basemaps.cartocdn.com", "https://*.basemaps.cartocdn.com",
|
||||
// Both forms: a CSP wildcard host never matches the apex, and OSM
|
||||
// serves everything from the bare tile.openstreetmap.org since it
|
||||
// retired the a/b/c/d shards (#1733). The sharded hosts stay listed
|
||||
|
||||
@@ -70,8 +70,14 @@ export function isManagedBlocked(env: RuntimeEnvService): boolean {
|
||||
* the one that reaches the browser: a managed instance ships with the operator's
|
||||
* public pk.* token, injected when the settings are read, and a per-user value
|
||||
* saved over it would only break the map for that user.
|
||||
*
|
||||
* carto_api_key is the same shape, injected on read and public in the browser,
|
||||
* and it is the operator's for one more reason: the key is registered to
|
||||
* whoever runs the instance, and CARTO's terms hold that account answerable for
|
||||
* the tiles it fetches.
|
||||
*/
|
||||
export const MANAGED_LOCKED_SETTING_KEYS = [
|
||||
'carto_api_key',
|
||||
'llm_api_key',
|
||||
'llm_base_url',
|
||||
'llm_model',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { JourneyDomainService } from './journey-domain.service';
|
||||
import { JournalRpc } from './journal.rpc';
|
||||
import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
|
||||
import { JourneyShareService } from './journey-share.service';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
|
||||
/**
|
||||
* Leaf module holding the journey domain itself, without the controllers.
|
||||
@@ -16,9 +17,14 @@ import { JourneyShareService } from './journey-share.service';
|
||||
*
|
||||
* RealtimeModule is imported explicitly even though it is @Global, so a
|
||||
* single-domain e2e TestingModule can still resolve the broadcast.
|
||||
*
|
||||
* SettingsModule is the one addition to that shape: the public journey payload
|
||||
* carries the owner's CARTO tile key, and resolving that (per-user value, admin
|
||||
* instance default, managed-instance key, all decrypted) is SettingsService's
|
||||
* job rather than a second copy of its SQL here.
|
||||
*/
|
||||
@Module({
|
||||
imports: [RealtimeModule, TrekPhotosModule, PluginGuardsModule],
|
||||
imports: [RealtimeModule, TrekPhotosModule, PluginGuardsModule, SettingsModule],
|
||||
providers: [JourneyDomainService, JourneyShareService, JournalRpc],
|
||||
exports: [JourneyDomainService, JourneyShareService],
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import crypto from 'crypto';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { JourneyDomainService } from './journey-domain.service';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
|
||||
interface JourneySharePermissions {
|
||||
share_timeline?: boolean;
|
||||
@@ -33,6 +34,7 @@ export class JourneyShareService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly journey: JourneyDomainService,
|
||||
private readonly settings: SettingsService,
|
||||
) {}
|
||||
|
||||
createOrUpdateJourneyShareLink(
|
||||
@@ -238,6 +240,16 @@ export class JourneyShareService {
|
||||
}));
|
||||
}
|
||||
|
||||
// Same reason as the trip share payload: CARTO watermarks every tile fetched
|
||||
// without a key (#2054) and the public journey map has no logged-in user to
|
||||
// read one from, so the owner's key travels with it. Only a valid share token
|
||||
// gets this far. getUserSettings composes the owner's own value, the admin
|
||||
// instance default and the managed-instance key in that order; carto_api_key is
|
||||
// encrypted at rest but deliberately unmasked, since it is useless until it
|
||||
// reaches a browser.
|
||||
const ownerCartoKey = this.settings.getUserSettings(journey.user_id)['carto_api_key'];
|
||||
const cartoApiKey = typeof ownerCartoKey === 'string' ? ownerCartoKey.trim() : '';
|
||||
|
||||
return {
|
||||
journey: {
|
||||
title: journey.title,
|
||||
@@ -252,6 +264,7 @@ export class JourneyShareService {
|
||||
// would hand out places the map was deliberately turned off for.
|
||||
gallery: shareGallery ? (shareMap ? gallery : stripPhotoGps(gallery)) : [],
|
||||
stats,
|
||||
cartoApiKey,
|
||||
permissions: {
|
||||
share_timeline: shareTimeline,
|
||||
share_gallery: shareGallery,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -280,15 +280,27 @@ export class ShareService {
|
||||
// honours per-user → admin-default; we then fall back to trip currency → EUR
|
||||
// (`||` on purpose: an empty-string trip currency also falls back).
|
||||
let baseCurrency = (trip as { currency?: string }).currency || 'EUR';
|
||||
if (shareRow.created_by != null) {
|
||||
const ownerDefault = this.settings.getUserSettings(shareRow.created_by)['default_currency'];
|
||||
if (typeof ownerDefault === 'string' && ownerDefault.trim()) {
|
||||
baseCurrency = ownerDefault.trim();
|
||||
}
|
||||
const ownerSettings: Record<string, unknown> = shareRow.created_by != null
|
||||
? this.settings.getUserSettings(shareRow.created_by)
|
||||
: {};
|
||||
const ownerDefault = ownerSettings['default_currency'];
|
||||
if (typeof ownerDefault === 'string' && ownerDefault.trim()) {
|
||||
baseCurrency = ownerDefault.trim();
|
||||
}
|
||||
|
||||
// CARTO stamps an "API KEY REQUIRED" watermark into every tile fetched without
|
||||
// a key (#2054), and a public viewer has no settings of their own to hold one,
|
||||
// so the owner's key travels in this payload. Nothing without a valid share
|
||||
// token reaches it, and the key is public in the browser anyway. getUserSettings
|
||||
// is the right accessor here even though it is the client-facing one:
|
||||
// carto_api_key is encrypted at rest but deliberately unmasked (same as the
|
||||
// Mapbox token, both have to reach a browser), and it is the only accessor that
|
||||
// composes per-user value → admin instance default → managed-instance key.
|
||||
const ownerCartoKey = ownerSettings['carto_api_key'];
|
||||
const cartoApiKey = typeof ownerCartoKey === 'string' ? ownerCartoKey.trim() : '';
|
||||
|
||||
return {
|
||||
trip, baseCurrency, categories, permissions,
|
||||
trip, baseCurrency, cartoApiKey, categories, permissions,
|
||||
days, assignments, dayNotes, places,
|
||||
reservations, accommodations,
|
||||
packing, budget,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -524,6 +524,27 @@ describe('Shared trip — display currency (issue #1361)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shared trip: CARTO tile key (issue #2054)', () => {
|
||||
it('SHARE-029: the payload carries the owner\'s carto_api_key, behind it the admin instance default', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_carto_api_key', 'instance-key')").run();
|
||||
|
||||
const { body: { token } } = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ share_map: true });
|
||||
|
||||
const inherited = await request(app).get(`/api/shared/${token}`);
|
||||
expect(inherited.status).toBe(200);
|
||||
expect(inherited.body.cartoApiKey).toBe('instance-key');
|
||||
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', 'owner-key')").run(user.id);
|
||||
const own = await request(app).get(`/api/shared/${token}`);
|
||||
expect(own.body.cartoApiKey).toBe('owner-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shared trip — place photos in shared links (issue #1100)', () => {
|
||||
const PLACE_ID = 'ChIJsharedPhoto1100';
|
||||
const PROXY_URL = `/api/maps/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`;
|
||||
|
||||
@@ -60,6 +60,22 @@ describe('globalMiddleware request-log redaction', () => {
|
||||
expect(out.grant_type).toBe('refresh_token');
|
||||
});
|
||||
|
||||
it('redacts the value of a {key, value} settings body when the key names a secret', () => {
|
||||
expect(redact({ key: 'carto_api_key', value: 'carto-secret' })).toEqual({
|
||||
key: 'carto_api_key',
|
||||
value: '[REDACTED]',
|
||||
});
|
||||
expect(redact({ key: 'mapbox_access_token', value: 'pk-1' })).toEqual({
|
||||
key: 'mapbox_access_token',
|
||||
value: '[REDACTED]',
|
||||
});
|
||||
// A harmless setting keeps its value: the name is what decides.
|
||||
expect(redact({ key: 'map_tile_url', value: 'https://tiles.example/{z}/{x}/{y}.png' })).toEqual({
|
||||
key: 'map_tile_url',
|
||||
value: 'https://tiles.example/{z}/{x}/{y}.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('matches the suffix on the end of the key only, so identifiers stay readable', () => {
|
||||
expect(redact({ accessKeyId: 'ak-1', tokenCount: 5 })).toEqual({ accessKeyId: 'ak-1', tokenCount: 5 });
|
||||
});
|
||||
|
||||
@@ -39,10 +39,15 @@ import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
|
||||
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
||||
import { JourneyShareService } from '../../../src/nest/journey/journey-share.service';
|
||||
import { SettingsService } from '../../../src/nest/settings/settings.service';
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
|
||||
const dbs = new DatabaseService(dbConn);
|
||||
const svc = new JourneyShareService(dbs, new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)));
|
||||
const svc = new JourneyShareService(
|
||||
dbs,
|
||||
new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)),
|
||||
new SettingsService(dbs),
|
||||
);
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
@@ -629,4 +634,16 @@ describe('getPublicJourney', () => {
|
||||
expect(result.entries).toHaveLength(1);
|
||||
expect((result.entries[0] as Record<string, unknown>).photos).toEqual([]); // inline photos withheld too
|
||||
});
|
||||
|
||||
it('JOURNEY-SHARE-030: cartoApiKey resolves owner setting → admin instance default → empty (#2054)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_map: true });
|
||||
|
||||
expect(svc.getPublicJourney(token)!.cartoApiKey).toBe('');
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_carto_api_key', 'instance-key')").run();
|
||||
expect(svc.getPublicJourney(token)!.cartoApiKey).toBe('instance-key');
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', ' owner-key ')").run(user.id);
|
||||
expect(svc.getPublicJourney(token)!.cartoApiKey).toBe('owner-key');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ describe('managed key assignment', () => {
|
||||
|
||||
it('MANAGED-KEYS-005: the locked list is pinned verbatim', () => {
|
||||
expect([...MANAGED_LOCKED_SETTING_KEYS]).toEqual([
|
||||
'carto_api_key',
|
||||
'llm_api_key',
|
||||
'llm_base_url',
|
||||
'llm_model',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Unit tests for the DI-native SettingsService — SET-SVC-001 through
|
||||
* SET-SVC-030 (001–026 moved 1:1 from the legacy
|
||||
* SET-SVC-034 (001–026 moved 1:1 from the legacy
|
||||
* tests/unit/services/settingsService.test.ts; 027–030 pin the post-migration
|
||||
* quirk fixes: null-serializes-as-'' and the bulk masked-sentinel skip).
|
||||
* quirk fixes: null-serializes-as-'' and the bulk masked-sentinel skip;
|
||||
* 031–034 cover the CARTO basemap key).
|
||||
* Uses a real in-memory SQLite DB; apiKeyCrypto is mocked to a passthrough
|
||||
* so we don't need real encryption for most tests.
|
||||
*/
|
||||
@@ -173,6 +174,60 @@ describe('getUserSettings', () => {
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'llm_api_key', '')").run(user.id);
|
||||
expect(svc.getUserSettings(user.id).llm_api_key).toBe('••••••••');
|
||||
});
|
||||
|
||||
// carto_api_key (#2054): encrypted at rest like the other credentials, but
|
||||
// Leaflet builds the tile URL in the browser, so it must come back readable.
|
||||
it('SET-SVC-031 — carto_api_key is stored encrypted and read back in cleartext, never masked', () => {
|
||||
const { user } = createUser(testDb);
|
||||
svc.upsertSetting(user.id, 'carto_api_key', 'carto-user');
|
||||
// Passthrough crypto mock: the stored row is whatever maybe_encrypt_api_key returned.
|
||||
const raw = testDb
|
||||
.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'carto_api_key'")
|
||||
.get(user.id) as { value: string };
|
||||
expect(raw.value).toBe('carto-user');
|
||||
expect(svc.getUserSettings(user.id).carto_api_key).toBe('carto-user');
|
||||
|
||||
// A digits-only key proves it takes the decrypt branch: a non-encrypted key
|
||||
// would come back JSON-parsed, as the number 12345.
|
||||
svc.upsertSetting(user.id, 'carto_api_key', '12345');
|
||||
expect(svc.getUserSettings(user.id).carto_api_key).toBe('12345');
|
||||
});
|
||||
|
||||
it('SET-SVC-032 — an empty user carto_api_key falls back to the admin default', () => {
|
||||
const { user } = createUser(testDb);
|
||||
setAdminDefault('carto_api_key', 'carto-admin');
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', '')").run(user.id);
|
||||
expect(svc.getUserSettings(user.id).carto_api_key).toBe('carto-admin');
|
||||
});
|
||||
|
||||
it('SET-SVC-033 — a managed instance injects the operator key over both the user and the admin value', () => {
|
||||
const { user } = createUser(testDb);
|
||||
setAdminDefault('carto_api_key', 'carto-admin');
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', 'carto-user')").run(user.id);
|
||||
vi.stubEnv('TREK_MANAGED', 'true');
|
||||
vi.stubEnv('CARTO_API_KEY', 'carto-operator');
|
||||
try {
|
||||
expect(svc.getUserSettings(user.id).carto_api_key).toBe('carto-operator');
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it('SET-SVC-034 — the injected CARTO key leaves map_provider alone', () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.stubEnv('TREK_MANAGED', 'true');
|
||||
vi.stubEnv('CARTO_API_KEY', 'carto-operator');
|
||||
vi.stubEnv('MAPBOX_ACCESS_TOKEN', '');
|
||||
try {
|
||||
// The Mapbox token flips a managed instance to GL because a token means a
|
||||
// renderer. A basemap key says nothing about which renderer the user wants.
|
||||
const s = svc.getUserSettings(user.id);
|
||||
expect(s.carto_api_key).toBe('carto-operator');
|
||||
expect(s.map_provider).toBeUndefined();
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── upsertSetting ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -375,6 +375,15 @@ describe('getSharedTripData', () => {
|
||||
expect(byName['Uploaded']).toBe('/uploads/pic.jpg');
|
||||
expect(byName['Bare']).toBeNull();
|
||||
});
|
||||
|
||||
it('SHARE-SVC-027: cartoApiKey resolves owner setting → admin instance default → empty (#2054)', () => {
|
||||
const { user, token } = seedSharedTrip();
|
||||
expect(svc.getSharedTripData(token)!.cartoApiKey).toBe('');
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_carto_api_key', 'instance-key')").run();
|
||||
expect(svc.getSharedTripData(token)!.cartoApiKey).toBe('instance-key');
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', ' owner-key ')").run(user.id);
|
||||
expect(svc.getSharedTripData(token)!.cartoApiKey).toBe('owner-key');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getSharedPlacePhotoKey ───────────────────────────────────────────────────
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 خاص بك',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 직접 입력',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '集成',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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/).
|
||||
|
||||
Reference in New Issue
Block a user