diff --git a/app.config.ts b/app.config.ts index 7c6fc25c..62503963 100644 --- a/app.config.ts +++ b/app.config.ts @@ -50,7 +50,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ UIBackgroundModes: ['remote-notification', 'audio', 'bluetooth-central', 'voip'], ITSAppUsesNonExemptEncryption: false, UIViewControllerBasedStatusBarAppearance: false, - NSBluetoothAlwaysUsageDescription: 'Allow Resgrid Dispatch to connect to bluetooth devices for PTT.', + NSBluetoothAlwaysUsageDescription: + 'Resgrid Dispatch uses Bluetooth to connect to wireless headsets and speaker-microphone accessories for Push-to-Talk audio. For example, when you pair a Bluetooth speaker-mic, pressing its talk button transmits your voice to your department audio channel.', LSApplicationQueriesSchemes: [Env.SCHEME, 'https', 'http'], }, entitlements: { @@ -97,9 +98,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ 'android.permission.POST_NOTIFICATIONS', 'android.permission.FOREGROUND_SERVICE', 'android.permission.FOREGROUND_SERVICE_MICROPHONE', - 'android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE', - 'android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK', ], + // FOREGROUND_SERVICE_CONNECTED_DEVICE is blocked, not merely absent: Bluetooth PTT handsets + // route through the microphone FGS session, so the type is unused, and Play rejects any + // declared foreground-service type whose use case cannot be demonstrated in the app. + blockedPermissions: ['android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE'], }, web: { favicon: './assets/favicon.png', @@ -215,9 +218,12 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ [ 'expo-location', { - locationWhenInUsePermission: 'Allow Resgrid Dispatch to show current location on map.', - locationAlwaysAndWhenInUsePermission: 'Allow Resgrid Dispatch to use your location for department updates.', - locationAlwaysPermission: 'Resgrid Dispatch needs to track your location for department AVL.', + locationWhenInUsePermission: + 'Resgrid Dispatch uses your location while you use the app to center the department map on you and to attach your coordinates when you dispatch or update a call. For example, when you create a call from the field, your location can be used as the incident address.', + locationAlwaysAndWhenInUsePermission: + 'Resgrid Dispatch uses your location, including in the background, to keep the department map updated with your position. For example, while you are working a call away from the console, your location is periodically sent so other dispatchers and responders can see where you are, even when the app is not on screen.', + locationAlwaysPermission: + 'Resgrid Dispatch uses your location in the background to keep the department map updated with your position. For example, while you are working a call away from the console, your location is periodically sent so other dispatchers and responders can see where you are, even when the app is not on screen.', isIosBackgroundLocationEnabled: true, isAndroidBackgroundLocationEnabled: true, isAndroidForegroundServiceEnabled: true, @@ -277,6 +283,15 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ iCloudContainerEnvironment: 'Production', }, ], + [ + 'expo-image-picker', + { + photosPermission: + 'Resgrid Dispatch uses your photo library so you can attach existing photos to calls and chat messages. For example, you can select a saved photo of an incident scene and attach it to an active call to share it with responders.', + cameraPermission: + 'Resgrid Dispatch uses the camera to take photos that you attach to calls. For example, you can photograph an incident scene or document and attach the image to the active call for responders and other dispatchers to see.', + }, + ], [ '@sentry/react-native/expo', { @@ -296,12 +311,21 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ [ 'expo-audio', { - microphonePermission: 'Allow Resgrid Dispatch to access the microphone for audio input used in PTT and calls.', + microphonePermission: + 'Resgrid Dispatch uses the microphone to capture your voice for Push-to-Talk and voice calls with your department. For example, when you press and hold the talk button in the dispatch console, your voice is transmitted live to responders on the audio channel.', }, ], 'react-native-ble-manager', '@livekit/react-native-expo-plugin', - '@config-plugins/react-native-webrtc', + [ + '@config-plugins/react-native-webrtc', + { + // Set explicitly so the plugin's vague 'Allow $(PRODUCT_NAME) to access your camera' + // default can never end up in the built Info.plist. + cameraPermission: + 'Resgrid Dispatch uses the camera to take photos that you attach to calls. For example, you can photograph an incident scene or document and attach the image to the active call for responders and other dispatchers to see.', + }, + ], '@config-plugins/react-native-callkeep', 'expo-web-browser', './customGradle.plugin.js', diff --git a/customManifest.plugin.js b/customManifest.plugin.js index 287639e7..7176d35a 100644 --- a/customManifest.plugin.js +++ b/customManifest.plugin.js @@ -1,5 +1,7 @@ const { withAndroidManifest, AndroidConfig } = require('@expo/config-plugins'); +const SERVICE_NAME = 'app.notifee.core.ForegroundService'; + const withForegroundService = (config) => { return withAndroidManifest(config, async (config) => { const manifest = config.modResults; @@ -11,13 +13,23 @@ const withForegroundService = (config) => { const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow(manifest); mainApplication['service'] = mainApplication['service'] || []; - mainApplication['service'].push({ + + // Idempotent: a prebuild that reuses an existing android/ dir already has this service in + // the base manifest — and non-clean prebuilds have already accumulated duplicates there — so + // drop every copy before adding the canonical one. + const serviceEntry = { $: { - 'android:name': 'app.notifee.core.ForegroundService', - 'android:foregroundServiceType': 'microphone|mediaPlayback|connectedDevice', + 'android:name': SERVICE_NAME, + // microphone only. mediaPlayback and connectedDevice are intentionally absent: this + // service backs PTT capture, expo-audio owns its own mediaPlayback service for stream + // playback, and Bluetooth PTT handsets run on the same microphone session. Play rejects + // foreground-service types whose use case cannot be demonstrated in the app. + 'android:foregroundServiceType': 'microphone', 'tools:replace': 'android:foregroundServiceType', }, - }); + }; + mainApplication['service'] = mainApplication['service'].filter((service) => service?.$?.['android:name'] !== SERVICE_NAME); + mainApplication['service'].push(serviceEntry); return config; }); }; diff --git a/src/api/calls/callFiles.ts b/src/api/calls/callFiles.ts index 8157b805..034f90f3 100644 --- a/src/api/calls/callFiles.ts +++ b/src/api/calls/callFiles.ts @@ -1,8 +1,10 @@ import axios, { type AxiosProgressEvent, type AxiosRequestConfig, type AxiosResponse } from 'axios'; import { createApiEndpoint } from '@/api/common/client'; +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'; @@ -29,6 +31,25 @@ const getCallFilesApi = createApiEndpoint('/CallFiles/GetFilesForCall'); const saveCallFileApi = createApiEndpoint('/CallFiles/SaveCallFile'); // Function to download a file with progress reporting +/** + * 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; + } +}; + export const getCallAttachmentFile = async (url: string, options: DownloadOptions = {}): Promise => { const { onEvent, headers = {}, timeout = 30000 } = options; @@ -38,9 +59,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 20cc0e01..604542d3 100644 --- a/src/api/chat/chat.ts +++ b/src/api/chat/chat.ts @@ -223,8 +223,11 @@ export const getChatAttachmentThumbnailUrl = (attachmentId: string): string => ` * Image source (with bearer auth header) suitable for expo-image / RN Image * when rendering a chat attachment. */ -export const getChatAttachmentImageSource = (attachmentId: string) => { - const token = useAuthStore.getState().accessToken; +export const getChatAttachmentImageSource = (attachmentId: string, accessToken?: string | null) => { + // `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. + 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 9e353e66..85872b8a 100644 --- a/src/api/common/client.tsx +++ b/src/api/common/client.tsx @@ -1,6 +1,7 @@ import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'; import { performTokenRefresh } from '@/lib/auth/token-refresh'; +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'; @@ -60,6 +61,21 @@ axiosInstance.interceptors.request.use( } 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 + // page 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 abd6c9c5..61da58a3 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Platform, StyleSheet, Text as RNText, TouchableOpacity, View as RNView } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { StepUpPromptHost } from '@/components/data-protection/step-up-prompt-host'; import { DashboardViewToggles } from '@/components/dispatch-console'; import { NotificationButton } from '@/components/notifications/NotificationButton'; import { NotificationInbox } from '@/components/notifications/NotificationInbox'; @@ -35,6 +36,7 @@ import { useLiveKitStore } from '@/stores/app/livekit-store'; import { useCallsStore } from '@/stores/calls/store'; import { useChatStore } from '@/stores/chat/store'; import { useCheckInStore } from '@/stores/checkIn/store'; +import { dataProtectionStore } from '@/stores/data-protection/store'; import { FeatureFlagKeys, featureFlagsStore } from '@/stores/feature-flags/store'; import useLockscreenStore from '@/stores/lockscreen/store'; import { useRolesStore } from '@/stores/roles/store'; @@ -216,7 +218,7 @@ export default function TabLayout() { context: { platform: Platform.OS }, }); - await featureFlagsStore.getState().fetchFlags(); + await featureFlagsStore.getState().fetchFlags(), dataProtectionStore.getState().fetchCapabilities(); logger.info({ message: 'Feature flags fetched, connecting SignalR', @@ -612,7 +614,14 @@ export default function TabLayout() { logger.info({ message: 'Rendering app layout for web platform (Novu disabled)', }); - return content; + // The app's single Advanced Data Protection prompt: any screen triggers it through the store, + // so no screen carries a modal of its own and two prompts can never stack. + return ( + <> + + {content} + + ); } return ( @@ -621,6 +630,7 @@ export default function TabLayout() { {/* NotificationInbox at the root level */} setIsNotificationsOpen(false)} /> + {content} ) : ( diff --git a/src/app/(app)/contacts.tsx b/src/app/(app)/contacts.tsx index 1657bd37..8902fd7f 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'; @@ -69,6 +70,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 b2ecf478..124e68dd 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -27,6 +27,8 @@ import { VideoFeedsTab } from '@/components/callVideoFeeds/video-feeds-tab'; import { CheckInTab } from '@/components/checkIn/check-in-tab'; 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 { IncidentCommandTab } from '@/components/incident-command/incident-command-tab'; // Import a static map component instead of react-native-maps import StaticMap from '@/components/maps/static-map'; @@ -41,6 +43,7 @@ import { SharedTabs, type TabItem } from '@/components/ui/shared-tabs'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; +import { isFieldRedacted, ProtectedFieldIds } from '@/lib/data-protection/redacted'; import { buildAddResourcesUpdateRequest, EMPTY_DISPATCH_SELECTION } from '@/lib/dispatch-helpers'; import { logger } from '@/lib/logging'; import { openMapsWithDirections } from '@/lib/navigation'; @@ -387,7 +390,7 @@ export default function CallDetail() { {t('call_detail.address')} - {call.Address} + {call.DestinationName ? ( @@ -464,11 +467,11 @@ export default function CallDetail() { {t('call_detail.contact_name')} - {call.ContactName} + {t('call_detail.contact_info')} - {call.ContactInfo} + @@ -643,7 +646,11 @@ export default function CallDetail() { const showDestinationMap = mapView === 'destination' && hasDestinationLocation; const mapLatitude = showDestinationMap ? call.DestinationLatitude : coordinates.latitude; const mapLongitude = showDestinationMap ? call.DestinationLongitude : coordinates.longitude; - const mapAddress = showDestinationMap ? call.DestinationAddress || call.DestinationName : call.Address; + // A withheld address must not leak through the map chrome. StaticMap prints `address` in its + // overlay AND its accessibility label, 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 mapAddress = showDestinationMap ? call.DestinationAddress || call.DestinationName : isAddressRedacted ? undefined : call.Address; return ( <> @@ -656,11 +663,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 && ( diff --git a/src/app/chat/[channelId].tsx b/src/app/chat/[channelId].tsx index ac27e20e..6f30664f 100644 --- a/src/app/chat/[channelId].tsx +++ b/src/app/chat/[channelId].tsx @@ -53,7 +53,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); @@ -241,7 +241,7 @@ export default function ChannelConversationScreen() { onToggleReaction={handleToggleReaction} onOpenThread={openThread} onRetry={(m) => m.ClientMessageId && useChatStore.getState().retryOutboxItem(m.ClientMessageId)} - onPressImage={setImageUri} + onPressImage={setImageSource} /> ), [currentUserId, showSender, handleToggleReaction, openThread] @@ -388,15 +388,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 94c8de1e..09dc3b83 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(); const router = useRouter(); @@ -61,6 +66,8 @@ export default function Login() { message: 'Starting Login (button press)', context: { username: data.username }, }); + setPendingCredentials({ username: data.username, password: data.password }); + setOtpDismissed(false); try { await login({ username: data.username, password: data.password }); logger.info({ @@ -75,6 +82,13 @@ export default function Login() { } }; + const onOtpSubmit = async (code: string) => { + if (!pendingCredentials) { + return; + } + await login({ ...pendingCredentials, otpCode: code }); + }; + return ( <> @@ -112,6 +126,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 0f784dd6..560c7362 100644 --- a/src/app/login/sso.tsx +++ b/src/app/login/sso.tsx @@ -11,6 +11,7 @@ import { Image, ScrollView } from 'react-native'; import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; import * as z from 'zod'; +import { LoginOtpModal } from '@/components/auth/login-otp-modal'; import { View } from '@/components/ui'; import { Button, ButtonSpinner, ButtonText } from '@/components/ui/button'; import { FormControl, FormControlError, FormControlErrorIcon, FormControlErrorText, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control'; @@ -19,6 +20,7 @@ import { Text } from '@/components/ui/text'; import colors from '@/constants/colors'; import { useOidcLogin } from '@/hooks/use-oidc-login'; import { useSamlLogin } from '@/hooks/use-saml-login'; +import { retrySsoExchangeWithOtp } from '@/lib/auth/api'; import type { AuthResponse, SsoConfig } from '@/lib/auth/types'; import { logger } from '@/lib/logging'; import { fetchSsoConfigForUser } from '@/services/sso-discovery'; @@ -37,10 +39,11 @@ interface OidcSignInSectionProps { onAuthStart: () => void; onAuthEnd: () => void; onTokenReceived: (authResponse: AuthResponse) => void; + onMfaRequired: () => void; onError: (msg: string) => void; } -function OidcSignInSection({ authority, clientId, username, departmentId, isAuthenticating, onAuthStart, onAuthEnd, onTokenReceived, onError }: OidcSignInSectionProps) { +function OidcSignInSection({ authority, clientId, username, departmentId, isAuthenticating, onAuthStart, onAuthEnd, onTokenReceived, onMfaRequired, onError }: OidcSignInSectionProps) { const { t } = useTranslation(); const { request, response, promptAsync, exchangeCodeForResgridToken } = useOidcLogin(authority, clientId, username, departmentId); @@ -51,7 +54,9 @@ function OidcSignInSection({ authority, clientId, username, departmentId, isAuth onAuthStart(); try { const authResponse = await exchangeCodeForResgridToken(); - if (!authResponse) { + if (authResponse === 'mfa_required') { + onMfaRequired(); + } else if (!authResponse) { onError(t('sso.error_token_exchange')); } else { onTokenReceived(authResponse); @@ -106,10 +111,11 @@ interface SamlSignInSectionProps { onAuthStart: () => void; onAuthEnd: () => void; onTokenReceived: (authResponse: AuthResponse) => void; + onMfaRequired: () => void; onError: (msg: string) => void; } -function SamlSignInSection({ idpSsoUrl, username, departmentId, isAuthenticating, onAuthStart, onAuthEnd, onTokenReceived, onError }: SamlSignInSectionProps) { +function SamlSignInSection({ idpSsoUrl, username, departmentId, isAuthenticating, onAuthStart, onAuthEnd, onTokenReceived, onMfaRequired, onError }: SamlSignInSectionProps) { const { t } = useTranslation(); const { startSamlLogin, handleSamlDeepLink } = useSamlLogin(idpSsoUrl, username, departmentId); @@ -120,7 +126,9 @@ function SamlSignInSection({ idpSsoUrl, username, departmentId, isAuthenticating onAuthStart(); try { const authResponse = await handleSamlDeepLink(url); - if (!authResponse) { + if (authResponse === 'mfa_required') { + onMfaRequired(); + } else if (!authResponse) { onError(t('sso.error_token_exchange')); } else { onTokenReceived(authResponse); @@ -197,6 +205,9 @@ export default function SsoLoginScreen() { const [authError, setAuthError] = useState(null); const [resolvedUsername, setResolvedUsername] = useState(''); const [resolvedDepartmentId, setResolvedDepartmentId] = useState(); + const [showOtpPrompt, setShowOtpPrompt] = useState(false); + const [otpInvalid, setOtpInvalid] = useState(false); + const [isOtpSubmitting, setIsOtpSubmitting] = useState(false); const loginWithSso = useAuthStore((s) => s.loginWithSso); const status = useAuthStore((s) => s.status); @@ -228,6 +239,34 @@ export default function SsoLoginScreen() { [loginWithSso, t] ); + // 2FA challenge from the exchange: the retained IdP token is retried with the code. + const handleMfaRequired = useCallback(() => { + setOtpInvalid(false); + setShowOtpPrompt(true); + }, []); + + const handleOtpSubmit = useCallback( + async (code: string) => { + setIsOtpSubmitting(true); + try { + const result = await retrySsoExchangeWithOtp(code); + if (result.successful && result.authResponse) { + setShowOtpPrompt(false); + setOtpInvalid(false); + await handleTokenReceived(result.authResponse); + } else if (result.mfaRequired) { + setOtpInvalid(true); + } else { + setShowOtpPrompt(false); + setAuthError(t('sso.error_generic')); + } + } finally { + setIsOtpSubmitting(false); + } + }, + [handleTokenReceived, t] + ); + const onLookup: SubmitHandler = async (data) => { setLookupError(null); setIsLookingUp(true); @@ -387,6 +426,7 @@ export default function SsoLoginScreen() { }} onAuthEnd={() => setIsAuthenticating(false)} onTokenReceived={handleTokenReceived} + onMfaRequired={handleMfaRequired} onError={(msg) => { setAuthError(msg); setIsAuthenticating(false); @@ -406,6 +446,7 @@ export default function SsoLoginScreen() { }} onAuthEnd={() => setIsAuthenticating(false)} onTokenReceived={handleTokenReceived} + onMfaRequired={handleMfaRequired} onError={(msg) => { setAuthError(msg); setIsAuthenticating(false); @@ -426,6 +467,9 @@ export default function SsoLoginScreen() { {t('sso.back_to_lookup')} + + {/* Two-factor challenge: SSO exchange answered mfa_required / invalid_totp */} + setShowOtpPrompt(false)} />
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 85ef6072..a0e8861f 100644 --- a/src/components/calls/call-notes-modal.tsx +++ b/src/components/calls/call-notes-modal.tsx @@ -6,9 +6,11 @@ import { Keyboard, Platform, useWindowDimensions } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { KeyboardAwareScrollView } 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 { useCallDetailStore } from '@/stores/calls/detail-store'; import { Loading } from '../common/loading'; @@ -143,7 +145,7 @@ const CallNotesModal = ({ isOpen, onClose, callId }: CallNotesModalProps) => { ) : filteredNotes.length > 0 ? ( filteredNotes.map((note) => ( - {note.Note} + {note.FullName} {note.TimestampFormatted} diff --git a/src/components/chat/message-bubble.tsx b/src/components/chat/message-bubble.tsx index 1bdf0569..4851bed7 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; } export function MessageBubble({ 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(() => { @@ -68,11 +74,13 @@ export function MessageBubble({ message, isOwn, showSender, currentUserId, onLon 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 76e911ba..9ec871fa 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 = ({ contact, onPress }) => ) : ( - {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-oidc-login.ts b/src/hooks/use-oidc-login.ts index f30e7f65..e2ce472b 100644 --- a/src/hooks/use-oidc-login.ts +++ b/src/hooks/use-oidc-login.ts @@ -14,7 +14,7 @@ export interface OidcLoginResult { request: AuthSession.AuthRequest | null; response: AuthSession.AuthSessionResult | null; promptAsync: (options?: AuthSession.AuthRequestPromptOptions) => Promise; - exchangeCodeForResgridToken: () => Promise; + exchangeCodeForResgridToken: () => Promise; } export function useOidcLogin(authority: string, clientId: string, username: string, departmentId?: number): OidcLoginResult { @@ -35,7 +35,7 @@ export function useOidcLogin(authority: string, clientId: string, username: stri discovery ); - async function exchangeCodeForResgridToken(): Promise { + async function exchangeCodeForResgridToken(): Promise { if (response?.type !== 'success' || !request?.codeVerifier || !discovery) { logger.error({ message: 'SSO OIDC: Cannot exchange code — missing response, code verifier, or discovery', @@ -65,6 +65,12 @@ export function useOidcLogin(authority: string, clientId: string, username: stri // Step 2: Exchange the id_token for a Resgrid access token const result = await externalTokenRequest('oidc', idToken, username, departmentId); + if (result.mfaRequired) { + // The exchange is retained by the auth api; the caller prompts for the code and + // retries via retrySsoExchangeWithOtp. + return 'mfa_required'; + } + if (!result.successful || !result.authResponse) { logger.error({ message: 'SSO OIDC: External token exchange failed', context: { message: result.message } }); return null; diff --git a/src/hooks/use-protected-reveal.ts b/src/hooks/use-protected-reveal.ts new file mode 100644 index 00000000..88724e64 --- /dev/null +++ b/src/hooks/use-protected-reveal.ts @@ -0,0 +1,51 @@ +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 isRequesting = dataProtectionStore((state) => state.isRequestingGrant); + + const hasGrantToken = useHasGrantToken(); + + // 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/hooks/use-saml-login.ts b/src/hooks/use-saml-login.ts index e56dd615..ce44a947 100644 --- a/src/hooks/use-saml-login.ts +++ b/src/hooks/use-saml-login.ts @@ -10,7 +10,7 @@ import { getItem, removeItem, setItem } from '@/lib/storage'; export interface SamlLoginHook { startSamlLogin: () => Promise; - handleSamlDeepLink: (url: string) => Promise; + handleSamlDeepLink: (url: string) => Promise; } // CSRF protection for the SAML flow: a random RelayState nonce is generated when the @@ -70,7 +70,7 @@ export function useSamlLogin(idpSsoUrl: string, username: string, departmentId?: } } - async function handleSamlDeepLink(url: string): Promise { + async function handleSamlDeepLink(url: string): Promise { try { const parsed = Linking.parse(url); const samlResponse = parsed.queryParams?.saml_response as string | undefined; @@ -101,6 +101,12 @@ export function useSamlLogin(idpSsoUrl: string, username: string, departmentId?: const result = await externalTokenRequest('saml2', samlResponse, username, departmentId); + if (result.mfaRequired) { + // The exchange is retained by the auth api; the caller prompts for the code and + // retries via retrySsoExchangeWithOtp. + return 'mfa_required'; + } + if (!result.successful || !result.authResponse) { logger.error({ message: 'SSO SAML: External token exchange failed', context: { message: result.message } }); return null; diff --git a/src/lib/auth/api.tsx b/src/lib/auth/api.tsx index 66b1c253..47eec84a 100644 --- a/src/lib/auth/api.tsx +++ b/src/lib/auth/api.tsx @@ -98,6 +98,8 @@ export const loginRequest = async (credentials: LoginCredentials): Promise => { +// Last SSO exchange that failed with a 2FA challenge, retained IN MEMORY ONLY so the OTP +// prompt can retry the same IdP token with a code. Cleared on success and on any final failure. +let pendingSsoMfaExchange: { provider: 'oidc' | 'saml2'; externalToken: string; username: string; departmentId?: number } | null = null; + +export const externalTokenRequest = async (provider: 'oidc' | 'saml2', externalToken: string, username: string, departmentId?: number, otpCode?: string): Promise => { const requestId = randomUUID(); try { const data: Record = { @@ -165,6 +189,11 @@ export const externalTokenRequest = async (provider: 'oidc' | 'saml2', externalT data.department_id = String(departmentId); } + // Accounts with Resgrid 2FA enabled must supply the current authenticator code even via SSO. + if (otpCode) { + data.totp_code = otpCode.trim(); + } + logger.info({ message: 'API: Sending SSO external token request', context: { provider, requestId }, @@ -174,11 +203,32 @@ export const externalTokenRequest = async (provider: 'oidc' | 'saml2', externalT if (response.status === 200) { logger.info({ message: 'SSO: External token exchange successful', context: { requestId } }); + pendingSsoMfaExchange = null; return { successful: true, message: 'SSO login successful', authResponse: response.data }; } return { successful: false, message: 'SSO login failed', authResponse: null }; } catch (error) { + // The error body distinguishes the 2FA challenge from a real failure. Neither the IdP + // token nor any code is ever logged. + const oauthError = (error as { response?: { data?: { error?: string } } })?.response?.data?.error; + if (oauthError === 'mfa_required' || oauthError === 'invalid_totp') { + logger.info({ + message: 'SSO login requires two-factor code', + context: { requestId, invalidOtp: oauthError === 'invalid_totp' }, + }); + + pendingSsoMfaExchange = { provider, externalToken, username, departmentId }; + return { + successful: false, + message: 'Two-factor authentication required', + authResponse: null, + mfaRequired: true, + invalidOtp: oauthError === 'invalid_totp', + }; + } + + pendingSsoMfaExchange = null; logger.error({ message: 'SSO: External token request failed', context: { ...sanitizeAuthError(error), requestId } }); return { successful: false, @@ -188,6 +238,16 @@ export const externalTokenRequest = async (provider: 'oidc' | 'saml2', externalT } }; +/** Retries the pending SSO exchange with the user's authenticator code (2FA challenge). */ +export const retrySsoExchangeWithOtp = async (otpCode: string): Promise => { + if (!pendingSsoMfaExchange) { + return { successful: false, message: 'No pending SSO sign-in to verify', authResponse: null }; + } + + const { provider, externalToken, username, departmentId } = pendingSsoMfaExchange; + return externalTokenRequest(provider, externalToken, username, departmentId, otpCode); +}; + export const refreshTokenRequest = async (refreshToken: string): Promise => { try { const data = queryString.stringify({ diff --git a/src/lib/auth/types.tsx b/src/lib/auth/types.tsx index b6e0ef4a..dca1a585 100644 --- a/src/lib/auth/types.tsx +++ b/src/lib/auth/types.tsx @@ -6,6 +6,8 @@ export interface AuthTokens { export interface LoginCredentials { username: string; password: string; + /** Current authenticator (TOTP) code; required when the account has 2FA enabled. */ + otpCode?: string; } export interface AuthResponse { @@ -21,6 +23,10 @@ export interface LoginResponse { successful: boolean; message: string; authResponse: AuthResponse | null; + /** The server requires a TOTP code for this account (error mfa_required / invalid_totp). */ + mfaRequired?: boolean; + /** A code was supplied but rejected (error invalid_totp). */ + invalidOtp?: boolean; } export interface ProfileModel { sub: string; @@ -52,7 +58,7 @@ export interface SsoConfig { departmentCode: string | null; } -export type AuthStatus = 'idle' | 'signedIn' | 'signedOut' | 'loading' | 'error' | 'onboarding'; +export type AuthStatus = 'idle' | 'signedIn' | 'signedOut' | 'loading' | 'error' | 'onboarding' | 'mfaRequired'; export interface AuthState { accessToken: string | null; 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..bf3e6a4e --- /dev/null +++ b/src/lib/data-protection/redacted.ts @@ -0,0 +1,75 @@ +/** + * 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 first. When no list is present — an older payload, + * or an endpoint that does not carry one — the sentinel value is the fallback. + */ +export const isFieldRedacted = (redactedFields: string[] | null | undefined, fieldId: string, value?: string | null): boolean => { + // 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, which is the false positive the list exists to prevent. + 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 8117f67a..623418e7 100644 --- a/src/models/v4/calls/callResultData.ts +++ b/src/models/v4/calls/callResultData.ts @@ -46,4 +46,13 @@ export class CallResultData { public Protocols: unknown[] = []; public UdfValues: unknown[] = []; 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/livekit-store.ts b/src/stores/app/livekit-store.ts index 9532e37d..1107549e 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -1,5 +1,5 @@ import type notifeeType from '@notifee/react-native'; -import type { AndroidImportance as AndroidImportanceType } from '@notifee/react-native'; +import type { AndroidForegroundServiceType as AndroidForegroundServiceTypeType, AndroidImportance as AndroidImportanceType } from '@notifee/react-native'; import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio'; import { Room, RoomEvent, Track } from 'livekit-client'; import { Platform } from 'react-native'; @@ -8,12 +8,15 @@ import { create } from 'zustand'; // Notifee is native-only - conditionally require it so web module evaluation doesn't crash let notifee: typeof notifeeType | null = null; let AndroidImportance: typeof AndroidImportanceType | null = null; +let AndroidForegroundServiceType: typeof AndroidForegroundServiceTypeType | null = null; if (Platform.OS === 'android') { // eslint-disable-next-line @typescript-eslint/no-var-requires notifee = require('@notifee/react-native').default; // eslint-disable-next-line @typescript-eslint/no-var-requires AndroidImportance = require('@notifee/react-native').AndroidImportance; + // eslint-disable-next-line @typescript-eslint/no-var-requires + AndroidForegroundServiceType = require('@notifee/react-native').AndroidForegroundServiceType; } import { getCanConnectToVoiceSession, getDepartmentVoiceSettings } from '../../api/voice'; @@ -486,7 +489,7 @@ export const useLiveKitStore = create((set, get) => ({ }, startAndroidForegroundService: async () => { - if (Platform.OS !== 'android' || !notifee || !AndroidImportance) return; + if (Platform.OS !== 'android' || !notifee || !AndroidImportance || !AndroidForegroundServiceType) return; try { logger.debug({ @@ -517,6 +520,9 @@ export const useLiveKitStore = create((set, get) => ({ android: { channelId: 'ptt-channel', asForegroundService: true, + // microphone only: keeps mic capture legal while backgrounded (Android 14+). + // Playback of remote audio needs no FGS type — any running FGS keeps the process alive. + foregroundServiceTypes: [AndroidForegroundServiceType.FOREGROUND_SERVICE_TYPE_MICROPHONE], smallIcon: 'ic_launcher', }, }); diff --git a/src/stores/auth/store.tsx b/src/stores/auth/store.tsx index a3e4de81..8aa9ab51 100644 --- a/src/stores/auth/store.tsx +++ b/src/stores/auth/store.tsx @@ -113,6 +113,17 @@ const useAuthStore = create()( // Set up automatic token refresh scheduleTokenRefresh(response.authResponse.expires_in); + } 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. + logger.info({ + message: 'Login requires two-factor verification', + context: { invalidOtp: !!response.invalidOtp }, + }); + set({ + status: 'mfaRequired', + error: response.invalidOtp ? 'invalid_totp' : null, + }); } else { logger.error({ message: 'Login: API returned unsuccessful response', 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..b3808307 --- /dev/null +++ b/src/stores/data-protection/__tests__/store.test.ts @@ -0,0 +1,153 @@ +// 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); + + 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 9f1c3ed4..e9c31d09 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -1591,5 +1591,21 @@ "all_in_rest_period": "الجميع في فترة راحة", "stations_exhausted": "لا توجد مراكز أخرى للبحث" } + }, + "data_protection": { + "step_up_title": "أكِّد هويتك", + "step_up_body": "هذه المعلومات محمية. أدخل الرمز الحالي من تطبيق المصادقة لعرضها لفترة محدودة.", + "step_up_placeholder": "رمز من 6 أرقام", + "step_up_verify": "تأكيد", + "step_up_invalid_code": "هذا الرمز غير صالح أو انتهت صلاحيته. أدخل الرمز الحالي من تطبيق المصادقة.", + "step_up_not_enrolled": "لم تُفعَّل المصادقة الثنائية لحسابك. فعِّل تطبيق مصادقة من إعدادات أمان الحساب أولًا.", + "step_up_too_many_attempts": "محاولات كثيرة جدًا. انتظر بضع دقائق ثم أعد المحاولة.", + "step_up_failed": "تعذّر التأكيد. تحقق من اتصالك وأعد المحاولة.", + "step_up_unavailable": "البيانات المحمية غير متاحة بعد على هذا الخادم. تواصل مع المسؤول.", + "reveal": "إظهار المعلومات المحمية", + "conceal": "إخفاؤها مجددًا", + "protected_value": "محمي", + "protected_notice": "بعض المعلومات في هذه الشاشة محمية.", + "revealed_notice": "المعلومات المحمية ظاهرة الآن." } } diff --git a/src/translations/de.json b/src/translations/de.json index 41747e42..ec7e0e95 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -1591,5 +1591,21 @@ "all_in_rest_period": "alle in Ruhezeit", "stations_exhausted": "keine weiteren Wachen" } + }, + "data_protection": { + "step_up_title": "Identität bestätigen", + "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_placeholder": "6-stelliger Code", + "step_up_verify": "Bestätigen", + "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_too_many_attempts": "Zu viele Versuche. Warten Sie einige Minuten und versuchen Sie es erneut.", + "step_up_failed": "Bestätigung fehlgeschlagen. Prüfen Sie Ihre Verbindung 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.", + "reveal": "Geschützte Informationen anzeigen", + "conceal": "Wieder ausblenden", + "protected_value": "Geschützt", + "protected_notice": "Einige Angaben auf diesem Bildschirm sind geschützt.", + "revealed_notice": "Geschützte Angaben sind sichtbar." } } diff --git a/src/translations/el.json b/src/translations/el.json index a3406e87..d1a3357e 100644 --- a/src/translations/el.json +++ b/src/translations/el.json @@ -1591,5 +1591,21 @@ "all_in_rest_period": "όλοι σε ανάπαυση", "stations_exhausted": "δεν απομένουν σταθμοί" } + }, + "data_protection": { + "step_up_title": "Επαληθεύστε την ταυτότητά σας", + "step_up_body": "Αυτές οι πληροφορίες είναι προστατευμένες. Εισαγάγετε τον τρέχοντα κωδικό από την εφαρμογή ελέγχου ταυτότητας για να τις δείτε για περιορισμένο χρόνο.", + "step_up_placeholder": "Εξαψήφιος κωδικός", + "step_up_verify": "Επαλήθευση", + "step_up_invalid_code": "Ο κωδικός δεν είναι έγκυρος ή έχει λήξει. Εισαγάγετε τον τρέχοντα κωδικό από την εφαρμογή ελέγχου ταυτότητας.", + "step_up_not_enrolled": "Δεν έχει ρυθμιστεί έλεγχος ταυτότητας δύο παραγόντων για τον λογαριασμό σας. Ρυθμίστε πρώτα μια εφαρμογή ελέγχου ταυτότητας στις ρυθμίσεις ασφαλείας του λογαριασμού.", + "step_up_too_many_attempts": "Πάρα πολλές προσπάθειες. Περιμένετε λίγα λεπτά και δοκιμάστε ξανά.", + "step_up_failed": "Η επαλήθευση απέτυχε. Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά.", + "step_up_unavailable": "Τα προστατευμένα δεδομένα δεν είναι ακόμη διαθέσιμα σε αυτόν τον διακομιστή. Επικοινωνήστε με τον διαχειριστή σας.", + "reveal": "Εμφάνιση προστατευμένων πληροφοριών", + "conceal": "Απόκρυψη ξανά", + "protected_value": "Προστατευμένο", + "protected_notice": "Ορισμένες πληροφορίες σε αυτήν την οθόνη είναι προστατευμένες.", + "revealed_notice": "Οι προστατευμένες πληροφορίες είναι ορατές." } } diff --git a/src/translations/en.json b/src/translations/en.json index e0aaffe9..4fbce1c6 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1591,5 +1591,21 @@ "all_in_rest_period": "all in rest period", "stations_exhausted": "no stations left to search" } + }, + "data_protection": { + "step_up_title": "Verify your identity", + "step_up_body": "This information is protected. Enter the current code from your authenticator app to view it for a limited time.", + "step_up_placeholder": "6-digit code", + "step_up_verify": "Verify", + "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_too_many_attempts": "Too many attempts. Wait a few minutes and try again.", + "step_up_failed": "Verification failed. Check your connection and try again.", + "step_up_unavailable": "Protected data is not available on this server yet. Contact your administrator.", + "reveal": "Show protected information", + "conceal": "Hide again", + "protected_value": "Protected", + "protected_notice": "Some information on this screen is protected.", + "revealed_notice": "Protected information is visible." } } diff --git a/src/translations/es.json b/src/translations/es.json index e42dd85a..1fd2dc7a 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -1591,5 +1591,21 @@ "all_in_rest_period": "todos en periodo de descanso", "stations_exhausted": "no quedan parques por buscar" } + }, + "data_protection": { + "step_up_title": "Verifique su identidad", + "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_placeholder": "Código de 6 dígitos", + "step_up_verify": "Verificar", + "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_too_many_attempts": "Demasiados intentos. Espere unos minutos e inténtelo de nuevo.", + "step_up_failed": "La verificación ha fallado. Compruebe su conexión 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.", + "reveal": "Mostrar información protegida", + "conceal": "Ocultar de nuevo", + "protected_value": "Protegido", + "protected_notice": "Parte de la información de esta pantalla está protegida.", + "revealed_notice": "La información protegida está visible." } } diff --git a/src/translations/fr.json b/src/translations/fr.json index d936502e..f850d61a 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -1591,5 +1591,21 @@ "all_in_rest_period": "tous en période de repos", "stations_exhausted": "plus aucune caserne à explorer" } + }, + "data_protection": { + "step_up_title": "Vérifiez votre identité", + "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_placeholder": "Code à 6 chiffres", + "step_up_verify": "Vérifier", + "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_too_many_attempts": "Trop de tentatives. Patientez quelques minutes et réessayez.", + "step_up_failed": "La vérification a échoué. Vérifiez votre connexion et réessayez.", + "step_up_unavailable": "Les données protégées ne sont pas encore disponibles sur ce serveur. Contactez votre administrateur.", + "reveal": "Afficher les informations protégées", + "conceal": "Masquer à nouveau", + "protected_value": "Protégé", + "protected_notice": "Certaines informations de cet écran sont protégées.", + "revealed_notice": "Les informations protégées sont visibles." } } diff --git a/src/translations/it.json b/src/translations/it.json index ce2860a8..bc8f74f3 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -1591,5 +1591,21 @@ "all_in_rest_period": "tutti in periodo di riposo", "stations_exhausted": "nessuna sede rimasta da cercare" } + }, + "data_protection": { + "step_up_title": "Verifica la tua identità", + "step_up_body": "Queste informazioni sono protette. Inserisci il codice attuale dalla tua app di autenticazione per visualizzarle per un tempo limitato.", + "step_up_placeholder": "Codice a 6 cifre", + "step_up_verify": "Verifica", + "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_too_many_attempts": "Troppi tentativi. Attendi qualche minuto e riprova.", + "step_up_failed": "Verifica non riuscita. Controlla la connessione e riprova.", + "step_up_unavailable": "I dati protetti non sono ancora disponibili su questo server. Contatta l'amministratore.", + "reveal": "Mostra le informazioni protette", + "conceal": "Nascondi di nuovo", + "protected_value": "Protetto", + "protected_notice": "Alcune informazioni in questa schermata sono protette.", + "revealed_notice": "Le informazioni protette sono visibili." } } diff --git a/src/translations/pl.json b/src/translations/pl.json index f707d94a..804b1ab5 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -1591,5 +1591,21 @@ "all_in_rest_period": "wszyscy w okresie odpoczynku", "stations_exhausted": "brak kolejnych jednostek do sprawdzenia" } + }, + "data_protection": { + "step_up_title": "Potwierdź swoją tożsamość", + "step_up_body": "Te informacje są chronione. Wprowadź aktualny kod z aplikacji uwierzytelniającej, aby zobaczyć je przez ograniczony czas.", + "step_up_placeholder": "Kod 6-cyfrowy", + "step_up_verify": "Potwierdź", + "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_too_many_attempts": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie.", + "step_up_failed": "Weryfikacja nie powiodła się. Sprawdź połączenie i spróbuj ponownie.", + "step_up_unavailable": "Chronione dane nie są jeszcze dostępne na tym serwerze. Skontaktuj się z administratorem.", + "reveal": "Pokaż chronione informacje", + "conceal": "Ukryj ponownie", + "protected_value": "Chronione", + "protected_notice": "Część informacji na tym ekranie jest chroniona.", + "revealed_notice": "Chronione informacje są widoczne." } } diff --git a/src/translations/sv.json b/src/translations/sv.json index e92924b4..0b2f7230 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -1591,5 +1591,21 @@ "all_in_rest_period": "alla i vilotid", "stations_exhausted": "inga fler stationer att söka" } + }, + "data_protection": { + "step_up_title": "Bekräfta din identitet", + "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_placeholder": "6-siffrig kod", + "step_up_verify": "Bekräfta", + "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_too_many_attempts": "För många försök. Vänta några minuter och försök igen.", + "step_up_failed": "Verifieringen misslyckades. Kontrollera din anslutning och försök igen.", + "step_up_unavailable": "Skyddade uppgifter är ännu inte tillgängliga på den här servern. Kontakta din administratör.", + "reveal": "Visa skyddad information", + "conceal": "Dölj igen", + "protected_value": "Skyddad", + "protected_notice": "En del uppgifter på den här skärmen är skyddade.", + "revealed_notice": "Skyddade uppgifter visas." } } diff --git a/src/translations/uk.json b/src/translations/uk.json index d85c8be3..bcf323b8 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -1591,5 +1591,21 @@ "all_in_rest_period": "усі на відпочинку", "stations_exhausted": "більше немає частин для пошуку" } + }, + "data_protection": { + "step_up_title": "Підтвердьте свою особу", + "step_up_body": "Ця інформація захищена. Введіть поточний код із застосунку автентифікації, щоб переглянути її протягом обмеженого часу.", + "step_up_placeholder": "6-значний код", + "step_up_verify": "Підтвердити", + "step_up_invalid_code": "Цей код недійсний або прострочений. Введіть поточний код із застосунку автентифікації.", + "step_up_not_enrolled": "Для вашого облікового запису не налаштовано двофакторну автентифікацію. Спершу налаштуйте застосунок автентифікації в параметрах безпеки облікового запису.", + "step_up_too_many_attempts": "Забагато спроб. Зачекайте кілька хвилин і повторіть.", + "step_up_failed": "Не вдалося підтвердити. Перевірте з’єднання та повторіть спробу.", + "step_up_unavailable": "Захищені дані ще недоступні на цьому сервері. Зверніться до адміністратора.", + "reveal": "Показати захищену інформацію", + "conceal": "Приховати знову", + "protected_value": "Захищено", + "protected_notice": "Частина інформації на цьому екрані захищена.", + "revealed_notice": "Захищену інформацію показано." } }