diff --git a/src/app/(app)/__tests__/index.test.tsx b/src/app/(app)/__tests__/index.test.tsx index 134ecc68..6326abf0 100644 --- a/src/app/(app)/__tests__/index.test.tsx +++ b/src/app/(app)/__tests__/index.test.tsx @@ -11,13 +11,14 @@ jest.mock('nativewind', () => ({ __esModule: true, })); -import { render, waitFor } from '@testing-library/react-native'; +import { render, screen, waitFor } from '@testing-library/react-native'; import { useColorScheme } from 'nativewind'; import React from 'react'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import Map from '../index'; import { useAppLifecycle } from '@/hooks/use-app-lifecycle'; +import { FALLBACK_DAY_MAP_STYLE, FALLBACK_NIGHT_MAP_STYLE } from '@/lib/map-style'; import { useLocationStore } from '@/stores/app/location-store'; import { locationService } from '@/services/location'; @@ -140,6 +141,7 @@ jest.mock('@/stores/app/core-store', () => { activePriority: null, activeUnit: null, activeUnitStatus: null, + config: null, }; const mockFn = jest.fn((selector) => (typeof selector === 'function' ? selector(storeState) : storeState)) as jest.Mock & { getState: () => typeof storeState }; mockFn.getState = () => storeState; @@ -373,8 +375,8 @@ describe('Map Component - App Lifecycle', () => { expect(mockLocationService.startLocationUpdates).toHaveBeenCalled(); }); - // The map should use the light style - // Since we can't directly test the MapView props, we test that the component renders without errors + // No config loaded yet: the department-style fallback for day. + expect(screen.getByTestId('map-view').props.styleURL).toBe(FALLBACK_DAY_MAP_STYLE); unmount(); }); @@ -391,8 +393,8 @@ describe('Map Component - App Lifecycle', () => { expect(mockLocationService.startLocationUpdates).toHaveBeenCalled(); }); - // The map should use the dark style - // Since we can't directly test the MapView props, we test that the component renders without errors + // No config loaded yet: the department-style fallback for night. + expect(screen.getByTestId('map-view').props.styleURL).toBe(FALLBACK_NIGHT_MAP_STYLE); unmount(); }); @@ -450,6 +452,61 @@ describe('Map Component - App Lifecycle', () => { unmount(); }); }); +describe('Map Component - department map style', () => { + const SATELLITE = 'mapbox://styles/mapbox/satellite-v9'; + const NAVIGATION_NIGHT = 'mapbox://styles/mapbox/navigation-night-v1'; + const coreStoreState = () => jest.requireMock('@/stores/app/core-store').useCoreStore.getState() as Record; + const setTheme = (colorScheme: 'light' | 'dark') => mockUseColorScheme.mockReturnValue({ colorScheme, setColorScheme: jest.fn(), toggleColorScheme: jest.fn() }); + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + mockUseAppLifecycle.mockReturnValue(defaultAppLifecycleState); + mockLocationService.startLocationUpdates = jest.fn().mockResolvedValue(undefined); + mockLocationService.stopLocationUpdates = jest.fn().mockResolvedValue(undefined); + currentLocationState = { ...defaultLocationState, speed: 0, accuracy: 10 }; + setLocationState({}); + + coreStoreState().config = { MapDayStyleUrl: SATELLITE, MapNightStyleUrl: NAVIGATION_NIGHT }; + }); + + afterEach(() => { + coreStoreState().config = null; + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('renders the configured day style in light mode', () => { + setTheme('light'); + + const { unmount } = render(, { wrapper: TestWrapper }); + + expect(screen.getByTestId('map-view').props.styleURL).toBe(SATELLITE); + unmount(); + }); + + it('renders the configured night style in dark mode', () => { + setTheme('dark'); + + const { unmount } = render(, { wrapper: TestWrapper }); + + expect(screen.getByTestId('map-view').props.styleURL).toBe(NAVIGATION_NIGHT); + unmount(); + }); + + it('switches to the night style when the theme flips', () => { + setTheme('light'); + const { rerender, unmount } = render(, { wrapper: TestWrapper }); + expect(screen.getByTestId('map-view').props.styleURL).toBe(SATELLITE); + + setTheme('dark'); + rerender(); + + expect(screen.getByTestId('map-view').props.styleURL).toBe(NAVIGATION_NIGHT); + unmount(); + }); +}); describe('Map Component - follow camera', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index 057cff53..1ffced7f 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -20,11 +20,12 @@ import { useAppLifecycle } from '@/hooks/use-app-lifecycle'; import { applyLiveLocationsSince, useMapLiveLocations } from '@/hooks/use-map-live-locations'; import { useMapSignalRUpdates } from '@/hooks/use-map-signalr-updates'; import { useWeatherAlertBanner } from '@/hooks/use-weather-alert-banner'; -import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; import { applyPitchHysteresis, applyZoomHysteresis, createCirclePolygon, normalizeHeading, normalizeSpeed, smoothSpeed, zoomForSpeed } from '@/lib/map-camera'; import { getDepartmentMapCenter } from '@/lib/map-center'; import { getPinEntityId } from '@/lib/map-pin-ids'; +import { useDepartmentMapStyle } from '@/lib/map-style'; +import { getMapboxAccessToken } from '@/lib/mapbox-token'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; import { locationService } from '@/services/location'; import { useCoreStore } from '@/stores/app/core-store'; @@ -34,7 +35,7 @@ import { useRoutesStore } from '@/stores/routes/store'; import { useToastStore } from '@/stores/toast/store'; import { useWeatherAlertsStore } from '@/stores/weather-alerts/store'; -Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY); +Mapbox.setAccessToken(getMapboxAccessToken()); // Minimum interval between programmatic camera-follow updates. GPS fixes // arrive every ~15s; without a throttle each fix drives a native camera @@ -116,10 +117,9 @@ function MapContent() { const fetchActiveLayers = useMapsStore((state) => state.fetchActiveLayers); const fetchLayerGeoJSON = useMapsStore((state) => state.fetchLayerGeoJSON); - // Get map style based on current theme - const getMapStyle = useCallback(() => { - return colorScheme === 'dark' ? Mapbox.StyleURL.Dark : Mapbox.StyleURL.Street; - }, [colorScheme]); + // The department's day/night base map for the current theme + const departmentMapStyle = useDepartmentMapStyle(); + const getMapStyle = useCallback(() => departmentMapStyle, [departmentMapStyle]); const [styleURL, setStyleURL] = useState({ styleURL: getMapStyle() }); diff --git a/src/app/call/new/__tests__/address-search.test.ts b/src/app/call/new/__tests__/address-search.test.ts index 69d7cebc..1afc6ee0 100644 --- a/src/app/call/new/__tests__/address-search.test.ts +++ b/src/app/call/new/__tests__/address-search.test.ts @@ -121,6 +121,10 @@ describe('Address Search Logic', () => { MapCenterLatitude: 0, MapCenterLongitude: 0, MapCenterZoomLevel: 9, + MapDayStyleUrl: '', + MapNightStyleUrl: '', + AppMapboxAccessToken: '', + IsDepartmentMapOverride: false, }; beforeEach(() => { @@ -172,6 +176,10 @@ describe('Address Search Logic', () => { MapCenterLatitude: 0, MapCenterLongitude: 0, MapCenterZoomLevel: 9, + MapDayStyleUrl: '', + MapNightStyleUrl: '', + AppMapboxAccessToken: '', + IsDepartmentMapOverride: false, }; const result = await performAddressSearch('123 Main St', configWithoutKey); diff --git a/src/app/call/new/__tests__/coordinates-search.test.tsx b/src/app/call/new/__tests__/coordinates-search.test.tsx index bad22b3b..7d9378d5 100644 --- a/src/app/call/new/__tests__/coordinates-search.test.tsx +++ b/src/app/call/new/__tests__/coordinates-search.test.tsx @@ -131,6 +131,10 @@ describe('Coordinates Search Logic', () => { MapCenterLatitude: 0, MapCenterLongitude: 0, MapCenterZoomLevel: 9, + MapDayStyleUrl: '', + MapNightStyleUrl: '', + AppMapboxAccessToken: '', + IsDepartmentMapOverride: false, }; beforeEach(() => { @@ -278,6 +282,10 @@ describe('Coordinates Search Logic', () => { MapCenterLatitude: 0, MapCenterLongitude: 0, MapCenterZoomLevel: 9, + MapDayStyleUrl: '', + MapNightStyleUrl: '', + AppMapboxAccessToken: '', + IsDepartmentMapOverride: false, }; const result = await performCoordinatesSearch('40.7128, -74.0060', configWithoutKey); diff --git a/src/app/call/new/__tests__/plus-code-search.test.ts b/src/app/call/new/__tests__/plus-code-search.test.ts index da7452e7..704280ab 100644 --- a/src/app/call/new/__tests__/plus-code-search.test.ts +++ b/src/app/call/new/__tests__/plus-code-search.test.ts @@ -94,6 +94,10 @@ describe('Plus Code Search Logic', () => { MapCenterLatitude: 0, MapCenterLongitude: 0, MapCenterZoomLevel: 9, + MapDayStyleUrl: '', + MapNightStyleUrl: '', + AppMapboxAccessToken: '', + IsDepartmentMapOverride: false, }; beforeEach(() => { @@ -145,6 +149,10 @@ describe('Plus Code Search Logic', () => { MapCenterLatitude: 0, MapCenterLongitude: 0, MapCenterZoomLevel: 9, + MapDayStyleUrl: '', + MapNightStyleUrl: '', + AppMapboxAccessToken: '', + IsDepartmentMapOverride: false, }; const result = await performPlusCodeSearch('849VCWC8+R9', configWithoutKey); diff --git a/src/app/call/new/__tests__/what3words.test.tsx b/src/app/call/new/__tests__/what3words.test.tsx index a0e4367a..3e8ccb8f 100644 --- a/src/app/call/new/__tests__/what3words.test.tsx +++ b/src/app/call/new/__tests__/what3words.test.tsx @@ -28,6 +28,10 @@ const mockConfig: GetConfigResultData = { MapCenterLatitude: 0, MapCenterLongitude: 0, MapCenterZoomLevel: 9, + MapDayStyleUrl: '', + MapNightStyleUrl: '', + AppMapboxAccessToken: '', + IsDepartmentMapOverride: false, }; // Mock the core store diff --git a/src/app/maps/custom/[id].tsx b/src/app/maps/custom/[id].tsx index fc6f0ad4..09a12a54 100644 --- a/src/app/maps/custom/[id].tsx +++ b/src/app/maps/custom/[id].tsx @@ -14,6 +14,7 @@ import { HStack } from '@/components/ui/hstack'; import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; +import { useDepartmentMapStyle } from '@/lib/map-style'; import { type CustomMapLayerResultData } from '@/models/v4/mapping/customMapResultData'; import { useMapsStore } from '@/stores/maps/store'; @@ -25,6 +26,7 @@ export default function CustomMapViewer() { const error = useMapsStore((state) => state.error); const fetchCustomMap = useMapsStore((state) => state.fetchCustomMap); const clearCurrentMap = useMapsStore((state) => state.clearCurrentMap); + const mapStyle = useDepartmentMapStyle(); const [layerVisibility, setLayerVisibility] = useState>({}); const [showLayerSheet, setShowLayerSheet] = useState(false); @@ -171,7 +173,7 @@ export default function CustomMapViewer() { {/* Map */} - + {visibleLayers.map(renderLayer)} diff --git a/src/app/maps/indoor/[id].tsx b/src/app/maps/indoor/[id].tsx index 116382a1..483b09b7 100644 --- a/src/app/maps/indoor/[id].tsx +++ b/src/app/maps/indoor/[id].tsx @@ -17,6 +17,7 @@ import { Icon } from '@/components/ui/icon'; import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; +import { useDepartmentMapStyle } from '@/lib/map-style'; import { type IndoorMapFloorResultData } from '@/models/v4/mapping/indoorMapResultData'; import { useMapsStore } from '@/stores/maps/store'; @@ -33,6 +34,7 @@ export default function IndoorMapViewer() { const fetchIndoorMap = useMapsStore((state) => state.fetchIndoorMap); const setCurrentFloor = useMapsStore((state) => state.setCurrentFloor); const clearCurrentMap = useMapsStore((state) => state.clearCurrentMap); + const mapStyle = useDepartmentMapStyle(); const [zoneSearchQuery, setZoneSearchQuery] = useState(''); const [selectedZone, setSelectedZone] = useState | null>(null); @@ -156,7 +158,7 @@ export default function IndoorMapViewer() { {/* The map stays mounted across floor switches — unmounting it tore down and rebuilt the whole GL context on every tab tap. The loader is overlaid instead. */} - + {/* Floor plan image overlay */} diff --git a/src/app/routes/active.tsx b/src/app/routes/active.tsx index 234460c5..fc9355f3 100644 --- a/src/app/routes/active.tsx +++ b/src/app/routes/active.tsx @@ -15,13 +15,14 @@ import { HStack } from '@/components/ui/hstack'; import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; -import { Env } from '@/lib/env'; +import { useDepartmentMapStyle } from '@/lib/map-style'; +import { getMapboxAccessToken } from '@/lib/mapbox-token'; import { RouteStopStatus } from '@/models/v4/routes/routeInstanceStopResultData'; import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; import { useRoutesStore } from '@/stores/routes/store'; -Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY); +Mapbox.setAccessToken(getMapboxAccessToken()); const POLL_INTERVAL_MS = 30_000; const GEOFENCE_CIRCLE_STEPS = 64; @@ -94,6 +95,7 @@ export default function ActiveRouteScreen() { const checkOut = useRoutesStore((s) => s.checkOut); const skip = useRoutesStore((s) => s.skip); const ackDeviation = useRoutesStore((s) => s.ackDeviation); + const mapStyle = useDepartmentMapStyle(); // instanceId may be absent when navigating from start.tsx — fall back to store. // Guard against the literal string "undefined" that can appear in URL params. @@ -238,11 +240,15 @@ export default function ActiveRouteScreen() { // Could navigate to a deviations detail screen in the future }, []); + const handleMapLoaded = useCallback(() => { + setIsMapReady(true); + }, []); + return ( {/* Map area - 60% height */} - setIsMapReady(true)}> + {/* Route polyline */} diff --git a/src/app/routes/directions.tsx b/src/app/routes/directions.tsx index b9600320..b9f62e58 100644 --- a/src/app/routes/directions.tsx +++ b/src/app/routes/directions.tsx @@ -6,13 +6,14 @@ import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Linking, Platform, ScrollView, StyleSheet, Text as RNText, View } from 'react-native'; import { Loading } from '@/components/common/loading'; -import { Camera, LineLayer, MapView, PointAnnotation, ShapeSource, StyleURL, UserLocation } from '@/components/maps/mapbox'; +import { Camera, LineLayer, MapView, PointAnnotation, ShapeSource, UserLocation } from '@/components/maps/mapbox'; import { Box } from '@/components/ui/box'; import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; import { HStack } from '@/components/ui/hstack'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; -import { Env } from '@/lib/env'; +import { useDepartmentMapStyle } from '@/lib/map-style'; +import { getMapboxAccessToken } from '@/lib/mapbox-token'; import { RouteStopStatus } from '@/models/v4/routes/routeInstanceStopResultData'; import { useRoutesStore } from '@/stores/routes/store'; @@ -146,7 +147,7 @@ export const deriveDrivingCondition = (congestion: CongestionSegment[]): { label export async function fetchMapboxDirections(waypoints: [number, number][], language: string = 'en'): Promise { if (waypoints.length < 2) return null; - const token = Env.UNIT_MAPBOX_PUBKEY; + const token = getMapboxAccessToken(); if (!token) return null; // Build the coordinate string for Mapbox Directions API @@ -381,6 +382,7 @@ export default function RouteDirectionsScreen() { const { t, i18n } = useTranslation(); const { instanceId } = useLocalSearchParams<{ instanceId: string }>(); const { colorScheme } = useColorScheme(); + const mapStyle = useDepartmentMapStyle(); const cameraRef = useRef(null); const [isMapReady, setIsMapReady] = useState(false); const [isFetchingDirections, setIsFetchingDirections] = useState(false); @@ -609,6 +611,10 @@ export default function RouteDirectionsScreen() { } }, [destination, t]); + const handleMapLoaded = useCallback(() => { + setIsMapReady(true); + }, []); + // --- Loading states --- if (isLoadingDirections && sortedStops.length === 0) { return ( @@ -631,7 +637,7 @@ export default function RouteDirectionsScreen() { {/* Map */} - setIsMapReady(true)}> + {/* Bare Camera — positioned imperatively via fitBounds once isMapReady && validStops */} diff --git a/src/app/routes/history/instance/[id].tsx b/src/app/routes/history/instance/[id].tsx index 22e8a285..d95cd7fc 100644 --- a/src/app/routes/history/instance/[id].tsx +++ b/src/app/routes/history/instance/[id].tsx @@ -16,6 +16,7 @@ import { HStack } from '@/components/ui/hstack'; import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; +import { useDepartmentMapStyle } from '@/lib/map-style'; import { type RouteDeviationResultData, RouteDeviationType } from '@/models/v4/routes/routeDeviationResultData'; import { type RouteInstanceResultData, RouteInstanceStatus } from '@/models/v4/routes/routeInstanceResultData'; import { type RouteInstanceStopResultData, RouteStopStatus } from '@/models/v4/routes/routeInstanceStopResultData'; @@ -116,6 +117,7 @@ function getStopIcon(status: number) { export default function RouteInstanceDetail() { const { t } = useTranslation(); const { id: instanceId } = useLocalSearchParams<{ id: string }>(); + const mapStyle = useDepartmentMapStyle(); // Local history state — never touches the global live-route slices const [activeInstance, setActiveInstance] = useState(null); const [instanceStops, setInstanceStops] = useState([]); @@ -250,7 +252,7 @@ export default function RouteInstanceDetail() { {/* Map */} - + {/* Camera fitted to bounds */} {mapBounds ? ( diff --git a/src/app/routes/start.tsx b/src/app/routes/start.tsx index 5d00d9f6..e5718433 100644 --- a/src/app/routes/start.tsx +++ b/src/app/routes/start.tsx @@ -1,13 +1,12 @@ import { router, useLocalSearchParams } from 'expo-router'; import { Clock, Info, MapPin, Navigation, Phone, Play, Truck, User } from 'lucide-react-native'; -import { useColorScheme } from 'nativewind'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, Pressable, ScrollView, StyleSheet, Text as RNText, View } from 'react-native'; import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; -import { Camera, MapView, PointAnnotation, StyleURL } from '@/components/maps/mapbox'; +import { Camera, MapView, PointAnnotation } from '@/components/maps/mapbox'; import { Box } from '@/components/ui/box'; import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; import { Divider } from '@/components/ui/divider'; @@ -16,6 +15,7 @@ import { HStack } from '@/components/ui/hstack'; import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; +import { useDepartmentMapStyle } from '@/lib/map-style'; import { type RouteStopResultData } from '@/models/v4/routes/routePlanResultData'; import { useCoreStore } from '@/stores/app/core-store'; import { useRoutesStore } from '@/stores/routes/store'; @@ -76,7 +76,7 @@ export default function RouteViewScreen() { const activeUnit = useCoreStore((state) => state.activeUnit); const units = useUnitsStore((state) => state.units); const { t } = useTranslation(); - const { colorScheme } = useColorScheme(); + const mapStyle = useDepartmentMapStyle(); const unitMap = useMemo(() => Object.fromEntries(units.map((u) => [u.UnitId, u.Name])), [units]); @@ -91,6 +91,10 @@ export default function RouteViewScreen() { const cameraRef = useRef(null); const [isMapReady, setIsMapReady] = useState(false); + const handleMapLoaded = useCallback(() => { + setIsMapReady(true); + }, []); + // Fit bounds to show all stops once the map is ready useEffect(() => { if (!isMapReady || mapStops.length === 0 || !cameraRef.current) return; @@ -217,7 +221,7 @@ export default function RouteViewScreen() { {/* Interactive stop map */} {mapStops.length > 0 ? ( - setIsMapReady(true)}> + {mapStops.map((stop) => ( diff --git a/src/app/routes/stop/[id].tsx b/src/app/routes/stop/[id].tsx index 22b50d7d..c7d4ecc6 100644 --- a/src/app/routes/stop/[id].tsx +++ b/src/app/routes/stop/[id].tsx @@ -7,7 +7,7 @@ import { ScrollView, StyleSheet, TextInput, TouchableOpacity, View } from 'react import { Loading } from '@/components/common/loading'; import { NativeModal } from '@/components/common/native-modal'; -import { Camera, FillLayer, LineLayer, MapView, PointAnnotation, ShapeSource, StyleURL } from '@/components/maps/mapbox'; +import { Camera, FillLayer, LineLayer, MapView, PointAnnotation, ShapeSource } from '@/components/maps/mapbox'; import { Badge, BadgeText } from '@/components/ui/badge'; import { Box } from '@/components/ui/box'; import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; @@ -18,6 +18,7 @@ import { Pressable } from '@/components/ui/pressable'; import { Text } from '@/components/ui/text'; import { Textarea, TextareaInput } from '@/components/ui/textarea'; import { VStack } from '@/components/ui/vstack'; +import { useDepartmentMapStyle } from '@/lib/map-style'; import { safeFormatDate } from '@/lib/utils'; import { RouteStopStatus } from '@/models/v4/routes/routeInstanceStopResultData'; import { useCoreStore } from '@/stores/app/core-store'; @@ -74,6 +75,7 @@ export default function StopDetailScreen() { const { t } = useTranslation(); const { id } = useLocalSearchParams<{ id: string }>(); const { colorScheme } = useColorScheme(); + const mapStyle = useDepartmentMapStyle(); const instanceStops = useRoutesStore((s) => s.instanceStops); const checkIn = useRoutesStore((s) => s.checkIn); @@ -233,7 +235,7 @@ export default function StopDetailScreen() { {/* Mini Map with geofence */} {stop.Latitude && stop.Longitude ? ( - + diff --git a/src/app/routes/stop/contact.tsx b/src/app/routes/stop/contact.tsx index 5bb4b9cd..418dde24 100644 --- a/src/app/routes/stop/contact.tsx +++ b/src/app/routes/stop/contact.tsx @@ -7,7 +7,7 @@ import { Linking, Platform, ScrollView, StyleSheet, View } from 'react-native'; import { getStopContact } from '@/api/routes/routes'; import { Loading } from '@/components/common/loading'; -import { Camera, MapView, PointAnnotation, StyleURL } from '@/components/maps/mapbox'; +import { Camera, MapView, PointAnnotation } from '@/components/maps/mapbox'; import { Box } from '@/components/ui/box'; import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; import { Heading } from '@/components/ui/heading'; @@ -15,6 +15,7 @@ import { HStack } from '@/components/ui/hstack'; import { Pressable } from '@/components/ui/pressable'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; +import { useDepartmentMapStyle } from '@/lib/map-style'; import type { ContactResultData } from '@/models/v4/contacts/contactResultData'; /** @@ -72,6 +73,7 @@ export default function StopContactScreen() { const { t } = useTranslation(); const { stopId } = useLocalSearchParams<{ stopId: string }>(); const { colorScheme } = useColorScheme(); + const mapStyle = useDepartmentMapStyle(); const [contact, setContact] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -205,7 +207,7 @@ export default function StopContactScreen() { {/* Mini Map */} {mapCenter && ( - + {locationGps && ( diff --git a/src/components/maps/__tests__/full-screen-location-picker.test.tsx b/src/components/maps/__tests__/full-screen-location-picker.test.tsx index 56cfcbd7..359a1414 100644 --- a/src/components/maps/__tests__/full-screen-location-picker.test.tsx +++ b/src/components/maps/__tests__/full-screen-location-picker.test.tsx @@ -23,6 +23,11 @@ jest.mock('@/lib/map-center', () => ({ getDepartmentMapCenter: jest.fn(() => mockDepartmentCenter), })); +const mockDepartmentMapStyle = 'mapbox://styles/mapbox/satellite-streets-v12'; +jest.mock('@/lib/map-style', () => ({ + useDepartmentMapStyle: () => mockDepartmentMapStyle, +})); + jest.mock('expo-location', () => ({ Accuracy: { Lowest: 1, Low: 2, Balanced: 3, High: 4, Highest: 5, BestForNavigation: 6 }, requestForegroundPermissionsAsync: jest.fn(), @@ -163,6 +168,16 @@ describe('FullScreenLocationPicker', () => { }); describe('initial location handling', () => { + it('renders the department map style', async () => { + const { unmount } = render(); + + await waitFor(() => expect(mockReverseGeocode).toHaveBeenCalled()); + + expect(screen.getByTestId('map-view').props.styleURL).toBe(mockDepartmentMapStyle); + + unmount(); + }); + it('opens on the supplied initial location at street zoom and skips the device fix', async () => { const { unmount } = render(); diff --git a/src/components/maps/__tests__/full-screen-map.test.tsx b/src/components/maps/__tests__/full-screen-map.test.tsx index c47b65cd..4d187b96 100644 --- a/src/components/maps/__tests__/full-screen-map.test.tsx +++ b/src/components/maps/__tests__/full-screen-map.test.tsx @@ -65,15 +65,24 @@ jest.mock('@/components/maps/mapbox', () => { MapView, PointAnnotation, setAccessToken: jest.fn(), - StyleURL: { - Dark: 'dark', - Street: 'street', - }, }, }; }); +const mockDepartmentMapStyle = 'mapbox://styles/mapbox/satellite-v9'; + +jest.mock('@/lib/map-style', () => ({ + useDepartmentMapStyle: () => mockDepartmentMapStyle, +})); + describe('FullScreenMap', () => { + it('renders the department map style', () => { + const { unmount } = render(); + + expect(screen.getByTestId('map-view').props.styleURL).toBe(mockDepartmentMapStyle); + unmount(); + }); + it('centers the camera and marker on the supplied location', () => { const onClose = jest.fn(); const { unmount } = render(); diff --git a/src/components/maps/__tests__/mapbox-native-token.test.ts b/src/components/maps/__tests__/mapbox-native-token.test.ts new file mode 100644 index 00000000..7607f5ab --- /dev/null +++ b/src/components/maps/__tests__/mapbox-native-token.test.ts @@ -0,0 +1,57 @@ +const mockSetAccessToken = jest.fn(); + +jest.mock('@rnmapbox/maps', () => ({ + __esModule: true, + default: { + setAccessToken: (token: string) => mockSetAccessToken(token), + }, +})); + +jest.mock('@/lib/mapbox-token', () => ({ + onMapboxAccessTokenChange: jest.fn((listener: (token: string) => void) => { + listener('pk.current.signature'); + return jest.fn(); + }), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); + +describe('mapbox.native token sync', () => { + beforeEach(() => { + jest.resetModules(); + mockSetAccessToken.mockReset(); + }); + + it('hands the token in use to the SDK when the module loads', async () => { + mockSetAccessToken.mockResolvedValue('pk.current.signature'); + + require('../mapbox.native'); + await flushPromises(); + + expect(mockSetAccessToken).toHaveBeenCalledWith('pk.current.signature'); + expect(jest.requireMock('@/lib/logging').logger.error).not.toHaveBeenCalled(); + }); + + it('logs a failed SDK token update instead of leaving the rejection unhandled', async () => { + const failure = new Error('native module unavailable'); + mockSetAccessToken.mockRejectedValue(failure); + + require('../mapbox.native'); + await flushPromises(); + + expect(jest.requireMock('@/lib/logging').logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to set the Mapbox access token', + context: { error: failure }, + }) + ); + }); +}); diff --git a/src/components/maps/full-screen-location-picker.tsx b/src/components/maps/full-screen-location-picker.tsx index 685419c5..621aa7ab 100644 --- a/src/components/maps/full-screen-location-picker.tsx +++ b/src/components/maps/full-screen-location-picker.tsx @@ -9,12 +9,13 @@ import Mapbox from '@/components/maps/mapbox'; import { Box } from '@/components/ui/box'; import { Button, ButtonText } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; -import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; import { getDepartmentMapCenter } from '@/lib/map-center'; +import { useDepartmentMapStyle } from '@/lib/map-style'; +import { getMapboxAccessToken } from '@/lib/mapbox-token'; // Ensure Mapbox access token is set before using any Mapbox components -Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY); +Mapbox.setAccessToken(getMapboxAccessToken()); // Falls back to the department's configured map center rather than a hardcoded point, so a // department outside the US does not open every picker on the middle of Kansas. @@ -40,6 +41,7 @@ const FullScreenLocationPicker: React.FC = ({ ini // Read live rather than at StyleSheet-create time — a module-level // Dimensions.get('window') keeps the pre-rotation size forever. const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + const mapStyle = useDepartmentMapStyle(); const cameraRef = useRef(null); // Using any due to imperative handle // Always start with a location - either initial, or default const [currentLocation, setCurrentLocation] = useState<{ @@ -212,7 +214,7 @@ const FullScreenLocationPicker: React.FC = ({ ini return ( - + {/* Camera is driven imperatively only (see getUserLocation). Passing centerCoordinate as well made every map tap re-fly the camera to the tapped point and updated the camera mid-pan, fighting the gesture. */} diff --git a/src/components/maps/full-screen-map.tsx b/src/components/maps/full-screen-map.tsx index 760d3712..a9e5b1d5 100644 --- a/src/components/maps/full-screen-map.tsx +++ b/src/components/maps/full-screen-map.tsx @@ -1,5 +1,4 @@ import { MapPinIcon, XIcon } from 'lucide-react-native'; -import { useColorScheme } from 'nativewind'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet, TouchableOpacity, View } from 'react-native'; @@ -9,9 +8,10 @@ import { NativeModal } from '@/components/common/native-modal'; import Mapbox from '@/components/maps/mapbox'; import { Text } from '@/components/ui/text'; import colors from '@/constants/colors'; -import { Env } from '@/lib/env'; +import { useDepartmentMapStyle } from '@/lib/map-style'; +import { getMapboxAccessToken } from '@/lib/mapbox-token'; -Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY); +Mapbox.setAccessToken(getMapboxAccessToken()); interface FullScreenMapProps { isOpen: boolean; @@ -24,7 +24,7 @@ interface FullScreenMapProps { export const FullScreenMap: React.FC = ({ isOpen, latitude, longitude, onClose, title, address }) => { const { t } = useTranslation(); - const { colorScheme } = useColorScheme(); + const mapStyle = useDepartmentMapStyle(); const insets = useSafeAreaInsets(); const markerTitle = title || t('call_detail.call_location'); const coordinate: [number, number] = [longitude, latitude]; @@ -32,17 +32,7 @@ export const FullScreenMap: React.FC = ({ isOpen, latitude, return ( - + diff --git a/src/components/maps/location-picker.tsx b/src/components/maps/location-picker.tsx index 0a38d2ae..e8eacdb9 100644 --- a/src/components/maps/location-picker.tsx +++ b/src/components/maps/location-picker.tsx @@ -8,14 +8,16 @@ import Mapbox from '@/components/maps/mapbox'; import { Box } from '@/components/ui/box'; import { Button, ButtonText } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; -import { Env } from '@/lib/env'; import { getDepartmentMapCenter } from '@/lib/map-center'; +import { useDepartmentMapStyle } from '@/lib/map-style'; +import { getMapboxAccessToken } from '@/lib/mapbox-token'; // Ensure Mapbox access token is set before using any Mapbox components -if (!Env.UNIT_MAPBOX_PUBKEY) { +const mapboxAccessToken = getMapboxAccessToken(); +if (!mapboxAccessToken) { console.error('Mapbox access token is not configured. Please set UNIT_MAPBOX_PUBKEY in your environment.'); } else { - Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY); + Mapbox.setAccessToken(mapboxAccessToken); } // Falls back to the department's configured map center rather than a hardcoded point, so a @@ -35,6 +37,7 @@ interface LocationPickerProps { const LocationPicker: React.FC = ({ initialLocation, onLocationSelected, height = 200 }) => { const { t } = useTranslation(); + const mapStyle = useDepartmentMapStyle(); const mapRef = useRef>(null); const cameraRef = useRef(null); // Using any due to imperative handle const isMountedRef = useRef(true); @@ -140,7 +143,7 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca return ( - + {/* Camera is driven imperatively only (see getUserLocation). Passing centerCoordinate as well made every map tap re-fly the camera to the tapped point and updated the camera mid-pan, fighting the gesture. */} diff --git a/src/components/maps/map-view.web.tsx b/src/components/maps/map-view.web.tsx index b0a38c46..e422f390 100644 --- a/src/components/maps/map-view.web.tsx +++ b/src/components/maps/map-view.web.tsx @@ -9,11 +9,16 @@ import React, { forwardRef, useCallback, useContext, useEffect, useImperativeHan // @ts-ignore - react-dom/client types may not be available import { createRoot } from 'react-dom/client'; -import { Env } from '@/lib/env'; import { getDepartmentMapCenter } from '@/lib/map-center'; +import { FALLBACK_DAY_MAP_STYLE } from '@/lib/map-style'; +import { onMapboxAccessTokenChange } from '@/lib/mapbox-token'; -// Set the access token globally -mapboxgl.accessToken = Env.UNIT_MAPBOX_PUBKEY; +// Set the access token globally, and follow it when a server-supplied token is verified or dropped. +// Listeners run before React re-renders, so a map never asks for a style with the old token. The +// subscription lives as long as the app, so it is never torn down. +onMapboxAccessTokenChange((token) => { + mapboxgl.accessToken = token; +}); // Context to share map instance with child components export const MapContext = React.createContext(null); @@ -180,7 +185,8 @@ export const MapView = forwardRef( ( { style, - styleURL = StyleURL.Street, + // Callers pass the department style (useDepartmentMapStyle); this only covers one that does not. + styleURL = FALLBACK_DAY_MAP_STYLE, onDidFinishLoadingMap, onCameraChanged, children, diff --git a/src/components/maps/mapbox.native.ts b/src/components/maps/mapbox.native.ts index f478d527..446c39a8 100644 --- a/src/components/maps/mapbox.native.ts +++ b/src/components/maps/mapbox.native.ts @@ -4,6 +4,23 @@ */ import Mapbox from '@rnmapbox/maps'; +import { logger } from '@/lib/logging'; +import { onMapboxAccessTokenChange } from '@/lib/mapbox-token'; + +// Keep the SDK on the token in use (built-in, or the server-supplied one once Mapbox verified it). Every map +// imports this module, so the subscription exists before any map renders; store listeners run before React +// re-renders, so the SDK has a new token before a map re-renders with a style that needs it. The subscription +// lives as long as the app, so it is never torn down. +onMapboxAccessTokenChange((token) => { + // The native call is asynchronous; a failure must not surface as an unhandled rejection. + Promise.resolve(Mapbox.setAccessToken(token)).catch((error: unknown) => { + logger.error({ + message: 'Failed to set the Mapbox access token', + context: { error }, + }); + }); +}); + // Re-export all Mapbox components for native platforms export const MapView = Mapbox.MapView; export const Camera = Mapbox.Camera; diff --git a/src/components/maps/static-map.tsx b/src/components/maps/static-map.tsx index 4ed80265..1efea680 100644 --- a/src/components/maps/static-map.tsx +++ b/src/components/maps/static-map.tsx @@ -1,11 +1,11 @@ -import { useColorScheme } from 'nativewind'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { Image, StyleSheet, useWindowDimensions } from 'react-native'; import { Box } from '@/components/ui/box'; import { Text } from '@/components/ui/text'; -import { Env } from '@/lib/env'; +import { useDepartmentMapStyle } from '@/lib/map-style'; +import { useMapboxAccessToken } from '@/lib/mapbox-token'; import { useLocationStore } from '@/stores/app/location-store'; interface StaticMapProps { @@ -26,7 +26,8 @@ interface StaticMapProps { */ const StaticMap: React.FC = ({ latitude, longitude, address, zoom = 15, height = 200, showUserLocation = false }) => { const { t } = useTranslation(); - const { colorScheme } = useColorScheme(); + const mapStyle = useDepartmentMapStyle(); + const mapboxAccessToken = useMapboxAccessToken(); const { width: windowWidth } = useWindowDimensions(); const imageUrl = React.useMemo(() => { @@ -34,7 +35,8 @@ const StaticMap: React.FC = ({ latitude, longitude, address, zoo return null; } - const styleId = colorScheme === 'dark' ? 'mapbox/dark-v11' : 'mapbox/streets-v12'; + // The Static Images API takes the style as owner/id, the mapbox:// url without its scheme. + const styleId = mapStyle.replace('mapbox://styles/', ''); const pins: string[] = [`pin-s+E53E3E(${longitude},${latitude})`]; // Snapshot of the user's own position (no subscription needed for a static image) @@ -53,8 +55,8 @@ const StaticMap: React.FC = ({ latitude, longitude, address, zoo // aspect ratio — otherwise wide landscape views crop the pins off-center. const width = Math.min(Math.max(Math.round(windowWidth), 1), 1280); - return `https://api.mapbox.com/styles/v1/${styleId}/static/${pins.join(',')}/${position}/${width}x${Math.round(height)}@2x?access_token=${Env.UNIT_MAPBOX_PUBKEY}&logo=false&attribution=false`; - }, [latitude, longitude, zoom, height, showUserLocation, colorScheme, windowWidth]); + return `https://api.mapbox.com/styles/v1/${styleId}/static/${pins.join(',')}/${position}/${width}x${Math.round(height)}@2x?access_token=${mapboxAccessToken}&logo=false&attribution=false`; + }, [latitude, longitude, zoom, height, showUserLocation, mapStyle, mapboxAccessToken, windowWidth]); if (!imageUrl) { return ( diff --git a/src/components/weather-alerts/weather-alert-detail-map.tsx b/src/components/weather-alerts/weather-alert-detail-map.tsx index 93bca946..fd2b6c98 100644 --- a/src/components/weather-alerts/weather-alert-detail-map.tsx +++ b/src/components/weather-alerts/weather-alert-detail-map.tsx @@ -5,6 +5,7 @@ import { StyleSheet, View } from 'react-native'; import Mapbox from '@/components/maps/mapbox'; import { Text } from '@/components/ui/text'; +import { useDepartmentMapStyle } from '@/lib/map-style'; import { getPolygonBounds, getSeverityColor, parseCenterLocation, parsePolygonGeoJSON } from '@/lib/weather-alert-utils'; import { type WeatherAlertResultData } from '@/models/v4/weatherAlerts/weatherAlertResultData'; @@ -18,6 +19,7 @@ export const WeatherAlertDetailMap: React.FC = ({ al const { t } = useTranslation(); const cameraRef = useRef(null); const [isMapReady, setIsMapReady] = useState(false); + const mapStyle = useDepartmentMapStyle(); const severityColor = getSeverityColor(alert.Severity); const polygonGeoJSON = useMemo(() => parsePolygonGeoJSON(alert.Polygon), [alert.Polygon]); @@ -60,7 +62,7 @@ export const WeatherAlertDetailMap: React.FC = ({ al return ( - + {polygonGeoJSON ? ( diff --git a/src/lib/__tests__/map-style.test.ts b/src/lib/__tests__/map-style.test.ts new file mode 100644 index 00000000..9f2d5f81 --- /dev/null +++ b/src/lib/__tests__/map-style.test.ts @@ -0,0 +1,115 @@ +import { FALLBACK_DAY_MAP_STYLE, FALLBACK_NIGHT_MAP_STYLE, getDepartmentMapStyle, resolveDepartmentMapStyle } from '@/lib/map-style'; +import { useCoreStore } from '@/stores/app/core-store'; + +jest.mock('nativewind', () => ({ + useColorScheme: jest.fn(() => ({ colorScheme: 'light' })), +})); + +jest.mock('@/stores/app/core-store', () => ({ + useCoreStore: { getState: jest.fn() }, +})); + +let mockActiveToken = 'pk.builtin.token'; + +jest.mock('@/lib/mapbox-token', () => ({ + getMapboxAccessToken: jest.fn(() => mockActiveToken), + useMapboxAccessToken: jest.fn(() => mockActiveToken), +})); + +const mockedGetState = useCoreStore.getState as unknown as jest.Mock; + +const SATELLITE = 'mapbox://styles/mapbox/satellite-v9'; +const NAVIGATION_NIGHT = 'mapbox://styles/mapbox/navigation-night-v1'; +const CUSTOM = 'mapbox://styles/county-fire/ckcustom123'; +const COMMUNITY = 'mapbox://styles/mapbox-map-design/cks97e1e37nsd17nzg7p0308g'; +const DEPARTMENT_TOKEN = 'pk.department.token'; + +describe('resolveDepartmentMapStyle', () => { + it('uses the day style in a light theme', () => { + expect(resolveDepartmentMapStyle({ MapDayStyleUrl: SATELLITE, MapNightStyleUrl: NAVIGATION_NIGHT }, 'light')).toBe(SATELLITE); + }); + + it('uses the night style in a dark theme', () => { + expect(resolveDepartmentMapStyle({ MapDayStyleUrl: SATELLITE, MapNightStyleUrl: NAVIGATION_NIGHT }, 'dark')).toBe(NAVIGATION_NIGHT); + }); + + it('treats an unknown theme as day', () => { + expect(resolveDepartmentMapStyle({ MapDayStyleUrl: SATELLITE, MapNightStyleUrl: NAVIGATION_NIGHT }, undefined)).toBe(SATELLITE); + }); + + it('falls back to Streets / Dark before config loads', () => { + expect(resolveDepartmentMapStyle(null, 'light')).toBe(FALLBACK_DAY_MAP_STYLE); + expect(resolveDepartmentMapStyle(null, 'dark')).toBe(FALLBACK_NIGHT_MAP_STYLE); + }); + + it('falls back when an older server sends no style', () => { + expect(resolveDepartmentMapStyle({}, 'light')).toBe(FALLBACK_DAY_MAP_STYLE); + expect(resolveDepartmentMapStyle({ MapDayStyleUrl: '', MapNightStyleUrl: '' }, 'dark')).toBe(FALLBACK_NIGHT_MAP_STYLE); + }); + + it('rejects anything that is not a mapbox:// style', () => { + // A raster tile url or an http style would not load on the app's own Mapbox token. + expect(resolveDepartmentMapStyle({ MapDayStyleUrl: 'https://tiles.example.com/{z}/{x}/{y}.png' }, 'light')).toBe(FALLBACK_DAY_MAP_STYLE); + }); + + it('trims surrounding whitespace', () => { + expect(resolveDepartmentMapStyle({ MapDayStyleUrl: ` ${SATELLITE} ` }, 'light')).toBe(SATELLITE); + }); + + it('uses a department custom style once its token is the one in use', () => { + const config = { MapDayStyleUrl: CUSTOM, MapNightStyleUrl: CUSTOM, AppMapboxAccessToken: DEPARTMENT_TOKEN, IsDepartmentMapOverride: true }; + + expect(resolveDepartmentMapStyle(config, 'light', DEPARTMENT_TOKEN)).toBe(CUSTOM); + expect(resolveDepartmentMapStyle(config, 'dark', DEPARTMENT_TOKEN)).toBe(CUSTOM); + }); + + it('holds a custom style back until the department token is active', () => { + // The custom style would load blank on the built-in token. + const config = { MapDayStyleUrl: CUSTOM, MapNightStyleUrl: CUSTOM, AppMapboxAccessToken: DEPARTMENT_TOKEN, IsDepartmentMapOverride: true }; + + expect(resolveDepartmentMapStyle(config, 'light', 'pk.builtin.token')).toBe(FALLBACK_DAY_MAP_STYLE); + expect(resolveDepartmentMapStyle(config, 'dark', undefined)).toBe(FALLBACK_NIGHT_MAP_STYLE); + }); + + it('never uses an override style the server sent without a token', () => { + expect(resolveDepartmentMapStyle({ MapDayStyleUrl: CUSTOM, IsDepartmentMapOverride: true }, 'light', 'pk.builtin.token')).toBe(FALLBACK_DAY_MAP_STYLE); + }); + + it('uses Mapbox styles on any token', () => { + expect(resolveDepartmentMapStyle({ MapDayStyleUrl: SATELLITE, AppMapboxAccessToken: DEPARTMENT_TOKEN }, 'light', 'pk.builtin.token')).toBe(SATELLITE); + }); + + it('uses community styles on any token, with or without a server token', () => { + // Gallery styles live under mapbox-map-design and are public. + expect(resolveDepartmentMapStyle({ MapDayStyleUrl: COMMUNITY, AppMapboxAccessToken: '' }, 'light', 'pk.builtin.token')).toBe(COMMUNITY); + expect(resolveDepartmentMapStyle({ MapDayStyleUrl: COMMUNITY, AppMapboxAccessToken: DEPARTMENT_TOKEN }, 'light', 'pk.builtin.token')).toBe(COMMUNITY); + expect(resolveDepartmentMapStyle({ MapNightStyleUrl: COMMUNITY }, 'dark', undefined)).toBe(COMMUNITY); + }); +}); + +describe('getDepartmentMapStyle', () => { + beforeEach(() => jest.clearAllMocks()); + + it('reads the current config from the store', () => { + mockedGetState.mockReturnValue({ config: { MapDayStyleUrl: SATELLITE, MapNightStyleUrl: NAVIGATION_NIGHT } }); + + expect(getDepartmentMapStyle('dark')).toBe(NAVIGATION_NIGHT); + expect(getDepartmentMapStyle('light')).toBe(SATELLITE); + }); + + it('falls back while the store has no config', () => { + mockedGetState.mockReturnValue({ config: null }); + + expect(getDepartmentMapStyle('dark')).toBe(FALLBACK_NIGHT_MAP_STYLE); + }); + + it('checks a custom style against the token in use', () => { + mockedGetState.mockReturnValue({ config: { MapDayStyleUrl: CUSTOM, AppMapboxAccessToken: DEPARTMENT_TOKEN, IsDepartmentMapOverride: true } }); + + mockActiveToken = 'pk.builtin.token'; + expect(getDepartmentMapStyle('light')).toBe(FALLBACK_DAY_MAP_STYLE); + + mockActiveToken = DEPARTMENT_TOKEN; + expect(getDepartmentMapStyle('light')).toBe(CUSTOM); + }); +}); diff --git a/src/lib/__tests__/mapbox-token.test.ts b/src/lib/__tests__/mapbox-token.test.ts new file mode 100644 index 00000000..6e33f583 --- /dev/null +++ b/src/lib/__tests__/mapbox-token.test.ts @@ -0,0 +1,255 @@ +import { + applyServerMapboxToken, + clearMapboxToken, + getBuiltInMapboxToken, + getMapboxAccessToken, + isUsableMapboxToken, + MAPBOX_TOKEN_RECHECK_MS, + onMapboxAccessTokenChange, + useMapboxTokenStore, + verifyMapboxToken, +} from '@/lib/mapbox-token'; + +jest.mock('@/lib/mapbox-built-in-token', () => ({ + MAPBOX_BUILT_IN_TOKEN: 'pk.builtin.signature', +})); + +// The map lives inside the factory: persist reads storage while the module under test is imported, +// which happens before any top-level const in this file is initialised. +jest.mock('@/lib/storage', () => { + const store = new Map(); + + return { + __store: store, + zustandStorage: { + getItem: (name: string) => store.get(name) ?? null, + setItem: (name: string, value: string) => { + store.set(name, value); + }, + removeItem: (name: string) => { + store.delete(name); + }, + }, + }; +}); + +const mockStorage: Map = jest.requireMock('@/lib/storage').__store; + +const SERVER_TOKEN = 'pk.eyJ1IjoiY291bnR5LWZpcmUifQ.server-signature_1'; +const OTHER_TOKEN = 'pk.eyJ1Ijoib3RoZXIifQ.other-signature'; + +const mockFetchCode = (code: string | null) => { + (global as any).fetch = jest.fn().mockResolvedValue({ + json: () => Promise.resolve(code === null ? {} : { code }), + }); +}; + +const mockFetchFailure = () => { + (global as any).fetch = jest.fn().mockRejectedValue(new Error('Network request failed')); +}; + +describe('mapbox-token', () => { + const originalFetch = (global as any).fetch; + + beforeEach(() => { + clearMapboxToken(); + mockStorage.clear(); + }); + + afterAll(() => { + (global as any).fetch = originalFetch; + }); + + describe('isUsableMapboxToken', () => { + it('accepts public tokens', () => { + expect(isUsableMapboxToken(SERVER_TOKEN)).toBe(true); + expect(isUsableMapboxToken(` ${SERVER_TOKEN} `)).toBe(true); + }); + + it('refuses secret, temporary, empty and malformed tokens', () => { + expect(isUsableMapboxToken('sk.eyJ1IjoieCJ9.secret')).toBe(false); + expect(isUsableMapboxToken('tk.eyJ1IjoieCJ9.temp')).toBe(false); + expect(isUsableMapboxToken('')).toBe(false); + expect(isUsableMapboxToken(null)).toBe(false); + expect(isUsableMapboxToken(undefined)).toBe(false); + expect(isUsableMapboxToken('pk.only-one-part')).toBe(false); + expect(isUsableMapboxToken('pk.has spaces.inside')).toBe(false); + }); + }); + + it('uses the built-in token until the server sends one', () => { + expect(getBuiltInMapboxToken()).toBe('pk.builtin.signature'); + expect(getMapboxAccessToken()).toBe('pk.builtin.signature'); + }); + + it('adopts and stores a server token Mapbox confirms', async () => { + mockFetchCode('TokenValid'); + + await applyServerMapboxToken(SERVER_TOKEN); + + expect(getMapboxAccessToken()).toBe(SERVER_TOKEN); + expect((global as any).fetch).toHaveBeenCalledWith(expect.stringContaining('https://api.mapbox.com/tokens/v2?access_token=')); + expect(mockStorage.get('mapbox-token-storage')).toContain(SERVER_TOKEN); + }); + + it('does not re-check a confirmed token on every config load', async () => { + mockFetchCode('TokenValid'); + await applyServerMapboxToken(SERVER_TOKEN); + (global as any).fetch.mockClear(); + + await applyServerMapboxToken(SERVER_TOKEN); + + expect((global as any).fetch).not.toHaveBeenCalled(); + }); + + it('re-checks a confirmed token once a day and drops it when revoked', async () => { + mockFetchCode('TokenValid'); + await applyServerMapboxToken(SERVER_TOKEN); + + mockFetchCode('TokenRevoked'); + await applyServerMapboxToken(SERVER_TOKEN, Date.now() + MAPBOX_TOKEN_RECHECK_MS + 1); + + expect(getMapboxAccessToken()).toBe('pk.builtin.signature'); + }); + + it.each(['TokenInvalid', 'TokenExpired', 'TokenRevoked', 'TokenMalformed'])('falls back to the built-in token when Mapbox says %s', async (code) => { + mockFetchCode('TokenValid'); + await applyServerMapboxToken(OTHER_TOKEN); + + mockFetchCode(code); + await applyServerMapboxToken(SERVER_TOKEN); + + expect(getMapboxAccessToken()).toBe('pk.builtin.signature'); + }); + + it('does not re-check a recently refused token', async () => { + mockFetchCode('TokenInvalid'); + await applyServerMapboxToken(SERVER_TOKEN); + (global as any).fetch.mockClear(); + + await applyServerMapboxToken(SERVER_TOKEN); + + expect((global as any).fetch).not.toHaveBeenCalled(); + expect(getMapboxAccessToken()).toBe('pk.builtin.signature'); + }); + + it('keeps the token in use when Mapbox cannot be reached', async () => { + mockFetchCode('TokenValid'); + await applyServerMapboxToken(OTHER_TOKEN); + + mockFetchFailure(); + await applyServerMapboxToken(SERVER_TOKEN); + + expect(getMapboxAccessToken()).toBe(OTHER_TOKEN); + }); + + it('keeps the token in use on an unexpected Mapbox answer', async () => { + expect(await (mockFetchCode(null), verifyMapboxToken(SERVER_TOKEN))).toBe('unknown'); + }); + + it('drops the stored token when the server stops sending one', async () => { + mockFetchCode('TokenValid'); + await applyServerMapboxToken(SERVER_TOKEN); + + await applyServerMapboxToken(''); + + expect(getMapboxAccessToken()).toBe('pk.builtin.signature'); + }); + + it('never checks or stores a secret token', async () => { + mockFetchCode('TokenValid'); + + await applyServerMapboxToken('sk.eyJ1IjoieCJ9.secret'); + + expect((global as any).fetch).not.toHaveBeenCalled(); + expect(getMapboxAccessToken()).toBe('pk.builtin.signature'); + }); + + it('tells listeners about the token now and on every change', async () => { + const listener = jest.fn(); + const unsubscribe = onMapboxAccessTokenChange(listener); + + mockFetchCode('TokenValid'); + await applyServerMapboxToken(SERVER_TOKEN); + clearMapboxToken(); + unsubscribe(); + await applyServerMapboxToken(OTHER_TOKEN); + + expect(listener.mock.calls.map((call) => call[0])).toEqual(['pk.builtin.signature', SERVER_TOKEN, 'pk.builtin.signature']); + }); + + it('does not restore a token whose check finishes after it was cleared', async () => { + let answer: (value: unknown) => void = () => undefined; + (global as any).fetch = jest.fn( + () => + new Promise((resolve) => { + answer = resolve; + }) + ); + + const pending = applyServerMapboxToken(SERVER_TOKEN); + clearMapboxToken(); + answer({ json: () => Promise.resolve({ code: 'TokenValid' }) }); + await pending; + + expect(getMapboxAccessToken()).toBe('pk.builtin.signature'); + expect(useMapboxTokenStore.getState().token).toBe(null); + }); + + it('lets a newer config load win over an older check still in flight', async () => { + const answers: ((value: unknown) => void)[] = []; + (global as any).fetch = jest.fn( + () => + new Promise((resolve) => { + answers.push(resolve); + }) + ); + + const older = applyServerMapboxToken(OTHER_TOKEN); + const newer = applyServerMapboxToken(SERVER_TOKEN); + answers[1]({ json: () => Promise.resolve({ code: 'TokenValid' }) }); + await newer; + answers[0]({ json: () => Promise.resolve({ code: 'TokenValid' }) }); + await older; + + expect(getMapboxAccessToken()).toBe(SERVER_TOKEN); + }); + + it('does not let a check still in flight bring back a token the server stopped sending', async () => { + let answer: (value: unknown) => void = () => undefined; + (global as any).fetch = jest.fn( + () => + new Promise((resolve) => { + answer = resolve; + }) + ); + + const pending = applyServerMapboxToken(SERVER_TOKEN); + await applyServerMapboxToken(''); + answer({ json: () => Promise.resolve({ code: 'TokenValid' }) }); + await pending; + + expect(getMapboxAccessToken()).toBe('pk.builtin.signature'); + }); + + it('keeps notifying and lets the change complete when a listener throws', async () => { + const failing = jest.fn(() => { + throw new Error('SDK unavailable'); + }); + const unsubscribe = onMapboxAccessTokenChange(failing); + + mockFetchCode('TokenValid'); + await applyServerMapboxToken(SERVER_TOKEN); + expect(() => clearMapboxToken()).not.toThrow(); + unsubscribe(); + + expect(failing).toHaveBeenCalledTimes(3); + expect(getMapboxAccessToken()).toBe('pk.builtin.signature'); + }); + + it('starts from the stored token', () => { + useMapboxTokenStore.setState({ token: SERVER_TOKEN, verifiedAt: Date.now() }); + + expect(getMapboxAccessToken()).toBe(SERVER_TOKEN); + }); +}); diff --git a/src/lib/map-style.ts b/src/lib/map-style.ts new file mode 100644 index 00000000..008810a4 --- /dev/null +++ b/src/lib/map-style.ts @@ -0,0 +1,67 @@ +import { useColorScheme } from 'nativewind'; + +import { getMapboxAccessToken, useMapboxAccessToken } from '@/lib/mapbox-token'; +import { useCoreStore } from '@/stores/app/core-store'; + +/** + * The department's Mapbox base map. + * + * Departments pick a day style and a night style on the web Mapping Settings screen (Streets, + * Outdoors, Light, Dark, Satellite, Satellite Streets, Navigation Day/Night, or one of the Mapbox + * gallery's community styles), or use their own Mapbox account's custom style. The server resolves both, including the "Automatic" pairing, and ships them + * in config as mapbox:// urls. Every base map in the app renders through here, so the choice reaches + * every map, and the device's dark mode switches to the night style. + * + * The fallbacks cover only the window before config loads and servers that predate the setting. They + * are the same Streets / Dark pair the server sends for a department that has not chosen, so a map that + * renders during bootstrap does not visibly change style a moment later. + */ + +export const FALLBACK_DAY_MAP_STYLE = 'mapbox://styles/mapbox/streets-v12'; +export const FALLBACK_NIGHT_MAP_STYLE = 'mapbox://styles/mapbox/dark-v11'; + +interface MapStyleConfig { + MapDayStyleUrl?: string | null; + MapNightStyleUrl?: string | null; + AppMapboxAccessToken?: string | null; + IsDepartmentMapOverride?: boolean | null; +} + +/** Only a mapbox:// style can load on the app's own token; anything else falls back. */ +const toMapboxStyle = (value: string | null | undefined, fallback: string): string => { + const trimmed = typeof value === 'string' ? value.trim() : ''; + + return trimmed.startsWith('mapbox://styles/') ? trimmed : fallback; +}; + +export const resolveDepartmentMapStyle = (config: MapStyleConfig | null | undefined, colorScheme: string | null | undefined, activeToken?: string | null): string => { + const isDark = colorScheme === 'dark'; + const fallback = isDark ? FALLBACK_NIGHT_MAP_STYLE : FALLBACK_DAY_MAP_STYLE; + const style = toMapboxStyle(isDark ? config?.MapNightStyleUrl : config?.MapDayStyleUrl, fallback); + + if (config?.IsDepartmentMapOverride === true) { + // The department's own Mapbox account: its custom style only loads on its own token. Until that token + // has been checked and is the one in use, a map asking for the style would come back blank, so show + // the default until the token switch lands. Every other style the server sends (Mapbox's own and the + // gallery's community styles) is public and loads on any token, the built-in one included. + const serverToken = typeof config?.AppMapboxAccessToken === 'string' ? config.AppMapboxAccessToken.trim() : ''; + + if (!serverToken || (activeToken ?? '').trim() !== serverToken) { + return fallback; + } + } + + return style; +}; + +/** Reactive: re-renders when config lands, the device theme flips or the Mapbox token changes. */ +export const useDepartmentMapStyle = (): string => { + const { colorScheme } = useColorScheme(); + const config = useCoreStore((state) => state.config); + const activeToken = useMapboxAccessToken(); + + return resolveDepartmentMapStyle(config, colorScheme, activeToken); +}; + +/** Non-reactive read for imperative paths (map construction inside effects). */ +export const getDepartmentMapStyle = (colorScheme: string | null | undefined): string => resolveDepartmentMapStyle(useCoreStore.getState().config, colorScheme, getMapboxAccessToken()); diff --git a/src/lib/mapbox-built-in-token.ts b/src/lib/mapbox-built-in-token.ts new file mode 100644 index 00000000..71f63b0b --- /dev/null +++ b/src/lib/mapbox-built-in-token.ts @@ -0,0 +1,4 @@ +import { Env } from '@/lib/env'; + +/** The Mapbox token built into this app; used whenever the server has not supplied a verified one. */ +export const MAPBOX_BUILT_IN_TOKEN: string = Env.UNIT_MAPBOX_PUBKEY ?? ''; diff --git a/src/lib/mapbox-token.ts b/src/lib/mapbox-token.ts new file mode 100644 index 00000000..8870b0ba --- /dev/null +++ b/src/lib/mapbox-token.ts @@ -0,0 +1,170 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +import { logger } from '@/lib/logging'; +import { MAPBOX_BUILT_IN_TOKEN } from '@/lib/mapbox-built-in-token'; +import { zustandStorage } from '@/lib/storage'; + +/** + * Which Mapbox token the app renders maps and calls Mapbox services (directions, geocoding, static + * images) with. + * + * The server hands out a public token in config (`AppMapboxAccessToken`): the department's own token + * when its Mapbox override is on, otherwise the token the installation configures for this app. A token + * the server sends is checked with Mapbox once, kept on the device, and used from then on, so a token + * can be rotated without an app release. Whenever the server sends none, or Mapbox says the token is + * invalid, expired or revoked, the app goes back to the token built into it. + * + * Storage is synchronous (MMKV), so the stored token is already in place when modules that call the + * Mapbox SDK at import time read it. + */ + +/** Public tokens only: "pk." plus a base64url payload and signature. Secret (sk.) tokens never qualify. */ +const PUBLIC_TOKEN_PATTERN = /^pk\.[A-Za-z0-9_=-]+\.[A-Za-z0-9_=-]+$/; + +/** A token in use is re-checked this often; a rejected token is not re-checked sooner than this. */ +export const MAPBOX_TOKEN_RECHECK_MS = 24 * 60 * 60 * 1000; + +const MAPBOX_TOKEN_CHECK_URL = 'https://api.mapbox.com/tokens/v2'; + +interface MapboxTokenState { + /** Server token that Mapbox confirmed valid; null = use the built-in token. */ + token: string | null; + verifiedAt: number | null; + /** Last server token Mapbox refused, so it is not re-checked on every config load. */ + rejectedToken: string | null; + rejectedAt: number | null; +} + +const EMPTY_STATE: MapboxTokenState = { token: null, verifiedAt: null, rejectedToken: null, rejectedAt: null }; + +/** + * Bumped by every apply and every clear. A Mapbox check is a network round trip: an answer that lands + * after a sign-out, a server switch or a newer config load is dropped, so it cannot bring back a token + * (possibly another department's) that no longer belongs to this session. + */ +let tokenGeneration = 0; + +export const useMapboxTokenStore = create()( + persist(() => ({ ...EMPTY_STATE }), { + name: 'mapbox-token-storage', + storage: createJSONStorage(() => zustandStorage), + }) +); + +export const isUsableMapboxToken = (value: string | null | undefined): value is string => typeof value === 'string' && PUBLIC_TOKEN_PATTERN.test(value.trim()); + +export const getBuiltInMapboxToken = (): string => (typeof MAPBOX_BUILT_IN_TOKEN === 'string' ? MAPBOX_BUILT_IN_TOKEN.trim() : ''); + +const resolveToken = (stored: string | null | undefined): string => (isUsableMapboxToken(stored) ? stored.trim() : getBuiltInMapboxToken()); + +/** Non-reactive: the token to use right now (stored server token, else built-in). */ +export const getMapboxAccessToken = (): string => resolveToken(useMapboxTokenStore.getState().token); + +/** Reactive: re-renders when the token in use changes. */ +export const useMapboxAccessToken = (): string => useMapboxTokenStore((state) => resolveToken(state.token)); + +/** + * A listener runs inside whatever changed the store, sign-out included: one that throws is logged + * rather than allowed to abort that caller. + */ +const notifyTokenListener = (listener: (token: string) => void, token: string): void => { + try { + listener(token); + } catch (error) { + logger.error({ + message: 'Mapbox access token listener failed', + context: { error }, + }); + } +}; + +/** + * Calls `listener` with the token in use now and again whenever it changes. Store listeners run + * synchronously inside the state change, before React re-renders, so an SDK token set here is in place + * before any map re-renders with a style that needs it. + */ +export const onMapboxAccessTokenChange = (listener: (token: string) => void): (() => void) => { + notifyTokenListener(listener, getMapboxAccessToken()); + + return useMapboxTokenStore.subscribe((state, previous) => { + const next = resolveToken(state.token); + + if (next !== resolveToken(previous.token)) { + notifyTokenListener(listener, next); + } + }); +}; + +export type MapboxTokenVerdict = 'valid' | 'invalid' | 'unknown'; + +const REFUSED_CODES = ['TokenInvalid', 'TokenExpired', 'TokenRevoked', 'TokenMalformed']; + +/** Asks Mapbox whether a token works. Network trouble is 'unknown', never 'invalid'. */ +export const verifyMapboxToken = async (token: string): Promise => { + try { + const response = await fetch(`${MAPBOX_TOKEN_CHECK_URL}?access_token=${encodeURIComponent(token)}`); + const body = (await response.json().catch(() => null)) as { code?: string } | null; + + if (body?.code === 'TokenValid') { + return 'valid'; + } + + if (body?.code && REFUSED_CODES.includes(body.code)) { + return 'invalid'; + } + + return 'unknown'; + } catch { + return 'unknown'; + } +}; + +/** + * Applies the token from a successful config load. Call it only when config actually arrived: an + * empty or missing token (an older server, or none configured) drops back to the built-in token. + */ +export const applyServerMapboxToken = async (serverToken: string | null | undefined, now: number = Date.now()): Promise => { + const generation = ++tokenGeneration; + const candidate = typeof serverToken === 'string' ? serverToken.trim() : ''; + const state = useMapboxTokenStore.getState(); + + if (!isUsableMapboxToken(candidate)) { + if (state.token !== null) { + useMapboxTokenStore.setState({ token: null, verifiedAt: null }); + } + return; + } + + if (candidate === state.token && state.verifiedAt !== null && now - state.verifiedAt < MAPBOX_TOKEN_RECHECK_MS) { + return; + } + + if (candidate === state.rejectedToken && state.rejectedAt !== null && now - state.rejectedAt < MAPBOX_TOKEN_RECHECK_MS) { + // Refused recently. The server is still sending it, so the stored token (if any) is stale too. + if (state.token !== null) { + useMapboxTokenStore.setState({ token: null, verifiedAt: null }); + } + return; + } + + const verdict = await verifyMapboxToken(candidate); + + if (generation !== tokenGeneration) { + // Cleared (sign-out, server switch) or superseded by a newer config load while Mapbox answered. + return; + } + + if (verdict === 'valid') { + useMapboxTokenStore.setState({ token: candidate, verifiedAt: Date.now(), rejectedToken: null, rejectedAt: null }); + } else if (verdict === 'invalid') { + useMapboxTokenStore.setState({ token: null, verifiedAt: null, rejectedToken: candidate, rejectedAt: Date.now() }); + } + // 'unknown' (offline, Mapbox unreachable): keep whatever is in use and try again on the next config load. +}; + +/** Forget the server token (sign-out, server switch); the built-in token is used until config loads again. */ +export const clearMapboxToken = (): void => { + tokenGeneration += 1; + useMapboxTokenStore.setState({ ...EMPTY_STATE }); +}; diff --git a/src/models/v4/configs/getConfigResultData.ts b/src/models/v4/configs/getConfigResultData.ts index da17be72..ab5e458e 100644 --- a/src/models/v4/configs/getConfigResultData.ts +++ b/src/models/v4/configs/getConfigResultData.ts @@ -22,4 +22,12 @@ export class GetConfigResultData { public MapCenterLongitude: number = 0; /** Zoom level for department-wide maps. */ public MapCenterZoomLevel: number = 9; + /** Department base map style (mapbox:// url) for the light theme. */ + public MapDayStyleUrl: string = ''; + /** Department base map style (mapbox:// url) for the dark theme. */ + public MapNightStyleUrl: string = ''; + /** Mapbox public token for this app ('' = keep the built-in token); the department's own when its Mapbox override is on. */ + public AppMapboxAccessToken: string = ''; + /** True when the department's own Mapbox account (token and custom style) is in effect; its style then waits for that token. */ + public IsDepartmentMapOverride: boolean = false; } diff --git a/src/services/__tests__/app-reset.service.test.ts b/src/services/__tests__/app-reset.service.test.ts index 7509a1f6..867e7898 100644 --- a/src/services/__tests__/app-reset.service.test.ts +++ b/src/services/__tests__/app-reset.service.test.ts @@ -57,6 +57,7 @@ jest.mock('@/services/signalr.service', () => ({ // Mock all the store imports jest.mock('@/stores/app/core-store', () => ({ + invalidateConfigRequests: jest.fn(), useCoreStore: { setState: jest.fn(), getState: jest.fn(() => ({})), @@ -265,6 +266,10 @@ jest.mock('@/stores/signalr/signalr-store', () => ({ }, })); +jest.mock('@/lib/mapbox-token', () => ({ + clearMapboxToken: jest.fn(), +})); + import { clearAllAppData, clearAppStorageItems, @@ -669,6 +674,10 @@ describe('app-reset.service', () => { expect(mockDeploymentsReset).toHaveBeenCalled(); expect(mockPreplanReset).toHaveBeenCalled(); expect(mockSiteInfoReset).toHaveBeenCalled(); + // A department's own Mapbox token must not outlive its session. + expect(jest.requireMock('@/lib/mapbox-token').clearMapboxToken).toHaveBeenCalled(); + // A config response still in flight for the old session must not repopulate config or the token. + expect(jest.requireMock('@/stores/app/core-store').invalidateConfigRequests).toHaveBeenCalled(); }); it('should disconnect from LiveKit room if connected', async () => { diff --git a/src/services/app-reset.service.ts b/src/services/app-reset.service.ts index 971a933f..d5485755 100644 --- a/src/services/app-reset.service.ts +++ b/src/services/app-reset.service.ts @@ -9,6 +9,7 @@ import { queryClient } from '@/api/common/api-provider'; import { registerSessionCleanupHandler } from '@/lib/auth/session-cleanup'; import { logger } from '@/lib/logging'; +import { clearMapboxToken } from '@/lib/mapbox-token'; import { SHARED_INSTALLATION_STORAGE_KEY } from '@/lib/mfa/shared-installation'; import { storage } from '@/lib/storage'; import { BASE_API_URL_STORAGE_KEY, removeActiveCallId, removeActiveUnitId, removeDeviceUuid } from '@/lib/storage/app'; @@ -17,7 +18,7 @@ import { pushNotificationService } from '@/services/push-notification'; import { signalRService } from '@/services/signalr.service'; import { useAudioStreamStore } from '@/stores/app/audio-stream-store'; import { INITIAL_STATE as BLUETOOTH_INITIAL_STATE, useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store'; -import { useCoreStore } from '@/stores/app/core-store'; +import { invalidateConfigRequests, useCoreStore } from '@/stores/app/core-store'; import { useLiveKitStore } from '@/stores/app/livekit-store'; import { useLoadingStore } from '@/stores/app/loading-store'; import { useLocationStore } from '@/stores/app/location-store'; @@ -275,6 +276,10 @@ export const clearAppStorageItems = (): void => { * Uses existing reset methods where available */ export const resetAllStores = async (): Promise => { + // A config request still in flight belongs to the session being cleared; its answer must not + // repopulate the config or the Mapbox token. + invalidateConfigRequests(); + // Core stores - use setState with initial state constants useCoreStore.setState(INITIAL_CORE_STATE); useCallsStore.setState(INITIAL_CALLS_STATE); @@ -337,6 +342,10 @@ export const resetAllStores = async (): Promise => { useRoutesStore.setState(INITIAL_ROUTES_STATE); useWeatherAlertsStore.getState().reset(); + // The server-supplied Mapbox token can be the department's own; maps use the built-in token until + // the next sign-in's config arrives. + clearMapboxToken(); + // Check-in timer store — reset() also stops the 30s polling interval that // would otherwise keep fetching the OLD call's timers under the NEW user's // credentials after re-login. diff --git a/src/stores/app/__tests__/core-store.test.ts b/src/stores/app/__tests__/core-store.test.ts index 23ddeb39..1a143755 100644 --- a/src/stores/app/__tests__/core-store.test.ts +++ b/src/stores/app/__tests__/core-store.test.ts @@ -61,8 +61,13 @@ jest.mock('@/lib/storage', () => ({ }, })); +jest.mock('@/lib/mapbox-token', () => ({ + applyServerMapboxToken: jest.fn(() => Promise.resolve()), +})); + // Import after mocks -import { UnitListUnavailableError, useCoreStore } from '../core-store'; +import { invalidateConfigRequests, UnitListUnavailableError, useCoreStore } from '../core-store'; +import { applyServerMapboxToken } from '@/lib/mapbox-token'; import { getActiveUnitId, getActiveCallId, removeActiveUnitId } from '@/lib/storage/app'; import { getConfig } from '@/api/config'; import { logger } from '@/lib/logging'; @@ -74,6 +79,7 @@ import { useUnitsStore } from '@/stores/units/store'; const mockGetActiveUnitId = getActiveUnitId as jest.MockedFunction; const mockGetActiveCallId = getActiveCallId as jest.MockedFunction; const mockGetConfig = getConfig as jest.MockedFunction; +const mockApplyServerMapboxToken = applyServerMapboxToken as jest.MockedFunction; describe('Core Store', () => { beforeEach(() => { @@ -312,6 +318,101 @@ describe('Core Store', () => { expect(result.current.config?.EventingUrl).toBe(eventingUrl); }); + + it('hands the server Mapbox token to the token store after a successful load', async () => { + mockGetConfig.mockResolvedValue({ + Data: { + EventingUrl: 'https://eventing.example.com/', + AppMapboxAccessToken: 'pk.server.token', + } as GetConfigResultData, + } as any); + + const { result } = renderHook(() => useCoreStore()); + + await act(async () => { + await result.current.fetchConfig(); + }); + + expect(mockApplyServerMapboxToken).toHaveBeenCalledTimes(1); + expect(mockApplyServerMapboxToken).toHaveBeenCalledWith('pk.server.token'); + }); + + it('leaves the Mapbox token alone when the config load fails', async () => { + mockGetConfig.mockRejectedValue(new Error('Config service unavailable')); + + const { result } = renderHook(() => useCoreStore()); + + await act(async () => { + await expect(result.current.fetchConfig()).rejects.toThrow('Config service unavailable'); + }); + + expect(mockApplyServerMapboxToken).not.toHaveBeenCalled(); + }); + + it('does not let a failed Mapbox token check fail the config load', async () => { + mockApplyServerMapboxToken.mockRejectedValueOnce(new Error('storage unavailable')); + mockGetConfig.mockResolvedValue({ + Data: { + EventingUrl: 'https://eventing.example.com/', + AppMapboxAccessToken: 'pk.server.token', + } as GetConfigResultData, + } as any); + + const { result } = renderHook(() => useCoreStore()); + + await act(async () => { + await result.current.fetchConfig(); + }); + + expect(result.current.error).toBe(null); + expect(result.current.config?.AppMapboxAccessToken).toBe('pk.server.token'); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('Mapbox token') })); + }); + + it('drops a config response that arrives after sign-out or a server switch', async () => { + let resolveConfig: (value: unknown) => void = () => undefined; + mockGetConfig.mockReturnValue( + new Promise((resolve) => { + resolveConfig = resolve; + }) as any + ); + + const { result } = renderHook(() => useCoreStore()); + + await act(async () => { + const pending = result.current.fetchConfig(); + invalidateConfigRequests(); + resolveConfig({ + Data: { + EventingUrl: 'https://previous-server.example.com/', + AppMapboxAccessToken: 'pk.previous.token', + } as GetConfigResultData, + }); + await pending; + }); + + expect(result.current.config).toBe(null); + expect(mockApplyServerMapboxToken).not.toHaveBeenCalled(); + }); + + it('applies config requested after an invalidation', async () => { + invalidateConfigRequests(); + mockGetConfig.mockResolvedValue({ + Data: { + EventingUrl: 'https://next-server.example.com/', + AppMapboxAccessToken: 'pk.next.token', + } as GetConfigResultData, + } as any); + + const { result } = renderHook(() => useCoreStore()); + + await act(async () => { + await result.current.fetchConfig(); + }); + + expect(result.current.config?.EventingUrl).toBe('https://next-server.example.com/'); + expect(mockApplyServerMapboxToken).toHaveBeenCalledWith('pk.next.token'); + }); }); describe('setActiveUnit', () => { diff --git a/src/stores/app/__tests__/server-url-store.test.ts b/src/stores/app/__tests__/server-url-store.test.ts new file mode 100644 index 00000000..1dab6d62 --- /dev/null +++ b/src/stores/app/__tests__/server-url-store.test.ts @@ -0,0 +1,55 @@ +jest.mock('@/lib/cache/cache-manager', () => ({ + cacheManager: { clear: jest.fn() }, +})); + +jest.mock('@/lib/mapbox-token', () => ({ + clearMapboxToken: jest.fn(), +})); + +jest.mock('@/lib/storage/app', () => ({ + getBaseApiUrl: jest.fn(), + setBaseApiUrl: jest.fn(() => Promise.resolve()), +})); + +jest.mock('@/stores/app/core-store', () => ({ + invalidateConfigRequests: jest.fn(), +})); + +import { cacheManager } from '@/lib/cache/cache-manager'; +import { clearMapboxToken } from '@/lib/mapbox-token'; +import { getBaseApiUrl, setBaseApiUrl } from '@/lib/storage/app'; +import { invalidateConfigRequests } from '@/stores/app/core-store'; + +import { useServerUrlStore } from '../server-url-store'; + +const mockGetBaseApiUrl = getBaseApiUrl as jest.MockedFunction; + +describe('server-url-store', () => { + beforeEach(() => { + jest.clearAllMocks(); + useServerUrlStore.setState({ url: '' }); + }); + + it('drops data from the previous server when the URL changes', async () => { + mockGetBaseApiUrl.mockReturnValue('https://old.example.com/api/v4'); + + await useServerUrlStore.getState().setUrl('https://new.example.com/api/v4'); + + expect(setBaseApiUrl).toHaveBeenCalledWith('https://new.example.com/api/v4'); + expect(useServerUrlStore.getState().url).toBe('https://new.example.com/api/v4'); + expect(cacheManager.clear).toHaveBeenCalled(); + // A config response still in flight for the old server must not bring back its config or Mapbox token. + expect(invalidateConfigRequests).toHaveBeenCalled(); + expect(clearMapboxToken).toHaveBeenCalled(); + }); + + it('keeps everything when the same URL is saved again', async () => { + mockGetBaseApiUrl.mockReturnValue('https://same.example.com/api/v4'); + + await useServerUrlStore.getState().setUrl('https://same.example.com/api/v4'); + + expect(cacheManager.clear).not.toHaveBeenCalled(); + expect(invalidateConfigRequests).not.toHaveBeenCalled(); + expect(clearMapboxToken).not.toHaveBeenCalled(); + }); +}); diff --git a/src/stores/app/core-store.ts b/src/stores/app/core-store.ts index 54b0a316..e958457a 100644 --- a/src/stores/app/core-store.ts +++ b/src/stores/app/core-store.ts @@ -6,6 +6,7 @@ import { createJSONStorage, persist } from 'zustand/middleware'; import { getConfig } from '@/api/config'; import { getUnitStatus } from '@/api/units/unitStatuses'; import { logger } from '@/lib/logging'; +import { applyServerMapboxToken } from '@/lib/mapbox-token'; import { zustandStorage } from '@/lib/storage'; import { getActiveCallId, getActiveUnitId, removeActiveCallId, removeActiveUnitId, setActiveCallId, setActiveUnitId } from '@/lib/storage/app'; import { type CallPriorityResultData } from '@/models/v4/callPriorities/callPriorityResultData'; @@ -21,6 +22,17 @@ import { useCallsStore } from '../calls/store'; //import { useRolesStore } from '../roles/store'; import { useUnitsStore } from '../units/store'; +/** + * Bumped on sign-out and on a server switch. A config response for a request started before that + * belongs to the previous session or server; applying it would bring back that department's config + * and Mapbox token, so fetchConfig drops it. + */ +let configSessionGeneration = 0; + +export const invalidateConfigRequests = (): void => { + configSessionGeneration += 1; +}; + /** * The unit list could not be loaded, so whether a unit still exists is unknown. * @@ -383,8 +395,17 @@ export const useCoreStore = create()( } }, fetchConfig: async () => { + const sessionGeneration = configSessionGeneration; try { const config = await getConfig(Env.APP_KEY); + + if (sessionGeneration !== configSessionGeneration) { + logger.info({ + message: 'Dropping a config response from before sign-out or a server switch', + }); + return; + } + // Only update if config actually changed to prevent unnecessary re-renders const current = get().config; if (!current || JSON.stringify(current) !== JSON.stringify(config.Data)) { @@ -393,6 +414,16 @@ export const useCoreStore = create()( // Clear error even if config hasn't changed set({ error: null }); } + + if (config.Data) { + // Fire and forget: the token is checked with Mapbox, which must never hold up or fail config loading. + applyServerMapboxToken(config.Data.AppMapboxAccessToken).catch((error) => { + logger.warn({ + message: 'Failed to apply the server Mapbox token', + context: { error }, + }); + }); + } } catch (error) { set({ error: 'Failed to fetch config', isLoading: false }); // Transient connectivity failures (offline, or the app cold-launched in the diff --git a/src/stores/app/server-url-store.ts b/src/stores/app/server-url-store.ts index d9195f35..65d963ae 100644 --- a/src/stores/app/server-url-store.ts +++ b/src/stores/app/server-url-store.ts @@ -1,7 +1,9 @@ import { create } from 'zustand'; import { cacheManager } from '@/lib/cache/cache-manager'; +import { clearMapboxToken } from '@/lib/mapbox-token'; import { getBaseApiUrl, setBaseApiUrl } from '@/lib/storage/app'; +import { invalidateConfigRequests } from '@/stores/app/core-store'; interface ServerUrlState { url: string; @@ -21,6 +23,10 @@ export const useServerUrlStore = create((set) => ({ // also scoped by base URL as a second layer of defense.) if (previousUrl !== url) { cacheManager.clear(); + // A config request still in flight is for the previous server; its answer must not be applied. + invalidateConfigRequests(); + // The Mapbox token came from the previous server; the built-in one applies until the new server's config loads. + clearMapboxToken(); } }, getUrl: async () => {