diff --git a/src/api/calls/callFiles.ts b/src/api/calls/callFiles.ts index 1a8ea0ce..56de28ae 100644 --- a/src/api/calls/callFiles.ts +++ b/src/api/calls/callFiles.ts @@ -3,8 +3,10 @@ import { Platform } from 'react-native'; import { createApiEndpoint } from '@/api/common/client'; import { logger } from '@/lib/logging'; +import { getBaseApiUrl } from '@/lib/storage/app'; import { type CallFilesResult } from '@/models/v4/callFiles/callFilesResult'; import { type SaveCallFileResult } from '@/models/v4/callFiles/saveCallFileResult'; +import useAuthStore from '@/stores/auth/store'; // Event types for the download process export type DownloadEventType = 'start' | 'progress' | 'complete' | 'error'; @@ -30,6 +32,25 @@ export interface DownloadOptions { const getCallFilesApi = createApiEndpoint('/CallFiles/GetFilesForCall'); const saveCallFileApi = createApiEndpoint('/CallFiles/SaveCallFile'); +/** + * Whether `url` points at the department's own Resgrid API. + * + * Attachment URLs arrive inside the server payload, and not all of them are ours: a department on + * external blob storage gets a pre-signed CDN link back. Those links carry their own credential in + * the query string and need no bearer, so sending one would hand this member's access token to a + * third-party host for nothing. + */ +const isApiOrigin = (url: string): boolean => { + try { + const target = new URL(url, getBaseApiUrl()); + const api = new URL(getBaseApiUrl()); + return target.origin === api.origin; + } catch { + // An unparseable URL is not a host we can vouch for. + return false; + } +}; + // Function to download a file with progress reporting export const getCallAttachmentFile = async (url: string, options: DownloadOptions = {}): Promise => { const { onEvent, headers = {}, timeout = 30000 } = options; @@ -40,9 +61,16 @@ export const getCallAttachmentFile = async (url: string, options: DownloadOption type: 'start', }); + // Attach the signed-in bearer, but only for our own API origin: authenticated file routes + // require it, the anonymous signed-link route simply ignores it, and an external storage or + // CDN host must never see it. Caller-supplied headers win on conflict. + const token = isApiOrigin(url) ? useAuthStore.getState().accessToken : null; const config: AxiosRequestConfig = { responseType: 'blob', - headers, + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...headers, + }, timeout, onDownloadProgress: (progressEvent: AxiosProgressEvent) => { if (progressEvent.total) { diff --git a/src/api/chat/chat.ts b/src/api/chat/chat.ts index f9399307..37ab1b11 100644 --- a/src/api/chat/chat.ts +++ b/src/api/chat/chat.ts @@ -206,9 +206,13 @@ export const getChatAttachmentThumbnailUrl = (attachmentId: string): string => ` /** * Image source (with bearer auth header) suitable for expo-image / RN Image * when rendering a chat attachment. + * + * `accessToken` lets a component pass the token it is subscribed to. Reading it from the store + * here is a one-shot snapshot, so a component that does not subscribe would keep rendering the + * pre-refresh bearer after a token rotation and the image request would 401. */ -export const getChatAttachmentImageSource = (attachmentId: string) => { - const token = useAuthStore.getState().accessToken; +export const getChatAttachmentImageSource = (attachmentId: string, accessToken?: string | null) => { + const token = accessToken !== undefined ? accessToken : useAuthStore.getState().accessToken; return { uri: getChatAttachmentUrl(attachmentId), headers: token ? { Authorization: `Bearer ${token}` } : undefined, diff --git a/src/api/common/client.tsx b/src/api/common/client.tsx index 5cc8ba85..b3144806 100644 --- a/src/api/common/client.tsx +++ b/src/api/common/client.tsx @@ -1,5 +1,6 @@ import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'; +import { readProtectedGrantHeaders } from '@/lib/data-protection/grant-provider'; import { logger } from '@/lib/logging'; import { getBaseApiUrl } from '@/lib/storage/app'; import useAuthStore from '@/stores/auth/store'; @@ -45,6 +46,21 @@ axiosInstance.interceptors.request.use( if (accessToken) { config.headers.Authorization = `Bearer ${accessToken}`; } + + // Advanced Data Protection: while the member holds a live grant, every read through this + // instance carries it, so a protected value comes back decrypted instead of REDACTED. + // + // Attached centrally on purpose. The alternative - each screen remembering to add the header - + // is the failure mode that already shipped twice on the web side, and it fails SILENTLY: the + // screen looks fine and simply shows placeholders. The grant only ever goes to Resgrid's own + // API (this instance's baseURL), is short-lived, and is bound to this member, department and + // policy epoch, so the server is the only thing that can act on it. + if (config.headers) { + for (const [name, value] of Object.entries(readProtectedGrantHeaders())) { + config.headers.set(name, value); + } + } + return config; }, (error: AxiosError) => { diff --git a/src/api/data-protection/data-protection.ts b/src/api/data-protection/data-protection.ts new file mode 100644 index 00000000..1794dc80 --- /dev/null +++ b/src/api/data-protection/data-protection.ts @@ -0,0 +1,71 @@ +import { api } from '../common/client'; + +const DATA_PROTECTION = '/DataProtection'; + +// --------------------------------------------------------------------------- +// Advanced Data Protection (ADP) — capability report, MFA step-up, and the +// exemption path. +// +// The step-up window is ABSOLUTE: the server returns its expiry once and never +// slides it. Clients conceal protected values at expiry and ask again on the +// next reveal. +// --------------------------------------------------------------------------- + +export interface DataProtectionCapabilitiesData { + State: number; + StateName?: string | null; + IsProtectionEnabled: boolean; + CatalogVersion: number; + CurrentCatalogVersion: number; + PolicyEpoch: number; + StepUpWindowMinutes: number; + IsDepartmentLocked: boolean; + LockReason?: string | null; + LockProjectedEndUtc?: string | null; +} + +export interface DataProtectionCapabilitiesResult { + Data?: DataProtectionCapabilitiesData; +} + +export interface StepUpResult { + /** Grant id (jti) for display/audit correlation; null when grants are not configured. */ + GrantId?: string | null; + /** Signed Protected Data Grant. MEMORY ONLY — never persisted, never logged. */ + GrantToken?: string | null; + /** Absolute UTC expiry of the step-up window (ISO 8601). */ + StepUpExpiresOnUtc?: string | null; + StepUpWindowMinutes?: number; +} + +/** Value-free ADP capability report for the caller's department. */ +export const getDataProtectionCapabilities = async (signal?: AbortSignal) => { + const response = await api.get(`${DATA_PROTECTION}/Capabilities`, { signal }); + return response.data; +}; + +/** + * Asks for a grant WITHOUT a second factor. + * + * A department may release named apps from the step-up prompt (ADP plan 3.3) — a dispatcher on a + * live incident cannot stop to read a code off a phone. The server answers with a grant when this + * department has exempted THIS app, and with `step_up_required` otherwise. The client never makes + * that decision; it only asks and reacts. + * + * Nothing is weakened by asking: the caller is still authenticated, and the grant that comes back + * is still tenant-bound, epoch-bound, short-lived and audited on every read it authorizes. + */ +export const requestProtectedGrant = async () => { + const response = await api.post(`${DATA_PROTECTION}/RequestGrant`, {}); + return response.data; +}; + +/** + * Verifies the user's authenticator (TOTP) code for the ADP step-up. + * Server problem types: invalid_totp (400/401), mfa_not_enrolled (409), + * too_many_attempts (429). The code is never logged anywhere. + */ +export const verifyStepUp = async (code: string) => { + const response = await api.post(`${DATA_PROTECTION}/VerifyStepUp`, { Code: code }); + return response.data; +}; diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index a09fa012..637708ac 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'; import { ActivityIndicator, type ColorValue, Platform, StyleSheet, useWindowDimensions } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { StepUpPromptHost } from '@/components/data-protection/step-up-prompt-host'; import { NotificationButton } from '@/components/notifications/NotificationButton'; import { NotificationInbox } from '@/components/notifications/NotificationInbox'; import Sidebar from '@/components/sidebar/sidebar'; @@ -33,6 +34,7 @@ import { bluetoothAudioService } from '@/services/bluetooth-audio.service'; import { usePushNotifications } from '@/services/push-notification'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; +import { dataProtectionStore } from '@/stores/data-protection/store'; import { FeatureFlagKeys, featureFlagsStore } from '@/stores/feature-flags/store'; import { useRolesStore } from '@/stores/roles/store'; import { securityStore } from '@/stores/security/store'; @@ -175,7 +177,14 @@ export default function TabLayout() { // These fetches are independent of each other — run in parallel to cut // time-to-interactive (previously 8+ serial network hops). - await Promise.all([useRolesStore.getState().init(), useCallsStore.getState().init(), useWeatherAlertsStore.getState().init(), securityStore.getState().getRights(), featureFlagsStore.getState().fetchFlags()]); + await Promise.all([ + useRolesStore.getState().init(), + useCallsStore.getState().init(), + useWeatherAlertsStore.getState().init(), + securityStore.getState().getRights(), + featureFlagsStore.getState().fetchFlags(), + dataProtectionStore.getState().fetchCapabilities(), + ]); if (!isCurrentRun()) return; @@ -575,6 +584,13 @@ export default function TabLayout() { const content = ( + {/* + The app's single Advanced Data Protection prompt. Mounted here so any screen can trigger it + through the store without carrying a modal of its own, and so two screens can never stack + two prompts over each other. + */} + + {/* Loading overlay during initialization — shown on top of Tabs so the navigator stays mounted */} {!isInitComplete ? ( diff --git a/src/app/(app)/contacts.tsx b/src/app/(app)/contacts.tsx index 9639163f..a1b83c11 100644 --- a/src/app/(app)/contacts.tsx +++ b/src/app/(app)/contacts.tsx @@ -7,6 +7,7 @@ import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; import { ContactCard } from '@/components/contacts/contact-card'; import { ContactDetailsSheet } from '@/components/contacts/contact-details-sheet'; +import { ProtectedRevealBar } from '@/components/data-protection/protected-reveal-bar'; import { FocusAwareStatusBar } from '@/components/ui'; import { Box } from '@/components/ui/box'; import { FlatList } from '@/components/ui/flat-list'; @@ -84,6 +85,13 @@ export default function Contacts() { + {/* + Contacts are heavily cataloged - names, phone numbers, email, government identifiers and + location. They arrive REDACTED and only come back decrypted on a request carrying a grant, + so revealing has to re-read the list. Renders nothing without the addon. + */} + fetchContacts(true)} /> + diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index 4285338f..a5162d39 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -9,6 +9,8 @@ import { CheckInTabContent } from '@/components/check-in-timers/check-in-tab-con import { HeaderBackButton } from '@/components/common/header-back-button'; import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; +import { ProtectedRevealBar } from '@/components/data-protection/protected-reveal-bar'; +import { ProtectedText } from '@/components/data-protection/protected-text'; import { IncidentCommandTabPanel } from '@/components/incident-command/incident-command-tab-panel'; import { FullScreenMap } from '@/components/maps/full-screen-map'; // Import a static map component instead of react-native-maps @@ -24,6 +26,7 @@ import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; import { getUnitTypeCheckInBadge } from '@/lib/check-in-timer-utils'; +import { isFieldRedacted, ProtectedFieldIds } from '@/lib/data-protection/redacted'; import { logger } from '@/lib/logging'; import { openMapsWithDirections } from '@/lib/navigation'; import { parseApiUtcDate, safeFormatDate } from '@/lib/utils'; @@ -373,7 +376,7 @@ export default function CallDetail() { {t('call_detail.address')} - {call.Address} + {destinationLabel ? ( @@ -385,7 +388,16 @@ export default function CallDetail() { {t('call_detail.note')} - + {/* + A withheld note is not empty HTML — rendering the sentinel through the HTML + renderer would print the bare word REDACTED in the body copy, which reads as + the note's content rather than as an absence. + */} + {isFieldRedacted(call.RedactedFields, ProtectedFieldIds.callNotes, call.Note) ? ( + + ) : ( + + )} @@ -409,11 +421,11 @@ export default function CallDetail() { {t('call_detail.contact_name')} - {call.ContactName} + {t('call_detail.contact_info')} - {call.ContactInfo} + @@ -545,8 +557,14 @@ export default function CallDetail() { const showingDestination = hasDestinationCoordinates && (mapTarget === 'destination' || !hasCallCoordinates); const mapLatitude = showingDestination ? destinationLatitude : coordinates.latitude; const mapLongitude = showingDestination ? destinationLongitude : coordinates.longitude; - const mapAddress = showingDestination ? call.DestinationAddress || call.DestinationName || '' : call.Address; - const mapTitle = showingDestination ? call.DestinationName || t('call_detail.destination') : call.Name || t('call_detail.call_location'); + // A withheld address or name must not leak through the map chrome. StaticMap prints `address` + // in its overlay AND its accessibility label, and FullScreenMap prints both the address overlay + // and the marker title, so the sentinel would surface there verbatim after being suppressed + // everywhere else on the screen. Destination fields are not in the protected catalog. + const isAddressRedacted = isFieldRedacted(call.RedactedFields, ProtectedFieldIds.callAddress, call.Address); + const isNameRedacted = isFieldRedacted(call.RedactedFields, ProtectedFieldIds.callName, call.Name); + const mapAddress = showingDestination ? call.DestinationAddress || call.DestinationName || '' : isAddressRedacted ? undefined : call.Address; + const mapTitle = showingDestination ? call.DestinationName || t('call_detail.destination') : (isNameRedacted ? undefined : call.Name) || t('call_detail.call_location'); return ( <> @@ -560,11 +578,25 @@ export default function CallDetail() { }} /> + {/* + Protected values (call name, nature, notes, address, contact details) arrive REDACTED and + only come back decrypted on a request carrying a grant, so revealing has to re-read the + call. Renders nothing for a department without the addon. + */} + fetchCallDetail(callId)} /> + {/* Header */} - {call.Name} ({call.Number}) + {/* The call NUMBER is not cataloged, so it stays visible and the record stays findable. */} + {isFieldRedacted(call.RedactedFields, ProtectedFieldIds.callName, call.Name) ? ( + + ) : ( + <> + {call.Name} ({call.Number}) + + )} {/* Show "Set Active" button if this call is not the active call and there is an active unit */} {activeUnit && activeCall?.CallId !== call.CallId ? ( @@ -576,7 +608,11 @@ export default function CallDetail() { - + {isFieldRedacted(call.RedactedFields, ProtectedFieldIds.callNature, call.Nature) ? ( + + ) : ( + + )} diff --git a/src/app/chat/[channelId].tsx b/src/app/chat/[channelId].tsx index 5297feb2..09ddadeb 100644 --- a/src/app/chat/[channelId].tsx +++ b/src/app/chat/[channelId].tsx @@ -56,7 +56,7 @@ export default function ChannelConversationScreen() { const [actionsMessage, setActionsMessage] = useState(null); const [editMessage, setEditMessage] = useState(null); const [editText, setEditText] = useState(''); - const [imageUri, setImageUri] = useState(null); + const [imageSource, setImageSource] = useState<{ uri: string; headers?: Record } | null>(null); const [presenceIds, setPresenceIds] = useState>(new Set()); const [resolveAttempted, setResolveAttempted] = useState(false); const unsubscribeRef = useRef<(() => void) | null>(null); @@ -249,7 +249,7 @@ export default function ChannelConversationScreen() { onToggleReaction={handleToggleReaction} onOpenThread={openThread} onRetry={handleRetry} - onPressImage={setImageUri} + onPressImage={setImageSource} /> ), [currentUserId, showSender, handleToggleReaction, openThread, handleRetry] @@ -406,15 +406,15 @@ export default function ChannelConversationScreen() { {/* Full-screen image preview */} - setImageUri(null)} snapPoints={[80]}> + setImageSource(null)} snapPoints={[80]}> - {imageUri ? ( + {imageSource ? (
- +
) : null}
diff --git a/src/app/login/index.tsx b/src/app/login/index.tsx index 218cebf7..401fb7c4 100644 --- a/src/app/login/index.tsx +++ b/src/app/login/index.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { LoginFormProps } from '@/app/login/login-form'; +import { LoginOtpModal } from '@/components/auth/login-otp-modal'; import { ServerUrlBottomSheet } from '@/components/settings/server-url-bottom-sheet'; import { FocusAwareStatusBar } from '@/components/ui'; import { Button, ButtonText } from '@/components/ui/button'; @@ -17,6 +18,10 @@ import { LoginForm } from './login-form'; export default function Login() { const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); const [showServerUrl, setShowServerUrl] = useState(false); + // Held only to resubmit with the TOTP code after an mfa_required challenge; memory-only, + // cleared on success/unmount with the rest of the component state. Never logged. + const [pendingCredentials, setPendingCredentials] = useState<{ username: string; password: string } | null>(null); + const [otpDismissed, setOtpDismissed] = useState(false); const { t } = useTranslation(); const { trackEvent } = useAnalytics(); @@ -48,9 +53,18 @@ export default function Login() { // ── Local login ─────────────────────────────────────────────────────────── const onSubmit: LoginFormProps['onSubmit'] = async (data) => { logger.info({ message: 'Starting Login (button press)' }); + setPendingCredentials({ username: data.username, password: data.password }); + setOtpDismissed(false); await login({ username: data.username, password: data.password }); }; + const onOtpSubmit = async (code: string) => { + if (!pendingCredentials) { + return; + } + await login({ ...pendingCredentials, otpCode: code }); + }; + return ( <> @@ -88,6 +102,15 @@ export default function Login() { setShowServerUrl(false)} /> + + {/* Two-factor challenge: token endpoint answered mfa_required / invalid_totp */} + setOtpDismissed(true)} + /> ); } diff --git a/src/app/login/sso.tsx b/src/app/login/sso.tsx index f910884d..da2a8966 100644 --- a/src/app/login/sso.tsx +++ b/src/app/login/sso.tsx @@ -8,6 +8,7 @@ import { ActivityIndicator } from 'react-native'; import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; import * as z from 'zod'; +import { LoginOtpModal } from '@/components/auth/login-otp-modal'; import { FocusAwareStatusBar, View } from '@/components/ui'; import { Button, ButtonSpinner, ButtonText } from '@/components/ui/button'; import { FormControl, FormControlError, FormControlErrorIcon, FormControlErrorText, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control'; @@ -22,6 +23,7 @@ import { useAuth } from '@/lib/auth'; import { logger } from '@/lib/logging'; import type { DepartmentSsoConfig } from '@/services/sso-discovery'; import { fetchSsoConfigForUser } from '@/services/sso-discovery'; +import useAuthStore from '@/stores/auth/store'; const ssoFormSchema = z.object({ username: z.string({ required_error: 'Username is required' }).min(3, 'Username must be at least 3 characters'), @@ -35,11 +37,17 @@ export default function SsoLogin() { const [isLookingUpSso, setIsLookingUpSso] = useState(false); const [isSsoLoading, setIsSsoLoading] = useState(false); const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); + const [otpDismissed, setOtpDismissed] = useState(false); const pendingUsernameRef = useRef(''); const { t } = useTranslation(); const router = useRouter(); const { ssoLogin, status } = useAuth(); + const authError = useAuthStore((s) => s.error); + // 'mfaRequired' is also how the password login reports its own 2FA challenge. Opening this + // screen's prompt on that status alone means retrySsoWithOtp fires with no pending SSO + // exchange, which drops the user into an error state instead of a code prompt. + const isSsoMfaPending = useAuthStore((s) => s.isSsoMfaPending); const oidc = useOidcLogin({ authority: ssoConfig?.authority ?? '', @@ -67,6 +75,18 @@ export default function SsoLogin() { } }, [status]); + // Re-arm the OTP prompt whenever a fresh SSO 2FA challenge arrives + useEffect(() => { + if (status === 'mfaRequired' && isSsoMfaPending) { + setIsSsoLoading(false); + setOtpDismissed(false); + } + }, [status, isSsoMfaPending]); + + const handleOtpSubmit = useCallback(async (code: string) => { + await useAuthStore.getState().retrySsoWithOtp(code); + }, []); + // ── OIDC response handler ───────────────────────────────────────────────── useEffect(() => { if (oidc.response?.type !== 'success') return; @@ -290,6 +310,15 @@ export default function SsoLogin() { + + {/* Two-factor challenge: SSO exchange answered mfa_required / invalid_totp */} + setOtpDismissed(true)} + /> ); } diff --git a/src/components/auth/__tests__/login-otp-modal.test.tsx b/src/components/auth/__tests__/login-otp-modal.test.tsx new file mode 100644 index 00000000..49d7fc55 --- /dev/null +++ b/src/components/auth/__tests__/login-otp-modal.test.tsx @@ -0,0 +1,138 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; + +import { LoginOtpModal } from '../login-otp-modal'; + +// The modal renders through gluestack's overlay primitives; the real ones need a provider and a +// portal host that the login screen supplies at runtime. Rendering plain views keeps this focused +// on the modal's own behaviour: what it submits, and when. +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }), +})); + +jest.mock('@/components/ui/modal', () => { + const { View } = require('react-native'); + return { + Modal: ({ isOpen, children, ...props }: any) => (isOpen ? {children} : null), + ModalBackdrop: ({ children }: any) => {children}, + ModalBody: ({ children }: any) => {children}, + ModalContent: ({ children }: any) => {children}, + ModalFooter: ({ children }: any) => {children}, + ModalHeader: ({ children }: any) => {children}, + }; +}); + +describe('LoginOtpModal', () => { + // gluestack's Button surfaces its disabled state differently depending on how the design system + // is wired up in each app: some expose accessibilityState, others pass isDisabled straight + // through. Read whichever one is present so this test asserts intent, not plumbing. + const isDisabled = (element: any): boolean => element.props.accessibilityState?.disabled ?? element.props.isDisabled ?? false; + + const renderModal = (overrides: Partial> = {}) => { + const props = { + isOpen: true, + isSubmitting: false, + invalidCode: false, + onSubmit: jest.fn(), + onClose: jest.fn(), + ...overrides, + }; + return { ...render(), props }; + }; + + it('renders nothing while closed', () => { + const { queryByTestId, unmount } = renderModal({ isOpen: false }); + + expect(queryByTestId('login-otp-input')).toBeNull(); + + unmount(); + }); + + it('blocks submission while the code is empty', () => { + const { getByTestId, props, unmount } = renderModal(); + + fireEvent.press(getByTestId('login-otp-submit')); + + expect(props.onSubmit).not.toHaveBeenCalled(); + expect(isDisabled(getByTestId('login-otp-submit'))).toBe(true); + + unmount(); + }); + + it('blocks submission for whitespace only', () => { + const { getByTestId, props, unmount } = renderModal(); + + fireEvent.changeText(getByTestId('login-otp-input'), ' '); + fireEvent.press(getByTestId('login-otp-submit')); + + expect(props.onSubmit).not.toHaveBeenCalled(); + + unmount(); + }); + + it('submits the trimmed code and clears the field', () => { + const { getByTestId, props, unmount } = renderModal(); + + const input = getByTestId('login-otp-input'); + fireEvent.changeText(input, ' 123456 '); + fireEvent.press(getByTestId('login-otp-submit')); + + expect(props.onSubmit).toHaveBeenCalledWith('123456'); + // The code is a live second factor: it must not sit in state after being handed off. + expect(getByTestId('login-otp-input').props.value).toBe(''); + + unmount(); + }); + + it('submits from the keyboard return key', () => { + const { getByTestId, props, unmount } = renderModal(); + + fireEvent.changeText(getByTestId('login-otp-input'), '654321'); + fireEvent(getByTestId('login-otp-input'), 'submitEditing'); + + expect(props.onSubmit).toHaveBeenCalledWith('654321'); + + unmount(); + }); + + it('shows the rejected-code message only when invalidCode is set', () => { + const { queryByTestId, unmount } = renderModal(); + expect(queryByTestId('login-otp-error')).toBeNull(); + unmount(); + + const invalid = renderModal({ invalidCode: true }); + expect(invalid.queryByTestId('login-otp-error')).not.toBeNull(); + invalid.unmount(); + }); + + it('disables both actions while submitting', () => { + const { getByTestId, unmount } = renderModal({ isSubmitting: true }); + + expect(isDisabled(getByTestId('login-otp-cancel'))).toBe(true); + expect(isDisabled(getByTestId('login-otp-submit'))).toBe(true); + + unmount(); + }); + + it('reports cancellation to the caller', () => { + const { getByTestId, props, unmount } = renderModal(); + + fireEvent.press(getByTestId('login-otp-cancel')); + + expect(props.onClose).toHaveBeenCalledTimes(1); + + unmount(); + }); + + it('drops a half-typed code when the modal closes', () => { + const { getByTestId, rerender, unmount, props } = renderModal(); + + fireEvent.changeText(getByTestId('login-otp-input'), '1234'); + rerender(); + rerender(); + + expect(getByTestId('login-otp-input').props.value).toBe(''); + + unmount(); + }); +}); diff --git a/src/components/auth/login-otp-modal.tsx b/src/components/auth/login-otp-modal.tsx new file mode 100644 index 00000000..8181d074 --- /dev/null +++ b/src/components/auth/login-otp-modal.tsx @@ -0,0 +1,95 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { Input, InputField } from '@/components/ui/input'; +import { Modal, ModalBackdrop, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/ui/modal'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; + +interface LoginOtpModalProps { + isOpen: boolean; + isSubmitting: boolean; + /** True when a previously submitted code was rejected. */ + invalidCode: boolean; + onSubmit: (code: string) => void; + onClose: () => void; +} + +/** + * Login two-factor prompt: collects the current authenticator (TOTP) code when the token + * endpoint answers mfa_required / invalid_totp. Controlled component — the login screen owns + * submission and retry. The code lives only in local state and is cleared on close/submit; + * it is never logged or persisted. + */ +export const LoginOtpModal: React.FC = ({ isOpen, isSubmitting, invalidCode, onSubmit, onClose }) => { + const { t } = useTranslation(); + const [code, setCode] = useState(''); + + useEffect(() => { + if (!isOpen) { + setCode(''); + } + }, [isOpen]); + + const handleSubmit = useCallback(() => { + const submitted = code.trim(); + if (submitted.length === 0) { + return; + } + setCode(''); + onSubmit(submitted); + }, [code, onSubmit]); + + return ( + + + + + {t('login.otp_title', 'Two-factor verification')} + + + + {t('login.otp_body', 'Your account has two-factor authentication enabled. Enter the current code from your authenticator app to finish signing in.')} + + + + {invalidCode ? ( + // Announced on appearance: the rejection arrives while focus is still in the field, + // so a screen reader would otherwise never reach it. + + {t('login.otp_invalid', 'That code is invalid or has expired. Enter the current code from your authenticator app.')} + + ) : null} + + + + + + + + + ); +}; diff --git a/src/components/calls/call-notes-modal.tsx b/src/components/calls/call-notes-modal.tsx index 3784a8a0..d7dbcf73 100644 --- a/src/components/calls/call-notes-modal.tsx +++ b/src/components/calls/call-notes-modal.tsx @@ -4,9 +4,11 @@ import { useTranslation } from 'react-i18next'; import { FlatList, Keyboard, Modal, SafeAreaView, StyleSheet, TouchableOpacity, View } from 'react-native'; import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; +import { ProtectedText } from '@/components/data-protection/protected-text'; import { SearchIcon, X } from '@/components/ui/lucide-icons'; import { useAnalytics } from '@/hooks/use-analytics'; import { useAuthStore } from '@/lib/auth'; +import { isRedactedValue, ProtectedFieldIds } from '@/lib/data-protection/redacted'; import { logger } from '@/lib/logging'; import { useCallDetailStore } from '@/stores/calls/detail-store'; @@ -92,7 +94,7 @@ const CallNotesModal = ({ isOpen, onClose, callId }: CallNotesModalProps) => { const renderNote = useCallback( ({ item: note }: { item: (typeof filteredNotes)[0] }) => ( - {note.Note} + {note.FullName} {note.TimestampFormatted} diff --git a/src/components/chat/message-bubble.tsx b/src/components/chat/message-bubble.tsx index d23f8a65..1ca0ce4a 100644 --- a/src/components/chat/message-bubble.tsx +++ b/src/components/chat/message-bubble.tsx @@ -12,6 +12,7 @@ import { Pressable } from '@/components/ui/pressable'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { ChatMessagePriority, type ChatMessageResultData, ChatMessageType } from '@/models/v4/chat'; +import useAuthStore from '@/stores/auth/store'; import { formatShortTime, getPersonAvatarUrl, linkifySegments, parseGifMetadata, parseLocationMetadata } from './chat-utils'; @@ -24,12 +25,17 @@ interface MessageBubbleProps { onToggleReaction: (message: ChatMessageResultData, emoji: string, mine: boolean) => void; onOpenThread?: (message: ChatMessageResultData) => void; onRetry?: (message: ChatMessageResultData) => void; - onPressImage?: (uri: string) => void; + onPressImage?: (source: { uri: string; headers?: Record }) => void; } function MessageBubbleComponent({ message, isOwn, showSender, currentUserId, onLongPress, onToggleReaction, onOpenThread, onRetry, onPressImage }: MessageBubbleProps) { const { t } = useTranslation(); + // getChatAttachmentImageSource() bakes the bearer into the source object, so the bubble has to + // re-render when the token rotates. Reading it from getState() alone leaves a mounted bubble + // holding the pre-refresh token, and the image request then 401s. + const accessToken = useAuthStore((state) => state.accessToken); + // Realtime payloads omit empty collections; the store normalizes them, but messages // persisted before that normalization existed can still come back without them. const groupedReactions = useMemo(() => { @@ -72,11 +78,13 @@ function MessageBubbleComponent({ message, isOwn, showSender, currentUserId, onL if (message.MessageType === ChatMessageType.Image) { const attachment = (message.Attachments ?? [])[0]; - const uri = message._localAttachmentUri ?? (attachment ? getChatAttachmentImageSource(attachment.ChatAttachmentId).uri : undefined); - const source = attachment ? getChatAttachmentImageSource(attachment.ChatAttachmentId) : uri ? { uri } : undefined; - if (!source) return {message.Body}; + const localUri = message._localAttachmentUri; + // Full source object (uri + Authorization header) travels with the press so the + // full-screen preview stays authenticated — extracting only .uri drops the bearer. + const source = localUri ? { uri: localUri } : attachment ? getChatAttachmentImageSource(attachment.ChatAttachmentId, accessToken) : undefined; + if (!source?.uri) return {message.Body}; return ( - uri && onPressImage?.(uri)}> + onPressImage?.(source as { uri: string; headers?: Record })}> {message.Body ? {message.Body} : null} diff --git a/src/components/contacts/contact-card.tsx b/src/components/contacts/contact-card.tsx index dab3b3fc..f37d8978 100644 --- a/src/components/contacts/contact-card.tsx +++ b/src/components/contacts/contact-card.tsx @@ -1,8 +1,10 @@ import React from 'react'; import { Pressable, Text, View } from 'react-native'; +import { ProtectedText } from '@/components/data-protection/protected-text'; import { Avatar, AvatarImage } from '@/components/ui/avatar'; import { BuildingIcon, MailIcon, PhoneIcon, StarIcon, UserIcon } from '@/components/ui/lucide-icons'; +import { isFieldRedacted, ProtectedFieldIds } from '@/lib/data-protection/redacted'; import { type ContactResultData, ContactType } from '@/models/v4/contacts/contactResultData'; interface ContactCardProps { @@ -39,28 +41,42 @@ export const ContactCard: React.FC = React.memo(({ contact, on ) : ( - {contact.ContactType === ContactType.Person ? : } + {contact.ContactType === ContactType.Person ? : } )} - {displayName} + {/* + A contact's name is composed from first/last/company, so when those are withheld the + composed value reads "REDACTED REDACTED" — a name, apparently. The list now carries + per-row RedactedFields, so this asks the server's answer rather than sniffing the + value, and a member who types REDACTED into a name still sees it back. + */} + {isFieldRedacted(contact.RedactedFields, ProtectedFieldIds.contactFirstName, contact.FirstName) || + isFieldRedacted(contact.RedactedFields, ProtectedFieldIds.contactLastName, contact.LastName) || + isFieldRedacted(contact.RedactedFields, ProtectedFieldIds.contactCompanyName, contact.CompanyName) ? ( + + + + ) : ( + {displayName} + )} {contact.IsImportant ? : null} {contact.Email ? ( - - {contact.Email} + + ) : null} {contact.Phone ? ( - - {contact.Phone} + + ) : null} diff --git a/src/components/data-protection/protected-reveal-bar.tsx b/src/components/data-protection/protected-reveal-bar.tsx new file mode 100644 index 00000000..bf896ad4 --- /dev/null +++ b/src/components/data-protection/protected-reveal-bar.tsx @@ -0,0 +1,79 @@ +import { EyeIcon, EyeOffIcon, ShieldIcon } from 'lucide-react-native'; +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { HStack } from '@/components/ui/hstack'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { useProtectedReveal } from '@/hooks/use-protected-reveal'; +import { useIsProtectionEnabled } from '@/stores/data-protection/store'; + +interface ProtectedRevealBarProps { + /** + * Re-reads whatever the screen is showing. Values arrive REDACTED from the server and only come + * back decrypted on a request that carries the grant, so a reveal that does not re-fetch changes + * nothing on screen — which reads to the member as a broken button. + */ + onRefresh: () => void | Promise; + testID?: string; +} + +/** + * The reveal control for a screen showing protected values (ADP plan 7.2): the button and the + * re-fetch. The OTP prompt it may trigger is mounted once at the app shell, so adding this to a + * screen costs it no modal. + * + * Renders nothing at all when the department is not protected, so a screen can include it + * unconditionally and departments without the addon never see it. + * + * When the department has exempted this app from the prompt (plan 3.3) the grant arrives silently + * and the values simply appear. The screen does not know or care which happened. + */ +export const ProtectedRevealBar: React.FC = ({ onRefresh, testID }) => { + const { t } = useTranslation(); + const isProtectionEnabled = useIsProtectionEnabled(); + + const handleRevealed = useCallback(() => { + void onRefresh(); + }, [onRefresh]); + + const { isRevealed, isRequesting, reveal, conceal } = useProtectedReveal(handleRevealed); + + const handleConceal = useCallback(() => { + conceal(); + // Re-read without the grant so the plaintext leaves memory as well as the screen. Clearing the + // grant alone would leave the values already rendered sitting there until the next navigation. + void onRefresh(); + }, [conceal, onRefresh]); + + if (!isProtectionEnabled) { + return null; + } + + return ( + + + + {isRevealed ? t('data_protection.revealed_notice', 'Protected information is visible.') : t('data_protection.protected_notice', 'Some information on this screen is protected.')} + + {isRevealed ? ( + + ) : ( + + )} + + ); +}; diff --git a/src/components/data-protection/protected-text.tsx b/src/components/data-protection/protected-text.tsx new file mode 100644 index 00000000..9608c21b --- /dev/null +++ b/src/components/data-protection/protected-text.tsx @@ -0,0 +1,57 @@ +import { LockIcon } from 'lucide-react-native'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { isFieldRedacted } from '@/lib/data-protection/redacted'; + +interface ProtectedTextProps { + /** The value as the server returned it. */ + value?: string | null; + /** Catalog field id, e.g. ProtectedFieldIds.callName. */ + fieldId: string; + /** The RedactedFields list from the same response. */ + redactedFields?: string[] | null; + /** Rendered when the value is present and not redacted. Defaults to the value as plain text. */ + children?: React.ReactNode; + className?: string; + size?: 'xs' | 'sm' | 'md' | 'lg'; + testID?: string; +} + +/** + * One protected field. + * + * Withheld values render as a lock and a short label rather than the literal word "REDACTED" the + * server sends. That word is a wire sentinel, not copy: shown raw it reads as data — members have + * asked why a caller is named REDACTED — and it gives no hint that the information exists and can + * be revealed. The lock says both. + * + * Everything else passes straight through, so this is safe to use on fields that are only + * sometimes protected, and on departments that have no addon at all. + */ +export const ProtectedText: React.FC = ({ value, fieldId, redactedFields, children, className, size = 'md', testID }) => { + const { t } = useTranslation(); + + if (isFieldRedacted(redactedFields, fieldId, value)) { + return ( + + + + {t('data_protection.protected_value', 'Protected')} + + + ); + } + + if (children) { + return <>{children}; + } + + return ( + + {value} + + ); +}; diff --git a/src/components/data-protection/step-up-modal.tsx b/src/components/data-protection/step-up-modal.tsx new file mode 100644 index 00000000..5c2ace95 --- /dev/null +++ b/src/components/data-protection/step-up-modal.tsx @@ -0,0 +1,118 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { Input, InputField } from '@/components/ui/input'; +import { Modal, ModalBackdrop, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/ui/modal'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { dataProtectionStore } from '@/stores/data-protection/store'; + +interface StepUpModalProps { + isOpen: boolean; + onClose: () => void; + /** Invoked after a successful verification, before the modal closes. */ + onVerified?: () => void; +} + +/** + * Advanced Data Protection step-up prompt: collects the user's current authenticator (TOTP) + * code and exchanges it for an absolute step-up window. Shown before revealing or editing a + * protected field. The code lives only in local component state and is cleared on every + * close/submit; it is never logged or persisted. + */ +export const StepUpModal: React.FC = ({ isOpen, onClose, onVerified }) => { + const { t } = useTranslation(); + const [code, setCode] = useState(''); + const isVerifying = dataProtectionStore((state) => state.isVerifying); + const lastError = dataProtectionStore((state) => state.lastError); + + useEffect(() => { + if (!isOpen) { + setCode(''); + } + }, [isOpen]); + + const handleVerify = useCallback(async () => { + const submitted = code.trim(); + if (submitted.length === 0) { + return; + } + + const ok = await dataProtectionStore.getState().verifyOtp(submitted); + setCode(''); + if (ok) { + onVerified?.(); + onClose(); + } + }, [code, onClose, onVerified]); + + const errorText = (() => { + switch (lastError) { + case 'invalid_totp': + return t('data_protection.step_up_invalid_code', 'That code is invalid or has expired. Enter the current code from your authenticator app.'); + case 'mfa_not_enrolled': + return t('data_protection.step_up_not_enrolled', 'Two-factor authentication is not set up for your account. Enroll an authenticator app in your account security settings first.'); + case 'too_many_attempts': + return t('data_protection.step_up_too_many_attempts', 'Too many attempts. Wait a few minutes and try again.'); + case 'grants_not_configured': + return t('data_protection.step_up_unavailable', 'Protected data is not available on this server yet. Contact your administrator.'); + case 'unknown': + return t('data_protection.step_up_failed', 'Verification failed. Check your connection and try again.'); + default: + return null; + } + })(); + + return ( + + + + + {t('data_protection.step_up_title', 'Verify your identity')} + + + + {t('data_protection.step_up_body', 'This information is protected. Enter the current code from your authenticator app to view it for a limited time.')} + + + + {errorText ? ( + // Announced on appearance: the error arrives while focus is still in the field, so + // a screen reader would otherwise never reach it. + + {errorText} + + ) : null} + + + + + + + + + ); +}; diff --git a/src/components/data-protection/step-up-prompt-host.tsx b/src/components/data-protection/step-up-prompt-host.tsx new file mode 100644 index 00000000..d3e9c075 --- /dev/null +++ b/src/components/data-protection/step-up-prompt-host.tsx @@ -0,0 +1,21 @@ +import React, { useCallback } from 'react'; + +import { StepUpModal } from '@/components/data-protection/step-up-modal'; +import { dataProtectionStore, useIsStepUpPromptOpen } from '@/stores/data-protection/store'; + +/** + * The app's ONE Advanced Data Protection prompt (ADP plan 7.2). + * + * Mounted at the authenticated shell so any screen can trigger it through the store without + * carrying a modal of its own. One prompt per app also means two screens cannot stack two prompts + * over each other, and the member always answers in the same place. + */ +export const StepUpPromptHost: React.FC = () => { + const isOpen = useIsStepUpPromptOpen(); + + const handleClose = useCallback(() => { + dataProtectionStore.getState().closePrompt(); + }, []); + + return ; +}; diff --git a/src/hooks/use-protected-reveal.ts b/src/hooks/use-protected-reveal.ts new file mode 100644 index 00000000..71e63ca2 --- /dev/null +++ b/src/hooks/use-protected-reveal.ts @@ -0,0 +1,50 @@ +import { useCallback } from 'react'; + +import { dataProtectionStore, useHasGrantToken, useStepUpExpiresAt } from '@/stores/data-protection/store'; + +/** + * The screen-facing half of an ADP reveal. + * + * A screen calls `reveal()`. If the department has exempted this app from the step-up prompt + * (ADP plan 3.3) the grant arrives without any interaction and `isRevealed` flips straight to + * true; otherwise the app's single OTP prompt opens and the reveal completes when the code is + * accepted. The screen never decides which of those happens — the server does, per department and + * per app, and this hook just reacts. + * + * The prompt itself is mounted once at the app shell, so a screen using this pulls in no modal. + */ +export const useProtectedReveal = (onRevealed?: () => void) => { + const stepUpExpiresAt = useStepUpExpiresAt(); + const hasGrantToken = useHasGrantToken(); + const isRequesting = dataProtectionStore((state) => state.isRequestingGrant); + + // The token is part of the invariant, not just the expiry: without it the request goes out with + // no grant header and the value comes back redacted, so a "revealed" screen would show nothing + // new and reveal() would refuse to retry until the window lapsed. + const isRevealed = hasGrantToken && stepUpExpiresAt != null && Date.now() < stepUpExpiresAt; + + const reveal = useCallback(async () => { + const store = dataProtectionStore.getState(); + + if (store.isStepUpActive()) { + onRevealed?.(); + return; + } + + const outcome = await store.ensureGrant(); + if (outcome === 'granted') { + onRevealed?.(); + return; + } + + // 'unavailable' prompts too: the modal is where the caller is told grants are not configured, + // and silently doing nothing would look like a broken button. + dataProtectionStore.getState().openPrompt(); + }, [onRevealed]); + + const conceal = useCallback(() => { + dataProtectionStore.getState().clearStepUp(); + }, []); + + return { isRevealed, isRequesting, reveal, conceal }; +}; diff --git a/src/lib/auth/api.tsx b/src/lib/auth/api.tsx index d5d293a5..78c0e7d4 100644 --- a/src/lib/auth/api.tsx +++ b/src/lib/auth/api.tsx @@ -31,6 +31,8 @@ export const loginRequest = async (credentials: LoginCredentials): Promise | null; + /** + * True only while an SSO exchange is waiting on an authenticator code. `status === 'mfaRequired'` + * cannot stand in for this: the password login sets the same status, and the SSO screen must not + * open its OTP prompt for a challenge it has no pending exchange to retry. + */ + isSsoMfaPending: boolean; login: (credentials: LoginCredentials) => Promise; ssoLogin: (credentials: SsoLoginCredentials) => Promise; + /** Retries the pending SSO exchange with the user's authenticator code (2FA challenge). */ + retrySsoWithOtp: (otpCode: string) => Promise; logout: () => Promise; refreshAccessToken: () => Promise; isFirstTime: boolean; diff --git a/src/lib/data-protection/__tests__/field-ids.test.ts b/src/lib/data-protection/__tests__/field-ids.test.ts new file mode 100644 index 00000000..f719395c --- /dev/null +++ b/src/lib/data-protection/__tests__/field-ids.test.ts @@ -0,0 +1,48 @@ +import { ProtectedFieldIds } from '@/lib/data-protection/redacted'; + +/** + * A wrong field id is SILENT: it simply never matches the server's RedactedFields list, so the + * field renders raw and nothing looks broken until someone notices protected data on screen. These + * pin the shape and the exact values against the server's protected-field catalog. + */ +describe('ProtectedFieldIds', () => { + const ids = Object.entries(ProtectedFieldIds); + + it('are all lowercase table.field keys', () => { + // Collected rather than asserted one at a time, so a failure names every offender at once. + const malformed = ids.filter(([, id]) => !/^[a-z0-9]+\.[a-z0-9]+$/.test(id)).map(([name, id]) => `${name}=${id}`); + + expect(malformed).toEqual([]); + }); + + it('has no duplicates pointing at the same catalog field', () => { + const values = ids.map(([, id]) => id); + expect(new Set(values).size).toBe(values.length); + }); + + it('matches the server catalog for the surfaces the apps render', () => { + // Copied from Core's ProtectedReadService accessor maps. If the catalog is renamed there, this + // is what fails rather than a screen quietly showing plaintext. + expect(ProtectedFieldIds.callName).toBe('calls.name'); + expect(ProtectedFieldIds.callNature).toBe('calls.natureofcall'); + expect(ProtectedFieldIds.callNotes).toBe('calls.notes'); + expect(ProtectedFieldIds.callAddress).toBe('calls.address'); + expect(ProtectedFieldIds.callContactName).toBe('calls.contactname'); + expect(ProtectedFieldIds.callContactNumber).toBe('calls.contactnumber'); + expect(ProtectedFieldIds.callNote).toBe('callnotes.note'); + + expect(ProtectedFieldIds.contactFirstName).toBe('contacts.firstname'); + expect(ProtectedFieldIds.contactEmail).toBe('contacts.email'); + expect(ProtectedFieldIds.contactCellPhone).toBe('contacts.cellphonenumber'); + + expect(ProtectedFieldIds.personnelIdentificationNumber).toBe('departmentmembersensitivedata.identificationnumber'); + expect(ProtectedFieldIds.emergencyContactName).toBe('departmentmemberemergencycontacts.name'); + + expect(ProtectedFieldIds.userStateNote).toBe('userstates.note'); + expect(ProtectedFieldIds.unitLogNarrative).toBe('unitlogs.narrative'); + + expect(ProtectedFieldIds.calendarTitle).toBe('calendaritems.title'); + expect(ProtectedFieldIds.calendarDescription).toBe('calendaritems.description'); + expect(ProtectedFieldIds.calendarLocation).toBe('calendaritems.location'); + }); +}); diff --git a/src/lib/data-protection/__tests__/redacted.test.ts b/src/lib/data-protection/__tests__/redacted.test.ts new file mode 100644 index 00000000..35a80359 --- /dev/null +++ b/src/lib/data-protection/__tests__/redacted.test.ts @@ -0,0 +1,46 @@ +import { isFieldRedacted, isRedactedValue, ProtectedFieldIds, REDACTION_VALUE } from '@/lib/data-protection/redacted'; + +/** + * Which signal wins matters. The server's RedactedFields list is authoritative; the sentinel value + * is only a fallback, because a member can legitimately type "REDACTED" into a note and masking + * their own words is a bug they cannot explain or work around. + */ +describe('isFieldRedacted', () => { + it('trusts the field list over the value', () => { + expect(isFieldRedacted([ProtectedFieldIds.callName], ProtectedFieldIds.callName, 'Structure Fire')).toBe(true); + expect(isFieldRedacted([ProtectedFieldIds.callNotes], ProtectedFieldIds.callName, REDACTION_VALUE)).toBe(false); + }); + + it('does not mask a member who typed the sentinel themselves', () => { + // A list is present and does not name this field, so the value is beside the point. + expect(isFieldRedacted([ProtectedFieldIds.callNotes], ProtectedFieldIds.callName, 'REDACTED')).toBe(false); + }); + + it('falls back to the value only when no list came with the payload', () => { + expect(isFieldRedacted(undefined, ProtectedFieldIds.callName, REDACTION_VALUE)).toBe(true); + expect(isFieldRedacted(null, ProtectedFieldIds.callName, 'Structure Fire')).toBe(false); + }); + + it('trusts an explicitly empty list over the sentinel', () => { + // [] is the server saying nothing was withheld. Sniffing the value anyway would re-mask a + // member who legitimately typed REDACTED, which is the false positive the list prevents. + expect(isFieldRedacted([], ProtectedFieldIds.callName, REDACTION_VALUE)).toBe(false); + }); + + it('matches field ids case-insensitively', () => { + // The catalog is lowercase but a serializer between here and there may not be. + expect(isFieldRedacted(['Calls.Name'], ProtectedFieldIds.callName, 'x')).toBe(true); + }); + + it('survives a malformed list without throwing', () => { + expect(isFieldRedacted([null as unknown as string], ProtectedFieldIds.callName, 'x')).toBe(false); + }); + + it('reads a plain value only on exact match', () => { + expect(isRedactedValue(REDACTION_VALUE)).toBe(true); + expect(isRedactedValue('redacted')).toBe(false); + expect(isRedactedValue('REDACTED ')).toBe(false); + expect(isRedactedValue(null)).toBe(false); + expect(isRedactedValue(undefined)).toBe(false); + }); +}); diff --git a/src/lib/data-protection/grant-provider.ts b/src/lib/data-protection/grant-provider.ts new file mode 100644 index 00000000..3259c436 --- /dev/null +++ b/src/lib/data-protection/grant-provider.ts @@ -0,0 +1,33 @@ +/** + * Where the API client finds the current Protected Data Grant header. + * + * A module of its own on purpose. The client cannot import the data-protection store (the store's + * own API layer is built on the client, so that is a cycle), and the store must not import the + * client either — doing so drags the whole HTTP stack into the module graph of every screen that + * shows a protected value, which breaks unrelated tests and slows unrelated startups. Both sides + * depend on this instead, and it depends on nothing. + */ +type GrantHeaderProvider = () => Record; + +let provider: GrantHeaderProvider | null = null; + +/** Registered by the data-protection store at module load. */ +export const setProtectedGrantProvider = (next: GrantHeaderProvider | null) => { + provider = next; +}; + +/** + * The grant header, or an empty object when no grant is held or the provider throws. Never throws: + * a request must still go out, it simply comes back with protected values redacted. + */ +export const readProtectedGrantHeaders = (): Record => { + if (!provider) { + return {}; + } + + try { + return provider(); + } catch { + return {}; + } +}; diff --git a/src/lib/data-protection/redacted.ts b/src/lib/data-protection/redacted.ts new file mode 100644 index 00000000..2ec3aa48 --- /dev/null +++ b/src/lib/data-protection/redacted.ts @@ -0,0 +1,77 @@ +/** + * Recognising a protected value the server declined to decrypt (ADP plan 7.2). + * + * There are two signals and they are not equal. The authoritative one is the RedactedFields list + * the server returns beside the record: it names exactly which catalog fields were withheld. The + * literal placeholder in the value is the fallback for payloads that carry no list. + * + * The list is preferred because value-sniffing has a false positive that matters — a member can + * legitimately type "REDACTED" into a call note, and masking their own words would be a bug the + * member cannot explain or work around. + */ + +/** The exact sentinel the server substitutes. Compare with strict equality; never localize it. */ +export const REDACTION_VALUE = 'REDACTED'; + +/** Catalog field ids, matching the server's protected-field catalog. */ +export const ProtectedFieldIds = { + callName: 'calls.name', + callNature: 'calls.natureofcall', + callNotes: 'calls.notes', + callAddress: 'calls.address', + callContactName: 'calls.contactname', + callContactNumber: 'calls.contactnumber', + callGeolocation: 'calls.geolocationdata', + callWhat3Words: 'calls.w3w', + + contactFirstName: 'contacts.firstname', + contactLastName: 'contacts.lastname', + contactCompanyName: 'contacts.companyname', + contactEmail: 'contacts.email', + contactHomePhone: 'contacts.homephonenumber', + contactCellPhone: 'contacts.cellphonenumber', + + callNote: 'callnotes.note', + + personnelIdentificationNumber: 'departmentmembersensitivedata.identificationnumber', + personnelNotes: 'departmentmembersensitivedata.notes', + personnelHomeAddress: 'departmentmembersensitivedata.homeaddress1', + personnelMailingAddress: 'departmentmembersensitivedata.mailingaddress1', + + emergencyContactName: 'departmentmemberemergencycontacts.name', + emergencyContactRelationship: 'departmentmemberemergencycontacts.relationship', + emergencyContactPhone: 'departmentmemberemergencycontacts.phonenumber', + emergencyContactEmail: 'departmentmemberemergencycontacts.email', + + unitLogNarrative: 'unitlogs.narrative', + userStateNote: 'userstates.note', + + calendarTitle: 'calendaritems.title', + calendarDescription: 'calendaritems.description', + calendarLocation: 'calendaritems.location', +} as const; + +/** + * True when this field was withheld. + * + * `fieldId` is checked against the server's list whenever a list is present. Only an ABSENT list — + * an older payload, or an endpoint that does not carry one — falls back to the sentinel value. + * + * An empty list is a list: the server said "nothing was withheld from this record", and that is a + * stronger statement than the sentinel can make. Treating `[]` as absent would re-mask a member + * who legitimately typed REDACTED into a note, which is exactly the false positive the list exists + * to prevent. + */ +export const isFieldRedacted = (redactedFields: string[] | null | undefined, fieldId: string, value?: string | null): boolean => { + if (redactedFields != null) { + return redactedFields.some((field) => field?.toLowerCase() === fieldId.toLowerCase()); + } + + return value === REDACTION_VALUE; +}; + +/** + * True when a value is the bare sentinel, for surfaces with no field id to hand (a list cell built + * from a summary DTO). Weaker than isFieldRedacted and should not be preferred to it. + */ +export const isRedactedValue = (value?: string | null): boolean => value === REDACTION_VALUE; diff --git a/src/models/v4/calls/callResultData.ts b/src/models/v4/calls/callResultData.ts index e9d6f804..9fb0efc5 100644 --- a/src/models/v4/calls/callResultData.ts +++ b/src/models/v4/calls/callResultData.ts @@ -34,4 +34,13 @@ export class CallResultData { public Latitude: string = ''; public Longitude: string = ''; public CheckInTimersEnabled: boolean = false; + /** + * Catalog field ids the server withheld from this response (ADP plan 7.2). Empty for a + * department without the addon, and empty again once a grant reveals the record. + * + * Left undefined rather than defaulted to `[]`: a server that never sends the list must stay + * distinguishable from one that sends an empty one, because isFieldRedacted() only falls back to + * sentinel-sniffing for the former. + */ + public RedactedFields?: string[]; } diff --git a/src/models/v4/contacts/contactResultData.ts b/src/models/v4/contacts/contactResultData.ts index ef7df7bf..483658e6 100644 --- a/src/models/v4/contacts/contactResultData.ts +++ b/src/models/v4/contacts/contactResultData.ts @@ -61,4 +61,9 @@ export interface ContactResultData { EditedOn?: string; EditedByUserId?: string; EditedByUserName?: string; + /** + * Catalog field ids the server withheld from THIS contact (ADP plan 7.2). Per row, not a union + * across the list: a field withheld on one contact must not mark it on every other one. + */ + RedactedFields?: string[]; } diff --git a/src/stores/app/__tests__/audio-stream-store.test.ts b/src/stores/app/__tests__/audio-stream-store.test.ts index 9b18301f..2b91b42d 100644 --- a/src/stores/app/__tests__/audio-stream-store.test.ts +++ b/src/stores/app/__tests__/audio-stream-store.test.ts @@ -26,11 +26,12 @@ jest.mock('expo-audio', () => ({ })); import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import base64 from 'react-native-base64'; import { createAudioPlayer, setAudioModeAsync } from 'expo-audio'; import { getDepartmentAudioStreams } from '@/api/voice'; import { logger } from '@/lib/logging'; import { type DepartmentAudioResultStreamData } from '@/models/v4/voice/departmentAudioResultStreamData'; -import { useAudioStreamStore } from '../audio-stream-store'; +import { redactStreamUrl, resolveStreamSource, useAudioStreamStore } from '../audio-stream-store'; const mockGetDepartmentAudioStreams = getDepartmentAudioStreams as jest.MockedFunction; const mockCreateAudioPlayer = createAudioPlayer as jest.MockedFunction; @@ -51,6 +52,7 @@ describe('AudioStreamStore', () => { muted: false, play: jest.fn(), pause: jest.fn(), + replace: jest.fn(), remove: jest.fn(), seekTo: jest.fn(() => Promise.resolve()), addListener: jest.fn(() => ({ remove: jest.fn() })), @@ -58,7 +60,7 @@ describe('AudioStreamStore', () => { beforeEach(() => { jest.clearAllMocks(); - + // Reset store state useAudioStreamStore.setState({ availableStreams: [], @@ -86,7 +88,7 @@ describe('AudioStreamStore', () => { describe('initial state', () => { it('should have the correct initial state', () => { const state = useAudioStreamStore.getState(); - + expect(state.availableStreams).toEqual([]); expect(state.isLoadingStreams).toBe(false); expect(state.currentStream).toBeNull(); @@ -102,43 +104,43 @@ describe('AudioStreamStore', () => { it('should set available streams', () => { const streams = [mockStream]; useAudioStreamStore.getState().setAvailableStreams(streams); - + expect(useAudioStreamStore.getState().availableStreams).toEqual(streams); }); it('should set loading streams state', () => { useAudioStreamStore.getState().setIsLoadingStreams(true); - + expect(useAudioStreamStore.getState().isLoadingStreams).toBe(true); }); it('should set current stream', () => { useAudioStreamStore.getState().setCurrentStream(mockStream); - + expect(useAudioStreamStore.getState().currentStream).toEqual(mockStream); }); it('should set playing state', () => { useAudioStreamStore.getState().setIsPlaying(true); - + expect(useAudioStreamStore.getState().isPlaying).toBe(true); }); it('should set loading state', () => { useAudioStreamStore.getState().setIsLoading(true); - + expect(useAudioStreamStore.getState().isLoading).toBe(true); }); it('should set buffering state', () => { useAudioStreamStore.getState().setIsBuffering(true); - + expect(useAudioStreamStore.getState().isBuffering).toBe(true); }); it('should set bottom sheet visibility', () => { useAudioStreamStore.getState().setIsBottomSheetVisible(true); - + expect(useAudioStreamStore.getState().isBottomSheetVisible).toBe(true); }); }); @@ -155,11 +157,11 @@ describe('AudioStreamStore', () => { Environment: '', Data: [mockStream], }; - + mockGetDepartmentAudioStreams.mockResolvedValue(mockResponse); - + await useAudioStreamStore.getState().fetchAvailableStreams(); - + const state = useAudioStreamStore.getState(); expect(state.availableStreams).toEqual([mockStream]); expect(state.isLoadingStreams).toBe(false); @@ -172,9 +174,9 @@ describe('AudioStreamStore', () => { it('should handle fetch error', async () => { const mockError = new Error('Fetch failed'); mockGetDepartmentAudioStreams.mockRejectedValue(mockError); - + await useAudioStreamStore.getState().fetchAvailableStreams(); - + const state = useAudioStreamStore.getState(); expect(state.availableStreams).toEqual([]); expect(state.isLoadingStreams).toBe(false); @@ -195,11 +197,11 @@ describe('AudioStreamStore', () => { Environment: '', Data: null, }; - + mockGetDepartmentAudioStreams.mockResolvedValue(mockResponse as any); - + await useAudioStreamStore.getState().fetchAvailableStreams(); - + const state = useAudioStreamStore.getState(); expect(state.availableStreams).toEqual([]); expect(mockLogger.debug).toHaveBeenCalledWith({ @@ -235,14 +237,14 @@ describe('AudioStreamStore', () => { it('should play stream successfully', async () => { await useAudioStreamStore.getState().playStream(mockStream); - + const state = useAudioStreamStore.getState(); expect(state.currentStream).toEqual(mockStream); expect(state.soundObject).toEqual(mockSoundObject); expect(state.isPlaying).toBe(true); expect(state.isLoading).toBe(false); expect(state.isBuffering).toBe(false); - + expect(mockSetAudioModeAsync).toHaveBeenCalledWith({ allowsRecording: false, shouldPlayInBackground: true, @@ -250,21 +252,24 @@ describe('AudioStreamStore', () => { interruptionMode: 'duckOthers', shouldRouteThroughEarpiece: false, }); - - expect(mockCreateAudioPlayer).toHaveBeenCalledWith(mockStream.Url, { - updateInterval: 1000, - keepAudioSessionActive: true, - preferredForwardBufferDuration: 5, - }); + + expect(mockCreateAudioPlayer).toHaveBeenCalledWith( + { uri: mockStream.Url }, + { + updateInterval: 1000, + keepAudioSessionActive: true, + preferredForwardBufferDuration: 5, + } + ); expect(mockSoundObject.addListener).toHaveBeenCalledWith('playbackStatusUpdate', expect.any(Function)); - + expect(mockSoundObject.play).toHaveBeenCalled(); - + expect(mockLogger.debug).toHaveBeenCalledWith({ message: 'Starting audio stream', context: { streamName: mockStream.Name, streamUrl: mockStream.Url }, }); - + expect(mockLogger.info).toHaveBeenCalledWith({ message: 'Audio stream started successfully', context: { streamName: mockStream.Name }, @@ -278,11 +283,11 @@ describe('AudioStreamStore', () => { currentStream: mockStream, isPlaying: true, }); - + const newStream = { ...mockStream, Id: '2', Name: 'New Stream' }; - + await useAudioStreamStore.getState().playStream(newStream); - + expect(mockSoundObject.pause).toHaveBeenCalled(); expect(mockSoundObject.remove).toHaveBeenCalled(); }); @@ -292,16 +297,16 @@ describe('AudioStreamStore', () => { mockCreateAudioPlayer.mockImplementationOnce(() => { throw mockError; }); - + await useAudioStreamStore.getState().playStream(mockStream); - + const state = useAudioStreamStore.getState(); expect(state.soundObject).toBeNull(); expect(state.currentStream).toBeNull(); expect(state.isPlaying).toBe(false); expect(state.isLoading).toBe(false); expect(state.isBuffering).toBe(false); - + expect(mockLogger.error).toHaveBeenCalledWith({ message: 'Failed to play audio stream', context: { error: mockError, streamName: mockStream.Name }, @@ -390,19 +395,19 @@ describe('AudioStreamStore', () => { currentStream: mockStream, isPlaying: true, }); - + await useAudioStreamStore.getState().stopStream(); - + const state = useAudioStreamStore.getState(); expect(state.soundObject).toBeNull(); expect(state.currentStream).toBeNull(); expect(state.isPlaying).toBe(false); expect(state.isLoading).toBe(false); expect(state.isBuffering).toBe(false); - + expect(mockSoundObject.pause).toHaveBeenCalled(); expect(mockSoundObject.remove).toHaveBeenCalled(); - + expect(mockLogger.info).toHaveBeenCalledWith({ message: 'Audio stream stopped', context: { streamName: mockStream.Name }, @@ -414,15 +419,15 @@ describe('AudioStreamStore', () => { mockSoundObject.pause.mockImplementationOnce(() => { throw mockError; }); - + useAudioStreamStore.setState({ soundObject: mockSoundObject, currentStream: mockStream, isPlaying: true, }); - + await useAudioStreamStore.getState().stopStream(); - + expect(mockLogger.error).toHaveBeenCalledWith({ message: 'Failed to stop audio stream', context: { error: mockError }, @@ -443,14 +448,14 @@ describe('AudioStreamStore', () => { currentStream: mockStream, isPlaying: true, }); - + await useAudioStreamStore.getState().stopStream(); - + const state = useAudioStreamStore.getState(); expect(state.soundObject).toBeNull(); expect(state.currentStream).toBeNull(); expect(state.isPlaying).toBe(false); - + expect(mockSoundObject.pause).not.toHaveBeenCalled(); expect(mockSoundObject.remove).not.toHaveBeenCalled(); }); @@ -463,17 +468,17 @@ describe('AudioStreamStore', () => { currentStream: mockStream, isPlaying: true, }); - + await useAudioStreamStore.getState().cleanup(); - + const state = useAudioStreamStore.getState(); expect(state.soundObject).toBeNull(); expect(state.currentStream).toBeNull(); expect(state.isPlaying).toBe(false); - + expect(mockSoundObject.pause).toHaveBeenCalled(); expect(mockSoundObject.remove).toHaveBeenCalled(); - + expect(mockLogger.debug).toHaveBeenCalledWith({ message: 'Audio stream store cleaned up', }); @@ -484,15 +489,15 @@ describe('AudioStreamStore', () => { mockSoundObject.pause.mockImplementationOnce(() => { throw mockError; }); - + useAudioStreamStore.setState({ soundObject: mockSoundObject, currentStream: mockStream, isPlaying: true, }); - + await useAudioStreamStore.getState().cleanup(); - + // The cleanup method calls stopStream, which catches its own errors // So we expect the stopStream error message, not the cleanup error message expect(mockLogger.error).toHaveBeenCalledWith({ @@ -502,3 +507,65 @@ describe('AudioStreamStore', () => { }); }); }); + +describe('resolveStreamSource', () => { + it('leaves a credential-free URL untouched', () => { + expect(resolveStreamSource('https://audio.broadcastify.com/12345.mp3')).toEqual({ + uri: 'https://audio.broadcastify.com/12345.mp3', + }); + }); + + it('hoists inline credentials into an Authorization header', () => { + expect(resolveStreamSource('https://scanner:s3cret@audio.broadcastify.com/12345.mp3')).toEqual({ + uri: 'https://audio.broadcastify.com/12345.mp3', + headers: { Authorization: `Basic ${base64.encode('scanner:s3cret')}` }, + }); + }); + + it('percent-decodes credentials before encoding them', () => { + expect(resolveStreamSource('http://user%40dept.org:p%40ss@relay.example.com/live')).toEqual({ + uri: 'http://relay.example.com/live', + headers: { Authorization: `Basic ${base64.encode('user@dept.org:p@ss')}` }, + }); + }); + + it('treats a username without a password as an empty password', () => { + expect(resolveStreamSource('https://token@relay.example.com/live')).toEqual({ + uri: 'https://relay.example.com/live', + headers: { Authorization: `Basic ${base64.encode('token:')}` }, + }); + }); + + it('splits at the last @ so an unencoded @ in the password survives', () => { + // RFC 3986 wants that @ percent-encoded, but departments paste raw passwords. Splitting at + // the first @ sent 'scanner:p' as the credentials and left 'ss@relay...' in the playback URI. + expect(resolveStreamSource('https://scanner:p@ss@relay.example.com/live')).toEqual({ + uri: 'https://relay.example.com/live', + headers: { Authorization: `Basic ${base64.encode('scanner:p@ss')}` }, + }); + }); + + it('does not treat an @ in the path as credentials', () => { + expect(resolveStreamSource('https://relay.example.com/live@2x.mp3')).toEqual({ uri: 'https://relay.example.com/live@2x.mp3' }); + }); +}); + +describe('redactStreamUrl', () => { + it('returns a credential-free URL unchanged', () => { + expect(redactStreamUrl('https://audio.broadcastify.com/12345.mp3')).toBe('https://audio.broadcastify.com/12345.mp3'); + }); + + it('masks inline credentials', () => { + expect(redactStreamUrl('https://scanner:s3cret@audio.broadcastify.com/12345.mp3')).toBe('https://***@audio.broadcastify.com/12345.mp3'); + }); + + it('masks a password containing an unencoded @ without leaking its tail', () => { + // Splitting at the FIRST @ used to leave 'ss@relay...' in the redacted output, publishing + // part of the password to the log. + expect(redactStreamUrl('https://scanner:p@ss@relay.example.com/live')).toBe('https://***@relay.example.com/live'); + }); + + it('leaves an @ in the path alone', () => { + expect(redactStreamUrl('https://relay.example.com/live@2x.mp3')).toBe('https://relay.example.com/live@2x.mp3'); + }); +}); diff --git a/src/stores/app/audio-stream-store.ts b/src/stores/app/audio-stream-store.ts index d959b0de..9045f863 100644 --- a/src/stores/app/audio-stream-store.ts +++ b/src/stores/app/audio-stream-store.ts @@ -1,5 +1,6 @@ import { type AudioPlayer, type AudioStatus, createAudioPlayer, setAudioModeAsync } from 'expo-audio'; import { Platform } from 'react-native'; +import base64 from 'react-native-base64'; import { create } from 'zustand'; import { getDepartmentAudioStreams } from '@/api/voice'; @@ -37,6 +38,134 @@ interface AudioStreamState { cleanup: () => Promise; } +export interface ResolvedStreamSource { + uri: string; + headers?: Record; +} + +// `scheme://user:pass@host/rest`. Icecast relays (Broadcastify and most department scanner +// feeds) put premium feeds behind HTTP Basic auth, and departments store those credentials +// inline in the stream URL. +// +// The userinfo group is greedy within the authority (`[^/?#]*`) so it splits at the LAST `@` +// before the path, not the first. RFC 3986 requires a literal `@` in a password to be +// percent-encoded, but departments paste raw passwords: `https://scanner:p@ss@relay/live` must +// yield `p@ss`, not credentials of `scanner:p` with `ss@relay` left in the playback URI (which +// also leaked half the password through redactStreamUrl). +const CREDENTIALED_URL = /^(https?:\/\/)([^/?#]*)@([\s\S]*)$/i; + +const decodeUrlComponent = (value: string): string => { + try { + return decodeURIComponent(value); + } catch { + // Credentials that are not percent-encoded decode to themselves. + return value; + } +}; + +/** + * Splits inline `user:pass@` credentials out of a stream URL and into an explicit + * `Authorization` header. + * + * iOS plays credentialed URLs as-is because CFNetwork applies the inline credentials for us. + * Android does not: expo-audio drives ExoPlayer through `OkHttpDataSource`, and OkHttp parses + * the userinfo but never sends an `Authorization` header for it, so the relay answers `401` + * and the failure reaches JS as a bare "Source error". Handing the player a clean URL plus the + * header behaves identically on both platforms. + */ +export const resolveStreamSource = (url: string): ResolvedStreamSource => { + const match = CREDENTIALED_URL.exec(url); + if (!match) { + return { uri: url }; + } + + const [, scheme, userInfo, rest] = match; + if (!userInfo) { + return { uri: `${scheme}${rest}` }; + } + + const separatorIndex = userInfo.indexOf(':'); + const username = decodeUrlComponent(separatorIndex < 0 ? userInfo : userInfo.slice(0, separatorIndex)); + const password = separatorIndex < 0 ? '' : decodeUrlComponent(userInfo.slice(separatorIndex + 1)); + + return { + uri: `${scheme}${rest}`, + headers: { + Authorization: `Basic ${base64.encode(`${username}:${password}`)}`, + }, + }; +}; + +/** Stream URLs can carry credentials, so never log or report one verbatim. */ +export const redactStreamUrl = (url: string): string => url.replace(CREDENTIALED_URL, (_match, scheme: string, _userInfo: string, rest: string) => `${scheme}***@${rest}`); + +/** + * Reads just the response status line for a stream that failed to play. ExoPlayer collapses + * every IO failure into "Source error", which is not actionable on its own; the status code + * separates auth failures from dead feeds and unsupported content. + */ +const probeStreamStatus = (source: ResolvedStreamSource): Promise => + new Promise((resolve) => { + if (typeof XMLHttpRequest === 'undefined') { + resolve(null); + return; + } + + const request = new XMLHttpRequest(); + let settled = false; + let timer: ReturnType | null = null; + + const finish = (status: number | null) => { + if (settled) { + return; + } + settled = true; + if (timer) { + clearTimeout(timer); + } + try { + // Live streams never finish on their own, so drop the connection as soon as the + // status line is in. + request.abort(); + } catch { + // The request may already have been torn down. + } + resolve(status); + }; + + timer = setTimeout(() => finish(null), 5000); + request.onreadystatechange = () => { + if (request.readyState >= 2) { + finish(request.status); + } + }; + request.onerror = () => finish(null); + + try { + request.open('GET', source.uri); + Object.entries(source.headers ?? {}).forEach(([key, value]) => { + request.setRequestHeader(key, value); + }); + request.send(); + } catch { + finish(null); + } + }); + +const logStreamDiagnostics = (source: ResolvedStreamSource, stream: DepartmentAudioResultStreamData) => { + void probeStreamStatus(source).then((status) => { + logger.error({ + message: 'Audio stream diagnostic', + context: { + streamName: stream.Name, + streamUrl: redactStreamUrl(source.uri), + httpStatus: status, + hasCredentials: source.headers?.Authorization !== undefined, + }, + }); + }); +}; + let latestPlayRequestId = 0; export const useAudioStreamStore = create((set, get) => ({ @@ -95,6 +224,7 @@ export const useAudioStreamStore = create((set, get) => ({ } const requestId = ++latestPlayRequestId; + const source = resolveStreamSource(streamUrl); try { const { soundObject: currentSound, stopStream } = get(); @@ -108,7 +238,7 @@ export const useAudioStreamStore = create((set, get) => ({ logger.debug({ message: 'Starting audio stream', - context: { streamName: stream.Name, streamUrl }, + context: { streamName: stream.Name, streamUrl: redactStreamUrl(streamUrl) }, }); // Configure audio mode for streaming @@ -120,7 +250,7 @@ export const useAudioStreamStore = create((set, get) => ({ shouldRouteThroughEarpiece: false, }); - const sound = createAudioPlayer(streamUrl, { + const sound = createAudioPlayer(source, { updateInterval: 1000, keepAudioSessionActive: true, preferredForwardBufferDuration: 5, @@ -150,8 +280,9 @@ export const useAudioStreamStore = create((set, get) => ({ if (status.error) { logger.error({ message: 'Audio playback error', - context: { error: status.error, streamName: stream.Name }, + context: { error: status.error, streamName: stream.Name, streamUrl: redactStreamUrl(streamUrl) }, }); + logStreamDiagnostics(source, stream); sound.remove(); set({ soundObject: null, @@ -184,7 +315,9 @@ export const useAudioStreamStore = create((set, get) => ({ } try { - await sound.seekTo(0); + // Re-point the player at the source rather than seeking: a live stream has + // nothing buffered to seek back into, so only a fresh connection resumes it. + sound.replace(source); sound.play(); } catch (replayError) { logger.error({ @@ -221,6 +354,8 @@ export const useAudioStreamStore = create((set, get) => ({ return; } + logStreamDiagnostics(source, stream); + const { soundObject } = get(); if (soundObject) { try { diff --git a/src/stores/auth/store.tsx b/src/stores/auth/store.tsx index b22ff6dc..da29ca44 100644 --- a/src/stores/auth/store.tsx +++ b/src/stores/auth/store.tsx @@ -22,6 +22,10 @@ import { removeItem, setItem, zustandStorage } from '../../lib/storage'; // a later, genuine logout still executes. let logoutInFlight: Promise | null = null; +// Last SSO exchange that failed with a 2FA challenge, retained IN MEMORY ONLY (module scope, +// never the persisted store) so the OTP prompt can retry the same IdP token with a code. +let pendingSsoMfaCredentials: SsoLoginCredentials | null = null; + const useAuthStore = create()( persist( (set, get) => ({ @@ -34,6 +38,7 @@ const useAuthStore = create()( userId: null, isFirstTime: true, refreshTimeoutId: null, + isSsoMfaPending: false, login: async (credentials: LoginCredentials) => { try { set({ status: 'loading' }); @@ -86,6 +91,13 @@ const useAuthStore = create()( } const timeoutId = setTimeout(() => get().refreshAccessToken(), refreshDelayMs); set({ refreshTimeoutId: timeoutId }); + } else if (response.mfaRequired) { + // 2FA challenge: the login screen prompts for the authenticator code and calls + // login() again with otpCode. Credentials are never retained here. + set({ + status: 'mfaRequired', + error: response.invalidOtp ? 'invalid_totp' : null, + }); } else { set({ status: 'error', @@ -136,18 +148,50 @@ const useAuthStore = create()( clearTimeout(existingTimeoutId); } const timeoutId = setTimeout(() => get().refreshAccessToken(), refreshDelayMs); - set({ refreshTimeoutId: timeoutId }); + set({ refreshTimeoutId: timeoutId, isSsoMfaPending: false }); + pendingSsoMfaCredentials = null; + } else if (response.mfaRequired) { + // 2FA challenge: retain the exchange in module memory (never the persisted store) + // so retrySsoWithOtp can replay it with the authenticator code. + // + // The code itself is deliberately dropped. On an invalid_totp retry `credentials` + // still carries the rejected otpCode, and keeping it would hold a known-bad secret in + // module state until the next successful SSO login. retrySsoWithOtp supplies the new + // code on every attempt, so nothing needs it here. + const { otpCode: _rejectedOtpCode, ...ssoExchange } = credentials; + pendingSsoMfaCredentials = ssoExchange; + set({ isSsoMfaPending: true }); + logger.info({ + message: 'SSO login requires two-factor verification', + context: { provider: credentials.provider, invalidOtp: !!response.invalidOtp }, + }); + set({ + status: 'mfaRequired', + error: response.invalidOtp ? 'invalid_totp' : null, + }); } else { - set({ status: 'error', error: response.message }); + pendingSsoMfaCredentials = null; + set({ status: 'error', error: response.message, isSsoMfaPending: false }); } } catch (error) { + pendingSsoMfaCredentials = null; set({ status: 'error', error: error instanceof Error ? error.message : 'SSO login failed', + isSsoMfaPending: false, }); } }, + retrySsoWithOtp: async (otpCode: string) => { + if (!pendingSsoMfaCredentials) { + set({ status: 'error', error: 'No pending SSO sign-in to verify' }); + return; + } + + await get().ssoLogin({ ...pendingSsoMfaCredentials, otpCode }); + }, + logout: async () => { // Single-flight: concurrent logout triggers (every 401 caller queued // behind a rejected refresh) share one run so the full data wipe never @@ -173,7 +217,10 @@ const useAuthStore = create()( userId: null, isFirstTime: true, refreshTimeoutId: null, + isSsoMfaPending: false, }); + // The retained IdP exchange is a credential; it must not outlive the session. + pendingSsoMfaCredentials = null; Sentry.setUser(null); // Remove the standalone stored auth response so no valid refresh diff --git a/src/stores/data-protection/__tests__/grant.test.ts b/src/stores/data-protection/__tests__/grant.test.ts new file mode 100644 index 00000000..6f556590 --- /dev/null +++ b/src/stores/data-protection/__tests__/grant.test.ts @@ -0,0 +1,153 @@ +// Mock the API +jest.mock('@/api/data-protection/data-protection', () => ({ + getDataProtectionCapabilities: jest.fn(), + requestProtectedGrant: jest.fn(), + verifyStepUp: jest.fn(), +})); + +// Mock logging +jest.mock('@/lib/logging', () => ({ + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, +})); + +jest.mock('../../auth/store', () => ({ + __esModule: true, + default: { + subscribe: () => () => {}, + getState: jest.fn(), + }, +})); + +import { dataProtectionStore } from '../store'; + +const { requestProtectedGrant, verifyStepUp } = require('@/api/data-protection/data-protection'); + +const inTenMinutes = () => new Date(Date.now() + 10 * 60 * 1000).toISOString(); + +const problem = (type: string) => Object.assign(new Error(type), { response: { data: { type } } }); + +/** + * A department can release named apps from the step-up prompt (ADP plan 3.3), because a + * dispatcher on a live incident cannot stop to read a code off a phone. + * + * The client never decides that. It asks the server, and every uncertain answer resolves towards + * showing the prompt — the direction that cannot cause harm. + */ +describe('dataProtectionStore grant acquisition', () => { + beforeEach(() => { + jest.clearAllMocks(); + dataProtectionStore.setState({ + capabilities: null, + isCapabilitiesLoaded: false, + stepUpExpiresAt: null, + grantToken: null, + isVerifying: false, + isRequestingGrant: false, + lastError: null, + }); + }); + + it('takes the grant when this app is exempt', async () => { + requestProtectedGrant.mockResolvedValue({ + GrantToken: 'grant-abc', + StepUpExpiresOnUtc: inTenMinutes(), + }); + + await expect(dataProtectionStore.getState().ensureGrant()).resolves.toBe('granted'); + expect(dataProtectionStore.getState().grantToken).toBe('grant-abc'); + expect(dataProtectionStore.getState().isStepUpActive()).toBe(true); + }); + + it('asks for a code when this app is not exempt', async () => { + requestProtectedGrant.mockRejectedValue(problem('step_up_required')); + + await expect(dataProtectionStore.getState().ensureGrant()).resolves.toBe('step_up_required'); + expect(dataProtectionStore.getState().grantToken).toBeNull(); + }); + + it('asks for a code when the request fails for any other reason', async () => { + // A network failure must not silently reveal anything, and must not leave the member with a + // button that does nothing either. + requestProtectedGrant.mockRejectedValue(new Error('offline')); + + await expect(dataProtectionStore.getState().ensureGrant()).resolves.toBe('step_up_required'); + }); + + it('reports grants being unconfigured separately from needing a code', async () => { + requestProtectedGrant.mockRejectedValue(problem('grants_not_configured')); + + await expect(dataProtectionStore.getState().ensureGrant()).resolves.toBe('unavailable'); + }); + + it('asks for a code when the server answers without a usable grant', async () => { + // A 200 carrying no token, or one that has already expired, is not a grant. + requestProtectedGrant.mockResolvedValue({ GrantToken: null, StepUpExpiresOnUtc: inTenMinutes() }); + await expect(dataProtectionStore.getState().ensureGrant()).resolves.toBe('step_up_required'); + + requestProtectedGrant.mockResolvedValue({ + GrantToken: 'grant-abc', + StepUpExpiresOnUtc: new Date(Date.now() - 1000).toISOString(), + }); + await expect(dataProtectionStore.getState().ensureGrant()).resolves.toBe('step_up_required'); + expect(dataProtectionStore.getState().grantToken).toBeNull(); + }); + + it('does not ask again while a grant is already held', async () => { + dataProtectionStore.setState({ + grantToken: 'grant-abc', + stepUpExpiresAt: Date.now() + 60000, + }); + + await expect(dataProtectionStore.getState().ensureGrant()).resolves.toBe('granted'); + expect(requestProtectedGrant).not.toHaveBeenCalled(); + }); + + it('keeps the grant from a verified code', async () => { + verifyStepUp.mockResolvedValue({ + GrantToken: 'grant-from-otp', + StepUpExpiresOnUtc: inTenMinutes(), + }); + + await expect(dataProtectionStore.getState().verifyOtp('123456')).resolves.toBe(true); + expect(dataProtectionStore.getState().grantToken).toBe('grant-from-otp'); + }); +}); + +describe('dataProtectionStore grant headers', () => { + beforeEach(() => { + dataProtectionStore.setState({ stepUpExpiresAt: null, grantToken: null }); + }); + + it('sends nothing when no grant is held', () => { + expect(dataProtectionStore.getState().getGrantHeaders()).toEqual({}); + }); + + it('sends the grant while it is live', () => { + dataProtectionStore.setState({ grantToken: 'grant-abc', stepUpExpiresAt: Date.now() + 60000 }); + + expect(dataProtectionStore.getState().getGrantHeaders()).toEqual({ + 'X-Resgrid-Protected-Grant': 'grant-abc', + }); + }); + + it('stops sending a grant that lapsed while the screen sat open', () => { + // Expiry is re-checked at the moment of use rather than trusted from state, so a screen left + // open past the window cannot attach a dead grant to its next request. + dataProtectionStore.setState({ grantToken: 'grant-abc', stepUpExpiresAt: Date.now() - 1 }); + + expect(dataProtectionStore.getState().getGrantHeaders()).toEqual({}); + }); + + it('sends nothing after concealing', () => { + dataProtectionStore.setState({ grantToken: 'grant-abc', stepUpExpiresAt: Date.now() + 60000 }); + dataProtectionStore.getState().clearStepUp(); + + expect(dataProtectionStore.getState().getGrantHeaders()).toEqual({}); + expect(dataProtectionStore.getState().grantToken).toBeNull(); + }); +}); diff --git a/src/stores/data-protection/__tests__/store.test.ts b/src/stores/data-protection/__tests__/store.test.ts new file mode 100644 index 00000000..6b829132 --- /dev/null +++ b/src/stores/data-protection/__tests__/store.test.ts @@ -0,0 +1,155 @@ +// Mock the API +jest.mock('@/api/data-protection/data-protection', () => ({ + getDataProtectionCapabilities: jest.fn(), + verifyStepUp: jest.fn(), +})); + +// Mock logging +jest.mock('@/lib/logging', () => ({ + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, +})); + +// Capture the auth-store subscription the store registers at module load. +// eslint-disable-next-line no-var +var mockAuthListener: ((state: { status: string }, prevState: { status: string }) => void) | undefined; +jest.mock('../../auth/store', () => ({ + __esModule: true, + default: { + subscribe: (listener: (state: { status: string }, prevState: { status: string }) => void) => { + mockAuthListener = listener; + return () => {}; + }, + getState: jest.fn(), + }, +})); + +import { dataProtectionStore } from '../store'; + +const { getDataProtectionCapabilities, verifyStepUp } = require('@/api/data-protection/data-protection'); + +describe('dataProtectionStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + dataProtectionStore.setState({ + capabilities: null, + isCapabilitiesLoaded: false, + stepUpExpiresAt: null, + isVerifying: false, + lastError: null, + }); + }); + + describe('fetchCapabilities', () => { + it('stores the department capability report', async () => { + getDataProtectionCapabilities.mockResolvedValue({ + Data: { IsProtectionEnabled: true, StepUpWindowMinutes: 30, IsDepartmentLocked: false }, + }); + + await dataProtectionStore.getState().fetchCapabilities(); + + const state = dataProtectionStore.getState(); + expect(state.isCapabilitiesLoaded).toBe(true); + expect(state.capabilities?.isProtectionEnabled).toBe(true); + expect(state.capabilities?.stepUpWindowMinutes).toBe(30); + }); + + it('marks loaded without capabilities on failure', async () => { + getDataProtectionCapabilities.mockRejectedValue(new Error('network')); + + await dataProtectionStore.getState().fetchCapabilities(); + + const state = dataProtectionStore.getState(); + expect(state.isCapabilitiesLoaded).toBe(true); + expect(state.capabilities).toBeNull(); + }); + }); + + describe('verifyOtp', () => { + it('activates the absolute window on success', async () => { + const expires = new Date(Date.now() + 15 * 60 * 1000).toISOString(); + verifyStepUp.mockResolvedValue({ GrantToken: 'grant-token', StepUpExpiresOnUtc: expires, StepUpWindowMinutes: 15 }); + + const ok = await dataProtectionStore.getState().verifyOtp('123456'); + + expect(ok).toBe(true); + expect(verifyStepUp).toHaveBeenCalledWith('123456'); + expect(dataProtectionStore.getState().isStepUpActive()).toBe(true); + expect(dataProtectionStore.getState().lastError).toBeNull(); + }); + + it('maps the server problem type on failure and stays locked', async () => { + verifyStepUp.mockRejectedValue({ response: { data: { type: 'invalid_totp' } } }); + + const ok = await dataProtectionStore.getState().verifyOtp('000000'); + + expect(ok).toBe(false); + expect(dataProtectionStore.getState().lastError).toBe('invalid_totp'); + expect(dataProtectionStore.getState().isStepUpActive()).toBe(false); + }); + + it('rejects an already-expired window from the server', async () => { + verifyStepUp.mockResolvedValue({ GrantToken: 'grant-token', StepUpExpiresOnUtc: new Date(Date.now() - 1000).toISOString() }); + + const ok = await dataProtectionStore.getState().verifyOtp('123456'); + + expect(ok).toBe(false); + expect(dataProtectionStore.getState().isStepUpActive()).toBe(false); + }); + + it('rejects a verification response that carries no grant token', async () => { + // Accepting one would report the value as revealed while every request goes out without the + // grant header, so the data stays redacted with no error to explain it. + verifyStepUp.mockResolvedValue({ StepUpExpiresOnUtc: new Date(Date.now() + 15 * 60 * 1000).toISOString() }); + + const ok = await dataProtectionStore.getState().verifyOtp('123456'); + + expect(ok).toBe(false); + expect(dataProtectionStore.getState().lastError).toBe('unknown'); + expect(dataProtectionStore.getState().isStepUpActive()).toBe(false); + expect(dataProtectionStore.getState().getGrantHeaders()).toEqual({}); + }); + }); + + describe('window lifecycle', () => { + it('expires by wall clock — the window is absolute, never sliding', () => { + dataProtectionStore.setState({ grantToken: 'grant-token', stepUpExpiresAt: Date.now() - 1 }); + expect(dataProtectionStore.getState().isStepUpActive()).toBe(false); + + dataProtectionStore.setState({ grantToken: 'grant-token', stepUpExpiresAt: Date.now() + 60_000 }); + expect(dataProtectionStore.getState().isStepUpActive()).toBe(true); + }); + + it('an unexpired window with no token is not an active grant', () => { + dataProtectionStore.setState({ grantToken: null, stepUpExpiresAt: Date.now() + 60_000 }); + expect(dataProtectionStore.getState().isStepUpActive()).toBe(false); + expect(dataProtectionStore.getState().getGrantHeaders()).toEqual({}); + }); + + it('clearStepUp drops the window immediately', () => { + dataProtectionStore.setState({ grantToken: 'grant-token', stepUpExpiresAt: Date.now() + 60_000 }); + dataProtectionStore.getState().clearStepUp(); + expect(dataProtectionStore.getState().isStepUpActive()).toBe(false); + }); + + it('signing out drops everything — the grant is memory-only', () => { + dataProtectionStore.setState({ + stepUpExpiresAt: Date.now() + 60_000, + capabilities: { isProtectionEnabled: true, stepUpWindowMinutes: 15, isDepartmentLocked: false, lockReason: null }, + isCapabilitiesLoaded: true, + }); + + expect(mockAuthListener).toBeDefined(); + mockAuthListener?.({ status: 'signedOut' }, { status: 'signedIn' }); + + const state = dataProtectionStore.getState(); + expect(state.stepUpExpiresAt).toBeNull(); + expect(state.capabilities).toBeNull(); + expect(state.isCapabilitiesLoaded).toBe(false); + }); + }); +}); diff --git a/src/stores/data-protection/store.ts b/src/stores/data-protection/store.ts new file mode 100644 index 00000000..71bd18ea --- /dev/null +++ b/src/stores/data-protection/store.ts @@ -0,0 +1,248 @@ +import { create } from 'zustand'; + +import { getDataProtectionCapabilities, requestProtectedGrant, verifyStepUp } from '@/api/data-protection/data-protection'; +import { setProtectedGrantProvider } from '@/lib/data-protection/grant-provider'; +import { logger } from '@/lib/logging'; + +import useAuthStore from '../auth/store'; + +// --------------------------------------------------------------------------- +// Advanced Data Protection (ADP) grant state. +// +// DELIBERATELY NOT PERSISTED: the grant is a security credential and lives in +// memory only (ADP plan 7.2). App restart, logout or department switch always +// starts locked. The window is ABSOLUTE — activity never extends it. +// --------------------------------------------------------------------------- + +export type StepUpErrorCode = 'invalid_totp' | 'mfa_not_enrolled' | 'too_many_attempts' | 'grants_not_configured' | 'unknown'; + +/** What ensureGrant() concluded. The caller shows the OTP prompt only for 'step_up_required'. */ +export type GrantOutcome = 'granted' | 'step_up_required' | 'unavailable'; + +interface DataProtectionCapabilities { + isProtectionEnabled: boolean; + stepUpWindowMinutes: number; + isDepartmentLocked: boolean; + lockReason: string | null; +} + +export interface DataProtectionState { + capabilities: DataProtectionCapabilities | null; + isCapabilitiesLoaded: boolean; + /** Epoch ms the current window expires, or null when no grant is held. */ + stepUpExpiresAt: number | null; + /** The signed grant. Memory only; never written to storage or logs. */ + grantToken: string | null; + isVerifying: boolean; + isRequestingGrant: boolean; + /** + * Whether the OTP prompt is showing. Held here rather than in a screen because the prompt is + * mounted ONCE at the app shell: a modal per screen means several can stack, and it drags the + * whole modal import graph into every screen that shows a protected value. + */ + isPromptOpen: boolean; + openPrompt: () => void; + closePrompt: () => void; + lastError: StepUpErrorCode | null; + fetchCapabilities: () => Promise; + /** + * Tries to obtain a grant without prompting. Returns 'granted' when the department has exempted + * this app, 'step_up_required' when the caller must enter a code, and 'unavailable' when grants + * are not configured at all. + * + * Anything unexpected resolves to 'step_up_required'. Erring towards asking for a second factor + * is the direction that cannot cause harm. + */ + ensureGrant: () => Promise; + /** Sends the TOTP code; true on success. */ + verifyOtp: (code: string) => Promise; + /** True while an unexpired grant token is held. Evaluate at the moment of use. */ + isStepUpActive: () => boolean; + /** + * Headers for a request that needs to read protected values, or {} when no grant is held. + * Spread into the request config: `{ headers: { ...getGrantHeaders() } }`. + */ + getGrantHeaders: () => Record; + /** Drops the grant immediately (logout, department switch, manual conceal). */ + clearStepUp: () => void; +} + +const GRANT_HEADER = 'X-Resgrid-Protected-Grant'; + +const parseErrorCode = (error: unknown): StepUpErrorCode => { + const type = (error as { response?: { data?: { type?: string } } })?.response?.data?.type; + if (type === 'invalid_totp' || type === 'mfa_not_enrolled' || type === 'too_many_attempts' || type === 'grants_not_configured') { + return type; + } + return 'unknown'; +}; + +const problemType = (error: unknown): string | undefined => (error as { response?: { data?: { type?: string } } })?.response?.data?.type; + +export const dataProtectionStore = create()((set, get) => ({ + capabilities: null, + isCapabilitiesLoaded: false, + stepUpExpiresAt: null, + grantToken: null, + isVerifying: false, + isRequestingGrant: false, + isPromptOpen: false, + lastError: null, + openPrompt: () => set({ isPromptOpen: true, lastError: null }), + closePrompt: () => set({ isPromptOpen: false }), + fetchCapabilities: async () => { + try { + const response = await getDataProtectionCapabilities(); + const data = response?.Data; + set({ + capabilities: data + ? { + isProtectionEnabled: !!data.IsProtectionEnabled, + stepUpWindowMinutes: data.StepUpWindowMinutes ?? 15, + isDepartmentLocked: !!data.IsDepartmentLocked, + lockReason: data.LockReason ?? null, + } + : null, + isCapabilitiesLoaded: true, + }); + } catch (error) { + // Unknown capability state fails closed: consumers treat "no capabilities" as protected + // when the server later marks fields redacted, and as unprotected for legacy departments. + logger.error({ + message: 'Failed to fetch data protection capabilities', + context: { error }, + }); + set({ isCapabilitiesLoaded: true }); + } + }, + ensureGrant: async () => { + if (get().isStepUpActive()) { + return 'granted'; + } + + set({ isRequestingGrant: true, lastError: null }); + try { + const result = await requestProtectedGrant(); + const expiresAt = result?.StepUpExpiresOnUtc ? Date.parse(result.StepUpExpiresOnUtc) : NaN; + + if (!result?.GrantToken || !Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + set({ isRequestingGrant: false }); + return 'step_up_required'; + } + + set({ grantToken: result.GrantToken, stepUpExpiresAt: expiresAt, isRequestingGrant: false, lastError: null }); + return 'granted'; + } catch (error) { + set({ isRequestingGrant: false }); + + const type = problemType(error); + if (type === 'grants_not_configured') { + return 'unavailable'; + } + + // Everything else — including a network failure — means prompt. The server refuses with + // step_up_required whenever this app is not exempt, which is the normal case. + return 'step_up_required'; + } + }, + verifyOtp: async (code: string) => { + set({ isVerifying: true, lastError: null }); + try { + const result = await verifyStepUp(code.trim()); + const expiresAt = result?.StepUpExpiresOnUtc ? Date.parse(result.StepUpExpiresOnUtc) : NaN; + // A token-less response is a failure, not a grant. Accepting one would flip the UI to + // "revealed" while getGrantHeaders() still sends nothing, so every value stays REDACTED + // with no error to explain it — the same invariant ensureGrant() already enforces. + if (!result?.GrantToken || !Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + set({ isVerifying: false, lastError: 'unknown' }); + return false; + } + set({ + grantToken: result.GrantToken, + stepUpExpiresAt: expiresAt, + isVerifying: false, + lastError: null, + }); + return true; + } catch (error) { + // Never log the code; the error object carries only the HTTP problem envelope. + logger.warn({ + message: 'ADP step-up verification failed', + context: { errorType: parseErrorCode(error) }, + }); + set({ isVerifying: false, lastError: parseErrorCode(error) }); + return false; + } + }, + isStepUpActive: () => { + const { grantToken, stepUpExpiresAt } = get(); + // Both halves are required. A future expiry with no token buys nothing: getGrantHeaders() + // would send no header, so the record comes back redacted while the UI claims otherwise. + return !!grantToken && stepUpExpiresAt != null && Date.now() < stepUpExpiresAt; + }, + getGrantHeaders: () => { + const state = get(); + // Expiry is checked here rather than trusted from state: a grant that lapsed while a screen + // sat open must not be attached to the next request. + const headers: Record = {}; + if (!state.grantToken || !state.isStepUpActive()) { + return headers; + } + + headers[GRANT_HEADER] = state.grantToken; + return headers; + }, + clearStepUp: () => set({ stepUpExpiresAt: null, grantToken: null, lastError: null }), +})); + +// The grant is memory-only and must never survive the session: drop everything the moment the +// auth status leaves 'signedIn' (logout, token revocation, forced deauth). +// +// Guarded because this module is now in the import graph of any screen showing a protected value, +// and a store that throws at import time takes the whole screen down with it. Losing the +// subscription costs the in-session logout sweep only — the grant is memory-only either way, so it +// never survives a reload — but it is logged rather than swallowed, so it cannot go unnoticed. +if (typeof useAuthStore?.subscribe === 'function') { + useAuthStore.subscribe((state: { status: string }, prevState: { status: string }) => { + if (prevState.status === 'signedIn' && state.status !== 'signedIn') { + dataProtectionStore.setState({ + capabilities: null, + isCapabilitiesLoaded: false, + stepUpExpiresAt: null, + grantToken: null, + isVerifying: false, + isRequestingGrant: false, + isPromptOpen: false, + lastError: null, + }); + } + }); +} else { + logger.warn({ message: 'ADP grant store could not subscribe to auth changes; sign-out will not sweep the grant early.' }); +} + +// Every read through the shared API client carries the grant while one is held — see +// setProtectedGrantProvider. Registered here rather than imported there, because the client is +// what this store's own API layer is built on. +setProtectedGrantProvider(() => dataProtectionStore.getState().getGrantHeaders()); + +/** Reactive: true while protection is enabled for the department (unknown reads as false). */ +export const useIsProtectionEnabled = () => dataProtectionStore((state) => !!state.capabilities?.isProtectionEnabled); + +/** + * Reactive step-up flag. Re-renders on verify/clear; expiry itself is time-based, so callers + * gating a reveal must ALSO call isStepUpActive() at the moment of use. + */ +export const useStepUpExpiresAt = () => dataProtectionStore((state) => state.stepUpExpiresAt); + +/** + * Reactive: whether a grant token is held at all. Paired with useStepUpExpiresAt by callers that + * render a reveal state, because an expiry alone does not make a grant usable. + */ +export const useHasGrantToken = () => dataProtectionStore((state) => !!state.grantToken); + +/** Headers helper for one-off calls outside a component. */ +export const getProtectedGrantHeaders = () => dataProtectionStore.getState().getGrantHeaders(); + +/** Reactive: whether the single app-level OTP prompt should be showing. */ +export const useIsStepUpPromptOpen = () => dataProtectionStore((state) => state.isPromptOpen); diff --git a/src/translations/ar.json b/src/translations/ar.json index e1b2317e..3e867ba1 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -580,6 +580,22 @@ "website": "الموقع الإلكتروني", "zip": "الرمز البريدي" }, + "data_protection": { + "conceal": "إخفاؤها مجددًا", + "protected_notice": "بعض المعلومات في هذه الشاشة محمية.", + "protected_value": "محمي", + "reveal": "إظهار المعلومات المحمية", + "revealed_notice": "المعلومات المحمية ظاهرة الآن.", + "step_up_body": "هذه المعلومات محمية. أدخل الرمز الحالي من تطبيق المصادقة لعرضها لفترة محدودة.", + "step_up_failed": "تعذّر التأكيد. تحقق من اتصالك وأعد المحاولة.", + "step_up_invalid_code": "هذا الرمز غير صالح أو انتهت صلاحيته. أدخل الرمز الحالي من تطبيق المصادقة.", + "step_up_not_enrolled": "لم تُفعَّل المصادقة الثنائية لحسابك. فعِّل تطبيق مصادقة من إعدادات أمان الحساب أولًا.", + "step_up_placeholder": "رمز من 6 أرقام", + "step_up_title": "أكِّد هويتك", + "step_up_too_many_attempts": "محاولات كثيرة جدًا. انتظر بضع دقائق ثم أعد المحاولة.", + "step_up_unavailable": "البيانات المحمية غير متاحة بعد على هذا الخادم. تواصل مع المسؤول.", + "step_up_verify": "تأكيد" + }, "form": { "invalid_url": "يرجى إدخال عنوان URL صالح يبدأ بـ http:// أو https://", "required": "هذا الحقل مطلوب" diff --git a/src/translations/de.json b/src/translations/de.json index a0998b11..eda08ed2 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -580,6 +580,22 @@ "website": "Website", "zip": "Postleitzahl" }, + "data_protection": { + "conceal": "Wieder ausblenden", + "protected_notice": "Einige Angaben auf diesem Bildschirm sind geschützt.", + "protected_value": "Geschützt", + "reveal": "Geschützte Informationen anzeigen", + "revealed_notice": "Geschützte Angaben sind sichtbar.", + "step_up_body": "Diese Angaben sind geschützt. Geben Sie den aktuellen Code aus Ihrer Authenticator-App ein, um sie für begrenzte Zeit zu sehen.", + "step_up_failed": "Bestätigung fehlgeschlagen. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.", + "step_up_invalid_code": "Dieser Code ist ungültig oder abgelaufen. Geben Sie den aktuellen Code aus Ihrer Authenticator-App ein.", + "step_up_not_enrolled": "Für Ihr Konto ist keine Zwei-Faktor-Authentifizierung eingerichtet. Richten Sie zuerst eine Authenticator-App in den Sicherheitseinstellungen Ihres Kontos ein.", + "step_up_placeholder": "6-stelliger Code", + "step_up_title": "Identität bestätigen", + "step_up_too_many_attempts": "Zu viele Versuche. Warten Sie einige Minuten und versuchen Sie es erneut.", + "step_up_unavailable": "Geschützte Daten sind auf diesem Server noch nicht verfügbar. Wenden Sie sich an Ihre Administration.", + "step_up_verify": "Bestätigen" + }, "form": { "invalid_url": "Bitte eine gültige URL eingeben, die mit http:// oder https:// beginnt", "required": "Dieses Feld ist erforderlich" diff --git a/src/translations/el.json b/src/translations/el.json index b0b3d3cb..ba1f7179 100644 --- a/src/translations/el.json +++ b/src/translations/el.json @@ -580,6 +580,22 @@ "website": "Ιστότοπος", "zip": "Ταχυδρομικός Κώδικας" }, + "data_protection": { + "conceal": "Απόκρυψη ξανά", + "protected_notice": "Ορισμένες πληροφορίες σε αυτήν την οθόνη είναι προστατευμένες.", + "protected_value": "Προστατευμένο", + "reveal": "Εμφάνιση προστατευμένων πληροφοριών", + "revealed_notice": "Οι προστατευμένες πληροφορίες είναι ορατές.", + "step_up_body": "Αυτές οι πληροφορίες είναι προστατευμένες. Εισαγάγετε τον τρέχοντα κωδικό από την εφαρμογή ελέγχου ταυτότητας για να τις δείτε για περιορισμένο χρόνο.", + "step_up_failed": "Η επαλήθευση απέτυχε. Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά.", + "step_up_invalid_code": "Ο κωδικός δεν είναι έγκυρος ή έχει λήξει. Εισαγάγετε τον τρέχοντα κωδικό από την εφαρμογή ελέγχου ταυτότητας.", + "step_up_not_enrolled": "Δεν έχει ρυθμιστεί έλεγχος ταυτότητας δύο παραγόντων για τον λογαριασμό σας. Ρυθμίστε πρώτα μια εφαρμογή ελέγχου ταυτότητας στις ρυθμίσεις ασφαλείας του λογαριασμού.", + "step_up_placeholder": "Εξαψήφιος κωδικός", + "step_up_title": "Επαληθεύστε την ταυτότητά σας", + "step_up_too_many_attempts": "Πάρα πολλές προσπάθειες. Περιμένετε λίγα λεπτά και δοκιμάστε ξανά.", + "step_up_unavailable": "Τα προστατευμένα δεδομένα δεν είναι ακόμη διαθέσιμα σε αυτόν τον διακομιστή. Επικοινωνήστε με τον διαχειριστή σας.", + "step_up_verify": "Επαλήθευση" + }, "form": { "invalid_url": "Εισαγάγετε έγκυρη διεύθυνση URL που ξεκινά με http:// ή https://", "required": "Αυτό το πεδίο είναι υποχρεωτικό" diff --git a/src/translations/en.json b/src/translations/en.json index a3fcbeea..870391ee 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -580,6 +580,22 @@ "website": "Website", "zip": "Zip Code" }, + "data_protection": { + "conceal": "Hide again", + "protected_notice": "Some information on this screen is protected.", + "protected_value": "Protected", + "reveal": "Show protected information", + "revealed_notice": "Protected information is visible.", + "step_up_body": "This information is protected. Enter the current code from your authenticator app to view it for a limited time.", + "step_up_failed": "Verification failed. Check your connection and try again.", + "step_up_invalid_code": "That code is invalid or has expired. Enter the current code from your authenticator app.", + "step_up_not_enrolled": "Two-factor authentication is not set up for your account. Enroll an authenticator app in your account security settings first.", + "step_up_placeholder": "6-digit code", + "step_up_title": "Verify your identity", + "step_up_too_many_attempts": "Too many attempts. Wait a few minutes and try again.", + "step_up_unavailable": "Protected data is not available on this server yet. Contact your administrator.", + "step_up_verify": "Verify" + }, "form": { "invalid_url": "Please enter a valid URL starting with http:// or https://", "required": "This field is required" diff --git a/src/translations/es.json b/src/translations/es.json index 21713c3d..d3b6e145 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -580,6 +580,22 @@ "website": "Sitio web", "zip": "Código postal" }, + "data_protection": { + "conceal": "Ocultar de nuevo", + "protected_notice": "Parte de la información de esta pantalla está protegida.", + "protected_value": "Protegido", + "reveal": "Mostrar información protegida", + "revealed_notice": "La información protegida está visible.", + "step_up_body": "Esta información está protegida. Introduzca el código actual de su aplicación de autenticación para verla durante un tiempo limitado.", + "step_up_failed": "La verificación ha fallado. Compruebe su conexión e inténtelo de nuevo.", + "step_up_invalid_code": "Ese código no es válido o ha caducado. Introduzca el código actual de su aplicación de autenticación.", + "step_up_not_enrolled": "Su cuenta no tiene configurada la autenticación en dos pasos. Configure primero una aplicación de autenticación en los ajustes de seguridad de su cuenta.", + "step_up_placeholder": "Código de 6 dígitos", + "step_up_title": "Verifique su identidad", + "step_up_too_many_attempts": "Demasiados intentos. Espere unos minutos e inténtelo de nuevo.", + "step_up_unavailable": "Los datos protegidos aún no están disponibles en este servidor. Póngase en contacto con su administrador.", + "step_up_verify": "Verificar" + }, "form": { "invalid_url": "Por favor, introduce una URL válida que comience con http:// o https://", "required": "Este campo es obligatorio" diff --git a/src/translations/fr.json b/src/translations/fr.json index 843f0282..a22015e5 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -580,6 +580,22 @@ "website": "Site web", "zip": "Code postal" }, + "data_protection": { + "conceal": "Masquer à nouveau", + "protected_notice": "Certaines informations de cet écran sont protégées.", + "protected_value": "Protégé", + "reveal": "Afficher les informations protégées", + "revealed_notice": "Les informations protégées sont visibles.", + "step_up_body": "Ces informations sont protégées. Saisissez le code actuel de votre application d'authentification pour les afficher pendant une durée limitée.", + "step_up_failed": "La vérification a échoué. Vérifiez votre connexion et réessayez.", + "step_up_invalid_code": "Ce code est invalide ou a expiré. Saisissez le code actuel de votre application d'authentification.", + "step_up_not_enrolled": "L'authentification à deux facteurs n'est pas configurée pour votre compte. Configurez d'abord une application d'authentification dans les paramètres de sécurité de votre compte.", + "step_up_placeholder": "Code à 6 chiffres", + "step_up_title": "Vérifiez votre identité", + "step_up_too_many_attempts": "Trop de tentatives. Patientez quelques minutes et réessayez.", + "step_up_unavailable": "Les données protégées ne sont pas encore disponibles sur ce serveur. Contactez votre administrateur.", + "step_up_verify": "Vérifier" + }, "form": { "invalid_url": "Veuillez saisir une URL valide commençant par http:// ou https://", "required": "Ce champ est obligatoire" diff --git a/src/translations/it.json b/src/translations/it.json index 3abafd64..1e4cf26d 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -580,6 +580,22 @@ "website": "Sito web", "zip": "CAP" }, + "data_protection": { + "conceal": "Nascondi di nuovo", + "protected_notice": "Alcune informazioni in questa schermata sono protette.", + "protected_value": "Protetto", + "reveal": "Mostra le informazioni protette", + "revealed_notice": "Le informazioni protette sono visibili.", + "step_up_body": "Queste informazioni sono protette. Inserisci il codice attuale dalla tua app di autenticazione per visualizzarle per un tempo limitato.", + "step_up_failed": "Verifica non riuscita. Controlla la connessione e riprova.", + "step_up_invalid_code": "Il codice non è valido o è scaduto. Inserisci il codice attuale dalla tua app di autenticazione.", + "step_up_not_enrolled": "L'autenticazione a due fattori non è configurata per il tuo account. Configura prima un'app di autenticazione nelle impostazioni di sicurezza dell'account.", + "step_up_placeholder": "Codice a 6 cifre", + "step_up_title": "Verifica la tua identità", + "step_up_too_many_attempts": "Troppi tentativi. Attendi qualche minuto e riprova.", + "step_up_unavailable": "I dati protetti non sono ancora disponibili su questo server. Contatta l'amministratore.", + "step_up_verify": "Verifica" + }, "form": { "invalid_url": "Inserisci un URL valido che inizia con http:// o https://", "required": "Questo campo è obbligatorio" diff --git a/src/translations/pl.json b/src/translations/pl.json index 36ddf151..f35bf69c 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -580,6 +580,22 @@ "website": "Strona internetowa", "zip": "Kod pocztowy" }, + "data_protection": { + "conceal": "Ukryj ponownie", + "protected_notice": "Część informacji na tym ekranie jest chroniona.", + "protected_value": "Chronione", + "reveal": "Pokaż chronione informacje", + "revealed_notice": "Chronione informacje są widoczne.", + "step_up_body": "Te informacje są chronione. Wprowadź aktualny kod z aplikacji uwierzytelniającej, aby zobaczyć je przez ograniczony czas.", + "step_up_failed": "Weryfikacja nie powiodła się. Sprawdź połączenie i spróbuj ponownie.", + "step_up_invalid_code": "Ten kod jest nieprawidłowy lub wygasł. Wprowadź aktualny kod z aplikacji uwierzytelniającej.", + "step_up_not_enrolled": "Dla Twojego konta nie skonfigurowano uwierzytelniania dwuskładnikowego. Najpierw skonfiguruj aplikację uwierzytelniającą w ustawieniach bezpieczeństwa konta.", + "step_up_placeholder": "Kod 6-cyfrowy", + "step_up_title": "Potwierdź swoją tożsamość", + "step_up_too_many_attempts": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie.", + "step_up_unavailable": "Chronione dane nie są jeszcze dostępne na tym serwerze. Skontaktuj się z administratorem.", + "step_up_verify": "Potwierdź" + }, "form": { "invalid_url": "Wpisz prawidłowy URL zaczynający się od http:// lub https://", "required": "To pole jest wymagane" diff --git a/src/translations/sv.json b/src/translations/sv.json index 58f66ecf..f93133f4 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -580,6 +580,22 @@ "website": "Webbplats", "zip": "Postnummer" }, + "data_protection": { + "conceal": "Dölj igen", + "protected_notice": "En del uppgifter på den här skärmen är skyddade.", + "protected_value": "Skyddad", + "reveal": "Visa skyddad information", + "revealed_notice": "Skyddade uppgifter visas.", + "step_up_body": "De här uppgifterna är skyddade. Ange den aktuella koden från din autentiseringsapp för att visa dem en begränsad tid.", + "step_up_failed": "Verifieringen misslyckades. Kontrollera din anslutning och försök igen.", + "step_up_invalid_code": "Koden är ogiltig eller har gått ut. Ange den aktuella koden från din autentiseringsapp.", + "step_up_not_enrolled": "Tvåfaktorsautentisering är inte konfigurerad för ditt konto. Konfigurera först en autentiseringsapp i kontots säkerhetsinställningar.", + "step_up_placeholder": "6-siffrig kod", + "step_up_title": "Bekräfta din identitet", + "step_up_too_many_attempts": "För många försök. Vänta några minuter och försök igen.", + "step_up_unavailable": "Skyddade uppgifter är ännu inte tillgängliga på den här servern. Kontakta din administratör.", + "step_up_verify": "Bekräfta" + }, "form": { "invalid_url": "Ange en giltig URL som börjar med http:// eller https://", "required": "Detta fält är obligatoriskt" diff --git a/src/translations/uk.json b/src/translations/uk.json index 1052df6d..2d8aead6 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -580,6 +580,22 @@ "website": "Веб-сайт", "zip": "Поштовий індекс" }, + "data_protection": { + "conceal": "Приховати знову", + "protected_notice": "Частина інформації на цьому екрані захищена.", + "protected_value": "Захищено", + "reveal": "Показати захищену інформацію", + "revealed_notice": "Захищену інформацію показано.", + "step_up_body": "Ця інформація захищена. Введіть поточний код із застосунку автентифікації, щоб переглянути її протягом обмеженого часу.", + "step_up_failed": "Не вдалося підтвердити. Перевірте з’єднання та повторіть спробу.", + "step_up_invalid_code": "Цей код недійсний або прострочений. Введіть поточний код із застосунку автентифікації.", + "step_up_not_enrolled": "Для вашого облікового запису не налаштовано двофакторну автентифікацію. Спершу налаштуйте застосунок автентифікації в параметрах безпеки облікового запису.", + "step_up_placeholder": "6-значний код", + "step_up_title": "Підтвердьте свою особу", + "step_up_too_many_attempts": "Забагато спроб. Зачекайте кілька хвилин і повторіть.", + "step_up_unavailable": "Захищені дані ще недоступні на цьому сервері. Зверніться до адміністратора.", + "step_up_verify": "Підтвердити" + }, "form": { "invalid_url": "Введіть правильний URL, що починається з http:// або https://", "required": "Це поле обов'язкове"