SELF-1645: Disable Verify button for expired documents (#1497)

* Disable Verify button for expired documents

* coderabbit feedbacks
This commit is contained in:
Seshanth.S
2025-12-15 17:36:09 +05:30
committed by GitHub
parent e8461664cd
commit f54c668274
4 changed files with 309 additions and 178 deletions

View File

@@ -8,10 +8,6 @@ import { Dimensions } from 'react-native';
import { Separator, Text, XStack, YStack } from 'tamagui';
import type { AadhaarData } from '@selfxyz/common';
import {
attributeToPosition,
attributeToPosition_ID,
} from '@selfxyz/common/constants';
import type { PassportData } from '@selfxyz/common/types/passport';
import { isAadhaarDocument, isMRZDocument } from '@selfxyz/common/utils/types';
import {
@@ -28,6 +24,11 @@ import EPassport from '@selfxyz/mobile-sdk-alpha/svgs/icons/epassport.svg';
import LogoGray from '@/assets/images/logo_gray.svg';
import { SvgXml } from '@/components/homescreen/SvgXmlWrapper';
import {
formatDateFromYYMMDD,
getDocumentAttributes,
getNameAndSurname,
} from '@/utils/documentAttributes';
// Import the logo SVG as a string
const logoSvg = `<svg width="47" height="46" viewBox="0 0 47 46" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -332,6 +333,7 @@ const IdCardLayout: FC<IdCardLayoutAttributes> = ({
name="DOB"
value={formatDateFromYYMMDD(
getDocumentAttributes(idDocument).dobSlice,
true,
)}
maskValue="XX/XX/XXXX"
hidden={hidden}
@@ -342,7 +344,6 @@ const IdCardLayout: FC<IdCardLayoutAttributes> = ({
name="EXPIRY DATE"
value={formatDateFromYYMMDD(
getDocumentAttributes(idDocument).expiryDateSlice,
true,
)}
maskValue="XX/XX/XXXX"
hidden={hidden}
@@ -520,164 +521,3 @@ const IdAttribute: FC<IdAttributeProps> = ({
};
export default IdCardLayout;
// Helper functions to safely extract document data
function getDocumentAttributes(document: PassportData | AadhaarData) {
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,
};
}
}
function getAadhaarAttributes(document: AadhaarData) {
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,
};
}
function getPassportAttributes(mrz: string, documentCategory: string) {
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] + 1,
);
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,
};
}
function getNameAndSurname(nameSlice: string) {
// Split by double << to separate surname from names
const parts = nameSlice.split('<<');
if (parts.length < 2) {
return { surname: [], names: [] };
}
// First part is surname, second part contains names separated by single <
const surname = parts[0].replace(/</g, '').trim();
const namesString = parts[1];
// Split names by single < and filter out empty strings
const names = namesString.split('<').filter(name => name.length > 0);
return {
surname: surname ? [surname] : [],
names: names[0] ? [names[0]] : [],
};
}
function formatDateFromYYMMDD(
dateString: string,
isExpiry: boolean = false,
): string {
if (dateString.length !== 6) {
return dateString;
}
const yy = parseInt(dateString.substring(0, 2), 10);
const mm = dateString.substring(2, 4);
const dd = dateString.substring(4, 6);
const currentYear = new Date().getFullYear();
const century = Math.floor(currentYear / 100) * 100;
let year = century + yy;
if (isExpiry) {
// For expiry: if year is in the past, assume next century
if (year < currentYear) {
year += 100;
}
} else {
// For birth: if year is in the future, assume previous century
if (year > currentYear) {
year -= 100;
}
}
return `${dd}/${mm}/${year}`;
}

View File

@@ -21,9 +21,10 @@ import { useIsFocused, useNavigation } 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 { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { loadSelectedDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import miscAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/misc.json';
import {
BodyText,
@@ -48,6 +49,10 @@ import {
import { getPointsAddress } from '@/services/points';
import { useProofHistoryStore } from '@/stores/proofHistoryStore';
import { ProofStatus } from '@/stores/proofTypes';
import {
checkDocumentExpiration,
getDocumentAttributes,
} from '@/utils/documentAttributes';
import { formatUserId } from '@/utils/formatUserId';
const ProveScreen: React.FC = () => {
@@ -64,6 +69,8 @@ const ProveScreen: React.FC = () => {
const [scrollViewContentHeight, setScrollViewContentHeight] = useState(0);
const [scrollViewHeight, setScrollViewHeight] = useState(0);
const [showFullAddress, setShowFullAddress] = useState(false);
const [isDocumentExpired, setIsDocumentExpired] = useState(false);
const isDocumentExpiredRef = useRef(false);
const scrollViewRef = useRef<ScrollView>(null);
const isContentShorterThanScrollView = useMemo(
@@ -115,11 +122,43 @@ const ProveScreen: React.FC = () => {
setDefaultDocumentTypeIfNeeded();
if (selectedAppRef.current?.sessionId !== selectedApp.sessionId) {
provingStore.init(selfClient, 'disclose');
}
selectedAppRef.current = selectedApp;
}, [selectedApp, isFocused, provingStore, selfClient]);
const checkExpirationAndInit = async () => {
let isExpired = false;
try {
const selectedDocument = await loadSelectedDocument(selfClient);
if (!selectedDocument || !isMRZDocument(selectedDocument.data)) {
setIsDocumentExpired(false);
isExpired = false;
isDocumentExpiredRef.current = false;
} else {
const { data: passportData } = selectedDocument;
const attributes = getDocumentAttributes(passportData);
const expiryDateSlice = attributes.expiryDateSlice;
isExpired = checkDocumentExpiration(expiryDateSlice);
setIsDocumentExpired(isExpired);
isDocumentExpiredRef.current = isExpired;
}
} catch (error) {
console.error('Error checking document expiration:', error);
setIsDocumentExpired(false);
isExpired = false;
isDocumentExpiredRef.current = false;
}
if (
!isExpired &&
selectedAppRef.current?.sessionId !== selectedApp.sessionId
) {
provingStore.init(selfClient, 'disclose');
}
selectedAppRef.current = selectedApp;
};
checkExpirationAndInit();
//removed provingStore from dependencies because it causes infinite re-render on longpressing the button
//as it sets provingStore.setUserConfirmed()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedApp?.sessionId, isFocused, selfClient]);
// Enhance selfApp with user's points address if not already set
useEffect(() => {
@@ -206,7 +245,11 @@ const ProveScreen: React.FC = () => {
const isCloseToBottom =
layoutMeasurement.height + contentOffset.y >=
contentSize.height - paddingToBottom;
if (isCloseToBottom && !hasScrolledToBottom) {
if (
isCloseToBottom &&
!hasScrolledToBottom &&
!isDocumentExpiredRef.current
) {
setHasScrolledToBottom(true);
buttonTap();
trackEvent(ProofEvents.PROOF_DISCLOSURES_SCROLLED, {
@@ -425,6 +468,7 @@ const ProveScreen: React.FC = () => {
selectedAppSessionId={selectedApp?.sessionId}
hasScrolledToBottom={hasScrolledToBottom}
isReadyToProve={isReadyToProve}
isDocumentExpired={isDocumentExpired}
/>
</ExpandableBottomLayout.BottomSection>
</ExpandableBottomLayout.Layout>

View File

@@ -0,0 +1,236 @@
// 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 { AadhaarData } from '@selfxyz/common';
import {
attributeToPosition,
attributeToPosition_ID,
} from '@selfxyz/common/constants';
import type { PassportData } from '@selfxyz/common/types/passport';
import { isAadhaarDocument, isMRZDocument } from '@selfxyz/common/utils/types';
export interface DocumentAttributes {
nameSlice: string;
dobSlice: string;
yobSlice: string;
issuingStateSlice: string;
nationalitySlice: string;
passNoSlice: string;
sexSlice: string;
expiryDateSlice: string;
isPassportType: boolean;
}
/**
* 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,
};
}
/**
* Checks if a document expiration date (in YYMMDD format) has passed.
* we assume dateOfExpiry is this century because ICAO standard for biometric passport
* became standard around 2002
* @param dateOfExpiry - Expiration date in YYMMDD format from MRZ
* @returns true if the document is expired, false otherwise
*/
export function checkDocumentExpiration(dateOfExpiry: string): boolean {
if (!dateOfExpiry || dateOfExpiry.length !== 6) {
return false; // Invalid format, don't treat as expired
}
const year = parseInt(dateOfExpiry.slice(0, 2), 10);
const fullyear = 2000 + year;
const month = parseInt(dateOfExpiry.slice(2, 4), 10) - 1; // JS months are 0-indexed
const day = parseInt(dateOfExpiry.slice(4, 6), 10);
const expiryDateUTC = new Date(Date.UTC(fullyear, month, day, 0, 0, 0, 0));
const nowUTC = new Date();
const todayUTC = new Date(
Date.UTC(
nowUTC.getFullYear(),
nowUTC.getMonth(),
nowUTC.getDate(),
0,
0,
0,
0,
),
);
return todayUTC >= expiryDateUTC;
}
/**
* Formats date from YYMMDD format to DD/MM/YYYY format
* For expiry (isDOB is false), we assume its this century because ICAO standard for biometric passport
* became standard around 2002
*/
export function formatDateFromYYMMDD(
dateString: string,
isDOB: boolean = false,
): string {
if (dateString.length !== 6) {
return dateString;
}
const yy = parseInt(dateString.substring(0, 2), 10);
const mm = dateString.substring(2, 4);
const dd = dateString.substring(4, 6);
const currentYear = new Date().getFullYear();
const century = Math.floor(currentYear / 100) * 100;
let year = century + yy;
if (isDOB) {
// For birth: if year is in the future, assume previous century
if (year > currentYear) {
year -= 100;
}
}
return `${dd}/${mm}/${year}`;
}
// Helper functions to safely extract document data
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,
};
}
}
/**
* Parses name from MRZ format (surname<<given names)
* Returns separated surname and names arrays
*/
export function getNameAndSurname(nameSlice: string): {
surname: string[];
names: string[];
} {
// Split by double << to separate surname from names
const parts = nameSlice.split('<<');
if (parts.length < 2) {
return { surname: [], names: [] };
}
// First part is surname, second part contains names separated by single <
const surname = parts[0].replace(/</g, '').trim();
const namesString = parts[1];
// Split names by single < and filter out empty strings
const names = namesString.split('<').filter(name => name.length > 0);
return {
surname: surname ? [surname] : [],
names: names[0] ? [names[0]] : [],
};
}

View File

@@ -19,6 +19,7 @@ interface HeldPrimaryButtonProveScreenProps {
selectedAppSessionId: string | undefined | null;
hasScrolledToBottom: boolean;
isReadyToProve: boolean;
isDocumentExpired: boolean;
}
interface ButtonContext {
@@ -26,6 +27,7 @@ interface ButtonContext {
hasScrolledToBottom: boolean;
isReadyToProve: boolean;
onVerify: () => void;
isDocumentExpired: boolean;
}
type ButtonEvent =
@@ -34,6 +36,7 @@ type ButtonEvent =
selectedAppSessionId: string | undefined | null;
hasScrolledToBottom: boolean;
isReadyToProve: boolean;
isDocumentExpired: boolean;
}
| { type: 'VERIFY' };
@@ -51,6 +54,7 @@ const buttonMachine = createMachine(
hasScrolledToBottom: false,
isReadyToProve: false,
onVerify: input.onVerify,
isDocumentExpired: false,
}),
on: {
PROPS_UPDATED: {
@@ -88,7 +92,7 @@ const buttonMachine = createMachine(
},
{
target: 'ready',
guard: ({ context }) => context.isReadyToProve,
guard: ({ context }) => context.isReadyToProve && !context.isDocumentExpired,
},
],
after: {
@@ -107,7 +111,7 @@ const buttonMachine = createMachine(
},
{
target: 'ready',
guard: ({ context }) => context.isReadyToProve,
guard: ({ context }) => context.isReadyToProve && !context.isDocumentExpired,
},
],
after: {
@@ -126,7 +130,7 @@ const buttonMachine = createMachine(
},
{
target: 'ready',
guard: ({ context }) => context.isReadyToProve,
guard: ({ context }) => context.isReadyToProve && !context.isDocumentExpired,
},
],
},
@@ -167,12 +171,14 @@ const buttonMachine = createMachine(
if (
context.selectedAppSessionId !== event.selectedAppSessionId ||
context.hasScrolledToBottom !== event.hasScrolledToBottom ||
context.isReadyToProve !== event.isReadyToProve
context.isReadyToProve !== event.isReadyToProve ||
context.isDocumentExpired !== event.isDocumentExpired
) {
return {
selectedAppSessionId: event.selectedAppSessionId,
hasScrolledToBottom: event.hasScrolledToBottom,
isReadyToProve: event.isReadyToProve,
isDocumentExpired: event.isDocumentExpired,
};
}
}
@@ -190,6 +196,7 @@ export const HeldPrimaryButtonProveScreen: React.FC<HeldPrimaryButtonProveScreen
selectedAppSessionId,
hasScrolledToBottom,
isReadyToProve,
isDocumentExpired,
}) => {
const [state, send] = useMachine(buttonMachine, {
input: { onVerify },
@@ -201,12 +208,16 @@ export const HeldPrimaryButtonProveScreen: React.FC<HeldPrimaryButtonProveScreen
selectedAppSessionId,
hasScrolledToBottom,
isReadyToProve,
isDocumentExpired,
});
}, [selectedAppSessionId, hasScrolledToBottom, isReadyToProve, send]);
}, [selectedAppSessionId, hasScrolledToBottom, isReadyToProve, isDocumentExpired, send]);
const isDisabled = !state.matches('ready');
const renderButtonContent = () => {
if (isDocumentExpired) {
return 'Document expired';
}
if (state.matches('waitingForSession')) {
return (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>