-
Notifications
You must be signed in to change notification settings - Fork 6
RG-T89 ADP Support #285
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
RG-T89 ADP Support #285
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<DataProtectionCapabilitiesResult>(`${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<StepUpResult>(`${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<StepUpResult>(`${DATA_PROTECTION}/VerifyStepUp`, { Code: code }); | ||
| return response.data; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,8 @@ import { CheckInTabContent } from '@/components/check-in-timers/check-in-tab-con | |
| import { HeaderBackButton } from '@/components/common/header-back-button'; | ||
| import { Loading } from '@/components/common/loading'; | ||
| import ZeroState from '@/components/common/zero-state'; | ||
| import { ProtectedRevealBar } from '@/components/data-protection/protected-reveal-bar'; | ||
| import { ProtectedText } from '@/components/data-protection/protected-text'; | ||
| import { IncidentCommandTabPanel } from '@/components/incident-command/incident-command-tab-panel'; | ||
| import { FullScreenMap } from '@/components/maps/full-screen-map'; | ||
| // Import a static map component instead of react-native-maps | ||
|
|
@@ -24,6 +26,7 @@ import { Text } from '@/components/ui/text'; | |
| import { VStack } from '@/components/ui/vstack'; | ||
| import { useAnalytics } from '@/hooks/use-analytics'; | ||
| import { getUnitTypeCheckInBadge } from '@/lib/check-in-timer-utils'; | ||
| import { isFieldRedacted, ProtectedFieldIds } from '@/lib/data-protection/redacted'; | ||
| import { logger } from '@/lib/logging'; | ||
| import { openMapsWithDirections } from '@/lib/navigation'; | ||
| import { parseApiUtcDate, safeFormatDate } from '@/lib/utils'; | ||
|
|
@@ -373,7 +376,7 @@ export default function CallDetail() { | |
| </Box> | ||
| <Box className="border-b border-outline-100 pb-2"> | ||
| <Text className="text-sm text-gray-500">{t('call_detail.address')}</Text> | ||
| <Text className="font-medium">{call.Address}</Text> | ||
| <ProtectedText value={call.Address} fieldId={ProtectedFieldIds.callAddress} redactedFields={call.RedactedFields} className="font-medium" /> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n -C 3 'mapAddress|mapTitle|<StaticMap' 'src/app/call/[id].tsx'
fd -t f -i 'static*map*' src | while IFS= read -r file; do
echo "== $file =="
ast-grep outline "$file" --items all
rg -n -C 5 'address|title|url|source|fetch|request' "$file"
doneRepository: Resgrid/Unit Length of output: 1709 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '== call detail data flow =='
sed -n '520,645p' 'src/app/call/[id].tsx'
printf '%s\n' '== map component definitions =='
fd -t f -i 'static' src
rg -n -g '*.{ts,tsx}' 'function StaticMap|const StaticMap|interface .*StaticMap|<StaticMap|address:|title:' src/components src/app src/lib src/services 2>/dev/null | head -200Repository: Resgrid/Unit Length of output: 25374 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '== static-map.tsx =='
cat -n src/components/maps/static-map.tsx
printf '%s\n' '== full-screen-map definition =='
fd -t f -i 'full-screen-map' src | while IFS= read -r file; do
echo "== $file =="
cat -n "$file"
done
printf '%s\n' '== redaction helpers and field identifiers =='
rg -n -C 5 'isFieldRedacted|ProtectedFieldIds|RedactedFields' 'src/app/call/[id].tsx' src | head -240Repository: Resgrid/Unit Length of output: 33410 Gate map metadata on redaction state. When the call location is shown, 🤖 Prompt for AI Agents |
||
| </Box> | ||
| {destinationLabel ? ( | ||
| <Box className="border-b border-outline-100 pb-2"> | ||
|
|
@@ -385,7 +388,16 @@ export default function CallDetail() { | |
| <Box className="border-b border-outline-100 pb-2"> | ||
| <Text className="text-sm text-gray-500">{t('call_detail.note')}</Text> | ||
| <Box> | ||
| <HtmlRenderer html={call.Note ?? ''} style={StyleSheet.flatten([styles.container, { height: 200 }])} /> | ||
| {/* | ||
| 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) ? ( | ||
| <ProtectedText value={call.Note} fieldId={ProtectedFieldIds.callNotes} redactedFields={call.RedactedFields} /> | ||
| ) : ( | ||
| <HtmlRenderer html={call.Note ?? ''} style={StyleSheet.flatten([styles.container, { height: 200 }])} /> | ||
| )} | ||
| </Box> | ||
| </Box> | ||
| </VStack> | ||
|
|
@@ -409,11 +421,11 @@ export default function CallDetail() { | |
| </Box> | ||
| <Box className="border-b border-outline-100 pb-2"> | ||
| <Text className="text-sm text-gray-500">{t('call_detail.contact_name')}</Text> | ||
| <Text className="font-medium">{call.ContactName}</Text> | ||
| <ProtectedText value={call.ContactName} fieldId={ProtectedFieldIds.callContactName} redactedFields={call.RedactedFields} className="font-medium" /> | ||
| </Box> | ||
| <Box className="border-b border-outline-100 pb-2"> | ||
| <Text className="text-sm text-gray-500">{t('call_detail.contact_info')}</Text> | ||
| <Text className="font-medium">{call.ContactInfo}</Text> | ||
| <ProtectedText value={call.ContactInfo} fieldId={ProtectedFieldIds.callContactNumber} redactedFields={call.RedactedFields} className="font-medium" /> | ||
| </Box> | ||
| </VStack> | ||
| </Box> | ||
|
|
@@ -545,8 +557,14 @@ export default function CallDetail() { | |
| const showingDestination = hasDestinationCoordinates && (mapTarget === 'destination' || !hasCallCoordinates); | ||
| const mapLatitude = showingDestination ? destinationLatitude : coordinates.latitude; | ||
| const mapLongitude = showingDestination ? destinationLongitude : coordinates.longitude; | ||
| const mapAddress = showingDestination ? call.DestinationAddress || call.DestinationName || '' : call.Address; | ||
| const mapTitle = showingDestination ? call.DestinationName || t('call_detail.destination') : call.Name || t('call_detail.call_location'); | ||
| // A withheld address or name must not leak through the map chrome. StaticMap prints `address` | ||
| // in its overlay AND its accessibility label, and FullScreenMap prints both the address overlay | ||
| // and the marker title, so the sentinel would surface there verbatim after being suppressed | ||
| // everywhere else on the screen. Destination fields are not in the protected catalog. | ||
| const isAddressRedacted = isFieldRedacted(call.RedactedFields, ProtectedFieldIds.callAddress, call.Address); | ||
| const isNameRedacted = isFieldRedacted(call.RedactedFields, ProtectedFieldIds.callName, call.Name); | ||
| const mapAddress = showingDestination ? call.DestinationAddress || call.DestinationName || '' : isAddressRedacted ? undefined : call.Address; | ||
| const mapTitle = showingDestination ? call.DestinationName || t('call_detail.destination') : (isNameRedacted ? undefined : call.Name) || t('call_detail.call_location'); | ||
|
|
||
| return ( | ||
| <> | ||
|
|
@@ -560,11 +578,25 @@ export default function CallDetail() { | |
| }} | ||
| /> | ||
| <ScrollView className="size-full w-full flex-1 bg-gray-50 dark:bg-gray-900" contentContainerStyle={{ paddingBottom: 16 }}> | ||
| {/* | ||
| 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. | ||
| */} | ||
| <ProtectedRevealBar onRefresh={() => fetchCallDetail(callId)} /> | ||
|
|
||
| {/* Header */} | ||
| <Box className="mx-4 mt-3 rounded-xl bg-white p-4 shadow-xs dark:bg-gray-800"> | ||
| <HStack className="mb-2 items-center justify-between"> | ||
| <Heading size="md"> | ||
| {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) ? ( | ||
| <ProtectedText value={call.Name} fieldId={ProtectedFieldIds.callName} redactedFields={call.RedactedFields} /> | ||
| ) : ( | ||
| <> | ||
| {call.Name} ({call.Number}) | ||
| </> | ||
| )} | ||
| </Heading> | ||
| {/* Show "Set Active" button if this call is not the active call and there is an active unit */} | ||
| {activeUnit && activeCall?.CallId !== call.CallId ? ( | ||
|
|
@@ -576,7 +608,11 @@ export default function CallDetail() { | |
| </HStack> | ||
| <VStack className="space-y-1"> | ||
| <ScrollView style={{ height: 180 }} nestedScrollEnabled={true} showsVerticalScrollIndicator={true}> | ||
| <HtmlRenderer html={call.Nature ?? ''} style={StyleSheet.flatten([styles.container, { minHeight: 170 }])} /> | ||
| {isFieldRedacted(call.RedactedFields, ProtectedFieldIds.callNature, call.Nature) ? ( | ||
| <ProtectedText value={call.Nature} fieldId={ProtectedFieldIds.callNature} redactedFields={call.RedactedFields} /> | ||
| ) : ( | ||
| <HtmlRenderer html={call.Nature ?? ''} style={StyleSheet.flatten([styles.container, { minHeight: 170 }])} /> | ||
| )} | ||
| </ScrollView> | ||
| </VStack> | ||
| </Box> | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.