diff --git a/app/jest.setup.js b/app/jest.setup.js index 1d0275e04b..1dc2862238 100644 --- a/app/jest.setup.js +++ b/app/jest.setup.js @@ -100,6 +100,7 @@ jest.mock('react-native', () => { get NativeModules() { return global.NativeModules || {}; }, + useColorScheme: jest.fn(() => 'light'), NativeEventEmitter: jest.fn().mockImplementation(nativeModule => { return { addListener: jest.fn(), @@ -110,10 +111,15 @@ jest.mock('react-native', () => { }), PixelRatio: mockPixelRatio, Dimensions: { - get: jest.fn(() => ({ - window: { width: 375, height: 667, scale: 2 }, - screen: { width: 375, height: 667, scale: 2 }, - })), + get: jest.fn(dimension => { + const dimensions = { + window: { width: 375, height: 667, scale: 2, fontScale: 1 }, + screen: { width: 375, height: 667, scale: 2, fontScale: 1 }, + }; + return dimension ? dimensions[dimension] : dimensions; + }), + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + removeEventListener: jest.fn(), }, Linking: { getInitialURL: jest.fn().mockResolvedValue(null), @@ -139,6 +145,7 @@ jest.mock('react-native', () => { ScrollView: 'ScrollView', TouchableOpacity: 'TouchableOpacity', TouchableHighlight: 'TouchableHighlight', + Pressable: 'Pressable', Image: 'Image', ActivityIndicator: 'ActivityIndicator', SafeAreaView: 'SafeAreaView', @@ -273,10 +280,15 @@ jest.mock( Version: 14, }, Dimensions: { - get: jest.fn(() => ({ - window: { width: 375, height: 667, scale: 2 }, - screen: { width: 375, height: 667, scale: 2 }, - })), + get: jest.fn(dimension => { + const dimensions = { + window: { width: 375, height: 667, scale: 2, fontScale: 1 }, + screen: { width: 375, height: 667, scale: 2, fontScale: 1 }, + }; + return dimension ? dimensions[dimension] : dimensions; + }), + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + removeEventListener: jest.fn(), }, StyleSheet: { create: jest.fn(styles => styles), @@ -359,15 +371,18 @@ jest.mock( '../packages/mobile-sdk-alpha/node_modules/react-native/Libraries/Utilities/Dimensions', () => ({ getConstants: jest.fn(() => ({ - window: { width: 375, height: 667, scale: 2 }, - screen: { width: 375, height: 667, scale: 2 }, + window: { width: 375, height: 667, scale: 2, fontScale: 1 }, + screen: { width: 375, height: 667, scale: 2, fontScale: 1 }, })), set: jest.fn(), - get: jest.fn(() => ({ - window: { width: 375, height: 667, scale: 2 }, - screen: { width: 375, height: 667, scale: 2 }, - })), - addEventListener: jest.fn(), + get: jest.fn(dimension => { + const dimensions = { + window: { width: 375, height: 667, scale: 2, fontScale: 1 }, + screen: { width: 375, height: 667, scale: 2, fontScale: 1 }, + }; + return dimension ? dimensions[dimension] : dimensions; + }), + addEventListener: jest.fn(() => ({ remove: jest.fn() })), removeEventListener: jest.fn(), }), { virtual: true }, @@ -550,8 +565,14 @@ jest.mock( { virtual: true }, ); +// Mock the hooks subpath from mobile-sdk-alpha +jest.mock('@selfxyz/mobile-sdk-alpha/hooks', () => ({ + useSafeBottomPadding: jest.fn((basePadding = 20) => basePadding + 50), +})); + // Mock problematic mobile-sdk-alpha components that use React Native StyleSheet jest.mock('@selfxyz/mobile-sdk-alpha', () => ({ + // Override only the specific mocks we need NFCScannerScreen: jest.fn(() => null), SelfClientProvider: jest.fn(({ children }) => children), useSelfClient: jest.fn(() => { diff --git a/app/src/components/Disclosures.tsx b/app/src/components/Disclosures.tsx index 8770f020e2..b95243a75b 100644 --- a/app/src/components/Disclosures.tsx +++ b/app/src/components/Disclosures.tsx @@ -5,45 +5,24 @@ import React from 'react'; import { XStack, YStack } from 'tamagui'; -import type { Country3LetterCode } from '@selfxyz/common/constants'; -import { countryCodes } from '@selfxyz/common/constants'; import type { SelfAppDisclosureConfig } from '@selfxyz/common/utils'; import { BodyText } from '@selfxyz/mobile-sdk-alpha/components'; import { slate200, slate500 } from '@selfxyz/mobile-sdk-alpha/constants/colors'; import CheckMark from '@/assets/icons/checkmark.svg'; +import { + getDisclosureText, + ORDERED_DISCLOSURE_KEYS, +} from '@/utils/disclosureUtils'; interface DisclosureProps { disclosures: SelfAppDisclosureConfig; } -function listToString(list: string[]): string { - if (list.length === 1) { - return list[0]; - } else if (list.length === 2) { - return list.join(' nor '); - } - return `${list.slice(0, -1).join(', ')} nor ${list.at(-1)}`; -} - export default function Disclosures({ disclosures }: DisclosureProps) { - // Define the order in which disclosures should appear. - const ORDERED_KEYS: Array = [ - 'issuing_state', - 'name', - 'passport_number', - 'nationality', - 'date_of_birth', - 'gender', - 'expiry_date', - 'ofac', - 'excludedCountries', - 'minimumAge', - ] as const; - return ( - {ORDERED_KEYS.map(key => { + {ORDERED_DISCLOSURE_KEYS.map(key => { const isEnabled = disclosures[key]; if ( !isEnabled || @@ -52,53 +31,17 @@ export default function Disclosures({ disclosures }: DisclosureProps) { return null; } - let text = ''; - switch (key) { - case 'ofac': - text = 'I am not on the OFAC sanction list'; - break; - case 'excludedCountries': - text = `I am not a citizen of the following countries: ${countriesToSentence( - (disclosures.excludedCountries as Country3LetterCode[]) || [], - )}`; - break; - case 'minimumAge': - text = `Age is over ${disclosures.minimumAge}`; - break; - case 'name': - text = 'Name'; - break; - case 'passport_number': - text = 'Passport Number'; - break; - case 'date_of_birth': - text = 'Date of Birth'; - break; - case 'gender': - text = 'Gender'; - break; - case 'expiry_date': - text = 'Passport Expiry Date'; - break; - case 'issuing_state': - text = 'Issuing State'; - break; - case 'nationality': - text = 'Nationality'; - break; - default: - return null; + const text = getDisclosureText(key, disclosures); + if (!text) { + return null; } + return ; })} ); } -function countriesToSentence(countries: Array): string { - return listToString(countries.map(country => countryCodes[country])); -} - interface DisclosureItemProps { text: string; } diff --git a/app/src/components/documents/IDSelectorItem.tsx b/app/src/components/documents/IDSelectorItem.tsx new file mode 100644 index 0000000000..297ac41862 --- /dev/null +++ b/app/src/components/documents/IDSelectorItem.tsx @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { Pressable } from 'react-native'; +import { Separator, Text, View, XStack, YStack } from 'tamagui'; +import { Check } from '@tamagui/lucide-icons'; + +import { + black, + green500, + green600, + iosSeparator, + slate200, + slate300, + slate400, +} from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +export interface IDSelectorItemProps { + documentName: string; + state: IDSelectorState; + onPress?: () => void; + disabled?: boolean; + isLastItem?: boolean; + testID?: string; +} + +export type IDSelectorState = 'active' | 'verified' | 'expired' | 'mock'; + +function getSubtitleText(state: IDSelectorState): string { + switch (state) { + case 'active': + return 'Currently active'; + case 'verified': + return 'Verified ID'; + case 'expired': + return 'Expired'; + case 'mock': + return 'Testing document'; + } +} + +function getSubtitleColor(state: IDSelectorState): string { + switch (state) { + case 'active': + return green600; + case 'verified': + return slate400; + case 'expired': + return slate400; + case 'mock': + return slate400; + } +} + +export const IDSelectorItem: React.FC = ({ + documentName, + state, + onPress, + disabled, + isLastItem, + testID, +}) => { + const isDisabled = disabled || isDisabledState(state); + const isActive = state === 'active'; + const subtitleText = getSubtitleText(state); + const subtitleColor = getSubtitleColor(state); + const textColor = isDisabled ? slate400 : black; + + // Determine circle color based on state + const circleColor = isDisabled ? slate200 : slate300; + + return ( + <> + + + {/* Radio button indicator */} + + + {isActive && } + + + + {/* Document info */} + + + {documentName} + + + {subtitleText} + + + + + {!isLastItem && } + + ); +}; + +export function isDisabledState(state: IDSelectorState): boolean { + return state === 'expired'; +} diff --git a/app/src/components/documents/IDSelectorSheet.tsx b/app/src/components/documents/IDSelectorSheet.tsx new file mode 100644 index 0000000000..152f811e96 --- /dev/null +++ b/app/src/components/documents/IDSelectorSheet.tsx @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { Button, ScrollView, Sheet, Text, View, XStack, YStack } from 'tamagui'; + +import { + black, + blue600, + slate200, + white, +} from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; +import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks'; + +import type { IDSelectorState } from '@/components/documents/IDSelectorItem'; +import { + IDSelectorItem, + isDisabledState, +} from '@/components/documents/IDSelectorItem'; + +export interface IDSelectorDocument { + id: string; + name: string; + state: IDSelectorState; +} + +export interface IDSelectorSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + documents: IDSelectorDocument[]; + selectedId?: string; + onSelect: (documentId: string) => void; + onDismiss: () => void; + onApprove: () => void; + testID?: string; +} + +export const IDSelectorSheet: React.FC = ({ + open, + onOpenChange, + documents, + selectedId, + onSelect, + onDismiss, + onApprove, + testID = 'id-selector-sheet', +}) => { + const bottomPadding = useSafeBottomPadding(16); + + // Check if the selected document is valid (not expired or unregistered) + const selectedDoc = documents.find(d => d.id === selectedId); + const canApprove = selectedDoc && !isDisabledState(selectedDoc.state); + + return ( + + + + + {/* Header */} + + Select an ID + + + {/* Document List Container with border radius */} + + + {documents.map((doc, index) => { + const isSelected = doc.id === selectedId; + // Don't override to 'active' if the document is in a disabled state + const itemState: IDSelectorState = + isSelected && !isDisabledState(doc.state) + ? 'active' + : doc.state; + + return ( + onSelect(doc.id)} + isLastItem={index === documents.length - 1} + testID={`${testID}-item-${doc.id}`} + /> + ); + })} + + + + {/* Footer Buttons */} + + + + + + + + ); +}; diff --git a/app/src/components/documents/index.ts b/app/src/components/documents/index.ts new file mode 100644 index 0000000000..e4bd904412 --- /dev/null +++ b/app/src/components/documents/index.ts @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +export type { + IDSelectorDocument, + IDSelectorSheetProps, +} from '@/components/documents/IDSelectorSheet'; + +export type { + IDSelectorItemProps, + IDSelectorState, +} from '@/components/documents/IDSelectorItem'; +export { + IDSelectorItem, + isDisabledState, +} from '@/components/documents/IDSelectorItem'; +export { IDSelectorSheet } from '@/components/documents/IDSelectorSheet'; diff --git a/app/src/components/navbar/BaseNavBar.tsx b/app/src/components/navbar/BaseNavBar.tsx index 9611b84bc8..e4f7186ebd 100644 --- a/app/src/components/navbar/BaseNavBar.tsx +++ b/app/src/components/navbar/BaseNavBar.tsx @@ -33,6 +33,7 @@ interface RightActionProps extends ViewProps { interface NavBarTitleProps extends TextProps { children?: React.ReactNode; size?: 'large' | undefined; + color?: string; } export const LeftAction: React.FC = ({ @@ -84,13 +85,20 @@ export const LeftAction: React.FC = ({ return {children}; }; -const NavBarTitle: React.FC = ({ children, ...props }) => { +const NavBarTitle: React.FC = ({ + children, + color, + style, + ...props +}) => { if (!children) { return null; } return typeof children === 'string' ? ( - {children} + + {children} + ) : ( children ); diff --git a/app/src/components/navbar/DefaultNavBar.tsx b/app/src/components/navbar/DefaultNavBar.tsx index 196a8f7b5d..f1a10885c7 100644 --- a/app/src/components/navbar/DefaultNavBar.tsx +++ b/app/src/components/navbar/DefaultNavBar.tsx @@ -18,6 +18,8 @@ export const DefaultNavBar = (props: NativeStackHeaderProps) => { const { options } = props; const headerStyle = (options.headerStyle || {}) as ViewStyle; const insets = useSafeAreaInsets(); + const headerTitleStyle = (options.headerTitleStyle || {}) as TextStyle; + return ( { paddingBottom={20} backgroundColor={headerStyle.backgroundColor as string} barStyle={ - options.headerTintColor === white || - (options.headerTitleStyle as TextStyle)?.color === white + options.headerTintColor === white || headerTitleStyle?.color === white ? 'light' : 'dark' } @@ -40,9 +41,12 @@ export const DefaultNavBar = (props: NativeStackHeaderProps) => { buttonTap(); goBack(); }} - {...(options.headerTitleStyle as ViewStyle)} + color={options.headerTintColor as string} /> - + {props.options.title} diff --git a/app/src/components/navbar/HomeNavBar.tsx b/app/src/components/navbar/HomeNavBar.tsx index 5c4c02f238..fe998873e8 100644 --- a/app/src/components/navbar/HomeNavBar.tsx +++ b/app/src/components/navbar/HomeNavBar.tsx @@ -56,7 +56,7 @@ export const HomeNavBar = (props: NativeStackHeaderProps) => { try { Clipboard.setString(''); } catch {} - props.navigation.navigate('Prove'); + props.navigation.navigate('ProvingScreenRouter'); } catch (error) { console.error('Error consuming token:', error); if ( diff --git a/app/src/components/proof-request/BottomActionBar.tsx b/app/src/components/proof-request/BottomActionBar.tsx new file mode 100644 index 0000000000..9c5f50fc38 --- /dev/null +++ b/app/src/components/proof-request/BottomActionBar.tsx @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React, { useMemo } from 'react'; +import { + ActivityIndicator, + Dimensions, + Pressable, + StyleSheet, +} from 'react-native'; +import { Text, View, XStack } from 'tamagui'; + +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; +import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks'; + +import { proofRequestColors } from '@/components/proof-request/designTokens'; +import { ChevronUpDownIcon } from '@/components/proof-request/icons'; + +export interface BottomActionBarProps { + selectedDocumentName: string; + onDocumentSelectorPress: () => void; + onApprovePress: () => void; + approveDisabled?: boolean; + approving?: boolean; + testID?: string; +} + +/** + * Bottom action bar with document selector and approve button. + * Matches Figma design 15234:9322. + */ +export const BottomActionBar: React.FC = ({ + selectedDocumentName, + onDocumentSelectorPress, + onApprovePress, + approveDisabled = false, + approving = false, + testID = 'bottom-action-bar', +}) => { + // Reduce top padding to balance with safe area bottom padding + // The safe area hook adds significant padding on small screens for system UI + const topPadding = 8; + + // Calculate dynamic bottom padding based on screen height + // Scales proportionally to better center the select box beneath the disclosure list + const { height: screenHeight } = Dimensions.get('window'); + const basePadding = 12; + + // Get safe area padding (handles small screens < 900px with extra padding) + const safeAreaPadding = useSafeBottomPadding(basePadding); + + // Dynamic padding calculation: + // - Start with safe area padding (includes base + small screen adjustment) + // - Add additional padding that scales with screen height + // - Formula: safeAreaPadding + (screenHeight - 800) * 0.12 + // - This provides base padding, safe area handling, plus 0-50px extra on larger screens + // - The multiplier (0.12) ensures smooth scaling across different screen sizes + const dynamicPadding = useMemo(() => { + const heightMultiplier = Math.max(0, (screenHeight - 800) * 0.12); + return Math.round(safeAreaPadding + heightMultiplier); + }, [screenHeight, safeAreaPadding]); + + const bottomPadding = dynamicPadding; + + return ( + + + {/* Document Selector Button */} + [ + styles.documentButton, + pressed && styles.documentButtonPressed, + ]} + testID={`${testID}-document-selector`} + > + + + {selectedDocumentName} + + + + + + + + {/* Approve Button */} + [ + styles.approveButton, + (approveDisabled || approving) && styles.approveButtonDisabled, + pressed && + !approveDisabled && + !approving && + styles.approveButtonPressed, + ]} + testID={`${testID}-approve`} + > + + {approving ? ( + + ) : ( + + Approve + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + documentButton: { + backgroundColor: proofRequestColors.white, + borderWidth: 1, + borderColor: proofRequestColors.slate200, + borderRadius: 4, + }, + documentButtonPressed: { + backgroundColor: proofRequestColors.slate100, + }, + approveButton: { + flex: 1, + backgroundColor: proofRequestColors.blue600, + borderRadius: 4, + }, + approveButtonDisabled: { + opacity: 0.5, + }, + approveButtonPressed: { + backgroundColor: proofRequestColors.blue700, + }, +}); diff --git a/app/src/components/proof-request/BottomVerifyBar.tsx b/app/src/components/proof-request/BottomVerifyBar.tsx new file mode 100644 index 0000000000..3c57a162d0 --- /dev/null +++ b/app/src/components/proof-request/BottomVerifyBar.tsx @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { View } from 'tamagui'; + +import { HeldPrimaryButtonProveScreen } from '@selfxyz/mobile-sdk-alpha/components'; + +import { proofRequestColors } from '@/components/proof-request/designTokens'; + +export interface BottomVerifyBarProps { + onVerify: () => void; + selectedAppSessionId: string | undefined | null; + hasScrolledToBottom: boolean; + isReadyToProve: boolean; + isDocumentExpired: boolean; + testID?: string; +} + +export const BottomVerifyBar: React.FC = ({ + onVerify, + selectedAppSessionId, + hasScrolledToBottom, + isReadyToProve, + isDocumentExpired, + testID = 'bottom-verify-bar', +}) => { + const insets = useSafeAreaInsets(); + + return ( + + + + ); +}; diff --git a/app/src/components/proof-request/ConnectedWalletBadge.tsx b/app/src/components/proof-request/ConnectedWalletBadge.tsx new file mode 100644 index 0000000000..905f196c15 --- /dev/null +++ b/app/src/components/proof-request/ConnectedWalletBadge.tsx @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import { Pressable } from 'react-native'; +import { Text, View, XStack } from 'tamagui'; + +import { plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { proofRequestColors } from '@/components/proof-request/designTokens'; +import { WalletIcon } from '@/components/proof-request/icons'; + +export interface ConnectedWalletBadgeProps { + address: string; + userIdType?: string; + onToggle?: () => void; + testID?: string; +} + +/** + * Blue badge showing connected wallet address. + * Matches Figma design 15234:9295 (icon). + */ +export const ConnectedWalletBadge: React.FC = ({ + address, + userIdType, + onToggle, + testID = 'connected-wallet-badge', +}) => { + const label = userIdType === 'hex' ? 'Connected Wallet' : 'Connected ID'; + + const content = ( + + {/* Label with icon */} + + + + {label} + + + + {/* Address */} + + + {truncateAddress(address)} + + + + ); + + if (onToggle) { + return ( + + {content} + + ); + } + + return content; +}; + +/** + * Truncates a wallet address for display. + * @example truncateAddress("0x1234567890abcdef1234567890abcdef12345678") // "0x12..5678" + */ +export function truncateAddress( + address: string, + startChars = 4, + endChars = 4, +): string { + if (address.length <= startChars + endChars + 2) { + return address; + } + return `${address.slice(0, startChars)}..${address.slice(-endChars)}`; +} diff --git a/app/src/components/proof-request/DisclosureItem.tsx b/app/src/components/proof-request/DisclosureItem.tsx new file mode 100644 index 0000000000..22e506c069 --- /dev/null +++ b/app/src/components/proof-request/DisclosureItem.tsx @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import { Pressable } from 'react-native'; +import { Text, View, XStack } from 'tamagui'; + +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { proofRequestColors } from '@/components/proof-request/designTokens'; +import { + FilledCircleIcon, + InfoCircleIcon, +} from '@/components/proof-request/icons'; + +export interface DisclosureItemProps { + text: string; + verified?: boolean; + onInfoPress?: () => void; + isLast?: boolean; + testID?: string; +} + +/** + * Individual disclosure row with green checkmark and optional info button. + * Matches Figma design 15234:9267. + */ +export const DisclosureItem: React.FC = ({ + text, + verified = true, + onInfoPress, + isLast = false, + testID = 'disclosure-item', +}) => { + return ( + + {/* Status Icon */} + + + + + {/* Disclosure Text */} + + + {text} + + + + {/* Info Button */} + {onInfoPress && ( + + + + + + )} + + ); +}; diff --git a/app/src/components/proof-request/ProofMetadataBar.tsx b/app/src/components/proof-request/ProofMetadataBar.tsx new file mode 100644 index 0000000000..be7337a159 --- /dev/null +++ b/app/src/components/proof-request/ProofMetadataBar.tsx @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import { Text, View, XStack } from 'tamagui'; + +import { plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { proofRequestColors } from '@/components/proof-request/designTokens'; +import { DocumentIcon } from '@/components/proof-request/icons'; + +export interface ProofMetadataBarProps { + timestamp: string; + testID?: string; +} + +/** + * Gray metadata bar showing "PROOFS REQUESTED" label and timestamp. + * Matches Figma design 15234:9281. + */ +export const ProofMetadataBar: React.FC = ({ + timestamp, + testID = 'proof-metadata-bar', +}) => { + return ( + + + {/* Icon + Label group */} + + + + Proofs Requested + + + + {/* Dot separator */} + + • + + + {/* Timestamp */} + + {timestamp} + + + + ); +}; + +/** + * Formats a Date object to match the Figma timestamp format. + * @example formatTimestamp(new Date()) // "4/7/2025 11:44 AM" + */ +export function formatTimestamp(date: Date): string { + const month = date.getMonth() + 1; + const day = date.getDate(); + const year = date.getFullYear(); + const hours = date.getHours(); + const minutes = date.getMinutes(); + const ampm = hours >= 12 ? 'PM' : 'AM'; + const displayHours = hours % 12 || 12; + const displayMinutes = minutes.toString().padStart(2, '0'); + + return `${month}/${day}/${year} ${displayHours}:${displayMinutes} ${ampm}`; +} diff --git a/app/src/components/proof-request/ProofRequestCard.tsx b/app/src/components/proof-request/ProofRequestCard.tsx new file mode 100644 index 0000000000..7d50c734ee --- /dev/null +++ b/app/src/components/proof-request/ProofRequestCard.tsx @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React, { useMemo } from 'react'; +import type { + ImageSourcePropType, + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView as ScrollViewType, +} from 'react-native'; +import { ScrollView } from 'react-native'; +import { Text, View } from 'tamagui'; + +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { + proofRequestColors, + proofRequestSpacing, +} from '@/components/proof-request/designTokens'; +import { + formatTimestamp, + ProofMetadataBar, +} from '@/components/proof-request/ProofMetadataBar'; +import { ProofRequestHeader } from '@/components/proof-request/ProofRequestHeader'; + +export interface ProofRequestCardProps { + logoSource: ImageSourcePropType | null; + appName: string; + appUrl: string | null; + documentType?: string; + timestamp?: Date; + children?: React.ReactNode; + testID?: string; + onScroll?: (event: NativeSyntheticEvent) => void; + scrollViewRef?: React.RefObject; + onContentSizeChange?: (width: number, height: number) => void; + onLayout?: (event: LayoutChangeEvent) => void; + initialScrollOffset?: number; +} + +/** + * Main card container for proof request screens. + * Combines header, metadata bar, and content section. + * Matches Figma design 15234:9267. + */ +export const ProofRequestCard: React.FC = ({ + logoSource, + appName, + appUrl, + documentType = '', + timestamp, + children, + testID = 'proof-request-card', + onScroll, + scrollViewRef, + onContentSizeChange, + onLayout, + initialScrollOffset, +}) => { + // Create default timestamp once and reuse it to avoid unnecessary re-renders + const defaultTimestamp = useMemo(() => new Date(), []); + const effectiveTimestamp = timestamp ?? defaultTimestamp; + + // Build request message with highlighted app name and document type + const requestMessage = ( + <> + + {appName} + + + { + ' is requesting access to the following information from your verified ' + } + + + {documentType} + + + . + + + ); + + return ( + + + {/* Black Header */} + + + {/* Metadata Bar */} + + + {/* White Content Area */} + + + {children} + + + + + ); +}; diff --git a/app/src/components/proof-request/ProofRequestHeader.tsx b/app/src/components/proof-request/ProofRequestHeader.tsx new file mode 100644 index 0000000000..4751c03f05 --- /dev/null +++ b/app/src/components/proof-request/ProofRequestHeader.tsx @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import type { ImageSourcePropType } from 'react-native'; +import { Image, Text, View, YStack } from 'tamagui'; + +import { + advercase, + dinot, + plexMono, +} from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { proofRequestColors } from '@/components/proof-request/designTokens'; + +export interface ProofRequestHeaderProps { + logoSource: ImageSourcePropType | null; + appName: string; + appUrl: string | null; + requestMessage: React.ReactNode; + testID?: string; +} + +/** + * Black header section for proof request screens. + * Displays app logo, name, URL, and request description. + * Matches Figma design 15234:9267. + */ +export const ProofRequestHeader: React.FC = ({ + logoSource, + appName, + appUrl, + requestMessage, + testID = 'proof-request-header', +}) => { + const hasLogo = logoSource !== null; + + return ( + + {/* Logo and App Info Row */} + + {logoSource && ( + + + + )} + + + {appName} + + {appUrl && ( + + + {appUrl} + + + )} + + + + {/* Request Description */} + + {requestMessage} + + + ); +}; diff --git a/app/src/components/proof-request/WalletAddressModal.tsx b/app/src/components/proof-request/WalletAddressModal.tsx new file mode 100644 index 0000000000..e73ecd511d --- /dev/null +++ b/app/src/components/proof-request/WalletAddressModal.tsx @@ -0,0 +1,240 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Modal, Pressable, StyleSheet } from 'react-native'; +import { Text, View, XStack, YStack } from 'tamagui'; +import Clipboard from '@react-native-clipboard/clipboard'; + +import { dinot, plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { proofRequestColors } from '@/components/proof-request/designTokens'; +import { CopyIcon, WalletIcon } from '@/components/proof-request/icons'; + +export interface WalletAddressModalProps { + visible: boolean; + onClose: () => void; + address: string; + userIdType?: string; + testID?: string; +} + +/** + * Modal that displays the full wallet address with copy functionality. + * Appears when user taps on the truncated wallet badge. + */ +export const WalletAddressModal: React.FC = ({ + visible, + onClose, + address, + userIdType, + testID = 'wallet-address-modal', +}) => { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef | null>(null); + const label = userIdType === 'hex' ? 'Connected Wallet' : 'Connected ID'; + + // Reset copied state when modal closes + useEffect(() => { + if (!visible) { + setCopied(false); + } + }, [visible]); + + // Clear timeout on unmount or when modal closes/address changes + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, [visible, address, onClose]); + + const handleCopy = useCallback(() => { + // Clear any existing timeout before setting a new one + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + Clipboard.setString(address); + setCopied(true); + + // Reset copied state and close after a brief delay + timeoutRef.current = setTimeout(() => { + setCopied(false); + onClose(); + timeoutRef.current = null; + }, 800); + }, [address, onClose]); + + return ( + + + + e.stopPropagation()}> + + {/* Header */} + + + + + {label} + + + + + {/* Full Address */} + + + {address} + + + + {/* Action Buttons */} + + [ + copied ? styles.copiedButton : styles.copyButton, + pressed && !copied && styles.copyButtonPressed, + ]} + testID={`${testID}-copy`} + > + + {copied ? ( + + ✓ + + ) : ( + + )} + + {copied ? 'Copied!' : 'Copy'} + + + + + {!copied && ( + [ + styles.closeButton, + pressed && styles.closeButtonPressed, + ]} + testID={`${testID}-close`} + > + + + Close + + + + )} + + + + + + + ); +}; + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + }, + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + copyButton: { + flex: 1, + backgroundColor: proofRequestColors.blue600, + borderRadius: 8, + }, + copyButtonPressed: { + backgroundColor: proofRequestColors.blue700, + }, + copiedButton: { + flex: 1, + backgroundColor: proofRequestColors.emerald500, + borderRadius: 8, + }, + closeButton: { + flex: 1, + backgroundColor: proofRequestColors.slate100, + borderRadius: 8, + borderWidth: 1, + borderColor: proofRequestColors.slate200, + }, + closeButtonPressed: { + backgroundColor: proofRequestColors.slate200, + }, +}); diff --git a/app/src/components/proof-request/designTokens.ts b/app/src/components/proof-request/designTokens.ts new file mode 100644 index 0000000000..5a5aa80797 --- /dev/null +++ b/app/src/components/proof-request/designTokens.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +/** + * Design tokens for proof request components. + * Extracted from Figma design 15234:9267 and 15234:9322. + */ + +export const proofRequestColors = { + // Base colors + black: '#000000', + white: '#FFFFFF', + + // Slate palette + slate100: '#F8FAFC', + slate200: '#E2E8F0', + slate400: '#94A3B8', + slate500: '#71717A', + slate900: '#0F172A', + + // Blue palette + blue500: '#3B82F6', + blue600: '#2563EB', + blue700: '#1D4ED8', + + // Status colors + emerald500: '#10B981', + + // Zinc palette + zinc500: '#71717A', +} as const; + +export const proofRequestSpacing = { + cardPadding: 20, + headerPadding: 30, + itemPadding: 16, + borderRadius: 10, + borderRadiusSmall: 4, +} as const; diff --git a/app/src/components/proof-request/icons.tsx b/app/src/components/proof-request/icons.tsx new file mode 100644 index 0000000000..9f31470b7f --- /dev/null +++ b/app/src/components/proof-request/icons.tsx @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import Svg, { Circle, Path, Rect } from 'react-native-svg'; + +export interface IconProps { + size?: number; + color?: string; +} + +/** + * Chevron up/down icon (dropdown) + */ +export const ChevronUpDownIcon: React.FC = ({ + size = 20, + color = '#94A3B8', +}) => ( + + + +); + +/** + * Copy icon + */ +export const CopyIcon: React.FC = ({ + size = 16, + color = '#FFFFFF', +}) => ( + + + + +); + +/** + * Document icon (lighter stroke to match SF Symbol design) + */ +export const DocumentIcon: React.FC = ({ + size = 18, + color = '#94A3B8', +}) => ( + + + + + +); + +/** + * Filled circle icon (checkmark/bullet point) + */ +export const FilledCircleIcon: React.FC = ({ + size = 18, + color = '#10B981', +}) => ( + + + +); + +/** + * Info circle icon + */ +export const InfoCircleIcon: React.FC = ({ + size = 20, + color = '#3B82F6', +}) => ( + + + + +); + +/** + * Wallet icon (credit card style to match SF Symbol creditcard 􀟿) + */ +export const WalletIcon: React.FC = ({ + size = 16, + color = '#FFFFFF', +}) => ( + + + + +); diff --git a/app/src/components/proof-request/index.ts b/app/src/components/proof-request/index.ts new file mode 100644 index 0000000000..ac15030317 --- /dev/null +++ b/app/src/components/proof-request/index.ts @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +export type { BottomActionBarProps } from '@/components/proof-request/BottomActionBar'; +export type { BottomVerifyBarProps } from '@/components/proof-request/BottomVerifyBar'; + +// Metadata bar +export type { ConnectedWalletBadgeProps } from '@/components/proof-request/ConnectedWalletBadge'; + +export type { DisclosureItemProps } from '@/components/proof-request/DisclosureItem'; + +export type { IconProps } from '@/components/proof-request/icons'; + +// Header section +export type { ProofMetadataBarProps } from '@/components/proof-request/ProofMetadataBar'; + +/** + * Proof Request Component Library + * + * Shared components for proof request preview and proving screens. + * These components implement the Figma designs 15234:9267 and 15234:9322. + */ +// Main card component +export type { ProofRequestCardProps } from '@/components/proof-request/ProofRequestCard'; +export type { ProofRequestHeaderProps } from '@/components/proof-request/ProofRequestHeader'; + +export type { WalletAddressModalProps } from '@/components/proof-request/WalletAddressModal'; + +// Icons +export { BottomActionBar } from '@/components/proof-request/BottomActionBar'; +export { BottomVerifyBar } from '@/components/proof-request/BottomVerifyBar'; + +// Bottom action bar +export { + ChevronUpDownIcon, + CopyIcon, + DocumentIcon, + FilledCircleIcon, + InfoCircleIcon, + WalletIcon, +} from '@/components/proof-request/icons'; + +export { + ConnectedWalletBadge, + truncateAddress, +} from '@/components/proof-request/ConnectedWalletBadge'; + +// Connected wallet badge +export { DisclosureItem } from '@/components/proof-request/DisclosureItem'; + +// Disclosure item +export { + ProofMetadataBar, + formatTimestamp, +} from '@/components/proof-request/ProofMetadataBar'; + +export { ProofRequestCard } from '@/components/proof-request/ProofRequestCard'; + +export { ProofRequestHeader } from '@/components/proof-request/ProofRequestHeader'; + +export { WalletAddressModal } from '@/components/proof-request/WalletAddressModal'; + +// Design tokens +export { + proofRequestColors, + proofRequestSpacing, +} from '@/components/proof-request/designTokens'; diff --git a/app/src/hooks/useEarnPointsFlow.ts b/app/src/hooks/useEarnPointsFlow.ts index 87dc80ba62..53749da9ae 100644 --- a/app/src/hooks/useEarnPointsFlow.ts +++ b/app/src/hooks/useEarnPointsFlow.ts @@ -40,7 +40,7 @@ export const useEarnPointsFlow = ({ // Use setTimeout to ensure modal dismisses before navigating setTimeout(() => { - navigation.navigate('Prove'); + navigation.navigate('ProvingScreenRouter'); }, 100); }, [selfClient, navigation]); diff --git a/app/src/hooks/useSelfAppData.ts b/app/src/hooks/useSelfAppData.ts new file mode 100644 index 0000000000..9136627594 --- /dev/null +++ b/app/src/hooks/useSelfAppData.ts @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { useMemo } from 'react'; + +import type { SelfApp } from '@selfxyz/common'; +import type { SelfAppDisclosureConfig } from '@selfxyz/common/utils/appType'; +import { formatEndpoint } from '@selfxyz/common/utils/scope'; + +import { getDisclosureItems } from '@/utils/disclosureUtils'; +import { formatUserId } from '@/utils/formatUserId'; + +/** + * Hook that extracts and transforms SelfApp data for use in UI components. + * Returns memoized values for logo source, URL, formatted user ID, and disclosure items. + */ +export function useSelfAppData(selfApp: SelfApp | null) { + const logoSource = useMemo(() => { + if (!selfApp?.logoBase64) { + return null; + } + + // Check if the logo is already a URL + if ( + selfApp.logoBase64.startsWith('http://') || + selfApp.logoBase64.startsWith('https://') + ) { + return { uri: selfApp.logoBase64 }; + } + + // Otherwise handle as base64 + const base64String = selfApp.logoBase64.startsWith('data:image') + ? selfApp.logoBase64 + : `data:image/png;base64,${selfApp.logoBase64}`; + return { uri: base64String }; + }, [selfApp?.logoBase64]); + + const url = useMemo(() => { + if (!selfApp?.endpoint) { + return null; + } + return formatEndpoint(selfApp.endpoint); + }, [selfApp?.endpoint]); + + const formattedUserId = useMemo( + () => formatUserId(selfApp?.userId, selfApp?.userIdType), + [selfApp?.userId, selfApp?.userIdType], + ); + + const disclosureItems = useMemo(() => { + const disclosures = (selfApp?.disclosures as SelfAppDisclosureConfig) || {}; + return getDisclosureItems(disclosures); + }, [selfApp?.disclosures]); + + return { + logoSource, + url, + formattedUserId, + disclosureItems, + }; +} diff --git a/app/src/hooks/useSelfAppStalenessCheck.ts b/app/src/hooks/useSelfAppStalenessCheck.ts new file mode 100644 index 0000000000..89614c9cb3 --- /dev/null +++ b/app/src/hooks/useSelfAppStalenessCheck.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { useCallback } from 'react'; +import { useFocusEffect } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +import type { SelfApp } from '@selfxyz/common'; + +import type { RootStackParamList } from '@/navigation'; + +/** + * Hook that checks if SelfApp data is stale (missing or empty disclosures) + * and navigates to Home screen if stale data is detected. + * + * Uses a small delay to allow store updates to propagate after navigation + * (e.g., after QR code scan sets selfApp data). + */ +export function useSelfAppStalenessCheck( + selfApp: SelfApp | null, + disclosureItems: Array<{ key: string; text: string }>, + navigation: NativeStackNavigationProp, +) { + useFocusEffect( + useCallback(() => { + // Add a small delay to allow Zustand store updates to propagate + // after navigation (e.g., when selfApp is set from QR scan) + const timeoutId = setTimeout(() => { + if (!selfApp || disclosureItems.length === 0) { + navigation.navigate({ name: 'Home', params: {} }); + } + }, 300); + + return () => { + clearTimeout(timeoutId); + }; + }, [selfApp, disclosureItems.length, navigation]), + ); +} diff --git a/app/src/navigation/account.ts b/app/src/navigation/account.ts index 7b1656dd96..93f4f8f60c 100644 --- a/app/src/navigation/account.ts +++ b/app/src/navigation/account.ts @@ -16,6 +16,7 @@ import AccountRecoveryScreen from '@/screens/account/recovery/AccountRecoveryScr import DocumentDataNotFoundScreen from '@/screens/account/recovery/DocumentDataNotFoundScreen'; import RecoverWithPhraseScreen from '@/screens/account/recovery/RecoverWithPhraseScreen'; import CloudBackupScreen from '@/screens/account/settings/CloudBackupScreen'; +import { ProofSettingsScreen } from '@/screens/account/settings/ProofSettingsScreen'; import SettingsScreen from '@/screens/account/settings/SettingsScreen'; import ShowRecoveryPhraseScreen from '@/screens/account/settings/ShowRecoveryPhraseScreen'; import { IS_EUCLID_ENABLED } from '@/utils/devUtils'; @@ -65,6 +66,18 @@ const accountScreens = { }, } as NativeStackNavigationOptions, }, + ProofSettings: { + screen: ProofSettingsScreen, + options: { + title: 'Proof Settings', + headerStyle: { + backgroundColor: white, + }, + headerTitleStyle: { + color: black, + }, + } as NativeStackNavigationOptions, + }, Settings: { screen: SettingsScreen, options: { diff --git a/app/src/navigation/deeplinks.ts b/app/src/navigation/deeplinks.ts index dc1cac680d..af4cd79ceb 100644 --- a/app/src/navigation/deeplinks.ts +++ b/app/src/navigation/deeplinks.ts @@ -126,7 +126,10 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => { selfClient.getSelfAppState().startAppListener(selfAppJson.sessionId); navigationRef.reset( - createDeeplinkNavigationState('Prove', correctParentScreen), + createDeeplinkNavigationState( + 'ProvingScreenRouter', + correctParentScreen, + ), ); return; @@ -143,7 +146,7 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => { selfClient.getSelfAppState().startAppListener(sessionId); navigationRef.reset( - createDeeplinkNavigationState('Prove', correctParentScreen), + createDeeplinkNavigationState('ProvingScreenRouter', correctParentScreen), ); } else if (mock_passport) { try { diff --git a/app/src/navigation/index.tsx b/app/src/navigation/index.tsx index 4ce48e8f38..67273f9331 100644 --- a/app/src/navigation/index.tsx +++ b/app/src/navigation/index.tsx @@ -73,6 +73,8 @@ export type RootStackParamList = Omit< | 'Disclaimer' | 'DocumentNFCScan' | 'DocumentOnboarding' + | 'DocumentSelectorForProving' + | 'ProvingScreenRouter' | 'Gratification' | 'Home' | 'IDPicker' @@ -142,13 +144,24 @@ export type RootStackParamList = Omit< returnToScreen?: 'Points'; } | undefined; + ProofSettings: undefined; AccountVerifiedSuccess: undefined; // Proof/Verification screens ProofHistoryDetail: { data: ProofHistory; }; - Prove: undefined; + Prove: + | { + scrollOffset?: number; + } + | undefined; + ProvingScreenRouter: undefined; + DocumentSelectorForProving: + | { + documentType?: string; + } + | undefined; // App screens Loading: { diff --git a/app/src/navigation/verification.ts b/app/src/navigation/verification.ts index 24492bdfc2..5499863629 100644 --- a/app/src/navigation/verification.ts +++ b/app/src/navigation/verification.ts @@ -6,11 +6,30 @@ import type { NativeStackNavigationOptions } from '@react-navigation/native-stac import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { DocumentSelectorForProvingScreen } from '@/screens/verification/DocumentSelectorForProvingScreen'; import ProofRequestStatusScreen from '@/screens/verification/ProofRequestStatusScreen'; import ProveScreen from '@/screens/verification/ProveScreen'; +import { ProvingScreenRouter } from '@/screens/verification/ProvingScreenRouter'; import QRCodeTroubleScreen from '@/screens/verification/QRCodeTroubleScreen'; import QRCodeViewFinderScreen from '@/screens/verification/QRCodeViewFinderScreen'; +/** + * Shared header configuration for proof request screens + */ +const proofRequestHeaderOptions: NativeStackNavigationOptions = { + title: 'Proof Requested', + headerStyle: { + backgroundColor: black, + }, + headerTitleStyle: { + color: white, + fontWeight: '600', + }, + headerTintColor: white, + gestureEnabled: false, + animation: 'none', +}; + const verificationScreens = { ProofRequestStatus: { screen: ProofRequestStatusScreen, @@ -20,18 +39,17 @@ const verificationScreens = { gestureEnabled: false, } as NativeStackNavigationOptions, }, + ProvingScreenRouter: { + screen: ProvingScreenRouter, + options: proofRequestHeaderOptions, + }, + DocumentSelectorForProving: { + screen: DocumentSelectorForProvingScreen, + options: proofRequestHeaderOptions, + }, Prove: { screen: ProveScreen, - options: { - title: 'Request Proof', - headerStyle: { - backgroundColor: black, - }, - headerTitleStyle: { - color: white, - }, - gestureEnabled: false, - } as NativeStackNavigationOptions, + options: proofRequestHeaderOptions, }, QRCodeTrouble: { screen: QRCodeTroubleScreen, diff --git a/app/src/screens/account/settings/ProofSettingsScreen.tsx b/app/src/screens/account/settings/ProofSettingsScreen.tsx new file mode 100644 index 0000000000..2227a5be94 --- /dev/null +++ b/app/src/screens/account/settings/ProofSettingsScreen.tsx @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import { StyleSheet, Switch, Text, View } from 'react-native'; +import { ScrollView, YStack } from 'tamagui'; + +import { + black, + blue600, + slate200, + slate500, + white, +} from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { useSettingStore } from '@/stores/settingStore'; + +const ProofSettingsScreen: React.FC = () => { + const { + skipDocumentSelector, + setSkipDocumentSelector, + skipDocumentSelectorIfSingle, + setSkipDocumentSelectorIfSingle, + } = useSettingStore(); + + return ( + + + + Document Selection + + + + + Always skip document selection + + + Go directly to proof generation using your previously selected + or first available document + + + + + + + + + + + Skip when only one document + + + Automatically select your document when you only have one valid + ID available + + + + + + {skipDocumentSelector && ( + + Document selection is always skipped. The "Skip when only one + document" setting has no effect. + + )} + + + + ); +}; + +const styles = StyleSheet.create({ + sectionTitle: { + fontSize: 14, + fontFamily: dinot, + fontWeight: '600', + color: slate500, + textTransform: 'uppercase', + letterSpacing: 1, + }, + settingRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 16, + }, + settingTextContainer: { + flex: 1, + gap: 4, + }, + settingLabel: { + fontSize: 16, + fontFamily: dinot, + fontWeight: '500', + color: black, + }, + settingDescription: { + fontSize: 14, + fontFamily: dinot, + color: slate500, + }, + divider: { + height: 1, + backgroundColor: slate200, + }, + infoText: { + fontSize: 13, + fontFamily: dinot, + fontStyle: 'italic', + color: slate500, + paddingHorizontal: 4, + }, +}); + +export { ProofSettingsScreen }; diff --git a/app/src/screens/account/settings/SettingsScreen.tsx b/app/src/screens/account/settings/SettingsScreen.tsx index b8e76452d7..535d52b560 100644 --- a/app/src/screens/account/settings/SettingsScreen.tsx +++ b/app/src/screens/account/settings/SettingsScreen.tsx @@ -12,7 +12,7 @@ import type { SvgProps } from 'react-native-svg'; import { Button, ScrollView, View, XStack, YStack } from 'tamagui'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { Bug, FileText } from '@tamagui/lucide-icons'; +import { Bug, FileText, Settings2 } from '@tamagui/lucide-icons'; import { BodyText, pressedStyle } from '@selfxyz/mobile-sdk-alpha/components'; import { @@ -78,6 +78,7 @@ const routes = [Data, 'View document info', 'DocumentDataInfo'], [Lock, 'Reveal recovery phrase', 'ShowRecoveryPhrase'], [Cloud, 'Cloud backup', 'CloudBackupSettings'], + [Settings2 as React.FC, 'Proof settings', 'ProofSettings'], [Feedback, 'Send feedback', 'email_feedback'], [ShareIcon, 'Share Self app', 'share'], [ @@ -88,6 +89,7 @@ const routes = ] satisfies [React.FC, string, RouteOption][]) : ([ [Data, 'View document info', 'DocumentDataInfo'], + [Settings2 as React.FC, 'Proof settings', 'ProofSettings'], [Feedback, 'Send feeback', 'email_feedback'], [ FileText as React.FC, diff --git a/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx b/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx new file mode 100644 index 0000000000..12ce5a348c --- /dev/null +++ b/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx @@ -0,0 +1,495 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; +import { ActivityIndicator, StyleSheet } from 'react-native'; +import { Text, View, YStack } from 'tamagui'; +import type { RouteProp } from '@react-navigation/native'; +import { + useFocusEffect, + useNavigation, + useRoute, +} from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +import type { + DocumentCatalog, + DocumentMetadata, + IDDocument, +} from '@selfxyz/common/utils/types'; +import { + getDocumentAttributes, + isDocumentValidForProving, + useSelfClient, +} from '@selfxyz/mobile-sdk-alpha'; +import { blue600, white } from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import type { IDSelectorState } from '@/components/documents'; +import { IDSelectorSheet, isDisabledState } from '@/components/documents'; +import { + BottomActionBar, + ConnectedWalletBadge, + DisclosureItem, + ProofRequestCard, + proofRequestColors, + truncateAddress, + WalletAddressModal, +} from '@/components/proof-request'; +import { useSelfAppData } from '@/hooks/useSelfAppData'; +import { useSelfAppStalenessCheck } from '@/hooks/useSelfAppStalenessCheck'; +import type { RootStackParamList } from '@/navigation'; +import { usePassport } from '@/providers/passportDataProvider'; +import { getDocumentTypeName } from '@/utils/documentUtils'; + +function getDocumentDisplayName( + metadata: DocumentMetadata, + documentData?: IDDocument, +): string { + const category = metadata.documentCategory || ''; + const isMock = metadata.mock; + + // Extract country information from document data + let countryCode: string | null = null; + if (documentData) { + try { + const attributes = getDocumentAttributes(documentData); + countryCode = attributes.nationalitySlice || null; + } catch { + // If we can't extract attributes, continue without country + } + } + + const mockPrefix = isMock ? 'Dev ' : ''; + + if (category === 'passport') { + const base = 'Passport'; + return countryCode + ? `${mockPrefix}${countryCode} ${base}` + : `${mockPrefix}${base}`; + } else if (category === 'id_card') { + const base = 'ID Card'; + return countryCode + ? `${mockPrefix}${countryCode} ${base}` + : `${mockPrefix}${base}`; + } else if (category === 'aadhaar') { + return isMock ? 'Dev Aadhaar ID' : 'Aadhaar ID'; + } + + return isMock ? `Dev ${metadata.documentType}` : metadata.documentType; +} + +function determineDocumentState( + metadata: DocumentMetadata, + documentData: IDDocument | undefined, +): IDSelectorState { + // Use SDK to check if document is valid (not expired) + if (!isDocumentValidForProving(metadata, documentData)) { + return 'expired'; + } + + // UI-specific state mapping: Mock documents are selectable but marked as developer/mock + if (metadata.mock) { + return 'mock'; + } + + // Both registered and non-registered real documents are valid for selection + // They will be registered during the proving flow if needed + return 'verified'; +} + +const DocumentSelectorForProvingScreen: React.FC = () => { + const navigation = + useNavigation>(); + const route = + useRoute>(); + const selfClient = useSelfClient(); + const { useSelfAppStore } = selfClient; + const selfApp = useSelfAppStore(state => state.selfApp); + const { loadDocumentCatalog, getAllDocuments, setSelectedDocument } = + usePassport(); + // Extract SelfApp data using hook + const { logoSource, url, formattedUserId, disclosureItems } = + useSelfAppData(selfApp); + + // Check for stale data and navigate to Home if needed + useSelfAppStalenessCheck(selfApp, disclosureItems, navigation); + + const [documentCatalog, setDocumentCatalog] = useState({ + documents: [], + }); + const [allDocuments, setAllDocuments] = useState< + Record + >({}); + const [selectedDocumentId, setSelectedDocumentId] = useState< + string | undefined + >(); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [sheetOpen, setSheetOpen] = useState(false); + const [walletModalOpen, setWalletModalOpen] = useState(false); + const abortControllerRef = useRef(null); + const scrollOffsetRef = useRef(0); + + const pickInitialDocument = useCallback( + ( + catalog: DocumentCatalog, + docs: Record, + ) => { + if (catalog.selectedDocumentId) { + const selectedMeta = catalog.documents.find( + doc => doc.id === catalog.selectedDocumentId, + ); + const selectedData = selectedMeta + ? docs[catalog.selectedDocumentId] + : undefined; + + if (selectedMeta && selectedData) { + const state = determineDocumentState(selectedMeta, selectedData.data); + if (!isDisabledState(state)) { + return catalog.selectedDocumentId; + } + } else if (selectedMeta) { + return catalog.selectedDocumentId; + } + } + + const firstValid = catalog.documents.find(doc => { + const docData = docs[doc.id]; + const state = determineDocumentState(doc, docData?.data); + return !isDisabledState(state); + }); + + return firstValid?.id; + }, + [], + ); + + const loadDocuments = useCallback(async () => { + // Cancel any in-flight request + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + + setLoading(true); + setError(null); + try { + const catalog = await loadDocumentCatalog(); + const docs = await getAllDocuments(); + + // Don't update state if this request was aborted + if (controller.signal.aborted) { + return; + } + + setDocumentCatalog(catalog); + setAllDocuments(docs); + setSelectedDocumentId(pickInitialDocument(catalog, docs)); + } catch (loadError) { + // Don't show error if this request was aborted + if (controller.signal.aborted) { + return; + } + console.warn('Failed to load documents:', loadError); + setError('Unable to load documents.'); + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + }, [getAllDocuments, loadDocumentCatalog, pickInitialDocument]); + + useFocusEffect( + useCallback(() => { + loadDocuments(); + }, [loadDocuments]), + ); + + // Cleanup abort controller on unmount + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + const documents = useMemo(() => { + return documentCatalog.documents + .map(metadata => { + const docData = allDocuments[metadata.id]; + const baseState = determineDocumentState(metadata, docData?.data); + const isSelected = metadata.id === selectedDocumentId; + const itemState = + isSelected && !isDisabledState(baseState) ? 'active' : baseState; + + return { + id: metadata.id, + name: getDocumentDisplayName(metadata, docData?.data), + state: itemState, + }; + }) + .sort((a, b) => { + // Get metadata for both documents + const metaA = documentCatalog.documents.find(d => d.id === a.id); + const metaB = documentCatalog.documents.find(d => d.id === b.id); + + // Sort real documents before mock documents + if (metaA && metaB) { + if (metaA.mock !== metaB.mock) { + return metaA.mock ? 1 : -1; // Real first + } + } + + // Within same type (real/mock), sort alphabetically by name + return a.name.localeCompare(b.name); + }); + }, [allDocuments, documentCatalog.documents, selectedDocumentId]); + + const selectedDocument = documents.find(doc => doc.id === selectedDocumentId); + const canContinue = + !!selectedDocument && !isDisabledState(selectedDocument.state); + + // Get document type for the proof request message + const selectedDocumentType = useMemo(() => { + // If we have a preloaded document type from route params, use it while loading + const preloadedType = route.params?.documentType; + if (loading && preloadedType) { + return preloadedType; + } + + if (!selectedDocumentId) return preloadedType || ''; + const metadata = documentCatalog.documents.find( + d => d.id === selectedDocumentId, + ); + return getDocumentTypeName(metadata?.documentCategory); + }, [ + selectedDocumentId, + documentCatalog.documents, + loading, + route.params?.documentType, + ]); + + const handleSelect = useCallback((documentId: string) => { + setSelectedDocumentId(documentId); + }, []); + + const handleSheetSelect = useCallback(async () => { + if (!selectedDocumentId || !canContinue || submitting) { + return; + } + + setSubmitting(true); + setError(null); + try { + await setSelectedDocument(selectedDocumentId); + setSheetOpen(false); // Close the sheet first + navigation.navigate('Prove', { scrollOffset: scrollOffsetRef.current }); + } catch (selectionError) { + console.error('Failed to set selected document:', selectionError); + setError('Failed to select document. Please try again.'); + } finally { + setSubmitting(false); + } + }, [ + selectedDocumentId, + canContinue, + submitting, + setSelectedDocument, + navigation, + ]); + + const handleApprove = async () => { + if (!selectedDocumentId || !canContinue || submitting) { + return; + } + + setSubmitting(true); + setError(null); + try { + await setSelectedDocument(selectedDocumentId); + navigation.navigate('Prove', { scrollOffset: scrollOffsetRef.current }); + } catch (selectionError) { + console.error('Failed to set selected document:', selectionError); + setError('Failed to select document. Please try again.'); + } finally { + setSubmitting(false); + } + }; + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + scrollOffsetRef.current = event.nativeEvent.contentOffset.y; + }, + [], + ); + + // Loading state + if (loading) { + return ( + + + + ); + } + + // Error state + if (error) { + return ( + + + {error} + + + + Retry + + + + ); + } + + // Empty state + if (documents.length === 0) { + return ( + + + No documents found. Please scan a document first. + + + ); + } + + return ( + + {/* Main Content - Proof Request Card */} + + {/* Connected Wallet Badge */} + {formattedUserId && ( + setWalletModalOpen(true)} + testID="document-selector-wallet-badge" + /> + )} + + {/* Disclosure Items */} + + {disclosureItems.map((item, index) => ( + + ))} + + + + {/* Bottom Action Bar */} + setSheetOpen(true)} + onApprovePress={handleApprove} + approveDisabled={!canContinue} + approving={submitting} + testID="document-selector-action-bar" + /> + + {/* ID Selector Sheet */} + setSheetOpen(false)} + onApprove={handleSheetSelect} + testID="document-selector-sheet" + /> + + {/* Wallet Address Modal */} + {formattedUserId && selfApp?.userId && ( + setWalletModalOpen(false)} + address={selfApp.userId} + userIdType={selfApp?.userIdType} + testID="document-selector-wallet-modal" + /> + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: white, + }, +}); + +export { DocumentSelectorForProvingScreen }; diff --git a/app/src/screens/verification/ProveScreen.tsx b/app/src/screens/verification/ProveScreen.tsx index 57a0f3dbe2..af48dd7c3a 100644 --- a/app/src/screens/verification/ProveScreen.tsx +++ b/app/src/screens/verification/ProveScreen.tsx @@ -2,7 +2,6 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import LottieView from 'lottie-react-native'; import React, { useCallback, useEffect, @@ -14,33 +13,34 @@ import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, + ScrollView as ScrollViewType, } from 'react-native'; -import { ScrollView, StyleSheet, TouchableOpacity } from 'react-native'; -import { Image, Text, View, XStack, YStack } from 'tamagui'; -import { useIsFocused, useNavigation } from '@react-navigation/native'; +import { StyleSheet } from 'react-native'; +import { View, YStack } from 'tamagui'; +import type { RouteProp } from '@react-navigation/native'; +import { + useIsFocused, + useNavigation, + useRoute, +} from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { Eye, EyeOff } from '@tamagui/lucide-icons'; import { isMRZDocument } from '@selfxyz/common'; -import type { SelfAppDisclosureConfig } from '@selfxyz/common/utils/appType'; -import { formatEndpoint } from '@selfxyz/common/utils/scope'; import { loadSelectedDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha'; -import miscAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/misc.json'; -import { - BodyText, - Caption, - HeldPrimaryButtonProveScreen, -} from '@selfxyz/mobile-sdk-alpha/components'; import { ProofEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics'; -import { - black, - slate300, - white, -} from '@selfxyz/mobile-sdk-alpha/constants/colors'; -import Disclosures from '@/components/Disclosures'; +import { + BottomVerifyBar, + ConnectedWalletBadge, + DisclosureItem, + ProofRequestCard, + proofRequestColors, + truncateAddress, + WalletAddressModal, +} from '@/components/proof-request'; +import { useSelfAppData } from '@/hooks/useSelfAppData'; +import { useSelfAppStalenessCheck } from '@/hooks/useSelfAppStalenessCheck'; import { buttonTap } from '@/integrations/haptics'; -import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; import type { RootStackParamList } from '@/navigation'; import { setDefaultDocumentTypeIfNeeded, @@ -56,26 +56,41 @@ import { checkDocumentExpiration, getDocumentAttributes, } from '@/utils/documentAttributes'; -import { formatUserId } from '@/utils/formatUserId'; +import { getDocumentTypeName } from '@/utils/documentUtils'; const ProveScreen: React.FC = () => { const selfClient = useSelfClient(); const { trackEvent } = selfClient; - const { navigate } = + const navigation = useNavigation>(); + const { navigate } = navigation; + const route = useRoute>(); const isFocused = useIsFocused(); const { useProvingStore, useSelfAppStore } = selfClient; const selectedApp = useSelfAppStore(state => state.selfApp); + + // Extract SelfApp data using hook + const { logoSource, url, formattedUserId, disclosureItems } = + useSelfAppData(selectedApp); + + // Check for stale data and navigate to Home if needed + useSelfAppStalenessCheck( + selectedApp, + disclosureItems, + navigation as NativeStackNavigationProp, + ); const selectedAppRef = useRef(null); const processedSessionsRef = useRef>(new Set()); const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false); const [scrollViewContentHeight, setScrollViewContentHeight] = useState(0); const [scrollViewHeight, setScrollViewHeight] = useState(0); - const [showFullAddress, setShowFullAddress] = useState(false); + const [hasLayoutMeasurements, setHasLayoutMeasurements] = useState(false); const [isDocumentExpired, setIsDocumentExpired] = useState(false); + const [documentType, setDocumentType] = useState(''); + const [walletModalOpen, setWalletModalOpen] = useState(false); const isDocumentExpiredRef = useRef(false); - const scrollViewRef = useRef(null); + const scrollViewRef = useRef(null); const isContentShorterThanScrollView = useMemo( () => scrollViewContentHeight <= scrollViewHeight, @@ -92,6 +107,7 @@ const ProveScreen: React.FC = () => { const addHistory = async () => { if (provingStore.uuid && selectedApp) { const catalog = await loadDocumentCatalog(); + const selectedDocumentId = catalog.selectedDocumentId; addProofHistory({ @@ -109,15 +125,18 @@ const ProveScreen: React.FC = () => { } }; addHistory(); - }, [addProofHistory, provingStore.uuid, selectedApp, loadDocumentCatalog]); + }, [addProofHistory, loadDocumentCatalog, provingStore.uuid, selectedApp]); useEffect(() => { - if (isContentShorterThanScrollView) { - setHasScrolledToBottom(true); - } else { - setHasScrolledToBottom(false); + // Only update hasScrolledToBottom once we have real layout measurements + if (hasLayoutMeasurements) { + if (isContentShorterThanScrollView) { + setHasScrolledToBottom(true); + } else { + setHasScrolledToBottom(false); + } } - }, [isContentShorterThanScrollView]); + }, [isContentShorterThanScrollView, hasLayoutMeasurements]); useEffect(() => { if (!isFocused || !selectedApp) { @@ -142,6 +161,9 @@ const ProveScreen: React.FC = () => { setIsDocumentExpired(isExpired); isDocumentExpiredRef.current = isExpired; } + setDocumentType( + getDocumentTypeName(selectedDocument?.data?.documentCategory), + ); } catch (error) { console.error('Error checking document expiration:', error); setIsDocumentExpired(false); @@ -212,43 +234,6 @@ const ProveScreen: React.FC = () => { enhanceApp(); }, [selectedApp, selfClient]); - const disclosureOptions = useMemo(() => { - return (selectedApp?.disclosures as SelfAppDisclosureConfig) || []; - }, [selectedApp?.disclosures]); - - // Format the logo source based on whether it's a URL or base64 string - const logoSource = useMemo(() => { - if (!selectedApp?.logoBase64) { - return null; - } - - // Check if the logo is already a URL - if ( - selectedApp.logoBase64.startsWith('http://') || - selectedApp.logoBase64.startsWith('https://') - ) { - return { uri: selectedApp.logoBase64 }; - } - - // Otherwise handle as base64 as before - const base64String = selectedApp.logoBase64.startsWith('data:image') - ? selectedApp.logoBase64 - : `data:image/png;base64,${selectedApp.logoBase64}`; - return { uri: base64String }; - }, [selectedApp?.logoBase64]); - - const url = useMemo(() => { - if (!selectedApp?.endpoint) { - return null; - } - return formatEndpoint(selectedApp.endpoint); - }, [selectedApp?.endpoint]); - - const formattedUserId = useMemo( - () => formatUserId(selectedApp?.userId, selectedApp?.userIdType), - [selectedApp?.userId, selectedApp?.userIdType], - ); - function onVerify() { provingStore.setUserConfirmed(selfClient); buttonTap(); @@ -299,218 +284,99 @@ const ProveScreen: React.FC = () => { const handleContentSizeChange = useCallback( (contentWidth: number, contentHeight: number) => { setScrollViewContentHeight(contentHeight); + // If we now have both measurements and content fits on screen, enable button immediately + if (contentHeight > 0 && scrollViewHeight > 0) { + setHasLayoutMeasurements(true); + if (contentHeight <= scrollViewHeight) { + setHasScrolledToBottom(true); + } + } }, - [], + [scrollViewHeight], ); - const handleScrollViewLayout = useCallback((event: LayoutChangeEvent) => { - setScrollViewHeight(event.nativeEvent.layout.height); - }, []); - - const handleAddressToggle = useCallback(() => { - if (selectedApp?.userIdType === 'hex') { - setShowFullAddress(!showFullAddress); - buttonTap(); - } - }, [selectedApp?.userIdType, showFullAddress]); + const handleScrollViewLayout = useCallback( + (event: LayoutChangeEvent) => { + const layoutHeight = event.nativeEvent.layout.height; + setScrollViewHeight(layoutHeight); + // If we now have both measurements and content fits on screen, enable button immediately + if (layoutHeight > 0 && scrollViewContentHeight > 0) { + setHasLayoutMeasurements(true); + if (scrollViewContentHeight <= layoutHeight) { + setHasScrolledToBottom(true); + } + } + }, + [scrollViewContentHeight], + ); return ( - - - - {!selectedApp?.sessionId ? ( - + + {formattedUserId && ( + setWalletModalOpen(true)} + testID="prove-screen-wallet-badge" + /> + )} + + + {disclosureItems.map((item, index) => ( + - ) : ( - - {logoSource && ( - - )} - - {url} - - - {selectedApp.appName} is requesting - you to prove the following information: - - - )} + ))} - - - - - - {/* Display connected wallet or UUID */} - {formattedUserId && ( - - - {selectedApp?.userIdType === 'hex' - ? 'Connected Wallet' - : 'Connected ID'} - : - - - - - - - {selectedApp?.userIdType === 'hex' && showFullAddress - ? selectedApp.userId - : formattedUserId} - - - {selectedApp?.userIdType === 'hex' && ( - - {showFullAddress ? ( - - ) : ( - - )} - - )} - - {selectedApp?.userIdType === 'hex' && ( - - {showFullAddress - ? 'Tap to hide address' - : 'Tap to show full address'} - - )} - - - - )} - - {/* Display userDefinedData if it exists */} - {selectedApp?.userDefinedData && ( - - - Additional Information: - - - - {selectedApp.userDefinedData} - - - - )} - - - - Self will confirm that these details are accurate and none of your - confidential info will be revealed to {selectedApp?.appName} - - - - + + + + {formattedUserId && selectedApp?.userId && ( + setWalletModalOpen(false)} + address={selectedApp.userId} + userIdType={selectedApp?.userIdType} + testID="prove-screen-wallet-modal" /> - - + )} + ); }; export default ProveScreen; const styles = StyleSheet.create({ - animation: { - top: 0, - width: 200, - height: 200, - transform: [{ scale: 2 }, { translateY: -20 }], + container: { + flex: 1, + backgroundColor: proofRequestColors.white, }, }); diff --git a/app/src/screens/verification/ProvingScreenRouter.tsx b/app/src/screens/verification/ProvingScreenRouter.tsx new file mode 100644 index 0000000000..dc589853ad --- /dev/null +++ b/app/src/screens/verification/ProvingScreenRouter.tsx @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ActivityIndicator } from 'react-native'; +import { Text, View } from 'tamagui'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +import { + isDocumentValidForProving, + pickBestDocumentToSelect, +} from '@selfxyz/mobile-sdk-alpha'; +import { blue600 } from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { proofRequestColors } from '@/components/proof-request'; +import type { RootStackParamList } from '@/navigation'; +import { usePassport } from '@/providers/passportDataProvider'; +import { useSettingStore } from '@/stores/settingStore'; +import { getDocumentTypeName } from '@/utils/documentUtils'; + +/** + * Router screen for the proving flow that decides whether to skip the document selector. + * + * This screen: + * 1. Loads document catalog and counts valid documents + * 2. Checks skip settings (skipDocumentSelector, skipDocumentSelectorIfSingle) + * 3. Routes to appropriate screen: + * - No valid documents -> DocumentDataNotFound + * - Skip enabled -> auto-select and go to Prove + * - Otherwise -> DocumentSelectorForProving + */ +const ProvingScreenRouter: React.FC = () => { + const navigation = + useNavigation>(); + const { loadDocumentCatalog, getAllDocuments, setSelectedDocument } = + usePassport(); + const { skipDocumentSelector, skipDocumentSelectorIfSingle } = + useSettingStore(); + const [error, setError] = useState(null); + const abortControllerRef = useRef(null); + const hasRoutedRef = useRef(false); + + const loadAndRoute = useCallback(async () => { + // Cancel any in-flight request + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + + // Prevent double routing + if (hasRoutedRef.current) { + return; + } + + setError(null); + try { + const catalog = await loadDocumentCatalog(); + const docs = await getAllDocuments(); + + // Don't continue if this request was aborted + if (controller.signal.aborted) { + return; + } + + // Count valid documents + const validDocuments = catalog.documents.filter(doc => { + const docData = docs[doc.id]; + return isDocumentValidForProving(doc, docData?.data); + }); + + const validCount = validDocuments.length; + + // Mark as routed to prevent re-routing + hasRoutedRef.current = true; + + // Route based on document availability and skip settings + if (validCount === 0) { + // No valid documents - redirect to onboarding + navigation.replace('DocumentDataNotFound'); + return; + } + + // Determine document type from first valid document for display + const firstValidDoc = validDocuments[0]; + const documentType = getDocumentTypeName(firstValidDoc?.documentCategory); + + // Determine if we should skip the selector + const shouldSkip = + skipDocumentSelector || + (skipDocumentSelectorIfSingle && validCount === 1); + + if (shouldSkip) { + // Auto-select and navigate to Prove + const docToSelect = pickBestDocumentToSelect(catalog, docs); + if (docToSelect) { + try { + await setSelectedDocument(docToSelect); + navigation.replace('Prove'); + } catch (selectError) { + console.error('Failed to auto-select document:', selectError); + // On error, fall back to showing the selector + hasRoutedRef.current = false; + navigation.replace('DocumentSelectorForProving', { + documentType, + }); + } + } else { + // No valid document to select, show selector + navigation.replace('DocumentSelectorForProving', { + documentType, + }); + } + } else { + // Show the document selector + navigation.replace('DocumentSelectorForProving', { + documentType, + }); + } + } catch (loadError) { + // Don't show error if this request was aborted + if (controller.signal.aborted) { + return; + } + console.warn('Failed to load documents for routing:', loadError); + setError('Unable to load documents.'); + // Reset routed flag to allow retry + hasRoutedRef.current = false; + } + }, [ + getAllDocuments, + loadDocumentCatalog, + navigation, + setSelectedDocument, + skipDocumentSelector, + skipDocumentSelectorIfSingle, + ]); + + useFocusEffect( + useCallback(() => { + // Reset routing flag when screen gains focus + hasRoutedRef.current = false; + loadAndRoute(); + }, [loadAndRoute]), + ); + + // Cleanup abort controller on unmount + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + return ( + + {error ? ( + + + {error} + + { + hasRoutedRef.current = false; + loadAndRoute(); + }} + pressStyle={{ opacity: 0.7 }} + testID="proving-router-retry" + > + + Retry + + + + ) : ( + <> + + + )} + + ); +}; + +export { ProvingScreenRouter }; diff --git a/app/src/screens/verification/QRCodeViewFinderScreen.tsx b/app/src/screens/verification/QRCodeViewFinderScreen.tsx index eea1055623..f01f1942f5 100644 --- a/app/src/screens/verification/QRCodeViewFinderScreen.tsx +++ b/app/src/screens/verification/QRCodeViewFinderScreen.tsx @@ -48,7 +48,7 @@ const QRCodeViewFinderScreen: React.FC = () => { const isFocused = useIsFocused(); const [doneScanningQR, setDoneScanningQR] = useState(false); const { top: safeAreaTop } = useSafeAreaInsets(); - const navigateToProve = useHapticNavigation('Prove'); + const navigateToDocumentSelector = useHapticNavigation('ProvingScreenRouter'); // This resets to the default state when we navigate back to this screen useFocusEffect( @@ -91,7 +91,7 @@ const QRCodeViewFinderScreen: React.FC = () => { .startAppListener(selfAppJson.sessionId); setTimeout(() => { - navigateToProve(); + navigateToDocumentSelector(); }, 100); } catch (parseError) { trackEvent(ProofEvents.QR_SCAN_FAILED, { @@ -115,7 +115,7 @@ const QRCodeViewFinderScreen: React.FC = () => { selfClient.getSelfAppState().startAppListener(sessionId); setTimeout(() => { - navigateToProve(); + navigateToDocumentSelector(); }, 100); } else { trackEvent(ProofEvents.QR_SCAN_FAILED, { @@ -129,7 +129,13 @@ const QRCodeViewFinderScreen: React.FC = () => { } } }, - [doneScanningQR, navigation, navigateToProve, trackEvent, selfClient], + [ + doneScanningQR, + navigation, + navigateToDocumentSelector, + trackEvent, + selfClient, + ], ); const shouldRenderCamera = !connectionModalVisible && !doneScanningQR; diff --git a/app/src/stores/settingStore.ts b/app/src/stores/settingStore.ts index 550bb792d7..4992344007 100644 --- a/app/src/stores/settingStore.ts +++ b/app/src/stores/settingStore.ts @@ -34,8 +34,12 @@ interface PersistedSettingsState { setKeychainMigrationCompleted: () => void; setLoggingSeverity: (severity: LoggingSeverity) => void; setPointsAddress: (address: string | null) => void; + setSkipDocumentSelector: (value: boolean) => void; + setSkipDocumentSelectorIfSingle: (value: boolean) => void; setSubscribedTopics: (topics: string[]) => void; setTurnkeyBackupEnabled: (turnkeyBackupEnabled: boolean) => void; + skipDocumentSelector: boolean; + skipDocumentSelectorIfSingle: boolean; subscribedTopics: string[]; toggleCloudBackupEnabled: () => void; turnkeyBackupEnabled: boolean; @@ -135,6 +139,14 @@ export const useSettingStore = create()( setPointsAddress: (address: string | null) => set({ pointsAddress: address }), + // Document selector skip settings + skipDocumentSelector: false, + setSkipDocumentSelector: (value: boolean) => + set({ skipDocumentSelector: value }), + skipDocumentSelectorIfSingle: true, + setSkipDocumentSelectorIfSingle: (value: boolean) => + set({ skipDocumentSelectorIfSingle: value }), + // Non-persisted state (will not be saved to storage) hideNetworkModal: false, setHideNetworkModal: (hideNetworkModal: boolean) => { diff --git a/app/src/utils/devUtils.ts b/app/src/utils/devUtils.ts index 1bd470b671..b26d770485 100644 --- a/app/src/utils/devUtils.ts +++ b/app/src/utils/devUtils.ts @@ -8,4 +8,4 @@ * Use this constant instead of checking __DEV__ directly throughout the codebase. */ export const IS_DEV_MODE = typeof __DEV__ !== 'undefined' && __DEV__; -export const IS_EUCLID_ENABLED = false; //IS_DEV_MODE; // just in case we forgot to turn it off before pushing to prod. +export const IS_EUCLID_ENABLED = false; // Enabled for proof request UI redesign diff --git a/app/src/utils/disclosureUtils.ts b/app/src/utils/disclosureUtils.ts new file mode 100644 index 0000000000..5bcde4df04 --- /dev/null +++ b/app/src/utils/disclosureUtils.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import type { Country3LetterCode } from '@selfxyz/common/constants'; +import { countryCodes } from '@selfxyz/common/constants'; +import type { SelfAppDisclosureConfig } from '@selfxyz/common/utils/appType'; + +function listToString(list: string[]): string { + if (list.length === 1) { + return list[0]; + } else if (list.length === 2) { + return list.join(' nor '); + } + return `${list.slice(0, -1).join(', ')} nor ${list.at(-1)}`; +} + +function countriesToSentence(countries: Country3LetterCode[]): string { + return listToString(countries.map(country => countryCodes[country])); +} + +export const ORDERED_DISCLOSURE_KEYS: Array = [ + 'issuing_state', + 'name', + 'passport_number', + 'nationality', + 'date_of_birth', + 'gender', + 'expiry_date', + 'ofac', + 'excludedCountries', + 'minimumAge', +] as const; + +export function getDisclosureItems( + disclosures: SelfAppDisclosureConfig, +): Array<{ key: string; text: string }> { + const items: Array<{ key: string; text: string }> = []; + + for (const key of ORDERED_DISCLOSURE_KEYS) { + const isEnabled = disclosures[key]; + if (!isEnabled || (Array.isArray(isEnabled) && isEnabled.length === 0)) { + continue; + } + + const text = getDisclosureText(key, disclosures); + if (text) { + items.push({ key, text }); + } + } + + return items; +} + +/** + * Generates the display text for a disclosure key. + * This is the single source of truth for disclosure text across the app. + */ +export function getDisclosureText( + key: keyof SelfAppDisclosureConfig, + disclosures: SelfAppDisclosureConfig, +): string { + switch (key) { + case 'ofac': + return 'I am not on the OFAC sanction list'; + case 'excludedCountries': + return `I am not a citizen of the following countries: ${countriesToSentence( + (disclosures.excludedCountries as Country3LetterCode[]) || [], + )}`; + case 'minimumAge': + return `Age is over ${disclosures.minimumAge}`; + case 'name': + return 'Name'; + case 'passport_number': + return 'Passport Number'; + case 'date_of_birth': + return 'Date of Birth'; + case 'gender': + return 'Gender'; + case 'expiry_date': + return 'Passport Expiry Date'; + case 'issuing_state': + return 'Issuing State'; + case 'nationality': + return 'Nationality'; + default: + return ''; + } +} diff --git a/app/src/utils/documentUtils.ts b/app/src/utils/documentUtils.ts new file mode 100644 index 0000000000..d60e271336 --- /dev/null +++ b/app/src/utils/documentUtils.ts @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +/** + * Gets the document type display name for the proof request message. + */ +export function getDocumentTypeName(category: string | undefined): string { + switch (category) { + case 'passport': + return 'Passport'; + case 'id_card': + return 'ID Card'; + case 'aadhaar': + return 'Aadhaar'; + default: + return 'Document'; + } +} diff --git a/app/src/utils/index.ts b/app/src/utils/index.ts index f54dee0ae7..62a866586e 100644 --- a/app/src/utils/index.ts +++ b/app/src/utils/index.ts @@ -39,6 +39,9 @@ export { extraYPadding, normalizeBorderWidth } from '@/utils/styleUtils'; // JSON utilities export { formatUserId } from '@/utils/formatUserId'; +// Document utilities +export { getDocumentTypeName } from '@/utils/documentUtils'; + export { getModalCallbacks, registerModalCallbacks, diff --git a/app/tests/__setup__/mocks/navigation.js b/app/tests/__setup__/mocks/navigation.js index 4dfcf7d383..83159a837c 100644 --- a/app/tests/__setup__/mocks/navigation.js +++ b/app/tests/__setup__/mocks/navigation.js @@ -9,10 +9,23 @@ jest.mock('@react-navigation/native', () => { const MockNavigator = (props, _ref) => props.children; MockNavigator.displayName = 'MockNavigator'; + // `useFocusEffect` should behave like an effect: it should not synchronously run + // on every re-render, otherwise any state updates inside the callback can cause + // an infinite render loop in tests. + const focusEffectCallbacks = new WeakSet(); + return { useFocusEffect: jest.fn(callback => { - // Immediately invoke the effect for testing without requiring a container - return callback(); + // Invoke only once per callback instance (per component mount), similar to + // how a real focus effect would run on focus rather than every render. + if ( + typeof callback === 'function' && + !focusEffectCallbacks.has(callback) + ) { + focusEffectCallbacks.add(callback); + return callback(); + } + return undefined; }), useNavigation: jest.fn(() => ({ navigate: jest.fn(), diff --git a/app/tests/__setup__/mocks/ui.js b/app/tests/__setup__/mocks/ui.js index 18d3b0d197..68c8b0e956 100644 --- a/app/tests/__setup__/mocks/ui.js +++ b/app/tests/__setup__/mocks/ui.js @@ -54,10 +54,18 @@ jest.mock('@selfxyz/mobile-sdk-alpha/components', () => { const Text = jest.fn(({ children, ...props }) => children || null); Text.displayName = 'MockText'; + const Title = jest.fn(({ children, ...props }) => children || null); + Title.displayName = 'MockTitle'; + + const View = jest.fn(({ children, ...props }) => children || null); + View.displayName = 'MockView'; + return { __esModule: true, Button, XStack, + Title, + View, // Provide minimal Text to satisfy potential usages Text, }; @@ -175,6 +183,10 @@ jest.mock('@tamagui/lucide-icons', () => { ExternalLink: makeIcon('external-link'), X: makeIcon('x'), Clipboard: makeIcon('clipboard'), + Check: makeIcon('check'), + Circle: makeIcon('circle'), + ChevronDown: makeIcon('chevron-down'), + ChevronLeft: makeIcon('chevron-left'), }; }); diff --git a/app/tests/src/components/documents/IDSelectorSheet.test.tsx b/app/tests/src/components/documents/IDSelectorSheet.test.tsx new file mode 100644 index 0000000000..255d6f35ec --- /dev/null +++ b/app/tests/src/components/documents/IDSelectorSheet.test.tsx @@ -0,0 +1,225 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { fireEvent, render } from '@testing-library/react-native'; + +import type { IDSelectorDocument } from '@/components/documents'; +import { IDSelectorItem, IDSelectorSheet } from '@/components/documents'; + +describe('IDSelectorItem', () => { + const mockOnPress = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders with testID', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('test-item')).toBeTruthy(); + }); + + it('calls onPress when pressed on active state', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('test-item')); + expect(mockOnPress).toHaveBeenCalledTimes(1); + }); + + it('calls onPress when pressed on verified state', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('test-item')); + expect(mockOnPress).toHaveBeenCalledTimes(1); + }); + + it('renders different states correctly', () => { + // Render active state + const { rerender, getByTestId } = render( + , + ); + expect(getByTestId('test-item')).toBeTruthy(); + + // Rerender with verified state + rerender( + , + ); + expect(getByTestId('test-item')).toBeTruthy(); + + // Rerender with expired state + rerender( + , + ); + expect(getByTestId('test-item')).toBeTruthy(); + + // Rerender with mock state + rerender( + , + ); + expect(getByTestId('test-item')).toBeTruthy(); + }); +}); + +describe('IDSelectorSheet', () => { + const mockDocuments: IDSelectorDocument[] = [ + { id: 'doc1', name: 'EU ID', state: 'verified' }, + { id: 'doc2', name: 'FRA Passport', state: 'verified' }, + { id: 'doc3', name: 'Dev USA Passport', state: 'mock' }, + { id: 'doc4', name: 'Aadhaar ID', state: 'expired' }, + ]; + + const mockOnOpenChange = jest.fn(); + const mockOnSelect = jest.fn(); + const mockOnDismiss = jest.fn(); + const mockOnApprove = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders document items with testIDs', () => { + const { getByTestId } = render( + , + ); + + // Document items use Pressable which properly passes testID + expect(getByTestId('sheet-item-doc1')).toBeTruthy(); + expect(getByTestId('sheet-item-doc2')).toBeTruthy(); + expect(getByTestId('sheet-item-doc3')).toBeTruthy(); + expect(getByTestId('sheet-item-doc4')).toBeTruthy(); + }); + + it('calls onSelect when a document item is pressed', () => { + const { getByTestId } = render( + , + ); + + // Press doc2 item + fireEvent.press(getByTestId('sheet-item-doc2')); + expect(mockOnSelect).toHaveBeenCalledWith('doc2'); + }); + + it('renders empty list without document items', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('sheet-item-doc1')).toBeNull(); + expect(queryByTestId('sheet-item-doc2')).toBeNull(); + }); + + it('shows selected document as active', () => { + const { getByTestId } = render( + , + ); + + // The selected item should have the check icon (indicating active state) + expect(getByTestId('icon-check')).toBeTruthy(); + }); + + it('calls onSelect with different document IDs', () => { + const { getByTestId } = render( + , + ); + + // Press each item and verify the correct ID is passed + fireEvent.press(getByTestId('sheet-item-doc1')); + expect(mockOnSelect).toHaveBeenLastCalledWith('doc1'); + + fireEvent.press(getByTestId('sheet-item-doc2')); + expect(mockOnSelect).toHaveBeenLastCalledWith('doc2'); + + fireEvent.press(getByTestId('sheet-item-doc3')); + expect(mockOnSelect).toHaveBeenLastCalledWith('doc3'); + + fireEvent.press(getByTestId('sheet-item-doc4')); + expect(mockOnSelect).toHaveBeenLastCalledWith('doc4'); + }); +}); diff --git a/app/tests/src/hooks/useEarnPointsFlow.test.ts b/app/tests/src/hooks/useEarnPointsFlow.test.ts index 40ff79ddd9..5b003e3b76 100644 --- a/app/tests/src/hooks/useEarnPointsFlow.test.ts +++ b/app/tests/src/hooks/useEarnPointsFlow.test.ts @@ -268,7 +268,7 @@ describe('useEarnPointsFlow', () => { jest.advanceTimersByTime(100); }); - expect(mockNavigate).toHaveBeenCalledWith('Prove'); + expect(mockNavigate).toHaveBeenCalledWith('ProvingScreenRouter'); }); it('should clear referrer when points disclosure modal is dismissed with referrer', async () => { diff --git a/app/tests/src/navigation.test.tsx b/app/tests/src/navigation.test.tsx index 5f0ea721c0..9a02ad058b 100644 --- a/app/tests/src/navigation.test.tsx +++ b/app/tests/src/navigation.test.tsx @@ -62,6 +62,7 @@ describe('navigation', () => { 'DocumentNFCScan', 'DocumentNFCTrouble', 'DocumentOnboarding', + 'DocumentSelectorForProving', 'Gratification', 'Home', 'IDPicker', @@ -75,7 +76,9 @@ describe('navigation', () => { 'ProofHistory', 'ProofHistoryDetail', 'ProofRequestStatus', + 'ProofSettings', 'Prove', + 'ProvingScreenRouter', 'QRCodeTrouble', 'QRCodeViewFinder', 'RecoverWithPhrase', diff --git a/app/tests/src/navigation/deeplinks.test.ts b/app/tests/src/navigation/deeplinks.test.ts index 1c97a10b3f..7ce0027e17 100644 --- a/app/tests/src/navigation/deeplinks.test.ts +++ b/app/tests/src/navigation/deeplinks.test.ts @@ -92,7 +92,7 @@ describe('deeplinks', () => { const { navigationRef } = require('@/navigation'); expect(navigationRef.reset).toHaveBeenCalledWith({ index: 1, - routes: [{ name: 'Home' }, { name: 'Prove' }], + routes: [{ name: 'Home' }, { name: 'ProvingScreenRouter' }], }); }); @@ -118,7 +118,7 @@ describe('deeplinks', () => { const { navigationRef } = require('@/navigation'); expect(navigationRef.reset).toHaveBeenCalledWith({ index: 1, - routes: [{ name: 'Home' }, { name: 'Prove' }], + routes: [{ name: 'Home' }, { name: 'ProvingScreenRouter' }], }); }); diff --git a/app/tests/src/screens/verification/DocumentSelectorForProvingScreen.test.tsx b/app/tests/src/screens/verification/DocumentSelectorForProvingScreen.test.tsx new file mode 100644 index 0000000000..3ef5587da5 --- /dev/null +++ b/app/tests/src/screens/verification/DocumentSelectorForProvingScreen.test.tsx @@ -0,0 +1,468 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { useNavigation } from '@react-navigation/native'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; + +import type { + DocumentCatalog, + DocumentMetadata, + IDDocument, +} from '@selfxyz/common/utils/types'; +import { + getDocumentAttributes, + isDocumentValidForProving, + useSelfClient, +} from '@selfxyz/mobile-sdk-alpha'; + +import { usePassport } from '@/providers/passportDataProvider'; +import { DocumentSelectorForProvingScreen } from '@/screens/verification/DocumentSelectorForProvingScreen'; + +// Mock useFocusEffect to behave like useEffect in tests +// Note: We use a closure-based approach to avoid requiring React (prevents OOM per test-memory-optimization rules) +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + + // Track execution per component instance using a Map + const executionMap = new Map(); + + return { + ...actual, + useFocusEffect: (callback: () => void | (() => void)) => { + // Use a stable object as key - in real usage, callback is stable due to useCallback + if (!executionMap.has(callback)) { + executionMap.set(callback, true); + // Schedule callback to run after current render (simulates focus effect) + Promise.resolve().then(() => { + const cleanup = callback(); + if (typeof cleanup === 'function') { + cleanup(); + } + }); + } + }, + }; +}); + +// Mock the WalletAddressModal to avoid Modal rendering issues in tests +// Note: We return a simple string component directly to avoid requiring React (prevents OOM in CI) +jest.mock('@/components/proof-request/WalletAddressModal', () => ({ + WalletAddressModal: jest.fn(() => null), +})); + +jest.mock('@selfxyz/mobile-sdk-alpha', () => ({ + useSelfClient: jest.fn(), + getDocumentAttributes: jest.fn(), + isDocumentValidForProving: jest.fn(), +})); + +jest.mock('@/providers/passportDataProvider', () => ({ + usePassport: jest.fn(), +})); + +const mockUseNavigation = useNavigation as jest.MockedFunction< + typeof useNavigation +>; +const mockUseSelfClient = useSelfClient as jest.MockedFunction< + typeof useSelfClient +>; +const mockGetDocumentAttributes = getDocumentAttributes as jest.MockedFunction< + typeof getDocumentAttributes +>; +const mockIsDocumentValidForProving = + isDocumentValidForProving as jest.MockedFunction< + typeof isDocumentValidForProving + >; +const mockUsePassport = usePassport as jest.MockedFunction; + +type MockDocumentEntry = { + metadata: DocumentMetadata; + data: IDDocument; +}; + +const createMetadata = ( + overrides: Partial & { id: string }, +): DocumentMetadata => ({ + id: overrides.id, + documentType: overrides.documentType ?? 'us', + documentCategory: overrides.documentCategory ?? 'passport', + data: overrides.data ?? 'mock-data', + mock: overrides.mock ?? false, + isRegistered: overrides.isRegistered, + registeredAt: overrides.registeredAt, +}); + +const createDocumentEntry = ( + metadata: DocumentMetadata, + expiryDateSlice?: string, + nationalitySlice?: string, +): MockDocumentEntry => ({ + metadata, + data: { + documentType: metadata.documentType as any, + documentCategory: metadata.documentCategory as any, + mock: metadata.mock, + expiryDateSlice, + nationalitySlice, + } as unknown as IDDocument, +}); + +const createAllDocuments = (entries: MockDocumentEntry[]) => + entries.reduce< + Record + >((acc, entry) => { + acc[entry.metadata.id] = { + data: entry.data, + metadata: entry.metadata, + }; + return acc; + }, {}); + +const mockSelfApp = { + appName: 'Example App', + endpoint: 'https://example.com', + logoBase64: 'https://example.com/logo.png', + sessionId: 'session-id', + disclosures: { + name: true, + passport_number: true, + }, + userId: '0x1234567890abcdef1234567890abcdef12345678', + userIdType: 'hex', +}; + +const mockNavigate = jest.fn(); +const mockLoadDocumentCatalog = jest.fn(); +const mockGetAllDocuments = jest.fn(); +const mockSetSelectedDocument = jest.fn(); + +// Stable passport context to prevent infinite re-renders +const stablePassportContext = { + loadDocumentCatalog: mockLoadDocumentCatalog, + getAllDocuments: mockGetAllDocuments, + setSelectedDocument: mockSetSelectedDocument, +}; + +// Stable navigation object +const stableNavigation = { + navigate: mockNavigate, +}; + +// Stable self client selector function +const stableSelfAppSelector = ( + selector: (state: { selfApp: typeof mockSelfApp }) => unknown, +) => selector({ selfApp: mockSelfApp }); + +// Stable self client object +const stableSelfClient = { + useSelfAppStore: stableSelfAppSelector, +}; + +describe('DocumentSelectorForProvingScreen', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseNavigation.mockReturnValue(stableNavigation as any); + + mockUseSelfClient.mockReturnValue(stableSelfClient as any); + + mockUsePassport.mockReturnValue(stablePassportContext as any); + + mockIsDocumentValidForProving.mockImplementation( + (_metadata, documentData) => + (documentData as { expiryDateSlice?: string } | undefined) + ?.expiryDateSlice !== 'expired', + ); + mockGetDocumentAttributes.mockImplementation((documentData: unknown) => ({ + nameSlice: '', + dobSlice: '', + yobSlice: '', + issuingStateSlice: '', + nationalitySlice: + (documentData as { nationalitySlice?: string })?.nationalitySlice || '', + passNoSlice: '', + sexSlice: '', + expiryDateSlice: + (documentData as { expiryDateSlice?: string })?.expiryDateSlice || '', + isPassportType: true, + })); + }); + + describe('Loading and Initial State', () => { + it('loads documents on mount and renders action bar', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + selectedDocumentId: 'doc-1', + }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + + const { getByTestId } = render(); + + // Wait for documents to load and verify action bar buttons are rendered + // Note: Tamagui View doesn't forward testID, but Pressable children do + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve'), + ).toBeTruthy(); + expect( + getByTestId('document-selector-action-bar-document-selector'), + ).toBeTruthy(); + }); + + // Verify mocks were called + expect(mockLoadDocumentCatalog).toHaveBeenCalledTimes(1); + expect(mockGetAllDocuments).toHaveBeenCalledTimes(1); + }); + + it('renders wallet badge when userId is present', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([createDocumentEntry(passport)]), + ); + + const { getByTestId } = render(); + + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve'), + ).toBeTruthy(); + }); + + // Wallet badge is a Pressable so testID works + expect( + getByTestId('document-selector-wallet-badge-pressable'), + ).toBeTruthy(); + }); + }); + + describe('Document Selection', () => { + it('enables approve button when valid documents exist', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([createDocumentEntry(passport)]), + ); + + const { getByTestId } = render(); + + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve').props.disabled, + ).toBe(false); + }); + }); + + it('auto-selects first valid document when current selection is expired', async () => { + const expiredPassport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const validCard = createMetadata({ + id: 'doc-2', + documentType: 'ca', + documentCategory: 'id_card', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [expiredPassport, validCard], + selectedDocumentId: 'doc-1', // Currently selected is expired + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([ + createDocumentEntry(expiredPassport, 'expired'), + createDocumentEntry(validCard), + ]), + ); + + const { getByTestId } = render(); + + // Should auto-select the valid document (doc-2) + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve').props.disabled, + ).toBe(false); + }); + + // Approve should select the auto-selected valid document + fireEvent.press(getByTestId('document-selector-action-bar-approve')); + + await waitFor(() => { + expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-2'); + }); + }); + + it('disables approve button when only expired documents exist', async () => { + const expiredPassport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const expiredCard = createMetadata({ + id: 'doc-2', + documentType: 'ca', + documentCategory: 'id_card', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [expiredPassport, expiredCard], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([ + createDocumentEntry(expiredPassport, 'expired'), + createDocumentEntry(expiredCard, 'expired'), + ]), + ); + + const { getByTestId } = render(); + + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve').props.disabled, + ).toBe(true); + }); + }); + }); + + describe('Navigation and Approval', () => { + it('navigates to Prove screen after successful approval', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([createDocumentEntry(passport)]), + ); + + const { getByTestId } = render(); + + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve').props.disabled, + ).toBe(false); + }); + + // Press approve directly from action bar + fireEvent.press(getByTestId('document-selector-action-bar-approve')); + + await waitFor(() => { + expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-1'); + expect(mockNavigate).toHaveBeenCalledWith('Prove', expect.any(Object)); + }); + }); + }); + + describe('Error Handling', () => { + it('shows error state when document loading fails', async () => { + mockLoadDocumentCatalog.mockRejectedValue(new Error('failure')); + mockGetAllDocuments.mockResolvedValue({}); + + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + render(); + + // Wait for the load to fail and verify the error was logged + await waitFor(() => { + expect(mockLoadDocumentCatalog).toHaveBeenCalledTimes(1); + }); + + // Verify error was logged (component shows error state) + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Failed to load documents:', + expect.any(Error), + ); + + consoleWarnSpy.mockRestore(); + }); + + it('shows error when document selection fails during approval', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([createDocumentEntry(passport)]), + ); + mockSetSelectedDocument.mockRejectedValue(new Error('Selection failed')); + + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const { getByTestId } = render(); + + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve').props.disabled, + ).toBe(false); + }); + + // Press approve directly from action bar + fireEvent.press(getByTestId('document-selector-action-bar-approve')); + + // Verify error was logged and navigation did not occur + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to set selected document:', + expect.any(Error), + ); + }); + + expect(mockNavigate).not.toHaveBeenCalledWith( + 'Prove', + expect.any(Object), + ); + + consoleErrorSpy.mockRestore(); + }); + }); +}); diff --git a/app/tests/src/screens/verification/ProvingScreenRouter.test.tsx b/app/tests/src/screens/verification/ProvingScreenRouter.test.tsx new file mode 100644 index 0000000000..027f2735aa --- /dev/null +++ b/app/tests/src/screens/verification/ProvingScreenRouter.test.tsx @@ -0,0 +1,321 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { useNavigation } from '@react-navigation/native'; +import { render, waitFor } from '@testing-library/react-native'; + +import type { + DocumentCatalog, + DocumentMetadata, + IDDocument, +} from '@selfxyz/common/utils/types'; +import { + isDocumentValidForProving, + pickBestDocumentToSelect, +} from '@selfxyz/mobile-sdk-alpha'; + +import { usePassport } from '@/providers/passportDataProvider'; +import { ProvingScreenRouter } from '@/screens/verification/ProvingScreenRouter'; +import { useSettingStore } from '@/stores/settingStore'; + +// Mock useFocusEffect to behave like useEffect in tests +// Note: We use jest.requireActual for React to avoid nested require() which causes OOM in CI +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + const ReactActual = jest.requireActual('react'); + return { + ...actual, + useFocusEffect: (callback: () => void) => { + ReactActual.useEffect(() => { + callback(); + }, [callback]); + }, + }; +}); + +jest.mock('@selfxyz/mobile-sdk-alpha', () => ({ + isDocumentValidForProving: jest.fn(), + pickBestDocumentToSelect: jest.fn(), +})); + +jest.mock('@/providers/passportDataProvider', () => ({ + usePassport: jest.fn(), +})); + +jest.mock('@/stores/settingStore', () => ({ + useSettingStore: jest.fn(), +})); + +const mockUseNavigation = useNavigation as jest.MockedFunction< + typeof useNavigation +>; +const mockIsDocumentValidForProving = + isDocumentValidForProving as jest.MockedFunction< + typeof isDocumentValidForProving + >; +const mockPickBestDocumentToSelect = + pickBestDocumentToSelect as jest.MockedFunction< + typeof pickBestDocumentToSelect + >; +const mockUsePassport = usePassport as jest.MockedFunction; +const mockUseSettingStore = useSettingStore as jest.MockedFunction< + typeof useSettingStore +>; +const mockReplace = jest.fn(); +const mockLoadDocumentCatalog = jest.fn(); +const mockGetAllDocuments = jest.fn(); +const mockSetSelectedDocument = jest.fn(); + +type MockDocumentEntry = { + metadata: DocumentMetadata; + data: IDDocument; +}; + +const createMetadata = ( + overrides: Partial & { id: string }, +): DocumentMetadata => ({ + id: overrides.id, + documentType: overrides.documentType ?? 'us', + documentCategory: overrides.documentCategory ?? 'passport', + data: overrides.data ?? 'mock-data', + mock: overrides.mock ?? false, + isRegistered: overrides.isRegistered, + registeredAt: overrides.registeredAt, +}); + +const createDocumentEntry = ( + metadata: DocumentMetadata, + expiryDateSlice?: string, +): MockDocumentEntry => ({ + metadata, + data: { + documentType: metadata.documentType as any, + documentCategory: metadata.documentCategory as any, + mock: metadata.mock, + expiryDateSlice, + } as unknown as IDDocument, +}); + +const createAllDocuments = (entries: MockDocumentEntry[]) => + entries.reduce< + Record + >((acc, entry) => { + acc[entry.metadata.id] = { + data: entry.data, + metadata: entry.metadata, + }; + return acc; + }, {}); + +describe('ProvingScreenRouter', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseNavigation.mockReturnValue({ replace: mockReplace } as any); + + mockUsePassport.mockReturnValue({ + loadDocumentCatalog: mockLoadDocumentCatalog, + getAllDocuments: mockGetAllDocuments, + setSelectedDocument: mockSetSelectedDocument, + } as any); + + mockUseSettingStore.mockReturnValue({ + skipDocumentSelector: false, + skipDocumentSelectorIfSingle: false, + } as any); + + mockIsDocumentValidForProving.mockImplementation( + (_metadata, documentData) => + (documentData as { expiryDateSlice?: string } | undefined) + ?.expiryDateSlice !== 'expired', + ); + }); + + it('routes to DocumentDataNotFound when no valid documents exist', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + }; + const allDocs = createAllDocuments([ + createDocumentEntry(passport, 'expired'), + ]); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + + render(); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('DocumentDataNotFound'); + }); + }); + + it('auto-selects and routes to Prove when skipping the selector', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); + + mockUseSettingStore.mockReturnValue({ + skipDocumentSelector: true, + skipDocumentSelectorIfSingle: false, + } as any); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + mockPickBestDocumentToSelect.mockReturnValue('doc-1'); + + render(); + + await waitFor(() => { + expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-1'); + expect(mockReplace).toHaveBeenCalledWith('Prove'); + }); + }); + + it('routes to the document selector when skipping is disabled', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + + render(); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('DocumentSelectorForProving', { + documentType: 'Passport', + }); + }); + }); + + it('shows error state when document loading fails', async () => { + mockLoadDocumentCatalog.mockRejectedValue(new Error('failure')); + mockGetAllDocuments.mockResolvedValue({}); + + render(); + + // Verify that the load was attempted and navigation was NOT called + await waitFor(() => { + expect(mockLoadDocumentCatalog).toHaveBeenCalledTimes(1); + }); + + // The error path should NOT navigate anywhere + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('auto-selects when skipDocumentSelectorIfSingle is true with exactly 1 valid document', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); + + mockUseSettingStore.mockReturnValue({ + skipDocumentSelector: false, + skipDocumentSelectorIfSingle: true, + } as any); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + mockPickBestDocumentToSelect.mockReturnValue('doc-1'); + + render(); + + await waitFor(() => { + expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-1'); + expect(mockReplace).toHaveBeenCalledWith('Prove'); + }); + }); + + it('shows document selector when skipDocumentSelectorIfSingle is true with multiple valid documents', async () => { + const passport1 = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const passport2 = createMetadata({ + id: 'doc-2', + documentType: 'gb', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport1, passport2], + }; + const allDocs = createAllDocuments([ + createDocumentEntry(passport1), + createDocumentEntry(passport2), + ]); + + mockUseSettingStore.mockReturnValue({ + skipDocumentSelector: false, + skipDocumentSelectorIfSingle: true, + } as any); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + + render(); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('DocumentSelectorForProving', { + documentType: 'Passport', + }); + }); + + // Should NOT auto-select since there are multiple documents + expect(mockSetSelectedDocument).not.toHaveBeenCalled(); + }); + + it('falls back to document selector when setSelectedDocument fails', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); + + mockUseSettingStore.mockReturnValue({ + skipDocumentSelector: true, + skipDocumentSelectorIfSingle: false, + } as any); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + mockPickBestDocumentToSelect.mockReturnValue('doc-1'); + mockSetSelectedDocument.mockRejectedValue(new Error('Selection failed')); + + render(); + + await waitFor(() => { + expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-1'); + expect(mockReplace).toHaveBeenCalledWith('DocumentSelectorForProving', { + documentType: 'Passport', + }); + }); + }); +}); diff --git a/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx b/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx index 9ee8068a83..3375113495 100644 --- a/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx +++ b/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx @@ -254,7 +254,7 @@ export const HeldPrimaryButtonProveScreen: React.FC= expiryDateUTC; +} + +/** + * Extracts attributes from Aadhaar document data + */ +function getAadhaarAttributes(document: AadhaarData): DocumentAttributes { + const extractedFields = document.extractedFields; + // For Aadhaar, we format the name to work with the existing getNameAndSurname function + // We'll put the full name in the "surname" position and leave names empty + const fullName = extractedFields?.name || ''; + const nameSliceFormatted = fullName ? `${fullName}<<` : ''; // Format like MRZ + + // Format DOB to YYMMDD for consistency with passport format + let dobFormatted = ''; + if (extractedFields?.dob && extractedFields?.mob && extractedFields?.yob) { + const year = extractedFields.yob.length === 4 ? extractedFields.yob.slice(-2) : extractedFields.yob; + const month = extractedFields.mob.padStart(2, '0'); + const day = extractedFields.dob.padStart(2, '0'); + dobFormatted = `${year}${month}${day}`; + } + + return { + nameSlice: nameSliceFormatted, + dobSlice: dobFormatted, + yobSlice: extractedFields?.yob || '', + issuingStateSlice: extractedFields?.state || '', + nationalitySlice: 'IND', // Aadhaar is always Indian + passNoSlice: extractedFields?.aadhaarLast4Digits || '', + sexSlice: + extractedFields?.gender === 'M' ? 'M' : extractedFields?.gender === 'F' ? 'F' : extractedFields?.gender || '', + expiryDateSlice: '', // Aadhaar doesn't expire + isPassportType: false, + }; +} + +/** + * Extracts attributes from MRZ string (passport or ID card) + */ +function getPassportAttributes(mrz: string, documentCategory: string): DocumentAttributes { + const isPassportType = documentCategory === 'passport'; + const attributePositions = isPassportType ? attributeToPosition : attributeToPosition_ID; + + const nameSlice = mrz.slice(attributePositions.name[0], attributePositions.name[1]); + const dobSlice = mrz.slice(attributePositions.date_of_birth[0], attributePositions.date_of_birth[1] + 1); + const yobSlice = mrz.slice(attributePositions.date_of_birth[0], attributePositions.date_of_birth[0] + 2); + const issuingStateSlice = mrz.slice(attributePositions.issuing_state[0], attributePositions.issuing_state[1] + 1); + const nationalitySlice = mrz.slice(attributePositions.nationality[0], attributePositions.nationality[1] + 1); + const passNoSlice = mrz.slice(attributePositions.passport_number[0], attributePositions.passport_number[1] + 1); + const sexSlice = mrz.slice(attributePositions.gender[0], attributePositions.gender[1] + 1); + const expiryDateSlice = mrz.slice(attributePositions.expiry_date[0], attributePositions.expiry_date[1] + 1); + return { + nameSlice, + dobSlice, + yobSlice, + issuingStateSlice, + nationalitySlice, + passNoSlice, + sexSlice, + expiryDateSlice, + isPassportType, + }; +} + +/** + * Extracts document attributes from passport, ID card, or Aadhaar data. + * + * @param document - Document data (PassportData, AadhaarData, or IDDocument) + * @returns Document attributes including name, DOB, expiry date, etc. + */ +export function getDocumentAttributes(document: PassportData | AadhaarData): DocumentAttributes { + if (isAadhaarDocument(document)) { + return getAadhaarAttributes(document); + } else if (isMRZDocument(document)) { + return getPassportAttributes(document.mrz, document.documentCategory); + } else { + // Fallback for unknown document types + return { + nameSlice: '', + dobSlice: '', + yobSlice: '', + issuingStateSlice: '', + nationalitySlice: '', + passNoSlice: '', + sexSlice: '', + expiryDateSlice: '', + isPassportType: false, + }; + } +} + +/** + * Checks if a document is valid for use in proving flows. + * A document is valid if it is not expired. + * Mock documents are considered valid for testing with staging environments. + * + * @param metadata - Document metadata from catalog + * @param documentData - Full document data (optional, used for expiry check) + * @returns true if document can be used for proving + */ +export function isDocumentValidForProving(metadata: DocumentMetadata, documentData?: IDDocument): boolean { + // Check if expired + if (documentData) { + try { + const attributes = getDocumentAttributes(documentData); + if (attributes.expiryDateSlice && checkDocumentExpiration(attributes.expiryDateSlice)) { + return false; + } + } catch { + // If we can't check expiry, assume valid + } + } + + return true; +} + +/** + * Picks the best document to auto-select from a catalog. + * Prefers the currently selected document if valid, otherwise picks the first valid one. + * + * @param catalog - Document catalog + * @param documents - Map of document ID to document data + * @returns Document ID to select, or undefined if no valid documents + */ +export function pickBestDocumentToSelect( + catalog: DocumentCatalog, + documents: Record, +): string | undefined { + // Check if currently selected document is valid + if (catalog.selectedDocumentId) { + const selectedMeta = catalog.documents.find(doc => doc.id === catalog.selectedDocumentId); + const selectedData = selectedMeta ? documents[catalog.selectedDocumentId] : undefined; + + if (selectedMeta && isDocumentValidForProving(selectedMeta, selectedData?.data)) { + return catalog.selectedDocumentId; + } + } + + // Find first valid document + const firstValid = catalog.documents.find(doc => { + const docData = documents[doc.id]; + return isDocumentValidForProving(doc, docData?.data); + }); + + return firstValid?.id; +} diff --git a/packages/mobile-sdk-alpha/src/index.ts b/packages/mobile-sdk-alpha/src/index.ts index b1517f99cf..7ebeae4908 100644 --- a/packages/mobile-sdk-alpha/src/index.ts +++ b/packages/mobile-sdk-alpha/src/index.ts @@ -33,6 +33,8 @@ export type { BaseContext, NFCScanContext, ProofContext } from './proving/intern export type { DG1, DG2, ParsedNFCResponse } from './nfc'; +export type { DocumentAttributes } from './documents/validation'; + export type { DocumentData, DocumentMetadata, PassportCameraProps, ScreenProps } from './types/ui'; export type { HapticOptions, HapticType } from './haptic/shared'; @@ -97,7 +99,13 @@ export { triggerFeedback, } from './haptic'; -/** @deprecated Use createSelfClient().extractMRZInfo or import from './mrz' */ +export { + checkDocumentExpiration, + getDocumentAttributes, + isDocumentValidForProving, + pickBestDocumentToSelect, +} from './documents/validation'; + export { clearPassportData, getAllDocuments, @@ -114,9 +122,10 @@ export { defaultConfig } from './config/defaults'; export { defaultOptions } from './haptic/shared'; -export { extractMRZInfo, extractNameFromMRZ, formatDateToYYMMDD } from './mrz'; - +/** @deprecated Use createSelfClient().extractMRZInfo or import from './mrz' */ +export { extractMRZInfo } from './mrz'; export { extractNameFromDocument } from './documents/utils'; +export { extractNameFromMRZ, formatDateToYYMMDD } from './mrz'; export { generateMockDocument, signatureAlgorithmToStrictSignatureAlgorithm } from './mock/generator'; diff --git a/packages/mobile-sdk-alpha/tests/documents/validation.test.ts b/packages/mobile-sdk-alpha/tests/documents/validation.test.ts new file mode 100644 index 0000000000..7a50013247 --- /dev/null +++ b/packages/mobile-sdk-alpha/tests/documents/validation.test.ts @@ -0,0 +1,314 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { describe, expect, it } from 'vitest'; + +import type { DocumentCatalog, DocumentMetadata } from '@selfxyz/common/types'; +import type { PassportData } from '@selfxyz/common/types/passport'; + +import { + checkDocumentExpiration, + isDocumentValidForProving, + pickBestDocumentToSelect, +} from '../../src/documents/validation'; + +describe('checkDocumentExpiration', () => { + it('returns false for invalid format (too short)', () => { + expect(checkDocumentExpiration('1234')).toBe(false); + }); + + it('returns false for invalid format (too long)', () => { + expect(checkDocumentExpiration('1234567')).toBe(false); + }); + + it('returns false for empty string', () => { + expect(checkDocumentExpiration('')).toBe(false); + }); + + it('returns true for expired date (past date)', () => { + // Date in 2020 + expect(checkDocumentExpiration('200101')).toBe(true); + }); + + it('returns false for future date', () => { + // Date in 2050 + expect(checkDocumentExpiration('500101')).toBe(false); + }); + + it('returns true for today (expired as of today)', () => { + const now = new Date(); + const year = now.getFullYear().toString().slice(-2); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); + const day = now.getDate().toString().padStart(2, '0'); + const today = `${year}${month}${day}`; + // Document that expires today is considered expired + expect(checkDocumentExpiration(today)).toBe(true); + }); + + it('returns true for yesterday (expired)', () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const year = yesterday.getFullYear().toString().slice(-2); + const month = (yesterday.getMonth() + 1).toString().padStart(2, '0'); + const day = yesterday.getDate().toString().padStart(2, '0'); + const yesterdayStr = `${year}${month}${day}`; + expect(checkDocumentExpiration(yesterdayStr)).toBe(true); + }); +}); + +describe('isDocumentValidForProving', () => { + const mockMetadata: DocumentMetadata = { + id: 'test-id', + documentType: 'passport', + documentCategory: 'passport', + data: 'mock-data', + mock: false, + }; + + it('returns true for document without data (cannot check expiry)', () => { + expect(isDocumentValidForProving(mockMetadata)).toBe(true); + }); + + it('returns true for mock document', () => { + const mockDoc: DocumentMetadata = { + ...mockMetadata, + mock: true, + }; + expect(isDocumentValidForProving(mockDoc)).toBe(true); + }); + + it('returns true for valid passport with future expiry', () => { + // MRZ with expiry date 501231 (December 31, 2050) + const validPassport: PassportData = { + mrz: 'P { + // Passport expired in 2012 + const expiredPassport: PassportData = { + mrz: 'P { + const invalidDocument = { + documentType: 'passport', + documentCategory: 'passport', + mock: false, + } as any; + + expect(isDocumentValidForProving(mockMetadata, invalidDocument)).toBe(true); + }); +}); + +describe('pickBestDocumentToSelect', () => { + // MRZ with expiry date 501231 (December 31, 2050) + const validPassport: PassportData = { + mrz: 'P { + const catalog: DocumentCatalog = { + documents: [], + }; + expect(pickBestDocumentToSelect(catalog, {})).toBeUndefined(); + }); + + it('returns currently selected document if valid', () => { + const metadata: DocumentMetadata = { + id: 'doc1', + documentType: 'passport', + documentCategory: 'passport', + data: 'data1', + mock: false, + }; + + const catalog: DocumentCatalog = { + documents: [metadata], + selectedDocumentId: 'doc1', + }; + + const documents = { + doc1: { data: validPassport, metadata }, + }; + + expect(pickBestDocumentToSelect(catalog, documents)).toBe('doc1'); + }); + + it('returns first valid document if currently selected is expired', () => { + const expiredMetadata: DocumentMetadata = { + id: 'doc1', + documentType: 'passport', + documentCategory: 'passport', + data: 'data1', + mock: false, + }; + + const validMetadata: DocumentMetadata = { + id: 'doc2', + documentType: 'passport', + documentCategory: 'passport', + data: 'data2', + mock: false, + }; + + const catalog: DocumentCatalog = { + documents: [expiredMetadata, validMetadata], + selectedDocumentId: 'doc1', + }; + + const documents = { + doc1: { data: expiredPassport, metadata: expiredMetadata }, + doc2: { data: validPassport, metadata: validMetadata }, + }; + + expect(pickBestDocumentToSelect(catalog, documents)).toBe('doc2'); + }); + + it('returns first valid document if no document is selected', () => { + const metadata1: DocumentMetadata = { + id: 'doc1', + documentType: 'passport', + documentCategory: 'passport', + data: 'data1', + mock: false, + }; + + const metadata2: DocumentMetadata = { + id: 'doc2', + documentType: 'passport', + documentCategory: 'passport', + data: 'data2', + mock: false, + }; + + const catalog: DocumentCatalog = { + documents: [metadata1, metadata2], + }; + + const documents = { + doc1: { data: validPassport, metadata: metadata1 }, + doc2: { data: validPassport, metadata: metadata2 }, + }; + + expect(pickBestDocumentToSelect(catalog, documents)).toBe('doc1'); + }); + + it('returns undefined if all documents are expired', () => { + const metadata: DocumentMetadata = { + id: 'doc1', + documentType: 'passport', + documentCategory: 'passport', + data: 'data1', + mock: false, + }; + + const catalog: DocumentCatalog = { + documents: [metadata], + }; + + const documents = { + doc1: { data: expiredPassport, metadata }, + }; + + expect(pickBestDocumentToSelect(catalog, documents)).toBeUndefined(); + }); + + it('selects mock document if it is the only option', () => { + const mockMetadata: DocumentMetadata = { + id: 'doc1', + documentType: 'passport', + documentCategory: 'passport', + data: 'mock-data', + mock: true, + }; + + const catalog: DocumentCatalog = { + documents: [mockMetadata], + }; + + const mockPassport: PassportData = { + ...validPassport, + mock: true, + }; + + const documents = { + doc1: { data: mockPassport, metadata: mockMetadata }, + }; + + expect(pickBestDocumentToSelect(catalog, documents)).toBe('doc1'); + }); + + it('prefers selected document even if it is mock', () => { + const mockMetadata: DocumentMetadata = { + id: 'mock1', + documentType: 'passport', + documentCategory: 'passport', + data: 'mock-data', + mock: true, + }; + + const realMetadata: DocumentMetadata = { + id: 'real1', + documentType: 'passport', + documentCategory: 'passport', + data: 'real-data', + mock: false, + }; + + const catalog: DocumentCatalog = { + documents: [mockMetadata, realMetadata], + selectedDocumentId: 'mock1', + }; + + const mockPassport: PassportData = { + ...validPassport, + mock: true, + }; + + const documents = { + mock1: { data: mockPassport, metadata: mockMetadata }, + real1: { data: validPassport, metadata: realMetadata }, + }; + + expect(pickBestDocumentToSelect(catalog, documents)).toBe('mock1'); + }); +});