From 44fd971cedac2006a2568bc474d1ffd375ae5098 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 27 Aug 2026 10:26:12 -0700 Subject: [PATCH 1/3] RG-T133 Fixes --- app.config.ts | 53 ++++++++++++++++++++++++++++------------ customManifest.plugin.js | 20 ++++++++++++--- 2 files changed, 53 insertions(+), 20 deletions(-) diff --git a/app.config.ts b/app.config.ts index 520ac28..6529faf 100644 --- a/app.config.ts +++ b/app.config.ts @@ -99,7 +99,6 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ 'android.permission.FOREGROUND_SERVICE', 'android.permission.FOREGROUND_SERVICE_MICROPHONE', 'android.permission.FOREGROUND_SERVICE_PHONE_CALL', - 'android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE', 'android.permission.READ_PHONE_STATE', 'android.permission.READ_PHONE_NUMBERS', 'android.permission.MANAGE_OWN_CALLS', @@ -108,6 +107,9 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ // and legacy storage permissions even when a transitive native dependency // contributes them during manifest merging. blockedPermissions: [ + // Bluetooth PTT handsets route through the microphone FGS session, so the type is + // unused; Play rejects declared foreground-service types that cannot be demonstrated. + 'android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE', // Background location was removed from the app (Play policy: no declarable // background-location feature). Block the permissions outright so a transitive // native dependency cannot reintroduce them during manifest merging. @@ -151,9 +153,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ [ 'expo-secure-store', { - // Biometric-gated secure storage is not used (no requireAuthentication / - // expo-local-authentication anywhere in src); omit NSFaceIDUsageDescription. - faceIDPermission: false, + // Required even though biometric-gated storage is not used: expo-secure-store + // instantiates LAContext() unconditionally (SecureStoreModule.swift), so App Store + // static analysis flags a missing NSFaceIDUsageDescription with ITMS-90683. + faceIDPermission: + 'Resgrid IC uses Face ID to unlock the securely stored credentials that keep you signed in to your department. For example, after your device locks, Face ID confirms it is you before the app restores your session.', }, ], 'expo-image', @@ -171,19 +175,36 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ [ 'expo-location', { - // Foreground-only. The IC app centers the map and computes distances while the - // user has it open; it has no background-location feature, so the background / - // foreground-service flags and the task manager block stay off. Turning any of - // them back on re-adds ACCESS_BACKGROUND_LOCATION and gets the Play listing - // rejected for an undeclared background-location feature. locationWhenInUsePermission: - 'Resgrid IC uses your location while you use the app to show your position on the incident map, to center the map when you set a call location, and to share your location in chat. For example, when you create a new call, the map starts at your current position so you can pinpoint the incident scene for responding units.', - // `false` deletes the key from Info.plist entirely (the plugin otherwise fills in - // its own default text). The "Always" strings advertise background location on - // iOS, and nothing here uses Core Motion. - locationAlwaysAndWhenInUsePermission: false, - locationAlwaysPermission: false, - motionUsagePermission: false, + 'Resgrid IC uses your location while you use the app to show your position on the incident map and to attach your coordinates to incident actions you take. For example, when you assign a resource, your location helps place command on the scene map.', + locationAlwaysAndWhenInUsePermission: + 'Resgrid IC uses your location, including in the background, to keep the incident and department maps updated with your position. For example, while you move around an incident scene, your location is periodically sent so other responders and dispatchers can see where command is, even when the app is not on screen.', + locationAlwaysPermission: + 'Resgrid IC uses your location in the background to keep the incident and department maps updated with your position. For example, while you move around an incident scene, your location is periodically sent so other responders and dispatchers can see where command is, even when the app is not on screen.', + // Required even though getMotionActivityAsync() is never called: expo-location links + // CoreMotion (MotionActivityPermissionRequester), and App Store static analysis rejects + // the binary with ITMS-90683 whenever the framework is referenced and the string is absent. + motionUsagePermission: + 'Resgrid IC uses motion data to improve the accuracy of the location shown on the department map. For example, while you are driving to a call, motion data helps distinguish travel from a stop so dispatchers see an accurate position and heading.', + isIosBackgroundLocationEnabled: true, + isAndroidBackgroundLocationEnabled: true, + isAndroidForegroundServiceEnabled: true, + taskManager: { + locationTaskName: 'location-updates', + locationTaskOptions: { + accuracy: 'balanced', + distanceInterval: 10, + timeInterval: 5000, + }, + }, + }, + ], + [ + 'expo-task-manager', + { + taskManager: { + taskName: 'location-updates', + }, }, ], [ diff --git a/customManifest.plugin.js b/customManifest.plugin.js index a8c858e..25e63ec 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|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; }); }; From 1e485b71ef44d5439386dd911808a947fcae331f Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 30 Aug 2026 16:39:05 -0700 Subject: [PATCH 2/3] RG-T89 ADP Support --- src/api/calls/callFiles.ts | 9 +- src/api/common/client.tsx | 17 ++ src/api/data-protection/data-protection.ts | 71 ++++++ src/app/(app)/_layout.tsx | 11 +- src/app/call/[id].tsx | 42 ++- src/app/chat/[channelId].tsx | 10 +- src/app/login/index.tsx | 23 ++ src/app/login/sso.tsx | 25 ++ src/components/auth/login-otp-modal.tsx | 88 +++++++ src/components/calls/call-notes-modal.tsx | 4 +- src/components/chat/message-bubble.tsx | 12 +- src/components/contacts/contact-card.tsx | 22 +- .../data-protection/protected-reveal-bar.tsx | 79 ++++++ .../data-protection/protected-text.tsx | 57 +++++ .../data-protection/step-up-modal.tsx | 116 +++++++++ .../data-protection/step-up-prompt-host.tsx | 21 ++ src/hooks/use-protected-reveal.ts | 46 ++++ src/lib/auth/api.tsx | 40 +++ src/lib/auth/types.tsx | 12 +- .../__tests__/field-ids.test.ts | 48 ++++ .../__tests__/redacted.test.ts | 41 +++ src/lib/data-protection/grant-provider.ts | 33 +++ src/lib/data-protection/redacted.ts | 72 ++++++ src/models/v4/calls/callResultData.ts | 5 + src/models/v4/contacts/contactResultData.ts | 5 + src/stores/auth/store.tsx | 33 +++ .../data-protection/__tests__/grant.test.ts | 153 +++++++++++ .../data-protection/__tests__/store.test.ts | 136 ++++++++++ src/stores/data-protection/store.ts | 239 ++++++++++++++++++ src/translations/ar.json | 18 +- src/translations/de.json | 18 +- src/translations/el.json | 18 +- src/translations/en.json | 18 +- src/translations/es.json | 18 +- src/translations/fr.json | 18 +- src/translations/it.json | 18 +- src/translations/pl.json | 18 +- src/translations/sv.json | 18 +- src/translations/uk.json | 18 +- 39 files changed, 1617 insertions(+), 33 deletions(-) create mode 100644 src/api/data-protection/data-protection.ts create mode 100644 src/components/auth/login-otp-modal.tsx create mode 100644 src/components/data-protection/protected-reveal-bar.tsx create mode 100644 src/components/data-protection/protected-text.tsx create mode 100644 src/components/data-protection/step-up-modal.tsx create mode 100644 src/components/data-protection/step-up-prompt-host.tsx create mode 100644 src/hooks/use-protected-reveal.ts create mode 100644 src/lib/data-protection/__tests__/field-ids.test.ts create mode 100644 src/lib/data-protection/__tests__/redacted.test.ts create mode 100644 src/lib/data-protection/grant-provider.ts create mode 100644 src/lib/data-protection/redacted.ts create mode 100644 src/stores/data-protection/__tests__/grant.test.ts create mode 100644 src/stores/data-protection/__tests__/store.test.ts create mode 100644 src/stores/data-protection/store.ts diff --git a/src/api/calls/callFiles.ts b/src/api/calls/callFiles.ts index 8eb0d80..d994c80 100644 --- a/src/api/calls/callFiles.ts +++ b/src/api/calls/callFiles.ts @@ -2,6 +2,7 @@ import axios, { type AxiosProgressEvent, type AxiosRequestConfig, type AxiosResp import { Platform } from 'react-native'; import { createApiEndpoint } from '@/api/common/client'; +import useAuthStore from '@/stores/auth/store'; import { type CallFilesResult } from '@/models/v4/callFiles/callFilesResult'; import { type SaveCallFileResult } from '@/models/v4/callFiles/saveCallFileResult'; @@ -39,9 +40,15 @@ export const getCallAttachmentFile = async (url: string, options: DownloadOption type: 'start', }); + // Attach the signed-in bearer: authenticated file routes require it, and the anonymous + // signed-link route simply ignores it. Caller-supplied headers win on conflict. + const token = useAuthStore.getState().accessToken; const config: AxiosRequestConfig = { responseType: 'blob', - headers, + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...headers, + }, timeout, onDownloadProgress: (progressEvent: AxiosProgressEvent) => { if (progressEvent.total) { diff --git a/src/api/common/client.tsx b/src/api/common/client.tsx index e614c0e..443975b 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, isAxiosError } from 'axios'; import { refreshTokenSingleFlight } from '@/lib/auth/api'; +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'; @@ -33,6 +34,7 @@ const processQueue = (error: Error | null) => { failedQueue = []; }; + // Request interceptor for API calls axiosInstance.interceptors.request.use( (config: InternalAxiosRequestConfig) => { @@ -44,6 +46,21 @@ axiosInstance.interceptors.request.use( if (accessToken) { config.headers.Authorization = `Bearer ${accessToken}`; } + + // Advanced Data Protection: while the member holds a live grant, every read through this + // instance carries it, so a protected value comes back decrypted instead of REDACTED. + // + // Attached centrally on purpose. The alternative - each screen remembering to add the header - + // is the failure mode that already shipped twice on the web side, and it fails SILENTLY: the + // screen looks fine and simply shows placeholders. The grant only ever goes to Resgrid's own + // API (this instance's baseURL), is short-lived, and is bound to this member, department and + // policy epoch, so the server is the only thing that can act on it. + if (config.headers) { + for (const [name, value] of Object.entries(readProtectedGrantHeaders())) { + config.headers.set(name, value); + } + } + return config; }, (error: AxiosError) => { diff --git a/src/api/data-protection/data-protection.ts b/src/api/data-protection/data-protection.ts new file mode 100644 index 0000000..1794dc8 --- /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 2fc0632..6a06c8c 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -34,6 +34,8 @@ import { audioService } from '@/services/audio.service'; import { bluetoothAudioService } from '@/services/bluetooth-audio.service'; import { usePushNotifications } from '@/services/push-notification'; import { useCoreStore } from '@/stores/app/core-store'; +import { StepUpPromptHost } from '@/components/data-protection/step-up-prompt-host'; +import { dataProtectionStore } from '@/stores/data-protection/store'; import { useCallsStore } from '@/stores/calls/store'; import { useCommandStore } from '@/stores/command/store'; import { FeatureFlagKeys, featureFlagsStore } from '@/stores/feature-flags/store'; @@ -184,7 +186,7 @@ export default function TabLayout() { return; } - await featureFlagsStore.getState().fetchFlags(); + await featureFlagsStore.getState().fetchFlags(), dataProtectionStore.getState().fetchCapabilities(); if (!isCurrentRun()) return; @@ -563,6 +565,13 @@ export default function TabLayout() { const content = ( + {/* + The app's single Advanced Data Protection prompt. Mounted here so any screen can trigger it + through the store without carrying a modal of its own, and so two screens can never stack + two prompts over each other. + */} + + {/* Loading overlay during initialization — shown on top of Tabs so the navigator stays mounted */} {!isInitComplete ? ( diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index a742f33..4374806 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -11,6 +11,9 @@ import { CheckInTabContent } from '@/components/check-in-timers/check-in-tab-con import { MessageCommanderSheet } from '@/components/command/message-commander-sheet'; import { ReopenCommandSheet } from '@/components/command/reopen-command-sheet'; import { StartCommandSheet } from '@/components/command/start-command-sheet'; +import { ProtectedRevealBar } from '@/components/data-protection/protected-reveal-bar'; +import { ProtectedText } from '@/components/data-protection/protected-text'; +import { isFieldRedacted, ProtectedFieldIds } from '@/lib/data-protection/redacted'; import { HeaderBackButton } from '@/components/common/header-back-button'; import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; @@ -370,7 +373,7 @@ export default function CallDetail() { {t('call_detail.address')} - {call.Address} + {destinationLabel ? ( @@ -382,7 +385,16 @@ export default function CallDetail() { {t('call_detail.note')} - + {/* + A withheld note is not empty HTML — rendering the sentinel through the HTML + renderer would print the bare word REDACTED in the body copy, which reads as + the note's content rather than as an absence. + */} + {isFieldRedacted(call.RedactedFields, ProtectedFieldIds.callNotes, call.Note) ? ( + + ) : ( + + )} @@ -406,11 +418,11 @@ export default function CallDetail() { {t('call_detail.contact_name')} - {call.ContactName} + {t('call_detail.contact_info')} - {call.ContactInfo} + @@ -533,11 +545,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}) + + )} {/* Start Command opens (or creates) this call's IC board — multiple boards may exist */} @@ -566,7 +592,11 @@ export default function CallDetail() { - + {isFieldRedacted(call.RedactedFields, ProtectedFieldIds.callNature, call.Nature) ? ( + + ) : ( + + )} diff --git a/src/app/chat/[channelId].tsx b/src/app/chat/[channelId].tsx index 0239082..62a15c9 100644 --- a/src/app/chat/[channelId].tsx +++ b/src/app/chat/[channelId].tsx @@ -67,7 +67,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); @@ -317,7 +317,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] @@ -473,15 +473,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 218cebf..401fb7c 100644 --- a/src/app/login/index.tsx +++ b/src/app/login/index.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { LoginFormProps } from '@/app/login/login-form'; +import { LoginOtpModal } from '@/components/auth/login-otp-modal'; import { ServerUrlBottomSheet } from '@/components/settings/server-url-bottom-sheet'; import { FocusAwareStatusBar } from '@/components/ui'; import { Button, ButtonText } from '@/components/ui/button'; @@ -17,6 +18,10 @@ import { LoginForm } from './login-form'; export default function Login() { const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); const [showServerUrl, setShowServerUrl] = useState(false); + // Held only to resubmit with the TOTP code after an mfa_required challenge; memory-only, + // cleared on success/unmount with the rest of the component state. Never logged. + const [pendingCredentials, setPendingCredentials] = useState<{ username: string; password: string } | null>(null); + const [otpDismissed, setOtpDismissed] = useState(false); const { t } = useTranslation(); const { trackEvent } = useAnalytics(); @@ -48,9 +53,18 @@ export default function Login() { // ── Local login ─────────────────────────────────────────────────────────── const onSubmit: LoginFormProps['onSubmit'] = async (data) => { logger.info({ message: 'Starting Login (button press)' }); + setPendingCredentials({ username: data.username, password: data.password }); + setOtpDismissed(false); await login({ username: data.username, password: data.password }); }; + const onOtpSubmit = async (code: string) => { + if (!pendingCredentials) { + return; + } + await login({ ...pendingCredentials, otpCode: code }); + }; + return ( <> @@ -88,6 +102,15 @@ export default function Login() { setShowServerUrl(false)} /> + + {/* Two-factor challenge: token endpoint answered mfa_required / invalid_totp */} + setOtpDismissed(true)} + /> ); } diff --git a/src/app/login/sso.tsx b/src/app/login/sso.tsx index 83acf29..92f166b 100644 --- a/src/app/login/sso.tsx +++ b/src/app/login/sso.tsx @@ -8,6 +8,7 @@ import { ActivityIndicator } from 'react-native'; import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; import * as z from 'zod'; +import { LoginOtpModal } from '@/components/auth/login-otp-modal'; import { FocusAwareStatusBar, View } from '@/components/ui'; import { Button, ButtonSpinner, ButtonText } from '@/components/ui/button'; import { FormControl, FormControlError, FormControlErrorIcon, FormControlErrorText, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control'; @@ -22,6 +23,7 @@ import { useAuth } from '@/lib/auth'; import { logger } from '@/lib/logging'; import type { DepartmentSsoConfig } from '@/services/sso-discovery'; import { fetchSsoConfigForUser } from '@/services/sso-discovery'; +import useAuthStore from '@/stores/auth/store'; const ssoFormSchema = z.object({ username: z.string({ required_error: 'Username is required' }).min(3, 'Username must be at least 3 characters'), @@ -35,11 +37,13 @@ export default function SsoLogin() { const [isLookingUpSso, setIsLookingUpSso] = useState(false); const [isSsoLoading, setIsSsoLoading] = useState(false); const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); + const [otpDismissed, setOtpDismissed] = useState(false); const pendingUsernameRef = useRef(''); const { t } = useTranslation(); const router = useRouter(); const { ssoLogin, status } = useAuth(); + const authError = useAuthStore((s) => s.error); const oidc = useOidcLogin({ authority: ssoConfig?.authority ?? '', @@ -67,6 +71,18 @@ export default function SsoLogin() { } }, [status]); + // Re-arm the OTP prompt whenever a fresh 2FA challenge arrives + useEffect(() => { + if (status === 'mfaRequired') { + setIsSsoLoading(false); + setOtpDismissed(false); + } + }, [status]); + + const handleOtpSubmit = useCallback(async (code: string) => { + await useAuthStore.getState().retrySsoWithOtp(code); + }, []); + // ── OIDC response handler ───────────────────────────────────────────────── useEffect(() => { if (oidc.response?.type !== 'success') return; @@ -289,6 +305,15 @@ export default function SsoLogin() { + + {/* Two-factor challenge: SSO exchange answered mfa_required / invalid_totp */} + setOtpDismissed(true)} + /> ); } diff --git a/src/components/auth/login-otp-modal.tsx b/src/components/auth/login-otp-modal.tsx new file mode 100644 index 0000000..ce8b187 --- /dev/null +++ b/src/components/auth/login-otp-modal.tsx @@ -0,0 +1,88 @@ +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 ? ( + + {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 6f9ec92..e5063f9 100644 --- a/src/components/calls/call-notes-modal.tsx +++ b/src/components/calls/call-notes-modal.tsx @@ -4,6 +4,8 @@ import { useTranslation } from 'react-i18next'; import { FlatList, Keyboard, Modal, SafeAreaView, StyleSheet, TouchableOpacity, View } from 'react-native'; import { KeyboardProvider, KeyboardStickyView } from 'react-native-keyboard-controller'; +import { ProtectedText } from '@/components/data-protection/protected-text'; +import { isRedactedValue, ProtectedFieldIds } from '@/lib/data-protection/redacted'; import { SearchIcon, X } from '@/components/ui/lucide-icons'; import { useAnalytics } from '@/hooks/use-analytics'; import { useAuthStore } from '@/lib/auth'; @@ -89,7 +91,7 @@ const CallNotesModal = ({ isOpen, onClose, callId }: CallNotesModalProps) => { const renderNote = useCallback( ({ item: note }: { item: (typeof filteredNotes)[0] }) => ( - {note.Note} + {note.FullName} {note.TimestampFormatted} diff --git a/src/components/chat/message-bubble.tsx b/src/components/chat/message-bubble.tsx index 1bdf056..43b31ae 100644 --- a/src/components/chat/message-bubble.tsx +++ b/src/components/chat/message-bubble.tsx @@ -24,7 +24,7 @@ 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) { @@ -68,11 +68,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) : 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 76e911b..99911ac 100644 --- a/src/components/contacts/contact-card.tsx +++ b/src/components/contacts/contact-card.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { Pressable, Text, View } from 'react-native'; +import { ProtectedText } from '@/components/data-protection/protected-text'; +import { isFieldRedacted, ProtectedFieldIds } from '@/lib/data-protection/redacted'; import { Avatar, AvatarImage } from '@/components/ui/avatar'; import { BuildingIcon, MailIcon, PhoneIcon, StarIcon, UserIcon } from '@/components/ui/lucide-icons'; import { type ContactResultData, ContactType } from '@/models/v4/contacts/contactResultData'; @@ -46,21 +48,35 @@ export const ContactCard: React.FC = ({ contact, onPress }) => - {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 0000000..bf896ad --- /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 0000000..9608c21 --- /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 0000000..70f7c74 --- /dev/null +++ b/src/components/data-protection/step-up-modal.tsx @@ -0,0 +1,116 @@ +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 ? ( + + {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 0000000..d3e9c07 --- /dev/null +++ b/src/components/data-protection/step-up-prompt-host.tsx @@ -0,0 +1,21 @@ +import React, { useCallback } from 'react'; + +import { StepUpModal } from '@/components/data-protection/step-up-modal'; +import { dataProtectionStore, useIsStepUpPromptOpen } from '@/stores/data-protection/store'; + +/** + * The app's ONE Advanced Data Protection prompt (ADP plan 7.2). + * + * Mounted at the authenticated shell so any screen can trigger it through the store without + * carrying a modal of its own. One prompt per app also means two screens cannot stack two prompts + * over each other, and the member always answers in the same place. + */ +export const StepUpPromptHost: React.FC = () => { + const isOpen = useIsStepUpPromptOpen(); + + const handleClose = useCallback(() => { + dataProtectionStore.getState().closePrompt(); + }, []); + + return ; +}; diff --git a/src/hooks/use-protected-reveal.ts b/src/hooks/use-protected-reveal.ts new file mode 100644 index 0000000..beab23f --- /dev/null +++ b/src/hooks/use-protected-reveal.ts @@ -0,0 +1,46 @@ +import { useCallback } from 'react'; + +import { dataProtectionStore, 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 isRevealed = stepUpExpiresAt != null && Date.now() < stepUpExpiresAt; + + const reveal = useCallback(async () => { + const store = dataProtectionStore.getState(); + + if (store.isStepUpActive()) { + onRevealed?.(); + return; + } + + const outcome = await store.ensureGrant(); + if (outcome === 'granted') { + onRevealed?.(); + return; + } + + // 'unavailable' prompts too: the modal is where the caller is told grants are not configured, + // and silently doing nothing would look like a broken button. + dataProtectionStore.getState().openPrompt(); + }, [onRevealed]); + + const conceal = useCallback(() => { + dataProtectionStore.getState().clearStepUp(); + }, []); + + return { isRevealed, isRequesting, reveal, conceal }; +}; diff --git a/src/lib/auth/api.tsx b/src/lib/auth/api.tsx index fab35bb..40d5fa7 100644 --- a/src/lib/auth/api.tsx +++ b/src/lib/auth/api.tsx @@ -28,6 +28,8 @@ export const loginRequest = async (credentials: LoginCredentials): Promise | null; login: (credentials: LoginCredentials) => Promise; ssoLogin: (credentials: SsoLoginCredentials) => Promise; + /** Retries the pending SSO exchange with the user's authenticator code (2FA challenge). */ + retrySsoWithOtp: (otpCode: string) => Promise; logout: () => Promise; refreshAccessToken: () => Promise; hydrate: () => void; 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 0000000..f719395 --- /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 0000000..0b240cc --- /dev/null +++ b/src/lib/data-protection/__tests__/redacted.test.ts @@ -0,0 +1,41 @@ +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 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); + expect(isFieldRedacted([], ProtectedFieldIds.callName, REDACTION_VALUE)).toBe(true); + }); + + 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 0000000..3259c43 --- /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 0000000..b89d310 --- /dev/null +++ b/src/lib/data-protection/redacted.ts @@ -0,0 +1,72 @@ +/** + * 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 => { + if (redactedFields && redactedFields.length > 0) { + 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 e9d6f80..8c8e5d6 100644 --- a/src/models/v4/calls/callResultData.ts +++ b/src/models/v4/calls/callResultData.ts @@ -34,4 +34,9 @@ export class CallResultData { public Latitude: string = ''; public Longitude: string = ''; public CheckInTimersEnabled: boolean = false; + /** + * Catalog field ids the server withheld from this response (ADP plan 7.2). Empty for a + * department without the addon, and empty again once a grant reveals the record. + */ + public RedactedFields: string[] = []; } diff --git a/src/models/v4/contacts/contactResultData.ts b/src/models/v4/contacts/contactResultData.ts index ef7df7b..483658e 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/auth/store.tsx b/src/stores/auth/store.tsx index 743dbb9..c2fcd93 100644 --- a/src/stores/auth/store.tsx +++ b/src/stores/auth/store.tsx @@ -11,6 +11,10 @@ import { type ProfileModel } from '../../lib/auth/types'; import { getAuth } from '../../lib/auth/utils'; import { setItem, zustandStorage } from '../../lib/storage'; +// Last SSO exchange that failed with a 2FA challenge, retained IN MEMORY ONLY (module scope, +// never the persisted store) so the OTP prompt can retry the same IdP token with a code. +let pendingSsoMfaCredentials: SsoLoginCredentials | null = null; + const useAuthStore = create()( persist( (set, get) => ({ @@ -76,6 +80,13 @@ const useAuthStore = create()( } const timeoutId = setTimeout(() => get().refreshAccessToken(), refreshDelayMs); set({ refreshTimeoutId: timeoutId }); + } else if (response.mfaRequired) { + // 2FA challenge: the login screen prompts for the authenticator code and calls + // login() again with otpCode. Credentials are never retained here. + set({ + status: 'mfaRequired', + error: response.invalidOtp ? 'invalid_totp' : null, + }); } else { set({ status: 'error', @@ -128,6 +139,19 @@ const useAuthStore = create()( } const timeoutId = setTimeout(() => get().refreshAccessToken(), refreshDelayMs); set({ refreshTimeoutId: timeoutId }); + pendingSsoMfaCredentials = null; + } else if (response.mfaRequired) { + // 2FA challenge: retain the exchange in module memory (never the persisted store) + // so retrySsoWithOtp can replay it with the authenticator code. + pendingSsoMfaCredentials = credentials; + logger.info({ + message: 'SSO login requires two-factor verification', + context: { provider: credentials.provider, invalidOtp: !!response.invalidOtp }, + }); + set({ + status: 'mfaRequired', + error: response.invalidOtp ? 'invalid_totp' : null, + }); } else { set({ status: 'error', error: response.message }); } @@ -139,6 +163,15 @@ const useAuthStore = create()( } }, + retrySsoWithOtp: async (otpCode: string) => { + if (!pendingSsoMfaCredentials) { + set({ status: 'error', error: 'No pending SSO sign-in to verify' }); + return; + } + + await get().ssoLogin({ ...pendingSsoMfaCredentials, otpCode }); + }, + logout: async () => { // Clear any pending refresh timer to prevent stacked timeouts const existingTimeoutId = get().refreshTimeoutId; 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 0000000..6f55659 --- /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 0000000..ed1ca9d --- /dev/null +++ b/src/stores/data-protection/__tests__/store.test.ts @@ -0,0 +1,136 @@ +// 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({ 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({ StepUpExpiresOnUtc: new Date(Date.now() - 1000).toISOString() }); + + const ok = await dataProtectionStore.getState().verifyOtp('123456'); + + expect(ok).toBe(false); + expect(dataProtectionStore.getState().isStepUpActive()).toBe(false); + }); + }); + + describe('window lifecycle', () => { + it('expires by wall clock — the window is absolute, never sliding', () => { + dataProtectionStore.setState({ stepUpExpiresAt: Date.now() - 1 }); + expect(dataProtectionStore.getState().isStepUpActive()).toBe(false); + + dataProtectionStore.setState({ stepUpExpiresAt: Date.now() + 60_000 }); + expect(dataProtectionStore.getState().isStepUpActive()).toBe(true); + }); + + it('clearStepUp drops the window immediately', () => { + dataProtectionStore.setState({ 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 0000000..0ac45ec --- /dev/null +++ b/src/stores/data-protection/store.ts @@ -0,0 +1,239 @@ +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 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; + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + set({ isVerifying: false, lastError: 'unknown' }); + return false; + } + set({ + grantToken: result?.GrantToken ?? null, + 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 expiresAt = get().stepUpExpiresAt; + return expiresAt != null && Date.now() < expiresAt; + }, + 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); + +/** 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 848430e..12c5831 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -1804,5 +1804,21 @@ "unknown": "غير معروف" } }, - "welcome": "مرحبًا بك في موقع تطبيق obytes" + "welcome": "مرحبًا بك في موقع تطبيق obytes", + "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 1f709f1..48e0279 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -1804,5 +1804,21 @@ "unknown": "Unbekannt" } }, - "welcome": "Willkommen bei obytes app site" + "welcome": "Willkommen bei obytes app site", + "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 0eaf1ec..789d94d 100644 --- a/src/translations/el.json +++ b/src/translations/el.json @@ -1804,5 +1804,21 @@ "unknown": "Άγνωστο" } }, - "welcome": "Καλώς ήρθατε στον ιστότοπο της εφαρμογής obytes" + "welcome": "Καλώς ήρθατε στον ιστότοπο της εφαρμογής obytes", + "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 f28f7ef..a5826b5 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1804,5 +1804,21 @@ "unknown": "Unknown" } }, - "welcome": "Welcome to obytes app site" + "welcome": "Welcome to obytes app site", + "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 dbc9034..676bba4 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -1804,5 +1804,21 @@ "unknown": "Desconocido" } }, - "welcome": "Bienvenido al sitio de la aplicación obytes" + "welcome": "Bienvenido al sitio de la aplicación obytes", + "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 fa3cc6d..5fa57e2 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -1804,5 +1804,21 @@ "unknown": "Inconnu" } }, - "welcome": "Bienvenue sur l'application obytes" + "welcome": "Bienvenue sur l'application obytes", + "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 c730f24..4c7d960 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -1804,5 +1804,21 @@ "unknown": "Sconosciuto" } }, - "welcome": "Benvenuto nell'app obytes" + "welcome": "Benvenuto nell'app obytes", + "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 65922f8..fbd6c83 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -1804,5 +1804,21 @@ "unknown": "Nieznany" } }, - "welcome": "Witamy w aplikacji obytes" + "welcome": "Witamy w aplikacji obytes", + "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 29ffb03..56027c5 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -1804,5 +1804,21 @@ "unknown": "Okänd" } }, - "welcome": "Välkommen till obytes app site" + "welcome": "Välkommen till obytes app site", + "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 12e58da..14eade9 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -1804,5 +1804,21 @@ "unknown": "Невідомий" } }, - "welcome": "Ласкаво просимо до додатку obytes" + "welcome": "Ласкаво просимо до додатку obytes", + "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": "Захищену інформацію показано." + } } From afdde844efcdd8ed6741f09871f78932a07e6a5e Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 31 Aug 2026 08:10:30 -0700 Subject: [PATCH 3/3] RG-T89 PR #57 Fixes --- src/api/calls/callFiles.ts | 29 +++- src/api/chat/chat.ts | 7 +- src/api/common/client.tsx | 1 - src/app/(app)/_layout.tsx | 4 +- src/app/call/[id].tsx | 15 +- src/app/login/sso.tsx | 12 +- .../auth/__tests__/login-otp-modal.test.tsx | 138 ++++++++++++++++++ src/components/auth/login-otp-modal.tsx | 9 +- src/components/calls/call-notes-modal.tsx | 2 +- src/components/chat/message-bubble.tsx | 8 +- src/components/contacts/contact-card.tsx | 8 +- .../data-protection/step-up-modal.tsx | 16 +- src/hooks/use-protected-reveal.ts | 9 +- src/lib/auth/types.tsx | 6 + .../__tests__/redacted.test.ts | 9 +- src/lib/data-protection/redacted.ts | 5 +- src/models/v4/calls/callResultData.ts | 6 +- src/stores/auth/store.tsx | 23 ++- .../data-protection/__tests__/store.test.ts | 27 +++- src/stores/data-protection/store.ts | 25 +++- src/translations/ar.json | 34 ++--- src/translations/de.json | 34 ++--- src/translations/el.json | 34 ++--- src/translations/en.json | 34 ++--- src/translations/es.json | 34 ++--- src/translations/fr.json | 34 ++--- src/translations/it.json | 34 ++--- src/translations/pl.json | 34 ++--- src/translations/sv.json | 34 ++--- src/translations/uk.json | 34 ++--- 30 files changed, 476 insertions(+), 223 deletions(-) create mode 100644 src/components/auth/__tests__/login-otp-modal.test.tsx diff --git a/src/api/calls/callFiles.ts b/src/api/calls/callFiles.ts index d994c80..9d4478f 100644 --- a/src/api/calls/callFiles.ts +++ b/src/api/calls/callFiles.ts @@ -2,9 +2,10 @@ import axios, { type AxiosProgressEvent, type AxiosRequestConfig, type AxiosResp import { Platform } from 'react-native'; import { createApiEndpoint } from '@/api/common/client'; -import useAuthStore from '@/stores/auth/store'; +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'; @@ -31,6 +32,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; @@ -40,9 +60,10 @@ export const getCallAttachmentFile = async (url: string, options: DownloadOption type: 'start', }); - // Attach the signed-in bearer: authenticated file routes require it, and the anonymous - // signed-link route simply ignores it. Caller-supplied headers win on conflict. - const token = useAuthStore.getState().accessToken; + // 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: { diff --git a/src/api/chat/chat.ts b/src/api/chat/chat.ts index 15ac689..98acbcf 100644 --- a/src/api/chat/chat.ts +++ b/src/api/chat/chat.ts @@ -219,8 +219,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 443975b..403a6a8 100644 --- a/src/api/common/client.tsx +++ b/src/api/common/client.tsx @@ -34,7 +34,6 @@ const processQueue = (error: Error | null) => { failedQueue = []; }; - // Request interceptor for API calls axiosInstance.interceptors.request.use( (config: InternalAxiosRequestConfig) => { diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 6a06c8c..257f78c 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -11,6 +11,7 @@ import { ActivityIndicator, type ColorValue, Platform, StyleSheet, useWindowDime import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { OfflineStatusToast } from '@/components/common/offline-status-toast'; +import { StepUpPromptHost } from '@/components/data-protection/step-up-prompt-host'; import { NotificationButton } from '@/components/notifications/NotificationButton'; import { NotificationInbox } from '@/components/notifications/NotificationInbox'; import Sidebar from '@/components/sidebar/sidebar'; @@ -34,10 +35,9 @@ import { audioService } from '@/services/audio.service'; import { bluetoothAudioService } from '@/services/bluetooth-audio.service'; import { usePushNotifications } from '@/services/push-notification'; import { useCoreStore } from '@/stores/app/core-store'; -import { StepUpPromptHost } from '@/components/data-protection/step-up-prompt-host'; -import { dataProtectionStore } from '@/stores/data-protection/store'; import { useCallsStore } from '@/stores/calls/store'; import { useCommandStore } from '@/stores/command/store'; +import { dataProtectionStore } from '@/stores/data-protection/store'; import { FeatureFlagKeys, featureFlagsStore } from '@/stores/feature-flags/store'; import { useRolesStore } from '@/stores/roles/store'; import { securityStore } from '@/stores/security/store'; diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index 4374806..23b274b 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -11,12 +11,11 @@ import { CheckInTabContent } from '@/components/check-in-timers/check-in-tab-con import { MessageCommanderSheet } from '@/components/command/message-commander-sheet'; import { ReopenCommandSheet } from '@/components/command/reopen-command-sheet'; import { StartCommandSheet } from '@/components/command/start-command-sheet'; -import { ProtectedRevealBar } from '@/components/data-protection/protected-reveal-bar'; -import { ProtectedText } from '@/components/data-protection/protected-text'; -import { isFieldRedacted, ProtectedFieldIds } from '@/lib/data-protection/redacted'; import { HeaderBackButton } from '@/components/common/header-back-button'; import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; +import { ProtectedRevealBar } from '@/components/data-protection/protected-reveal-bar'; +import { ProtectedText } from '@/components/data-protection/protected-text'; // Import a static map component instead of react-native-maps import StaticMap from '@/components/maps/static-map'; import { FocusAwareStatusBar, SafeAreaView } from '@/components/ui'; @@ -29,6 +28,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 { logger } from '@/lib/logging'; import { openMapsWithDirections } from '@/lib/navigation'; import { type IncidentCommand, IncidentRoleType } from '@/models/v4/incidentCommand/incidentCommandModels'; @@ -604,7 +604,14 @@ export default function CallDetail() { {/* Map - only show when valid coordinates exist */} {coordinates.latitude !== null && coordinates.longitude !== null ? ( - + ) : null} diff --git a/src/app/login/sso.tsx b/src/app/login/sso.tsx index 92f166b..7329fd5 100644 --- a/src/app/login/sso.tsx +++ b/src/app/login/sso.tsx @@ -44,6 +44,10 @@ export default function SsoLogin() { const router = useRouter(); const { ssoLogin, status } = useAuth(); const authError = useAuthStore((s) => s.error); + // 'mfaRequired' is also how the password login reports its own 2FA challenge. Opening this + // screen's prompt on that status alone means retrySsoWithOtp fires with no pending SSO + // exchange, which drops the user into an error state instead of a code prompt. + const isSsoMfaPending = useAuthStore((s) => s.isSsoMfaPending); const oidc = useOidcLogin({ authority: ssoConfig?.authority ?? '', @@ -71,13 +75,13 @@ export default function SsoLogin() { } }, [status]); - // Re-arm the OTP prompt whenever a fresh 2FA challenge arrives + // Re-arm the OTP prompt whenever a fresh SSO 2FA challenge arrives useEffect(() => { - if (status === 'mfaRequired') { + if (status === 'mfaRequired' && isSsoMfaPending) { setIsSsoLoading(false); setOtpDismissed(false); } - }, [status]); + }, [status, isSsoMfaPending]); const handleOtpSubmit = useCallback(async (code: string) => { await useAuthStore.getState().retrySsoWithOtp(code); @@ -308,7 +312,7 @@ export default function SsoLogin() { {/* Two-factor challenge: SSO exchange answered mfa_required / invalid_totp */} ({ + 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 index ce8b187..8181d07 100644 --- a/src/components/auth/login-otp-modal.tsx +++ b/src/components/auth/login-otp-modal.tsx @@ -65,10 +65,17 @@ export const LoginOtpModal: React.FC = ({ isOpen, isSubmitti autoComplete="one-time-code" textContentType="oneTimeCode" onSubmitEditing={handleSubmit} + // A placeholder is not a label: it is announced once and then disappears the + // moment the member types, leaving the field unnamed for the rest of the entry. + accessibilityLabel={t('login.otp_placeholder', '6-digit code')} + accessibilityHint={t('login.otp_body', 'Your account has two-factor authentication enabled. Enter the current code from your authenticator app to finish signing in.')} + aria-label={t('login.otp_placeholder', '6-digit code')} /> {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 e5063f9..cceef81 100644 --- a/src/components/calls/call-notes-modal.tsx +++ b/src/components/calls/call-notes-modal.tsx @@ -5,10 +5,10 @@ import { FlatList, Keyboard, Modal, SafeAreaView, StyleSheet, TouchableOpacity, import { KeyboardProvider, KeyboardStickyView } from 'react-native-keyboard-controller'; import { ProtectedText } from '@/components/data-protection/protected-text'; -import { isRedactedValue, ProtectedFieldIds } from '@/lib/data-protection/redacted'; 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'; diff --git a/src/components/chat/message-bubble.tsx b/src/components/chat/message-bubble.tsx index 43b31ae..4851bed 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'; @@ -30,6 +31,11 @@ interface MessageBubbleProps { 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(() => { @@ -71,7 +77,7 @@ export function MessageBubble({ message, isOwn, showSender, currentUserId, onLon 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) : undefined; + const source = localUri ? { uri: localUri } : attachment ? getChatAttachmentImageSource(attachment.ChatAttachmentId, accessToken) : undefined; if (!source?.uri) return {message.Body}; return ( onPressImage?.(source as { uri: string; headers?: Record })}> diff --git a/src/components/contacts/contact-card.tsx b/src/components/contacts/contact-card.tsx index 99911ac..9ec871f 100644 --- a/src/components/contacts/contact-card.tsx +++ b/src/components/contacts/contact-card.tsx @@ -2,9 +2,9 @@ import React from 'react'; import { Pressable, Text, View } from 'react-native'; import { ProtectedText } from '@/components/data-protection/protected-text'; -import { isFieldRedacted, ProtectedFieldIds } from '@/lib/data-protection/redacted'; 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 { @@ -41,7 +41,7 @@ export const ContactCard: React.FC = ({ contact, onPress }) => ) : ( - {contact.ContactType === ContactType.Person ? : } + {contact.ContactType === ContactType.Person ? : } )} @@ -68,14 +68,14 @@ export const ContactCard: React.FC = ({ contact, onPress }) => {contact.Email ? ( - + ) : null} {contact.Phone ? ( - + ) : null} diff --git a/src/components/data-protection/step-up-modal.tsx b/src/components/data-protection/step-up-modal.tsx index 70f7c74..5c2ace9 100644 --- a/src/components/data-protection/step-up-modal.tsx +++ b/src/components/data-protection/step-up-modal.tsx @@ -75,12 +75,7 @@ export const StepUpModal: React.FC = ({ isOpen, onClose, onVer - - {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.' - )} - + {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.')} = ({ isOpen, onClose, onVer autoComplete="one-time-code" textContentType="oneTimeCode" onSubmitEditing={handleVerify} + // A placeholder is not a label: it is announced once and then disappears the + // moment the member types, leaving the field unnamed for the rest of the entry. + accessibilityLabel={t('data_protection.step_up_placeholder', '6-digit code')} + accessibilityHint={t('data_protection.step_up_body')} + aria-label={t('data_protection.step_up_placeholder', '6-digit code')} /> {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/hooks/use-protected-reveal.ts b/src/hooks/use-protected-reveal.ts index beab23f..88724e6 100644 --- a/src/hooks/use-protected-reveal.ts +++ b/src/hooks/use-protected-reveal.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; -import { dataProtectionStore, useStepUpExpiresAt } from '@/stores/data-protection/store'; +import { dataProtectionStore, useHasGrantToken, useStepUpExpiresAt } from '@/stores/data-protection/store'; /** * The screen-facing half of an ADP reveal. @@ -17,7 +17,12 @@ export const useProtectedReveal = (onRevealed?: () => void) => { const stepUpExpiresAt = useStepUpExpiresAt(); const isRequesting = dataProtectionStore((state) => state.isRequestingGrant); - const isRevealed = stepUpExpiresAt != null && Date.now() < stepUpExpiresAt; + 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(); diff --git a/src/lib/auth/types.tsx b/src/lib/auth/types.tsx index 64781a2..1a9d608 100644 --- a/src/lib/auth/types.tsx +++ b/src/lib/auth/types.tsx @@ -62,6 +62,12 @@ export interface AuthState { profile: ProfileModel | null; userId: string | null; refreshTimeoutId: ReturnType | null; + /** + * True only while an SSO exchange is waiting on an authenticator code. `status === 'mfaRequired'` + * cannot stand in for this: the password login sets the same status, and the SSO screen must not + * open its OTP prompt for a challenge it has no pending exchange to retry. + */ + isSsoMfaPending: boolean; login: (credentials: LoginCredentials) => Promise; ssoLogin: (credentials: SsoLoginCredentials) => Promise; /** Retries the pending SSO exchange with the user's authenticator code (2FA challenge). */ diff --git a/src/lib/data-protection/__tests__/redacted.test.ts b/src/lib/data-protection/__tests__/redacted.test.ts index 0b240cc..35a8035 100644 --- a/src/lib/data-protection/__tests__/redacted.test.ts +++ b/src/lib/data-protection/__tests__/redacted.test.ts @@ -16,10 +16,15 @@ describe('isFieldRedacted', () => { expect(isFieldRedacted([ProtectedFieldIds.callNotes], ProtectedFieldIds.callName, 'REDACTED')).toBe(false); }); - it('falls back to the value when no list came with the payload', () => { + 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); - expect(isFieldRedacted([], ProtectedFieldIds.callName, REDACTION_VALUE)).toBe(true); + }); + + 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', () => { diff --git a/src/lib/data-protection/redacted.ts b/src/lib/data-protection/redacted.ts index b89d310..bf3e6a4 100644 --- a/src/lib/data-protection/redacted.ts +++ b/src/lib/data-protection/redacted.ts @@ -58,7 +58,10 @@ export const ProtectedFieldIds = { * 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 => { - if (redactedFields && redactedFields.length > 0) { + // 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()); } diff --git a/src/models/v4/calls/callResultData.ts b/src/models/v4/calls/callResultData.ts index 8c8e5d6..9fb0efc 100644 --- a/src/models/v4/calls/callResultData.ts +++ b/src/models/v4/calls/callResultData.ts @@ -37,6 +37,10 @@ export class CallResultData { /** * 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[] = []; + public RedactedFields?: string[]; } diff --git a/src/stores/auth/store.tsx b/src/stores/auth/store.tsx index c2fcd93..98aa060 100644 --- a/src/stores/auth/store.tsx +++ b/src/stores/auth/store.tsx @@ -27,6 +27,7 @@ const useAuthStore = create()( userId: null, isFirstTime: true, refreshTimeoutId: null, + isSsoMfaPending: false, login: async (credentials: LoginCredentials) => { try { set({ status: 'loading' }); @@ -138,12 +139,18 @@ const useAuthStore = create()( clearTimeout(existingTimeoutId); } const timeoutId = setTimeout(() => get().refreshAccessToken(), refreshDelayMs); - set({ refreshTimeoutId: timeoutId }); + set({ refreshTimeoutId: timeoutId, isSsoMfaPending: false }); pendingSsoMfaCredentials = null; } else if (response.mfaRequired) { // 2FA challenge: retain the exchange in module memory (never the persisted store) // so retrySsoWithOtp can replay it with the authenticator code. - pendingSsoMfaCredentials = credentials; + // + // The code itself is deliberately dropped. On an invalid_totp retry `credentials` + // still carries the rejected otpCode, and keeping it would hold a known-bad secret in + // module state until the next successful SSO login. retrySsoWithOtp supplies the new + // code on every attempt, so nothing needs it here. + const { otpCode: _rejectedOtpCode, ...ssoExchange } = credentials; + pendingSsoMfaCredentials = ssoExchange; logger.info({ message: 'SSO login requires two-factor verification', context: { provider: credentials.provider, invalidOtp: !!response.invalidOtp }, @@ -151,14 +158,18 @@ const useAuthStore = create()( set({ status: 'mfaRequired', error: response.invalidOtp ? 'invalid_totp' : null, + isSsoMfaPending: true, }); } else { - set({ status: 'error', error: response.message }); + pendingSsoMfaCredentials = null; + set({ status: 'error', error: response.message, isSsoMfaPending: false }); } } catch (error) { + pendingSsoMfaCredentials = null; set({ status: 'error', error: error instanceof Error ? error.message : 'SSO login failed', + isSsoMfaPending: false, }); } }, @@ -178,6 +189,8 @@ const useAuthStore = create()( if (existingTimeoutId !== null) { clearTimeout(existingTimeoutId); } + // The retained IdP exchange is a credential; it must not outlive the session. + pendingSsoMfaCredentials = null; set({ accessToken: null, refreshToken: null, @@ -186,6 +199,7 @@ const useAuthStore = create()( profile: null, isFirstTime: true, refreshTimeoutId: null, + isSsoMfaPending: false, }); Sentry.setUser(null); }, @@ -337,6 +351,9 @@ const useAuthStore = create()( { name: 'auth-storage', storage: createJSONStorage(() => zustandStorage), + // The pending SSO exchange lives in module memory and dies with the process, so a rehydrated + // `true` here would open the OTP prompt with nothing to retry. Force it back to false. + merge: (persisted, current) => ({ ...current, ...(persisted as Partial), isSsoMfaPending: false }), onRehydrateStorage: () => { return (state, error) => { if (error) { diff --git a/src/stores/data-protection/__tests__/store.test.ts b/src/stores/data-protection/__tests__/store.test.ts index ed1ca9d..b380830 100644 --- a/src/stores/data-protection/__tests__/store.test.ts +++ b/src/stores/data-protection/__tests__/store.test.ts @@ -72,7 +72,7 @@ describe('dataProtectionStore', () => { describe('verifyOtp', () => { it('activates the absolute window on success', async () => { const expires = new Date(Date.now() + 15 * 60 * 1000).toISOString(); - verifyStepUp.mockResolvedValue({ StepUpExpiresOnUtc: expires, StepUpWindowMinutes: 15 }); + verifyStepUp.mockResolvedValue({ GrantToken: 'grant-token', StepUpExpiresOnUtc: expires, StepUpWindowMinutes: 15 }); const ok = await dataProtectionStore.getState().verifyOtp('123456'); @@ -93,26 +93,43 @@ describe('dataProtectionStore', () => { }); it('rejects an already-expired window from the server', async () => { - verifyStepUp.mockResolvedValue({ StepUpExpiresOnUtc: new Date(Date.now() - 1000).toISOString() }); + 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({ stepUpExpiresAt: Date.now() - 1 }); + dataProtectionStore.setState({ grantToken: 'grant-token', stepUpExpiresAt: Date.now() - 1 }); expect(dataProtectionStore.getState().isStepUpActive()).toBe(false); - dataProtectionStore.setState({ stepUpExpiresAt: Date.now() + 60_000 }); + 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({ stepUpExpiresAt: Date.now() + 60_000 }); + dataProtectionStore.setState({ grantToken: 'grant-token', stepUpExpiresAt: Date.now() + 60_000 }); dataProtectionStore.getState().clearStepUp(); expect(dataProtectionStore.getState().isStepUpActive()).toBe(false); }); diff --git a/src/stores/data-protection/store.ts b/src/stores/data-protection/store.ts index 0ac45ec..71bd18e 100644 --- a/src/stores/data-protection/store.ts +++ b/src/stores/data-protection/store.ts @@ -56,7 +56,7 @@ export interface DataProtectionState { ensureGrant: () => Promise; /** Sends the TOTP code; true on success. */ verifyOtp: (code: string) => Promise; - /** True while an unexpired grant is held. Evaluate at the moment of use. */ + /** 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. @@ -77,8 +77,7 @@ const parseErrorCode = (error: unknown): StepUpErrorCode => { return 'unknown'; }; -const problemType = (error: unknown): string | undefined => - (error as { response?: { data?: { type?: string } } })?.response?.data?.type; +const problemType = (error: unknown): string | undefined => (error as { response?: { data?: { type?: string } } })?.response?.data?.type; export const dataProtectionStore = create()((set, get) => ({ capabilities: null, @@ -151,12 +150,15 @@ export const dataProtectionStore = create()((set, get) => ( try { const result = await verifyStepUp(code.trim()); const expiresAt = result?.StepUpExpiresOnUtc ? Date.parse(result.StepUpExpiresOnUtc) : NaN; - if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + // 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 ?? null, + grantToken: result.GrantToken, stepUpExpiresAt: expiresAt, isVerifying: false, lastError: null, @@ -173,8 +175,10 @@ export const dataProtectionStore = create()((set, get) => ( } }, isStepUpActive: () => { - const expiresAt = get().stepUpExpiresAt; - return expiresAt != null && Date.now() < expiresAt; + 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(); @@ -217,7 +221,6 @@ if (typeof useAuthStore?.subscribe === 'function') { 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. @@ -232,6 +235,12 @@ export const useIsProtectionEnabled = () => dataProtectionStore((state) => !!sta */ 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(); diff --git a/src/translations/ar.json b/src/translations/ar.json index 12c5831..3f2e35d 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -973,6 +973,22 @@ "website": "الموقع الإلكتروني", "zip": "الرمز البريدي" }, + "data_protection": { + "conceal": "إخفاؤها مجددًا", + "protected_notice": "بعض المعلومات في هذه الشاشة محمية.", + "protected_value": "محمي", + "reveal": "إظهار المعلومات المحمية", + "revealed_notice": "المعلومات المحمية ظاهرة الآن.", + "step_up_body": "هذه المعلومات محمية. أدخل الرمز الحالي من تطبيق المصادقة لعرضها لفترة محدودة.", + "step_up_failed": "تعذّر التأكيد. تحقق من اتصالك وأعد المحاولة.", + "step_up_invalid_code": "هذا الرمز غير صالح أو انتهت صلاحيته. أدخل الرمز الحالي من تطبيق المصادقة.", + "step_up_not_enrolled": "لم تُفعَّل المصادقة الثنائية لحسابك. فعِّل تطبيق مصادقة من إعدادات أمان الحساب أولًا.", + "step_up_placeholder": "رمز من 6 أرقام", + "step_up_title": "أكِّد هويتك", + "step_up_too_many_attempts": "محاولات كثيرة جدًا. انتظر بضع دقائق ثم أعد المحاولة.", + "step_up_unavailable": "البيانات المحمية غير متاحة بعد على هذا الخادم. تواصل مع المسؤول.", + "step_up_verify": "تأكيد" + }, "form": { "invalid_url": "يرجى إدخال عنوان URL صالح يبدأ بـ http:// أو https://", "required": "هذا الحقل مطلوب" @@ -1804,21 +1820,5 @@ "unknown": "غير معروف" } }, - "welcome": "مرحبًا بك في موقع تطبيق obytes", - "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": "المعلومات المحمية ظاهرة الآن." - } + "welcome": "مرحبًا بك في موقع تطبيق obytes" } diff --git a/src/translations/de.json b/src/translations/de.json index 48e0279..b1a784a 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -973,6 +973,22 @@ "website": "Website", "zip": "Postleitzahl" }, + "data_protection": { + "conceal": "Wieder ausblenden", + "protected_notice": "Einige Angaben auf diesem Bildschirm sind geschützt.", + "protected_value": "Geschützt", + "reveal": "Geschützte Informationen anzeigen", + "revealed_notice": "Geschützte Angaben sind sichtbar.", + "step_up_body": "Diese Angaben sind geschützt. Geben Sie den aktuellen Code aus Ihrer Authenticator-App ein, um sie für begrenzte Zeit zu sehen.", + "step_up_failed": "Bestätigung fehlgeschlagen. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.", + "step_up_invalid_code": "Dieser Code ist ungültig oder abgelaufen. Geben Sie den aktuellen Code aus Ihrer Authenticator-App ein.", + "step_up_not_enrolled": "Für Ihr Konto ist keine Zwei-Faktor-Authentifizierung eingerichtet. Richten Sie zuerst eine Authenticator-App in den Sicherheitseinstellungen Ihres Kontos ein.", + "step_up_placeholder": "6-stelliger Code", + "step_up_title": "Identität bestätigen", + "step_up_too_many_attempts": "Zu viele Versuche. Warten Sie einige Minuten und versuchen Sie es erneut.", + "step_up_unavailable": "Geschützte Daten sind auf diesem Server noch nicht verfügbar. Wenden Sie sich an Ihre Administration.", + "step_up_verify": "Bestätigen" + }, "form": { "invalid_url": "Bitte eine gültige URL eingeben, die mit http:// oder https:// beginnt", "required": "Dieses Feld ist erforderlich" @@ -1804,21 +1820,5 @@ "unknown": "Unbekannt" } }, - "welcome": "Willkommen bei obytes app site", - "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." - } + "welcome": "Willkommen bei obytes app site" } diff --git a/src/translations/el.json b/src/translations/el.json index 789d94d..8fe2469 100644 --- a/src/translations/el.json +++ b/src/translations/el.json @@ -973,6 +973,22 @@ "website": "Ιστότοπος", "zip": "Ταχυδρομικός Κώδικας" }, + "data_protection": { + "conceal": "Απόκρυψη ξανά", + "protected_notice": "Ορισμένες πληροφορίες σε αυτήν την οθόνη είναι προστατευμένες.", + "protected_value": "Προστατευμένο", + "reveal": "Εμφάνιση προστατευμένων πληροφοριών", + "revealed_notice": "Οι προστατευμένες πληροφορίες είναι ορατές.", + "step_up_body": "Αυτές οι πληροφορίες είναι προστατευμένες. Εισαγάγετε τον τρέχοντα κωδικό από την εφαρμογή ελέγχου ταυτότητας για να τις δείτε για περιορισμένο χρόνο.", + "step_up_failed": "Η επαλήθευση απέτυχε. Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά.", + "step_up_invalid_code": "Ο κωδικός δεν είναι έγκυρος ή έχει λήξει. Εισαγάγετε τον τρέχοντα κωδικό από την εφαρμογή ελέγχου ταυτότητας.", + "step_up_not_enrolled": "Δεν έχει ρυθμιστεί έλεγχος ταυτότητας δύο παραγόντων για τον λογαριασμό σας. Ρυθμίστε πρώτα μια εφαρμογή ελέγχου ταυτότητας στις ρυθμίσεις ασφαλείας του λογαριασμού.", + "step_up_placeholder": "Εξαψήφιος κωδικός", + "step_up_title": "Επαληθεύστε την ταυτότητά σας", + "step_up_too_many_attempts": "Πάρα πολλές προσπάθειες. Περιμένετε λίγα λεπτά και δοκιμάστε ξανά.", + "step_up_unavailable": "Τα προστατευμένα δεδομένα δεν είναι ακόμη διαθέσιμα σε αυτόν τον διακομιστή. Επικοινωνήστε με τον διαχειριστή σας.", + "step_up_verify": "Επαλήθευση" + }, "form": { "invalid_url": "Εισαγάγετε έγκυρη διεύθυνση URL που ξεκινά με http:// ή https://", "required": "Αυτό το πεδίο είναι υποχρεωτικό" @@ -1804,21 +1820,5 @@ "unknown": "Άγνωστο" } }, - "welcome": "Καλώς ήρθατε στον ιστότοπο της εφαρμογής obytes", - "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": "Οι προστατευμένες πληροφορίες είναι ορατές." - } + "welcome": "Καλώς ήρθατε στον ιστότοπο της εφαρμογής obytes" } diff --git a/src/translations/en.json b/src/translations/en.json index a5826b5..e4d6d22 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -973,6 +973,22 @@ "website": "Website", "zip": "Zip Code" }, + "data_protection": { + "conceal": "Hide again", + "protected_notice": "Some information on this screen is protected.", + "protected_value": "Protected", + "reveal": "Show protected information", + "revealed_notice": "Protected information is visible.", + "step_up_body": "This information is protected. Enter the current code from your authenticator app to view it for a limited time.", + "step_up_failed": "Verification failed. Check your connection and try again.", + "step_up_invalid_code": "That code is invalid or has expired. Enter the current code from your authenticator app.", + "step_up_not_enrolled": "Two-factor authentication is not set up for your account. Enroll an authenticator app in your account security settings first.", + "step_up_placeholder": "6-digit code", + "step_up_title": "Verify your identity", + "step_up_too_many_attempts": "Too many attempts. Wait a few minutes and try again.", + "step_up_unavailable": "Protected data is not available on this server yet. Contact your administrator.", + "step_up_verify": "Verify" + }, "form": { "invalid_url": "Please enter a valid URL starting with http:// or https://", "required": "This field is required" @@ -1804,21 +1820,5 @@ "unknown": "Unknown" } }, - "welcome": "Welcome to obytes app site", - "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." - } + "welcome": "Welcome to obytes app site" } diff --git a/src/translations/es.json b/src/translations/es.json index 676bba4..829926c 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -973,6 +973,22 @@ "website": "Sitio web", "zip": "Código postal" }, + "data_protection": { + "conceal": "Ocultar de nuevo", + "protected_notice": "Parte de la información de esta pantalla está protegida.", + "protected_value": "Protegido", + "reveal": "Mostrar información protegida", + "revealed_notice": "La información protegida está visible.", + "step_up_body": "Esta información está protegida. Introduzca el código actual de su aplicación de autenticación para verla durante un tiempo limitado.", + "step_up_failed": "La verificación ha fallado. Compruebe su conexión e inténtelo de nuevo.", + "step_up_invalid_code": "Ese código no es válido o ha caducado. Introduzca el código actual de su aplicación de autenticación.", + "step_up_not_enrolled": "Su cuenta no tiene configurada la autenticación en dos pasos. Configure primero una aplicación de autenticación en los ajustes de seguridad de su cuenta.", + "step_up_placeholder": "Código de 6 dígitos", + "step_up_title": "Verifique su identidad", + "step_up_too_many_attempts": "Demasiados intentos. Espere unos minutos e inténtelo de nuevo.", + "step_up_unavailable": "Los datos protegidos aún no están disponibles en este servidor. Póngase en contacto con su administrador.", + "step_up_verify": "Verificar" + }, "form": { "invalid_url": "Por favor, introduce una URL válida que comience con http:// o https://", "required": "Este campo es obligatorio" @@ -1804,21 +1820,5 @@ "unknown": "Desconocido" } }, - "welcome": "Bienvenido al sitio de la aplicación obytes", - "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." - } + "welcome": "Bienvenido al sitio de la aplicación obytes" } diff --git a/src/translations/fr.json b/src/translations/fr.json index 5fa57e2..1db8181 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -973,6 +973,22 @@ "website": "Site web", "zip": "Code postal" }, + "data_protection": { + "conceal": "Masquer à nouveau", + "protected_notice": "Certaines informations de cet écran sont protégées.", + "protected_value": "Protégé", + "reveal": "Afficher les informations protégées", + "revealed_notice": "Les informations protégées sont visibles.", + "step_up_body": "Ces informations sont protégées. Saisissez le code actuel de votre application d'authentification pour les afficher pendant une durée limitée.", + "step_up_failed": "La vérification a échoué. Vérifiez votre connexion et réessayez.", + "step_up_invalid_code": "Ce code est invalide ou a expiré. Saisissez le code actuel de votre application d'authentification.", + "step_up_not_enrolled": "L'authentification à deux facteurs n'est pas configurée pour votre compte. Configurez d'abord une application d'authentification dans les paramètres de sécurité de votre compte.", + "step_up_placeholder": "Code à 6 chiffres", + "step_up_title": "Vérifiez votre identité", + "step_up_too_many_attempts": "Trop de tentatives. Patientez quelques minutes et réessayez.", + "step_up_unavailable": "Les données protégées ne sont pas encore disponibles sur ce serveur. Contactez votre administrateur.", + "step_up_verify": "Vérifier" + }, "form": { "invalid_url": "Veuillez saisir une URL valide commençant par http:// ou https://", "required": "Ce champ est obligatoire" @@ -1804,21 +1820,5 @@ "unknown": "Inconnu" } }, - "welcome": "Bienvenue sur l'application obytes", - "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." - } + "welcome": "Bienvenue sur l'application obytes" } diff --git a/src/translations/it.json b/src/translations/it.json index 4c7d960..35bfc5d 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -973,6 +973,22 @@ "website": "Sito web", "zip": "CAP" }, + "data_protection": { + "conceal": "Nascondi di nuovo", + "protected_notice": "Alcune informazioni in questa schermata sono protette.", + "protected_value": "Protetto", + "reveal": "Mostra le informazioni protette", + "revealed_notice": "Le informazioni protette sono visibili.", + "step_up_body": "Queste informazioni sono protette. Inserisci il codice attuale dalla tua app di autenticazione per visualizzarle per un tempo limitato.", + "step_up_failed": "Verifica non riuscita. Controlla la connessione e riprova.", + "step_up_invalid_code": "Il codice non è valido o è scaduto. Inserisci il codice attuale dalla tua app di autenticazione.", + "step_up_not_enrolled": "L'autenticazione a due fattori non è configurata per il tuo account. Configura prima un'app di autenticazione nelle impostazioni di sicurezza dell'account.", + "step_up_placeholder": "Codice a 6 cifre", + "step_up_title": "Verifica la tua identità", + "step_up_too_many_attempts": "Troppi tentativi. Attendi qualche minuto e riprova.", + "step_up_unavailable": "I dati protetti non sono ancora disponibili su questo server. Contatta l'amministratore.", + "step_up_verify": "Verifica" + }, "form": { "invalid_url": "Inserisci un URL valido che inizia con http:// o https://", "required": "Questo campo è obbligatorio" @@ -1804,21 +1820,5 @@ "unknown": "Sconosciuto" } }, - "welcome": "Benvenuto nell'app obytes", - "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." - } + "welcome": "Benvenuto nell'app obytes" } diff --git a/src/translations/pl.json b/src/translations/pl.json index fbd6c83..e70f997 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -973,6 +973,22 @@ "website": "Strona internetowa", "zip": "Kod pocztowy" }, + "data_protection": { + "conceal": "Ukryj ponownie", + "protected_notice": "Część informacji na tym ekranie jest chroniona.", + "protected_value": "Chronione", + "reveal": "Pokaż chronione informacje", + "revealed_notice": "Chronione informacje są widoczne.", + "step_up_body": "Te informacje są chronione. Wprowadź aktualny kod z aplikacji uwierzytelniającej, aby zobaczyć je przez ograniczony czas.", + "step_up_failed": "Weryfikacja nie powiodła się. Sprawdź połączenie i spróbuj ponownie.", + "step_up_invalid_code": "Ten kod jest nieprawidłowy lub wygasł. Wprowadź aktualny kod z aplikacji uwierzytelniającej.", + "step_up_not_enrolled": "Dla Twojego konta nie skonfigurowano uwierzytelniania dwuskładnikowego. Najpierw skonfiguruj aplikację uwierzytelniającą w ustawieniach bezpieczeństwa konta.", + "step_up_placeholder": "Kod 6-cyfrowy", + "step_up_title": "Potwierdź swoją tożsamość", + "step_up_too_many_attempts": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie.", + "step_up_unavailable": "Chronione dane nie są jeszcze dostępne na tym serwerze. Skontaktuj się z administratorem.", + "step_up_verify": "Potwierdź" + }, "form": { "invalid_url": "Wpisz prawidłowy URL zaczynający się od http:// lub https://", "required": "To pole jest wymagane" @@ -1804,21 +1820,5 @@ "unknown": "Nieznany" } }, - "welcome": "Witamy w aplikacji obytes", - "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." - } + "welcome": "Witamy w aplikacji obytes" } diff --git a/src/translations/sv.json b/src/translations/sv.json index 56027c5..844030c 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -973,6 +973,22 @@ "website": "Webbplats", "zip": "Postnummer" }, + "data_protection": { + "conceal": "Dölj igen", + "protected_notice": "En del uppgifter på den här skärmen är skyddade.", + "protected_value": "Skyddad", + "reveal": "Visa skyddad information", + "revealed_notice": "Skyddade uppgifter visas.", + "step_up_body": "De här uppgifterna är skyddade. Ange den aktuella koden från din autentiseringsapp för att visa dem en begränsad tid.", + "step_up_failed": "Verifieringen misslyckades. Kontrollera din anslutning och försök igen.", + "step_up_invalid_code": "Koden är ogiltig eller har gått ut. Ange den aktuella koden från din autentiseringsapp.", + "step_up_not_enrolled": "Tvåfaktorsautentisering är inte konfigurerad för ditt konto. Konfigurera först en autentiseringsapp i kontots säkerhetsinställningar.", + "step_up_placeholder": "6-siffrig kod", + "step_up_title": "Bekräfta din identitet", + "step_up_too_many_attempts": "För många försök. Vänta några minuter och försök igen.", + "step_up_unavailable": "Skyddade uppgifter är ännu inte tillgängliga på den här servern. Kontakta din administratör.", + "step_up_verify": "Bekräfta" + }, "form": { "invalid_url": "Ange en giltig URL som börjar med http:// eller https://", "required": "Detta fält är obligatoriskt" @@ -1804,21 +1820,5 @@ "unknown": "Okänd" } }, - "welcome": "Välkommen till obytes app site", - "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." - } + "welcome": "Välkommen till obytes app site" } diff --git a/src/translations/uk.json b/src/translations/uk.json index 14eade9..b379698 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -973,6 +973,22 @@ "website": "Веб-сайт", "zip": "Поштовий індекс" }, + "data_protection": { + "conceal": "Приховати знову", + "protected_notice": "Частина інформації на цьому екрані захищена.", + "protected_value": "Захищено", + "reveal": "Показати захищену інформацію", + "revealed_notice": "Захищену інформацію показано.", + "step_up_body": "Ця інформація захищена. Введіть поточний код із застосунку автентифікації, щоб переглянути її протягом обмеженого часу.", + "step_up_failed": "Не вдалося підтвердити. Перевірте з’єднання та повторіть спробу.", + "step_up_invalid_code": "Цей код недійсний або прострочений. Введіть поточний код із застосунку автентифікації.", + "step_up_not_enrolled": "Для вашого облікового запису не налаштовано двофакторну автентифікацію. Спершу налаштуйте застосунок автентифікації в параметрах безпеки облікового запису.", + "step_up_placeholder": "6-значний код", + "step_up_title": "Підтвердьте свою особу", + "step_up_too_many_attempts": "Забагато спроб. Зачекайте кілька хвилин і повторіть.", + "step_up_unavailable": "Захищені дані ще недоступні на цьому сервері. Зверніться до адміністратора.", + "step_up_verify": "Підтвердити" + }, "form": { "invalid_url": "Введіть правильний URL, що починається з http:// або https://", "required": "Це поле обов'язкове" @@ -1804,21 +1820,5 @@ "unknown": "Невідомий" } }, - "welcome": "Ласкаво просимо до додатку obytes", - "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": "Захищену інформацію показано." - } + "welcome": "Ласкаво просимо до додатку obytes" }