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; }); }; diff --git a/src/api/calls/callFiles.ts b/src/api/calls/callFiles.ts index 8eb0d80..9d4478f 100644 --- a/src/api/calls/callFiles.ts +++ b/src/api/calls/callFiles.ts @@ -2,8 +2,10 @@ import axios, { type AxiosProgressEvent, type AxiosRequestConfig, type AxiosResp import { Platform } from 'react-native'; import { createApiEndpoint } from '@/api/common/client'; +import { getBaseApiUrl } from '@/lib/storage/app'; import { type CallFilesResult } from '@/models/v4/callFiles/callFilesResult'; import { type SaveCallFileResult } from '@/models/v4/callFiles/saveCallFileResult'; +import useAuthStore from '@/stores/auth/store'; // Event types for the download process export type DownloadEventType = 'start' | 'progress' | 'complete' | 'error'; @@ -30,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; @@ -39,9 +60,16 @@ export const getCallAttachmentFile = async (url: string, options: DownloadOption type: 'start', }); + // Attach the signed-in bearer, but only for our own API origin: authenticated file routes + // require it, the anonymous signed-link route simply ignores it, and an external storage or + // CDN host must never see it. Caller-supplied headers win on conflict. + const token = isApiOrigin(url) ? useAuthStore.getState().accessToken : null; const config: AxiosRequestConfig = { responseType: 'blob', - headers, + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...headers, + }, timeout, onDownloadProgress: (progressEvent: AxiosProgressEvent) => { if (progressEvent.total) { diff --git a/src/api/chat/chat.ts b/src/api/chat/chat.ts index 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 e614c0e..403a6a8 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'; @@ -44,6 +45,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..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'; @@ -36,6 +37,7 @@ import { usePushNotifications } from '@/services/push-notification'; import { useCoreStore } from '@/stores/app/core-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'; @@ -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..23b274b 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -14,6 +14,8 @@ import { StartCommandSheet } from '@/components/command/start-command-sheet'; 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'; @@ -26,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'; @@ -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) ? ( + + ) : ( + + )} @@ -574,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/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..7329fd5 100644 --- a/src/app/login/sso.tsx +++ b/src/app/login/sso.tsx @@ -8,6 +8,7 @@ import { ActivityIndicator } from 'react-native'; import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; import * as z from 'zod'; +import { LoginOtpModal } from '@/components/auth/login-otp-modal'; import { FocusAwareStatusBar, View } from '@/components/ui'; import { Button, ButtonSpinner, ButtonText } from '@/components/ui/button'; import { FormControl, FormControlError, FormControlErrorIcon, FormControlErrorText, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control'; @@ -22,6 +23,7 @@ import { useAuth } from '@/lib/auth'; import { logger } from '@/lib/logging'; import type { DepartmentSsoConfig } from '@/services/sso-discovery'; import { fetchSsoConfigForUser } from '@/services/sso-discovery'; +import useAuthStore from '@/stores/auth/store'; const ssoFormSchema = z.object({ username: z.string({ required_error: 'Username is required' }).min(3, 'Username must be at least 3 characters'), @@ -35,11 +37,17 @@ export default function SsoLogin() { const [isLookingUpSso, setIsLookingUpSso] = useState(false); const [isSsoLoading, setIsSsoLoading] = useState(false); const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); + const [otpDismissed, setOtpDismissed] = useState(false); const pendingUsernameRef = useRef(''); const { t } = useTranslation(); const router = useRouter(); const { ssoLogin, status } = useAuth(); + const authError = useAuthStore((s) => s.error); + // 'mfaRequired' is also how the password login reports its own 2FA challenge. Opening this + // screen's prompt on that status alone means retrySsoWithOtp fires with no pending SSO + // exchange, which drops the user into an error state instead of a code prompt. + const isSsoMfaPending = useAuthStore((s) => s.isSsoMfaPending); const oidc = useOidcLogin({ authority: ssoConfig?.authority ?? '', @@ -67,6 +75,18 @@ export default function SsoLogin() { } }, [status]); + // Re-arm the OTP prompt whenever a fresh SSO 2FA challenge arrives + useEffect(() => { + if (status === 'mfaRequired' && isSsoMfaPending) { + setIsSsoLoading(false); + setOtpDismissed(false); + } + }, [status, isSsoMfaPending]); + + const handleOtpSubmit = useCallback(async (code: string) => { + await useAuthStore.getState().retrySsoWithOtp(code); + }, []); + // ── OIDC response handler ───────────────────────────────────────────────── useEffect(() => { if (oidc.response?.type !== 'success') return; @@ -289,6 +309,15 @@ export default function SsoLogin() { + + {/* Two-factor challenge: SSO exchange answered mfa_required / invalid_totp */} + setOtpDismissed(true)} + /> ); } diff --git a/src/components/auth/__tests__/login-otp-modal.test.tsx b/src/components/auth/__tests__/login-otp-modal.test.tsx new file mode 100644 index 0000000..49d7fc5 --- /dev/null +++ b/src/components/auth/__tests__/login-otp-modal.test.tsx @@ -0,0 +1,138 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; + +import { LoginOtpModal } from '../login-otp-modal'; + +// The modal renders through gluestack's overlay primitives; the real ones need a provider and a +// portal host that the login screen supplies at runtime. Rendering plain views keeps this focused +// on the modal's own behaviour: what it submits, and when. +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }), +})); + +jest.mock('@/components/ui/modal', () => { + const { View } = require('react-native'); + return { + Modal: ({ isOpen, children, ...props }: any) => (isOpen ? {children} : null), + ModalBackdrop: ({ children }: any) => {children}, + ModalBody: ({ children }: any) => {children}, + ModalContent: ({ children }: any) => {children}, + ModalFooter: ({ children }: any) => {children}, + ModalHeader: ({ children }: any) => {children}, + }; +}); + +describe('LoginOtpModal', () => { + // gluestack's Button surfaces its disabled state differently depending on how the design system + // is wired up in each app: some expose accessibilityState, others pass isDisabled straight + // through. Read whichever one is present so this test asserts intent, not plumbing. + const isDisabled = (element: any): boolean => element.props.accessibilityState?.disabled ?? element.props.isDisabled ?? false; + + const renderModal = (overrides: Partial> = {}) => { + const props = { + isOpen: true, + isSubmitting: false, + invalidCode: false, + onSubmit: jest.fn(), + onClose: jest.fn(), + ...overrides, + }; + return { ...render(), props }; + }; + + it('renders nothing while closed', () => { + const { queryByTestId, unmount } = renderModal({ isOpen: false }); + + expect(queryByTestId('login-otp-input')).toBeNull(); + + unmount(); + }); + + it('blocks submission while the code is empty', () => { + const { getByTestId, props, unmount } = renderModal(); + + fireEvent.press(getByTestId('login-otp-submit')); + + expect(props.onSubmit).not.toHaveBeenCalled(); + expect(isDisabled(getByTestId('login-otp-submit'))).toBe(true); + + unmount(); + }); + + it('blocks submission for whitespace only', () => { + const { getByTestId, props, unmount } = renderModal(); + + fireEvent.changeText(getByTestId('login-otp-input'), ' '); + fireEvent.press(getByTestId('login-otp-submit')); + + expect(props.onSubmit).not.toHaveBeenCalled(); + + unmount(); + }); + + it('submits the trimmed code and clears the field', () => { + const { getByTestId, props, unmount } = renderModal(); + + const input = getByTestId('login-otp-input'); + fireEvent.changeText(input, ' 123456 '); + fireEvent.press(getByTestId('login-otp-submit')); + + expect(props.onSubmit).toHaveBeenCalledWith('123456'); + // The code is a live second factor: it must not sit in state after being handed off. + expect(getByTestId('login-otp-input').props.value).toBe(''); + + unmount(); + }); + + it('submits from the keyboard return key', () => { + const { getByTestId, props, unmount } = renderModal(); + + fireEvent.changeText(getByTestId('login-otp-input'), '654321'); + fireEvent(getByTestId('login-otp-input'), 'submitEditing'); + + expect(props.onSubmit).toHaveBeenCalledWith('654321'); + + unmount(); + }); + + it('shows the rejected-code message only when invalidCode is set', () => { + const { queryByTestId, unmount } = renderModal(); + expect(queryByTestId('login-otp-error')).toBeNull(); + unmount(); + + const invalid = renderModal({ invalidCode: true }); + expect(invalid.queryByTestId('login-otp-error')).not.toBeNull(); + invalid.unmount(); + }); + + it('disables both actions while submitting', () => { + const { getByTestId, unmount } = renderModal({ isSubmitting: true }); + + expect(isDisabled(getByTestId('login-otp-cancel'))).toBe(true); + expect(isDisabled(getByTestId('login-otp-submit'))).toBe(true); + + unmount(); + }); + + it('reports cancellation to the caller', () => { + const { getByTestId, props, unmount } = renderModal(); + + fireEvent.press(getByTestId('login-otp-cancel')); + + expect(props.onClose).toHaveBeenCalledTimes(1); + + unmount(); + }); + + it('drops a half-typed code when the modal closes', () => { + const { getByTestId, rerender, unmount, props } = renderModal(); + + fireEvent.changeText(getByTestId('login-otp-input'), '1234'); + rerender(); + rerender(); + + expect(getByTestId('login-otp-input').props.value).toBe(''); + + unmount(); + }); +}); diff --git a/src/components/auth/login-otp-modal.tsx b/src/components/auth/login-otp-modal.tsx new file mode 100644 index 0000000..8181d07 --- /dev/null +++ b/src/components/auth/login-otp-modal.tsx @@ -0,0 +1,95 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { Input, InputField } from '@/components/ui/input'; +import { Modal, ModalBackdrop, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/ui/modal'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; + +interface LoginOtpModalProps { + isOpen: boolean; + isSubmitting: boolean; + /** True when a previously submitted code was rejected. */ + invalidCode: boolean; + onSubmit: (code: string) => void; + onClose: () => void; +} + +/** + * Login two-factor prompt: collects the current authenticator (TOTP) code when the token + * endpoint answers mfa_required / invalid_totp. Controlled component — the login screen owns + * submission and retry. The code lives only in local state and is cleared on close/submit; + * it is never logged or persisted. + */ +export const LoginOtpModal: React.FC = ({ isOpen, isSubmitting, invalidCode, onSubmit, onClose }) => { + const { t } = useTranslation(); + const [code, setCode] = useState(''); + + useEffect(() => { + if (!isOpen) { + setCode(''); + } + }, [isOpen]); + + const handleSubmit = useCallback(() => { + const submitted = code.trim(); + if (submitted.length === 0) { + return; + } + setCode(''); + onSubmit(submitted); + }, [code, onSubmit]); + + return ( + + + + + {t('login.otp_title', 'Two-factor verification')} + + + + {t('login.otp_body', 'Your account has two-factor authentication enabled. Enter the current code from your authenticator app to finish signing in.')} + + + + {invalidCode ? ( + // Announced on appearance: the rejection arrives while focus is still in the field, + // so a screen reader would otherwise never reach it. + + {t('login.otp_invalid', 'That code is invalid or has expired. Enter the current code from your authenticator app.')} + + ) : null} + + + + + + + + + ); +}; diff --git a/src/components/calls/call-notes-modal.tsx b/src/components/calls/call-notes-modal.tsx index 6f9ec92..cceef81 100644 --- a/src/components/calls/call-notes-modal.tsx +++ b/src/components/calls/call-notes-modal.tsx @@ -4,9 +4,11 @@ import { useTranslation } from 'react-i18next'; import { FlatList, Keyboard, Modal, SafeAreaView, StyleSheet, TouchableOpacity, View } from 'react-native'; import { KeyboardProvider, KeyboardStickyView } from 'react-native-keyboard-controller'; +import { ProtectedText } from '@/components/data-protection/protected-text'; import { SearchIcon, X } from '@/components/ui/lucide-icons'; import { useAnalytics } from '@/hooks/use-analytics'; import { useAuthStore } from '@/lib/auth'; +import { isRedactedValue, ProtectedFieldIds } from '@/lib/data-protection/redacted'; import { useCallDetailStore } from '@/stores/calls/detail-store'; import { Loading } from '../common/loading'; @@ -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..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'; @@ -24,12 +25,17 @@ interface MessageBubbleProps { onToggleReaction: (message: ChatMessageResultData, emoji: string, mine: boolean) => void; onOpenThread?: (message: ChatMessageResultData) => void; onRetry?: (message: ChatMessageResultData) => void; - onPressImage?: (uri: string) => void; + onPressImage?: (source: { uri: string; headers?: Record }) => void; } export function MessageBubble({ message, isOwn, showSender, currentUserId, onLongPress, onToggleReaction, onOpenThread, onRetry, onPressImage }: MessageBubbleProps) { const { t } = useTranslation(); + // getChatAttachmentImageSource() bakes the bearer into the source object, so the bubble has to + // re-render when the token rotates. Reading it from getState() alone leaves a mounted bubble + // holding the pre-refresh token, and the image request then 401s. + const accessToken = useAuthStore((state) => state.accessToken); + // Realtime payloads omit empty collections; the store normalizes them, but messages // persisted before that normalization existed can still come back without them. const groupedReactions = useMemo(() => { @@ -68,11 +74,13 @@ export function MessageBubble({ message, isOwn, showSender, currentUserId, onLon if (message.MessageType === ChatMessageType.Image) { const attachment = (message.Attachments ?? [])[0]; - const uri = message._localAttachmentUri ?? (attachment ? getChatAttachmentImageSource(attachment.ChatAttachmentId).uri : undefined); - const source = attachment ? getChatAttachmentImageSource(attachment.ChatAttachmentId) : uri ? { uri } : undefined; - if (!source) return {message.Body}; + const localUri = message._localAttachmentUri; + // Full source object (uri + Authorization header) travels with the press so the + // full-screen preview stays authenticated — extracting only .uri drops the bearer. + const source = localUri ? { uri: localUri } : attachment ? getChatAttachmentImageSource(attachment.ChatAttachmentId, accessToken) : undefined; + if (!source?.uri) return {message.Body}; return ( - uri && onPressImage?.(uri)}> + onPressImage?.(source as { uri: string; headers?: Record })}> {message.Body ? {message.Body} : null} diff --git a/src/components/contacts/contact-card.tsx b/src/components/contacts/contact-card.tsx index 76e911b..9ec871f 100644 --- a/src/components/contacts/contact-card.tsx +++ b/src/components/contacts/contact-card.tsx @@ -1,8 +1,10 @@ import React from 'react'; import { Pressable, Text, View } from 'react-native'; +import { ProtectedText } from '@/components/data-protection/protected-text'; import { Avatar, AvatarImage } from '@/components/ui/avatar'; import { BuildingIcon, MailIcon, PhoneIcon, StarIcon, UserIcon } from '@/components/ui/lucide-icons'; +import { isFieldRedacted, ProtectedFieldIds } from '@/lib/data-protection/redacted'; import { type ContactResultData, ContactType } from '@/models/v4/contacts/contactResultData'; interface ContactCardProps { @@ -39,28 +41,42 @@ export const ContactCard: React.FC = ({ contact, onPress }) => ) : ( - {contact.ContactType === ContactType.Person ? : } + {contact.ContactType === ContactType.Person ? : } )} - {displayName} + {/* + A contact's name is composed from first/last/company, so when those are withheld the + composed value reads "REDACTED REDACTED" — a name, apparently. The list now carries + per-row RedactedFields, so this asks the server's answer rather than sniffing the + value, and a member who types REDACTED into a name still sees it back. + */} + {isFieldRedacted(contact.RedactedFields, ProtectedFieldIds.contactFirstName, contact.FirstName) || + isFieldRedacted(contact.RedactedFields, ProtectedFieldIds.contactLastName, contact.LastName) || + isFieldRedacted(contact.RedactedFields, ProtectedFieldIds.contactCompanyName, contact.CompanyName) ? ( + + + + ) : ( + {displayName} + )} {contact.IsImportant ? : null} {contact.Email ? ( - - {contact.Email} + + ) : null} {contact.Phone ? ( - - {contact.Phone} + + ) : null} diff --git a/src/components/data-protection/protected-reveal-bar.tsx b/src/components/data-protection/protected-reveal-bar.tsx new file mode 100644 index 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..5c2ace9 --- /dev/null +++ b/src/components/data-protection/step-up-modal.tsx @@ -0,0 +1,118 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { Input, InputField } from '@/components/ui/input'; +import { Modal, ModalBackdrop, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/ui/modal'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { dataProtectionStore } from '@/stores/data-protection/store'; + +interface StepUpModalProps { + isOpen: boolean; + onClose: () => void; + /** Invoked after a successful verification, before the modal closes. */ + onVerified?: () => void; +} + +/** + * Advanced Data Protection step-up prompt: collects the user's current authenticator (TOTP) + * code and exchanges it for an absolute step-up window. Shown before revealing or editing a + * protected field. The code lives only in local component state and is cleared on every + * close/submit; it is never logged or persisted. + */ +export const StepUpModal: React.FC = ({ isOpen, onClose, onVerified }) => { + const { t } = useTranslation(); + const [code, setCode] = useState(''); + const isVerifying = dataProtectionStore((state) => state.isVerifying); + const lastError = dataProtectionStore((state) => state.lastError); + + useEffect(() => { + if (!isOpen) { + setCode(''); + } + }, [isOpen]); + + const handleVerify = useCallback(async () => { + const submitted = code.trim(); + if (submitted.length === 0) { + return; + } + + const ok = await dataProtectionStore.getState().verifyOtp(submitted); + setCode(''); + if (ok) { + onVerified?.(); + onClose(); + } + }, [code, onClose, onVerified]); + + const errorText = (() => { + switch (lastError) { + case 'invalid_totp': + return t('data_protection.step_up_invalid_code', 'That code is invalid or has expired. Enter the current code from your authenticator app.'); + case 'mfa_not_enrolled': + return t('data_protection.step_up_not_enrolled', 'Two-factor authentication is not set up for your account. Enroll an authenticator app in your account security settings first.'); + case 'too_many_attempts': + return t('data_protection.step_up_too_many_attempts', 'Too many attempts. Wait a few minutes and try again.'); + case 'grants_not_configured': + return t('data_protection.step_up_unavailable', 'Protected data is not available on this server yet. Contact your administrator.'); + case 'unknown': + return t('data_protection.step_up_failed', 'Verification failed. Check your connection and try again.'); + default: + return null; + } + })(); + + return ( + + + + + {t('data_protection.step_up_title', 'Verify your identity')} + + + + {t('data_protection.step_up_body', 'This information is protected. Enter the current code from your authenticator app to view it for a limited time.')} + + + + {errorText ? ( + // Announced on appearance: the error arrives while focus is still in the field, so + // a screen reader would otherwise never reach it. + + {errorText} + + ) : null} + + + + + + + + + ); +}; diff --git a/src/components/data-protection/step-up-prompt-host.tsx b/src/components/data-protection/step-up-prompt-host.tsx new file mode 100644 index 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..88724e6 --- /dev/null +++ b/src/hooks/use-protected-reveal.ts @@ -0,0 +1,51 @@ +import { useCallback } from 'react'; + +import { dataProtectionStore, useHasGrantToken, useStepUpExpiresAt } from '@/stores/data-protection/store'; + +/** + * The screen-facing half of an ADP reveal. + * + * A screen calls `reveal()`. If the department has exempted this app from the step-up prompt + * (ADP plan 3.3) the grant arrives without any interaction and `isRevealed` flips straight to + * true; otherwise the app's single OTP prompt opens and the reveal completes when the code is + * accepted. The screen never decides which of those happens — the server does, per department and + * per app, and this hook just reacts. + * + * The prompt itself is mounted once at the app shell, so a screen using this pulls in no modal. + */ +export const useProtectedReveal = (onRevealed?: () => void) => { + const stepUpExpiresAt = useStepUpExpiresAt(); + const isRequesting = dataProtectionStore((state) => state.isRequestingGrant); + + const hasGrantToken = useHasGrantToken(); + + // The token is part of the invariant, not just the expiry: without it the request goes out with + // no grant header and the value comes back redacted, so a "revealed" screen would show nothing + // new and reveal() would refuse to retry until the window lapsed. + const isRevealed = hasGrantToken && stepUpExpiresAt != null && Date.now() < stepUpExpiresAt; + + const reveal = useCallback(async () => { + const store = dataProtectionStore.getState(); + + if (store.isStepUpActive()) { + onRevealed?.(); + return; + } + + const outcome = await store.ensureGrant(); + if (outcome === 'granted') { + onRevealed?.(); + return; + } + + // 'unavailable' prompts too: the modal is where the caller is told grants are not configured, + // and silently doing nothing would look like a broken button. + dataProtectionStore.getState().openPrompt(); + }, [onRevealed]); + + const conceal = useCallback(() => { + dataProtectionStore.getState().clearStepUp(); + }, []); + + return { isRevealed, isRequesting, reveal, conceal }; +}; diff --git a/src/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; + /** + * True only while an SSO exchange is waiting on an authenticator code. `status === 'mfaRequired'` + * cannot stand in for this: the password login sets the same status, and the SSO screen must not + * open its OTP prompt for a challenge it has no pending exchange to retry. + */ + isSsoMfaPending: boolean; login: (credentials: LoginCredentials) => Promise; ssoLogin: (credentials: SsoLoginCredentials) => Promise; + /** Retries the pending SSO exchange with the user's authenticator code (2FA challenge). */ + retrySsoWithOtp: (otpCode: string) => Promise; logout: () => Promise; refreshAccessToken: () => Promise; 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..35a8035 --- /dev/null +++ b/src/lib/data-protection/__tests__/redacted.test.ts @@ -0,0 +1,46 @@ +import { isFieldRedacted, isRedactedValue, ProtectedFieldIds, REDACTION_VALUE } from '@/lib/data-protection/redacted'; + +/** + * Which signal wins matters. The server's RedactedFields list is authoritative; the sentinel value + * is only a fallback, because a member can legitimately type "REDACTED" into a note and masking + * their own words is a bug they cannot explain or work around. + */ +describe('isFieldRedacted', () => { + it('trusts the field list over the value', () => { + expect(isFieldRedacted([ProtectedFieldIds.callName], ProtectedFieldIds.callName, 'Structure Fire')).toBe(true); + expect(isFieldRedacted([ProtectedFieldIds.callNotes], ProtectedFieldIds.callName, REDACTION_VALUE)).toBe(false); + }); + + it('does not mask a member who typed the sentinel themselves', () => { + // A list is present and does not name this field, so the value is beside the point. + expect(isFieldRedacted([ProtectedFieldIds.callNotes], ProtectedFieldIds.callName, 'REDACTED')).toBe(false); + }); + + it('falls back to the value only when no list came with the payload', () => { + expect(isFieldRedacted(undefined, ProtectedFieldIds.callName, REDACTION_VALUE)).toBe(true); + expect(isFieldRedacted(null, ProtectedFieldIds.callName, 'Structure Fire')).toBe(false); + }); + + it('trusts an explicitly empty list over the sentinel', () => { + // [] is the server saying nothing was withheld. Sniffing the value anyway would re-mask a + // member who legitimately typed REDACTED, which is the false positive the list prevents. + expect(isFieldRedacted([], ProtectedFieldIds.callName, REDACTION_VALUE)).toBe(false); + }); + + it('matches field ids case-insensitively', () => { + // The catalog is lowercase but a serializer between here and there may not be. + expect(isFieldRedacted(['Calls.Name'], ProtectedFieldIds.callName, 'x')).toBe(true); + }); + + it('survives a malformed list without throwing', () => { + expect(isFieldRedacted([null as unknown as string], ProtectedFieldIds.callName, 'x')).toBe(false); + }); + + it('reads a plain value only on exact match', () => { + expect(isRedactedValue(REDACTION_VALUE)).toBe(true); + expect(isRedactedValue('redacted')).toBe(false); + expect(isRedactedValue('REDACTED ')).toBe(false); + expect(isRedactedValue(null)).toBe(false); + expect(isRedactedValue(undefined)).toBe(false); + }); +}); diff --git a/src/lib/data-protection/grant-provider.ts b/src/lib/data-protection/grant-provider.ts new file mode 100644 index 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..bf3e6a4 --- /dev/null +++ b/src/lib/data-protection/redacted.ts @@ -0,0 +1,75 @@ +/** + * Recognising a protected value the server declined to decrypt (ADP plan 7.2). + * + * There are two signals and they are not equal. The authoritative one is the RedactedFields list + * the server returns beside the record: it names exactly which catalog fields were withheld. The + * literal placeholder in the value is the fallback for payloads that carry no list. + * + * The list is preferred because value-sniffing has a false positive that matters — a member can + * legitimately type "REDACTED" into a call note, and masking their own words would be a bug the + * member cannot explain or work around. + */ + +/** The exact sentinel the server substitutes. Compare with strict equality; never localize it. */ +export const REDACTION_VALUE = 'REDACTED'; + +/** Catalog field ids, matching the server's protected-field catalog. */ +export const ProtectedFieldIds = { + callName: 'calls.name', + callNature: 'calls.natureofcall', + callNotes: 'calls.notes', + callAddress: 'calls.address', + callContactName: 'calls.contactname', + callContactNumber: 'calls.contactnumber', + callGeolocation: 'calls.geolocationdata', + callWhat3Words: 'calls.w3w', + + contactFirstName: 'contacts.firstname', + contactLastName: 'contacts.lastname', + contactCompanyName: 'contacts.companyname', + contactEmail: 'contacts.email', + contactHomePhone: 'contacts.homephonenumber', + contactCellPhone: 'contacts.cellphonenumber', + + callNote: 'callnotes.note', + + personnelIdentificationNumber: 'departmentmembersensitivedata.identificationnumber', + personnelNotes: 'departmentmembersensitivedata.notes', + personnelHomeAddress: 'departmentmembersensitivedata.homeaddress1', + personnelMailingAddress: 'departmentmembersensitivedata.mailingaddress1', + + emergencyContactName: 'departmentmemberemergencycontacts.name', + emergencyContactRelationship: 'departmentmemberemergencycontacts.relationship', + emergencyContactPhone: 'departmentmemberemergencycontacts.phonenumber', + emergencyContactEmail: 'departmentmemberemergencycontacts.email', + + unitLogNarrative: 'unitlogs.narrative', + userStateNote: 'userstates.note', + + calendarTitle: 'calendaritems.title', + calendarDescription: 'calendaritems.description', + calendarLocation: 'calendaritems.location', +} as const; + +/** + * True when this field was withheld. + * + * `fieldId` is checked against the server's list first. When no list is present — an older payload, + * or an endpoint that does not carry one — the sentinel value is the fallback. + */ +export const isFieldRedacted = (redactedFields: string[] | null | undefined, fieldId: string, value?: string | null): boolean => { + // An empty list is a list: the server said "nothing was withheld from this record", and that is + // a stronger statement than the sentinel can make. Treating [] as absent would re-mask a member + // who legitimately typed REDACTED, which is the false positive the list exists to prevent. + if (redactedFields != null) { + return redactedFields.some((field) => field?.toLowerCase() === fieldId.toLowerCase()); + } + + return value === REDACTION_VALUE; +}; + +/** + * True when a value is the bare sentinel, for surfaces with no field id to hand (a list cell built + * from a summary DTO). Weaker than isFieldRedacted and should not be preferred to it. + */ +export const isRedactedValue = (value?: string | null): boolean => value === REDACTION_VALUE; diff --git a/src/models/v4/calls/callResultData.ts b/src/models/v4/calls/callResultData.ts index e9d6f80..9fb0efc 100644 --- a/src/models/v4/calls/callResultData.ts +++ b/src/models/v4/calls/callResultData.ts @@ -34,4 +34,13 @@ export class CallResultData { public Latitude: string = ''; public Longitude: string = ''; public CheckInTimersEnabled: boolean = false; + /** + * Catalog field ids the server withheld from this response (ADP plan 7.2). Empty for a + * department without the addon, and empty again once a grant reveals the record. + * + * Left undefined rather than defaulted to `[]`: a server that never sends the list must stay + * distinguishable from one that sends an empty one, because isFieldRedacted() only falls back to + * sentinel-sniffing for the former. + */ + public RedactedFields?: string[]; } diff --git a/src/models/v4/contacts/contactResultData.ts b/src/models/v4/contacts/contactResultData.ts index 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..98aa060 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) => ({ @@ -23,6 +27,7 @@ const useAuthStore = create()( userId: null, isFirstTime: true, refreshTimeoutId: null, + isSsoMfaPending: false, login: async (credentials: LoginCredentials) => { try { set({ status: 'loading' }); @@ -76,6 +81,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', @@ -127,24 +139,58 @@ const useAuthStore = create()( clearTimeout(existingTimeoutId); } const timeoutId = setTimeout(() => get().refreshAccessToken(), refreshDelayMs); - set({ refreshTimeoutId: timeoutId }); + set({ refreshTimeoutId: timeoutId, isSsoMfaPending: false }); + pendingSsoMfaCredentials = null; + } else if (response.mfaRequired) { + // 2FA challenge: retain the exchange in module memory (never the persisted store) + // so retrySsoWithOtp can replay it with the authenticator code. + // + // The code itself is deliberately dropped. On an invalid_totp retry `credentials` + // still carries the rejected otpCode, and keeping it would hold a known-bad secret in + // module state until the next successful SSO login. retrySsoWithOtp supplies the new + // code on every attempt, so nothing needs it here. + const { otpCode: _rejectedOtpCode, ...ssoExchange } = credentials; + pendingSsoMfaCredentials = ssoExchange; + 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, + 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, }); } }, + 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; 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, @@ -153,6 +199,7 @@ const useAuthStore = create()( profile: null, isFirstTime: true, refreshTimeoutId: null, + isSsoMfaPending: false, }); Sentry.setUser(null); }, @@ -304,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__/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..b380830 --- /dev/null +++ b/src/stores/data-protection/__tests__/store.test.ts @@ -0,0 +1,153 @@ +// Mock the API +jest.mock('@/api/data-protection/data-protection', () => ({ + getDataProtectionCapabilities: jest.fn(), + verifyStepUp: jest.fn(), +})); + +// Mock logging +jest.mock('@/lib/logging', () => ({ + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, +})); + +// Capture the auth-store subscription the store registers at module load. +// eslint-disable-next-line no-var +var mockAuthListener: ((state: { status: string }, prevState: { status: string }) => void) | undefined; +jest.mock('../../auth/store', () => ({ + __esModule: true, + default: { + subscribe: (listener: (state: { status: string }, prevState: { status: string }) => void) => { + mockAuthListener = listener; + return () => {}; + }, + getState: jest.fn(), + }, +})); + +import { dataProtectionStore } from '../store'; + +const { getDataProtectionCapabilities, verifyStepUp } = require('@/api/data-protection/data-protection'); + +describe('dataProtectionStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + dataProtectionStore.setState({ + capabilities: null, + isCapabilitiesLoaded: false, + stepUpExpiresAt: null, + isVerifying: false, + lastError: null, + }); + }); + + describe('fetchCapabilities', () => { + it('stores the department capability report', async () => { + getDataProtectionCapabilities.mockResolvedValue({ + Data: { IsProtectionEnabled: true, StepUpWindowMinutes: 30, IsDepartmentLocked: false }, + }); + + await dataProtectionStore.getState().fetchCapabilities(); + + const state = dataProtectionStore.getState(); + expect(state.isCapabilitiesLoaded).toBe(true); + expect(state.capabilities?.isProtectionEnabled).toBe(true); + expect(state.capabilities?.stepUpWindowMinutes).toBe(30); + }); + + it('marks loaded without capabilities on failure', async () => { + getDataProtectionCapabilities.mockRejectedValue(new Error('network')); + + await dataProtectionStore.getState().fetchCapabilities(); + + const state = dataProtectionStore.getState(); + expect(state.isCapabilitiesLoaded).toBe(true); + expect(state.capabilities).toBeNull(); + }); + }); + + describe('verifyOtp', () => { + it('activates the absolute window on success', async () => { + const expires = new Date(Date.now() + 15 * 60 * 1000).toISOString(); + verifyStepUp.mockResolvedValue({ GrantToken: 'grant-token', StepUpExpiresOnUtc: expires, StepUpWindowMinutes: 15 }); + + const ok = await dataProtectionStore.getState().verifyOtp('123456'); + + expect(ok).toBe(true); + expect(verifyStepUp).toHaveBeenCalledWith('123456'); + expect(dataProtectionStore.getState().isStepUpActive()).toBe(true); + expect(dataProtectionStore.getState().lastError).toBeNull(); + }); + + it('maps the server problem type on failure and stays locked', async () => { + verifyStepUp.mockRejectedValue({ response: { data: { type: 'invalid_totp' } } }); + + const ok = await dataProtectionStore.getState().verifyOtp('000000'); + + expect(ok).toBe(false); + expect(dataProtectionStore.getState().lastError).toBe('invalid_totp'); + expect(dataProtectionStore.getState().isStepUpActive()).toBe(false); + }); + + it('rejects an already-expired window from the server', async () => { + verifyStepUp.mockResolvedValue({ GrantToken: 'grant-token', StepUpExpiresOnUtc: new Date(Date.now() - 1000).toISOString() }); + + const ok = await dataProtectionStore.getState().verifyOtp('123456'); + + expect(ok).toBe(false); + expect(dataProtectionStore.getState().isStepUpActive()).toBe(false); + }); + + it('rejects a verification response that carries no grant token', async () => { + // Accepting one would report the value as revealed while every request goes out without the + // grant header, so the data stays redacted with no error to explain it. + verifyStepUp.mockResolvedValue({ StepUpExpiresOnUtc: new Date(Date.now() + 15 * 60 * 1000).toISOString() }); + + const ok = await dataProtectionStore.getState().verifyOtp('123456'); + + expect(ok).toBe(false); + expect(dataProtectionStore.getState().lastError).toBe('unknown'); + expect(dataProtectionStore.getState().isStepUpActive()).toBe(false); + expect(dataProtectionStore.getState().getGrantHeaders()).toEqual({}); + }); + }); + + describe('window lifecycle', () => { + it('expires by wall clock — the window is absolute, never sliding', () => { + dataProtectionStore.setState({ grantToken: 'grant-token', stepUpExpiresAt: Date.now() - 1 }); + expect(dataProtectionStore.getState().isStepUpActive()).toBe(false); + + dataProtectionStore.setState({ grantToken: 'grant-token', stepUpExpiresAt: Date.now() + 60_000 }); + expect(dataProtectionStore.getState().isStepUpActive()).toBe(true); + + dataProtectionStore.setState({ grantToken: null, stepUpExpiresAt: Date.now() + 60_000 }); + expect(dataProtectionStore.getState().isStepUpActive()).toBe(false); + expect(dataProtectionStore.getState().getGrantHeaders()).toEqual({}); + }); + + it('clearStepUp drops the window immediately', () => { + dataProtectionStore.setState({ grantToken: 'grant-token', stepUpExpiresAt: Date.now() + 60_000 }); + dataProtectionStore.getState().clearStepUp(); + expect(dataProtectionStore.getState().isStepUpActive()).toBe(false); + }); + + it('signing out drops everything — the grant is memory-only', () => { + dataProtectionStore.setState({ + stepUpExpiresAt: Date.now() + 60_000, + capabilities: { isProtectionEnabled: true, stepUpWindowMinutes: 15, isDepartmentLocked: false, lockReason: null }, + isCapabilitiesLoaded: true, + }); + + expect(mockAuthListener).toBeDefined(); + mockAuthListener?.({ status: 'signedOut' }, { status: 'signedIn' }); + + const state = dataProtectionStore.getState(); + expect(state.stepUpExpiresAt).toBeNull(); + expect(state.capabilities).toBeNull(); + expect(state.isCapabilitiesLoaded).toBe(false); + }); + }); +}); diff --git a/src/stores/data-protection/store.ts b/src/stores/data-protection/store.ts new file mode 100644 index 0000000..71bd18e --- /dev/null +++ b/src/stores/data-protection/store.ts @@ -0,0 +1,248 @@ +import { create } from 'zustand'; + +import { getDataProtectionCapabilities, requestProtectedGrant, verifyStepUp } from '@/api/data-protection/data-protection'; +import { setProtectedGrantProvider } from '@/lib/data-protection/grant-provider'; +import { logger } from '@/lib/logging'; + +import useAuthStore from '../auth/store'; + +// --------------------------------------------------------------------------- +// Advanced Data Protection (ADP) grant state. +// +// DELIBERATELY NOT PERSISTED: the grant is a security credential and lives in +// memory only (ADP plan 7.2). App restart, logout or department switch always +// starts locked. The window is ABSOLUTE — activity never extends it. +// --------------------------------------------------------------------------- + +export type StepUpErrorCode = 'invalid_totp' | 'mfa_not_enrolled' | 'too_many_attempts' | 'grants_not_configured' | 'unknown'; + +/** What ensureGrant() concluded. The caller shows the OTP prompt only for 'step_up_required'. */ +export type GrantOutcome = 'granted' | 'step_up_required' | 'unavailable'; + +interface DataProtectionCapabilities { + isProtectionEnabled: boolean; + stepUpWindowMinutes: number; + isDepartmentLocked: boolean; + lockReason: string | null; +} + +export interface DataProtectionState { + capabilities: DataProtectionCapabilities | null; + isCapabilitiesLoaded: boolean; + /** Epoch ms the current window expires, or null when no grant is held. */ + stepUpExpiresAt: number | null; + /** The signed grant. Memory only; never written to storage or logs. */ + grantToken: string | null; + isVerifying: boolean; + isRequestingGrant: boolean; + /** + * Whether the OTP prompt is showing. Held here rather than in a screen because the prompt is + * mounted ONCE at the app shell: a modal per screen means several can stack, and it drags the + * whole modal import graph into every screen that shows a protected value. + */ + isPromptOpen: boolean; + openPrompt: () => void; + closePrompt: () => void; + lastError: StepUpErrorCode | null; + fetchCapabilities: () => Promise; + /** + * Tries to obtain a grant without prompting. Returns 'granted' when the department has exempted + * this app, 'step_up_required' when the caller must enter a code, and 'unavailable' when grants + * are not configured at all. + * + * Anything unexpected resolves to 'step_up_required'. Erring towards asking for a second factor + * is the direction that cannot cause harm. + */ + ensureGrant: () => Promise; + /** Sends the TOTP code; true on success. */ + verifyOtp: (code: string) => Promise; + /** True while an unexpired grant token is held. Evaluate at the moment of use. */ + isStepUpActive: () => boolean; + /** + * Headers for a request that needs to read protected values, or {} when no grant is held. + * Spread into the request config: `{ headers: { ...getGrantHeaders() } }`. + */ + getGrantHeaders: () => Record; + /** Drops the grant immediately (logout, department switch, manual conceal). */ + clearStepUp: () => void; +} + +const GRANT_HEADER = 'X-Resgrid-Protected-Grant'; + +const parseErrorCode = (error: unknown): StepUpErrorCode => { + const type = (error as { response?: { data?: { type?: string } } })?.response?.data?.type; + if (type === 'invalid_totp' || type === 'mfa_not_enrolled' || type === 'too_many_attempts' || type === 'grants_not_configured') { + return type; + } + return 'unknown'; +}; + +const problemType = (error: unknown): string | undefined => (error as { response?: { data?: { type?: string } } })?.response?.data?.type; + +export const dataProtectionStore = create()((set, get) => ({ + capabilities: null, + isCapabilitiesLoaded: false, + stepUpExpiresAt: null, + grantToken: null, + isVerifying: false, + isRequestingGrant: false, + isPromptOpen: false, + lastError: null, + openPrompt: () => set({ isPromptOpen: true, lastError: null }), + closePrompt: () => set({ isPromptOpen: false }), + fetchCapabilities: async () => { + try { + const response = await getDataProtectionCapabilities(); + const data = response?.Data; + set({ + capabilities: data + ? { + isProtectionEnabled: !!data.IsProtectionEnabled, + stepUpWindowMinutes: data.StepUpWindowMinutes ?? 15, + isDepartmentLocked: !!data.IsDepartmentLocked, + lockReason: data.LockReason ?? null, + } + : null, + isCapabilitiesLoaded: true, + }); + } catch (error) { + // Unknown capability state fails closed: consumers treat "no capabilities" as protected + // when the server later marks fields redacted, and as unprotected for legacy departments. + logger.error({ + message: 'Failed to fetch data protection capabilities', + context: { error }, + }); + set({ isCapabilitiesLoaded: true }); + } + }, + ensureGrant: async () => { + if (get().isStepUpActive()) { + return 'granted'; + } + + set({ isRequestingGrant: true, lastError: null }); + try { + const result = await requestProtectedGrant(); + const expiresAt = result?.StepUpExpiresOnUtc ? Date.parse(result.StepUpExpiresOnUtc) : NaN; + + if (!result?.GrantToken || !Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + set({ isRequestingGrant: false }); + return 'step_up_required'; + } + + set({ grantToken: result.GrantToken, stepUpExpiresAt: expiresAt, isRequestingGrant: false, lastError: null }); + return 'granted'; + } catch (error) { + set({ isRequestingGrant: false }); + + const type = problemType(error); + if (type === 'grants_not_configured') { + return 'unavailable'; + } + + // Everything else — including a network failure — means prompt. The server refuses with + // step_up_required whenever this app is not exempt, which is the normal case. + return 'step_up_required'; + } + }, + verifyOtp: async (code: string) => { + set({ isVerifying: true, lastError: null }); + try { + const result = await verifyStepUp(code.trim()); + const expiresAt = result?.StepUpExpiresOnUtc ? Date.parse(result.StepUpExpiresOnUtc) : NaN; + // A token-less response is a failure, not a grant. Accepting one would flip the UI to + // "revealed" while getGrantHeaders() still sends nothing, so every value stays REDACTED + // with no error to explain it — the same invariant ensureGrant() already enforces. + if (!result?.GrantToken || !Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + set({ isVerifying: false, lastError: 'unknown' }); + return false; + } + set({ + grantToken: result.GrantToken, + stepUpExpiresAt: expiresAt, + isVerifying: false, + lastError: null, + }); + return true; + } catch (error) { + // Never log the code; the error object carries only the HTTP problem envelope. + logger.warn({ + message: 'ADP step-up verification failed', + context: { errorType: parseErrorCode(error) }, + }); + set({ isVerifying: false, lastError: parseErrorCode(error) }); + return false; + } + }, + isStepUpActive: () => { + const { grantToken, stepUpExpiresAt } = get(); + // Both halves are required. A future expiry with no token buys nothing: getGrantHeaders() + // would send no header, so the record comes back redacted while the UI claims otherwise. + return !!grantToken && stepUpExpiresAt != null && Date.now() < stepUpExpiresAt; + }, + getGrantHeaders: () => { + const state = get(); + // Expiry is checked here rather than trusted from state: a grant that lapsed while a screen + // sat open must not be attached to the next request. + const headers: Record = {}; + if (!state.grantToken || !state.isStepUpActive()) { + return headers; + } + + headers[GRANT_HEADER] = state.grantToken; + return headers; + }, + clearStepUp: () => set({ stepUpExpiresAt: null, grantToken: null, lastError: null }), +})); + +// The grant is memory-only and must never survive the session: drop everything the moment the +// auth status leaves 'signedIn' (logout, token revocation, forced deauth). +// +// Guarded because this module is now in the import graph of any screen showing a protected value, +// and a store that throws at import time takes the whole screen down with it. Losing the +// subscription costs the in-session logout sweep only — the grant is memory-only either way, so it +// never survives a reload — but it is logged rather than swallowed, so it cannot go unnoticed. +if (typeof useAuthStore?.subscribe === 'function') { + useAuthStore.subscribe((state: { status: string }, prevState: { status: string }) => { + if (prevState.status === 'signedIn' && state.status !== 'signedIn') { + dataProtectionStore.setState({ + capabilities: null, + isCapabilitiesLoaded: false, + stepUpExpiresAt: null, + grantToken: null, + isVerifying: false, + isRequestingGrant: false, + isPromptOpen: false, + lastError: null, + }); + } + }); +} else { + logger.warn({ message: 'ADP grant store could not subscribe to auth changes; sign-out will not sweep the grant early.' }); +} + +// Every read through the shared API client carries the grant while one is held — see +// setProtectedGrantProvider. Registered here rather than imported there, because the client is +// what this store's own API layer is built on. +setProtectedGrantProvider(() => dataProtectionStore.getState().getGrantHeaders()); + +/** Reactive: true while protection is enabled for the department (unknown reads as false). */ +export const useIsProtectionEnabled = () => dataProtectionStore((state) => !!state.capabilities?.isProtectionEnabled); + +/** + * Reactive step-up flag. Re-renders on verify/clear; expiry itself is time-based, so callers + * gating a reveal must ALSO call isStepUpActive() at the moment of use. + */ +export const useStepUpExpiresAt = () => dataProtectionStore((state) => state.stepUpExpiresAt); + +/** + * Reactive: whether a grant token is held at all. Paired with useStepUpExpiresAt by callers that + * render a reveal state, because an expiry alone does not make a grant usable. + */ +export const useHasGrantToken = () => dataProtectionStore((state) => !!state.grantToken); + +/** Headers helper for one-off calls outside a component. */ +export const getProtectedGrantHeaders = () => dataProtectionStore.getState().getGrantHeaders(); + +/** Reactive: whether the single app-level OTP prompt should be showing. */ +export const useIsStepUpPromptOpen = () => dataProtectionStore((state) => state.isPromptOpen); diff --git a/src/translations/ar.json b/src/translations/ar.json index 848430e..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": "هذا الحقل مطلوب" diff --git a/src/translations/de.json b/src/translations/de.json index 1f709f1..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" diff --git a/src/translations/el.json b/src/translations/el.json index 0eaf1ec..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": "Αυτό το πεδίο είναι υποχρεωτικό" diff --git a/src/translations/en.json b/src/translations/en.json index f28f7ef..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" diff --git a/src/translations/es.json b/src/translations/es.json index dbc9034..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" diff --git a/src/translations/fr.json b/src/translations/fr.json index fa3cc6d..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" diff --git a/src/translations/it.json b/src/translations/it.json index c730f24..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" diff --git a/src/translations/pl.json b/src/translations/pl.json index 65922f8..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" diff --git a/src/translations/sv.json b/src/translations/sv.json index 29ffb03..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" diff --git a/src/translations/uk.json b/src/translations/uk.json index 12e58da..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": "Це поле обов'язкове"