Show badge for inactive documents

This commit is contained in:
Leszek Stachowski
2025-12-11 17:25:37 +01:00
parent e1c7ecdbb8
commit 80fa30b92a
5 changed files with 645 additions and 1 deletions

View File

@@ -4,19 +4,23 @@
import type { FC } from 'react';
import React from 'react';
import { Dimensions } from 'react-native';
import { Alert, Dimensions, Pressable } from 'react-native';
import { Separator, Text, XStack, YStack } from 'tamagui';
import { useNavigation } from '@react-navigation/native';
import type { AadhaarData } from '@selfxyz/common';
import type { PassportData } from '@selfxyz/common/types/passport';
import { isAadhaarDocument, isMRZDocument } from '@selfxyz/common/utils/types';
import { WarningTriangleIcon } from '@selfxyz/euclid/dist/components/icons/WarningTriangleIcon';
import {
black,
red600,
slate100,
slate300,
slate400,
slate500,
white,
yellow500,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot, plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import AadhaarIcon from '@selfxyz/mobile-sdk-alpha/svgs/icons/aadhaar.svg';
@@ -41,6 +45,7 @@ interface IdCardLayoutAttributes {
idDocument: PassportData | AadhaarData | null;
selected: boolean;
hidden: boolean;
isInactive: boolean;
}
// This layout should be fully adaptative. I should perfectly fit in any screen size.
@@ -53,7 +58,10 @@ const IdCardLayout: FC<IdCardLayoutAttributes> = ({
idDocument,
selected,
hidden,
isInactive,
}) => {
const navigation = useNavigation();
// Early return if document is null
if (!idDocument) {
return null;
@@ -96,6 +104,54 @@ const IdCardLayout: FC<IdCardLayoutAttributes> = ({
alignItems="center"
justifyContent="center"
>
{isInactive && (
<Pressable
style={{ width: '100%', marginBottom: 16 }}
onPress={() => {
switch (idDocument.documentCategory) {
case 'passport':
case 'id_card':
navigation.navigate('DocumentOnboarding');
break;
case 'aadhaar':
navigation.navigate('AadhaarUpload', { countryCode: 'IND' });
break;
default:
navigation.navigate('CountryPicker');
break;
}
}}
>
<XStack
backgroundColor={red600}
borderRadius={8}
padding={16}
gap={16}
>
<YStack padding={8} backgroundColor={white} borderRadius={8}>
<WarningTriangleIcon color={yellow500} />
</YStack>
<YStack gap={4}>
<Text
color={white}
fontFamily={dinot}
fontSize={16}
fontWeight="500"
>
Your document is inactive
</Text>
<Text
color={white}
fontFamily={dinot}
fontSize={14}
fontWeight="400"
>
Tap here to recover your ID
</Text>
</YStack>
</XStack>
</Pressable>
)}
<YStack
width={cardWidth}
height={cardHeight}
@@ -177,6 +233,29 @@ const IdCardLayout: FC<IdCardLayoutAttributes> = ({
</Text>
</YStack>
)}
{isInactive && (
<YStack
marginTop={padding / 4}
marginLeft={padding / 2}
borderWidth={1}
borderColor={red600}
backgroundColor={red600}
borderRadius={100}
paddingHorizontal={padding / 2}
alignSelf="flex-start"
paddingVertical={padding / 8}
>
<Text
fontSize={fontSize.xsmall}
color={white}
fontFamily={dinot}
letterSpacing={fontSize.xsmall * 0.15}
>
INACTIVE
</Text>
</YStack>
)}
</XStack>
</XStack>

View File

@@ -49,6 +49,7 @@ import type { RootStackParamList } from '@/navigation';
import { usePassport } from '@/providers/passportDataProvider';
import { useSettingStore } from '@/stores/settingStore';
import useUserStore from '@/stores/userStore';
import { isDocumentInactive } from '@/utils/documents';
const HomeScreen: React.FC = () => {
const selfClient = useSelfClient();
@@ -69,6 +70,9 @@ const HomeScreen: React.FC = () => {
>({});
const [loading, setLoading] = useState(true);
const hasIncrementedOnFocus = useRef(false);
const [isSelectedDocumentInactive, setIsSelectedDocumentInactive] = useState<
boolean | null
>(null);
const { amount: selfPoints } = usePoints();
@@ -108,12 +112,32 @@ const HomeScreen: React.FC = () => {
const loadDocuments = useCallback(async () => {
setLoading(true);
try {
const catalog = await loadDocumentCatalog();
const docs = await getAllDocuments();
setDocumentCatalog(catalog);
setAllDocuments(docs);
if (catalog.selectedDocumentId) {
const documentData = docs[catalog.selectedDocumentId];
if (documentData) {
try {
setIsSelectedDocumentInactive(
await isDocumentInactive(
selfClient,
documentData.data,
documentData.metadata,
),
);
} catch (error) {
// we don't want to block the home screen from loading
console.warn('Failed to check if document is inactive:', error);
}
}
}
} catch (error) {
console.warn('Failed to load documents:', error);
}
@@ -254,6 +278,8 @@ const HomeScreen: React.FC = () => {
return null;
}
// const isInactive = await isDocumentInactive(selfClient, documentData.data, secret);
return (
<Pressable
key={metadata.id}
@@ -261,6 +287,7 @@ const HomeScreen: React.FC = () => {
>
<IdCardLayout
idDocument={documentData.data}
isInactive={isSelected && isSelectedDocumentInactive === true}
selected={isSelected}
hidden={true}
/>

109
app/src/utils/documents.ts Normal file
View File

@@ -0,0 +1,109 @@
// 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 {
DocumentCategory,
Environment,
IDDocument,
} from '@selfxyz/common';
import { isUserRegisteredWithAlternativeCSCA } from '@selfxyz/common/utils/passports/validate';
import type { DocumentMetadata, SelfClient } from '@selfxyz/mobile-sdk-alpha';
import { getCommitmentTree } from '@selfxyz/mobile-sdk-alpha/stores';
import { getAlternativeCSCA } from '@/proving/validateDocument';
const REGISTERED_THRESHOLD_MINUTES = 5;
// Map to keep track of which commitment trees have already been fetched so
// we don't fetch them multiple times
const ALREADY_FETCHED_COMMITMENT_TREES = new Map<DocumentCategory, boolean>();
/**
* Resets the commitment tree cache. Only for testing purposes.
* @internal
*/
export function _resetCommitmentTreeCache(): void {
ALREADY_FETCHED_COMMITMENT_TREES.clear();
}
const fetchRequiredCommitmentTree = async (
selfClient: SelfClient,
document: IDDocument,
environment: Environment,
) => {
if (
ALREADY_FETCHED_COMMITMENT_TREES.get(document.documentCategory) === true
) {
console.log(`${document.documentCategory} commitment tree already fetched`);
return;
}
if (document.documentCategory === 'aadhaar') {
await selfClient.getProtocolState().aadhaar.fetch_all(environment);
} else {
await selfClient
.getProtocolState()
[
document.documentCategory
].fetch_all(environment, document.dsc_parsed?.authorityKeyIdentifier || '');
}
ALREADY_FETCHED_COMMITMENT_TREES.set(document.documentCategory, true);
};
export async function isDocumentInactive(
selfClient: SelfClient,
document: IDDocument,
metadata: DocumentMetadata,
): Promise<boolean> {
console.log(
`Checking if ${document.documentCategory} document is inactive...`,
);
const secret = await selfClient.getPrivateKey();
if (metadata.registeredAt) {
const registeredAt = new Date(metadata.registeredAt);
const now = new Date();
const diffMs = now.getTime() - registeredAt.getTime();
const diffMinutes = diffMs / (1000 * 60);
if (diffMinutes < REGISTERED_THRESHOLD_MINUTES) {
console.log('Document has been registered in the last 5 minutes');
// We consider the document active if it was registered in the last 5 minutes
// before checking the commitment tree
return false;
}
}
const environment = metadata.mock ? 'stg' : 'prod';
await fetchRequiredCommitmentTree(selfClient, document, environment);
// if secret is not available, the document is considered inactive
if (!secret) {
return true;
}
console.log(
`Checking if document is registered in the ${document.documentCategory} commitment tree...`,
);
const { isRegistered } = await isUserRegisteredWithAlternativeCSCA(
document,
secret,
{
getCommitmentTree: docCategory =>
getCommitmentTree(selfClient, docCategory),
getAltCSCA: docCategory =>
getAlternativeCSCA(selfClient.useProtocolStore, docCategory),
},
);
console.log('Document is registered:', isRegistered);
// TODO: add expiration check
return !isRegistered;
}

View File

@@ -0,0 +1,427 @@
// 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 { IDDocument } from '@selfxyz/common';
import type { DocumentMetadata, SelfClient } from '@selfxyz/mobile-sdk-alpha';
// Import module under test after mocks are set up
import {
_resetCommitmentTreeCache,
isDocumentInactive,
} from '@/utils/documents';
// Mock dependencies before importing the module under test
const mockGetAlternativeCSCA = jest.fn();
jest.mock('@/proving/validateDocument', () => ({
getAlternativeCSCA: jest.fn((...args: unknown[]) =>
mockGetAlternativeCSCA(...args),
),
}));
const mockIsUserRegisteredWithAlternativeCSCA = jest.fn();
jest.mock('@selfxyz/common/utils/passports/validate', () => ({
isUserRegisteredWithAlternativeCSCA: jest.fn((...args: unknown[]) =>
mockIsUserRegisteredWithAlternativeCSCA(...args),
),
}));
const mockGetCommitmentTree = jest.fn();
jest.mock('@selfxyz/mobile-sdk-alpha/stores', () => ({
getCommitmentTree: jest.fn((...args: unknown[]) =>
mockGetCommitmentTree(...args),
),
}));
describe('isDocumentInactive', () => {
// Mock implementations
const mockFetchAllPassport = jest.fn();
const mockFetchAllIdCard = jest.fn();
const mockFetchAllAadhaar = jest.fn();
const mockGetPrivateKey = jest.fn();
const createMockSelfClient = (): SelfClient =>
({
getPrivateKey: mockGetPrivateKey,
getProtocolState: jest.fn(() => ({
passport: { fetch_all: mockFetchAllPassport },
id_card: { fetch_all: mockFetchAllIdCard },
aadhaar: { fetch_all: mockFetchAllAadhaar },
})),
useProtocolStore: {},
}) as unknown as SelfClient;
const createMockDocument = (
category: 'passport' | 'id_card' | 'aadhaar',
authorityKeyIdentifier?: string,
): IDDocument =>
({
documentCategory: category,
dsc_parsed: authorityKeyIdentifier
? { authorityKeyIdentifier }
: undefined,
}) as unknown as IDDocument;
const createMockMetadata = (
overrides: Partial<DocumentMetadata> = {},
): DocumentMetadata =>
({
id: 'test-doc-id',
documentType: 'passport',
documentCategory: 'passport',
mock: false,
isRegistered: true,
registeredAt: undefined,
...overrides,
}) as DocumentMetadata;
beforeEach(() => {
jest.clearAllMocks();
// Reset the commitment tree cache to ensure test isolation
_resetCommitmentTreeCache();
// Reset all mock implementations to default successful state
mockGetPrivateKey.mockResolvedValue('test-secret');
mockFetchAllPassport.mockResolvedValue(undefined);
mockFetchAllIdCard.mockResolvedValue(undefined);
mockFetchAllAadhaar.mockResolvedValue(undefined);
mockIsUserRegisteredWithAlternativeCSCA.mockResolvedValue({
isRegistered: true,
});
mockGetAlternativeCSCA.mockReturnValue(null);
mockGetCommitmentTree.mockReturnValue('mock-commitment-tree');
});
describe('recently registered documents', () => {
it('returns false when document was registered within last 5 minutes', async () => {
const selfClient = createMockSelfClient();
const document = createMockDocument('passport');
const recentTimestamp = Date.now() - 2 * 60 * 1000; // 2 minutes ago
const metadata = createMockMetadata({ registeredAt: recentTimestamp });
const result = await isDocumentInactive(selfClient, document, metadata);
expect(result).toBe(false);
// Should not call fetch_all or check registration since we short-circuit
expect(mockFetchAllPassport).not.toHaveBeenCalled();
expect(mockIsUserRegisteredWithAlternativeCSCA).not.toHaveBeenCalled();
});
it('returns false when document was registered exactly 4 minutes ago', async () => {
const selfClient = createMockSelfClient();
const document = createMockDocument('passport');
const recentTimestamp = Date.now() - 4 * 60 * 1000; // 4 minutes ago
const metadata = createMockMetadata({ registeredAt: recentTimestamp });
const result = await isDocumentInactive(selfClient, document, metadata);
expect(result).toBe(false);
});
it('continues with full check when document was registered more than 5 minutes ago', async () => {
const selfClient = createMockSelfClient();
const document = createMockDocument('passport', 'test-aki');
const oldTimestamp = Date.now() - 10 * 60 * 1000; // 10 minutes ago
const metadata = createMockMetadata({ registeredAt: oldTimestamp });
await isDocumentInactive(selfClient, document, metadata);
expect(mockFetchAllPassport).toHaveBeenCalled();
expect(mockIsUserRegisteredWithAlternativeCSCA).toHaveBeenCalled();
});
it('continues with full check when registeredAt is undefined', async () => {
const selfClient = createMockSelfClient();
const document = createMockDocument('passport', 'test-aki');
const metadata = createMockMetadata({ registeredAt: undefined });
await isDocumentInactive(selfClient, document, metadata);
expect(mockFetchAllPassport).toHaveBeenCalled();
expect(mockIsUserRegisteredWithAlternativeCSCA).toHaveBeenCalled();
});
});
describe('secret availability', () => {
it('returns true when no secret is available', async () => {
mockGetPrivateKey.mockResolvedValue(null);
const selfClient = createMockSelfClient();
const document = createMockDocument('passport', 'test-aki');
const metadata = createMockMetadata();
const result = await isDocumentInactive(selfClient, document, metadata);
expect(result).toBe(true);
// Should still fetch commitment tree
expect(mockFetchAllPassport).toHaveBeenCalled();
// But should not check registration
expect(mockIsUserRegisteredWithAlternativeCSCA).not.toHaveBeenCalled();
});
it('returns true when secret is undefined', async () => {
mockGetPrivateKey.mockResolvedValue(undefined);
const selfClient = createMockSelfClient();
const document = createMockDocument('passport', 'test-aki');
const metadata = createMockMetadata();
const result = await isDocumentInactive(selfClient, document, metadata);
expect(result).toBe(true);
});
it('returns true when secret is empty string', async () => {
mockGetPrivateKey.mockResolvedValue('');
const selfClient = createMockSelfClient();
const document = createMockDocument('passport', 'test-aki');
const metadata = createMockMetadata();
const result = await isDocumentInactive(selfClient, document, metadata);
expect(result).toBe(true);
});
});
describe('registration check', () => {
it('returns false when document is registered', async () => {
mockIsUserRegisteredWithAlternativeCSCA.mockResolvedValue({
isRegistered: true,
});
const selfClient = createMockSelfClient();
const document = createMockDocument('passport', 'test-aki');
const metadata = createMockMetadata();
const result = await isDocumentInactive(selfClient, document, metadata);
expect(result).toBe(false);
});
it('returns true when document is not registered', async () => {
mockIsUserRegisteredWithAlternativeCSCA.mockResolvedValue({
isRegistered: false,
});
const selfClient = createMockSelfClient();
const document = createMockDocument('passport', 'test-aki');
const metadata = createMockMetadata();
const result = await isDocumentInactive(selfClient, document, metadata);
expect(result).toBe(true);
});
});
describe('environment selection', () => {
it('uses stg environment for mock documents', async () => {
const selfClient = createMockSelfClient();
const document = createMockDocument('passport', 'test-aki');
const metadata = createMockMetadata({ mock: true });
await isDocumentInactive(selfClient, document, metadata);
expect(mockFetchAllPassport).toHaveBeenCalledWith('stg', 'test-aki');
});
it('uses prod environment for non-mock documents', async () => {
const selfClient = createMockSelfClient();
const document = createMockDocument('passport', 'test-aki');
const metadata = createMockMetadata({ mock: false });
await isDocumentInactive(selfClient, document, metadata);
expect(mockFetchAllPassport).toHaveBeenCalledWith('prod', 'test-aki');
});
});
describe('document category handling', () => {
it('calls passport.fetch_all for passport documents', async () => {
const selfClient = createMockSelfClient();
const document = createMockDocument('passport', 'passport-aki');
const metadata = createMockMetadata();
await isDocumentInactive(selfClient, document, metadata);
expect(mockFetchAllPassport).toHaveBeenCalledWith('prod', 'passport-aki');
expect(mockFetchAllIdCard).not.toHaveBeenCalled();
expect(mockFetchAllAadhaar).not.toHaveBeenCalled();
});
it('calls id_card.fetch_all for id_card documents', async () => {
const selfClient = createMockSelfClient();
const document = createMockDocument('id_card', 'id-card-aki');
const metadata = createMockMetadata({ documentCategory: 'id_card' });
await isDocumentInactive(selfClient, document, metadata);
expect(mockFetchAllIdCard).toHaveBeenCalledWith('prod', 'id-card-aki');
expect(mockFetchAllPassport).not.toHaveBeenCalled();
expect(mockFetchAllAadhaar).not.toHaveBeenCalled();
});
it('calls aadhaar.fetch_all for aadhaar documents without authorityKeyIdentifier', async () => {
const selfClient = createMockSelfClient();
const document = createMockDocument('aadhaar');
const metadata = createMockMetadata({ documentCategory: 'aadhaar' });
await isDocumentInactive(selfClient, document, metadata);
expect(mockFetchAllAadhaar).toHaveBeenCalledWith('prod');
expect(mockFetchAllPassport).not.toHaveBeenCalled();
expect(mockFetchAllIdCard).not.toHaveBeenCalled();
});
it('uses empty string for authorityKeyIdentifier when not present', async () => {
const selfClient = createMockSelfClient();
const document = createMockDocument('passport'); // No AKI
const metadata = createMockMetadata();
await isDocumentInactive(selfClient, document, metadata);
expect(mockFetchAllPassport).toHaveBeenCalledWith('prod', '');
});
});
describe('isUserRegisteredWithAlternativeCSCA integration', () => {
it('passes correct arguments to isUserRegisteredWithAlternativeCSCA', async () => {
const selfClient = createMockSelfClient();
const document = createMockDocument('passport', 'test-aki');
const metadata = createMockMetadata();
await isDocumentInactive(selfClient, document, metadata);
expect(mockIsUserRegisteredWithAlternativeCSCA).toHaveBeenCalledWith(
document,
'test-secret',
expect.objectContaining({
getCommitmentTree: expect.any(Function),
getAltCSCA: expect.any(Function),
}),
);
});
it('getCommitmentTree callback uses getCommitmentTree from stores', async () => {
const selfClient = createMockSelfClient();
const document = createMockDocument('passport', 'test-aki');
const metadata = createMockMetadata();
await isDocumentInactive(selfClient, document, metadata);
// Get the callbacks passed to isUserRegisteredWithAlternativeCSCA
const callbacks =
mockIsUserRegisteredWithAlternativeCSCA.mock.calls[0][2];
// Call the getCommitmentTree callback
callbacks.getCommitmentTree('passport');
expect(mockGetCommitmentTree).toHaveBeenCalledWith(
selfClient,
'passport',
);
});
it('getAltCSCA callback uses getAlternativeCSCA', async () => {
const selfClient = createMockSelfClient();
const document = createMockDocument('passport', 'test-aki');
const metadata = createMockMetadata();
await isDocumentInactive(selfClient, document, metadata);
// Get the callbacks passed to isUserRegisteredWithAlternativeCSCA
const callbacks =
mockIsUserRegisteredWithAlternativeCSCA.mock.calls[0][2];
// Call the getAltCSCA callback
callbacks.getAltCSCA('passport');
expect(mockGetAlternativeCSCA).toHaveBeenCalledWith(
selfClient.useProtocolStore,
'passport',
);
});
});
describe('commitment tree caching', () => {
it('caches commitment tree fetch for same document category', async () => {
const selfClient = createMockSelfClient();
const document = createMockDocument('passport', 'test-aki');
const metadata = createMockMetadata();
// First call
await isDocumentInactive(selfClient, document, metadata);
expect(mockFetchAllPassport).toHaveBeenCalledTimes(1);
// Second call - should use cache
await isDocumentInactive(selfClient, document, metadata);
expect(mockFetchAllPassport).toHaveBeenCalledTimes(1); // Still 1
});
it('fetches separately for different document categories', async () => {
const selfClient = createMockSelfClient();
// Passport document
const passportDoc = createMockDocument('passport', 'passport-aki');
const passportMetadata = createMockMetadata();
await isDocumentInactive(selfClient, passportDoc, passportMetadata);
expect(mockFetchAllPassport).toHaveBeenCalledTimes(1);
// ID card document - should trigger separate fetch
const idCardDoc = createMockDocument('id_card', 'id-card-aki');
const idCardMetadata = createMockMetadata({
documentCategory: 'id_card',
});
await isDocumentInactive(selfClient, idCardDoc, idCardMetadata);
expect(mockFetchAllIdCard).toHaveBeenCalledTimes(1);
// Passport shouldn't be fetched again
expect(mockFetchAllPassport).toHaveBeenCalledTimes(1);
});
it('cache persists across multiple calls for same category', async () => {
const selfClient = createMockSelfClient();
const document = createMockDocument('passport', 'test-aki');
const metadata = createMockMetadata();
// Multiple calls
await isDocumentInactive(selfClient, document, metadata);
await isDocumentInactive(selfClient, document, metadata);
await isDocumentInactive(selfClient, document, metadata);
// Should only fetch once
expect(mockFetchAllPassport).toHaveBeenCalledTimes(1);
});
});
describe('error handling', () => {
it('propagates errors from getPrivateKey', async () => {
mockGetPrivateKey.mockRejectedValue(new Error('Key retrieval failed'));
const selfClient = createMockSelfClient();
const document = createMockDocument('passport', 'test-aki');
const metadata = createMockMetadata();
await expect(
isDocumentInactive(selfClient, document, metadata),
).rejects.toThrow('Key retrieval failed');
});
it('propagates errors from fetch_all', async () => {
mockFetchAllPassport.mockRejectedValue(new Error('Fetch failed'));
const selfClient = createMockSelfClient();
const document = createMockDocument('passport', 'test-aki');
const metadata = createMockMetadata();
await expect(
isDocumentInactive(selfClient, document, metadata),
).rejects.toThrow('Fetch failed');
});
it('propagates errors from isUserRegisteredWithAlternativeCSCA', async () => {
mockIsUserRegisteredWithAlternativeCSCA.mockRejectedValue(
new Error('Registration check failed'),
);
const selfClient = createMockSelfClient();
const document = createMockDocument('passport', 'test-aki');
const metadata = createMockMetadata();
await expect(
isDocumentInactive(selfClient, document, metadata),
).rejects.toThrow('Registration check failed');
});
});
});

View File

@@ -26,6 +26,8 @@ export const neutral700 = '#404040';
export const red500 = '#EF4444';
export const red600 = '#DC2626';
export const separatorColor = '#E0E0E0';
export const sky500 = '#0EA5E9';