SELF-1754: Implement selective disclosure on Proving Screen (#1549)

* add document selector test screen

* clean up mock docs

* update selection options

* Add DocumentSelectorForProving screen and route proof flows through it (#1555)

* Add document selector to proving flow

* fix formatting

* improvements

* redirect user to document not found screen when no documents

* option flow tweaks and tests

* wip tweaks

* fix scrollview bottom padding (#1556)

* tighten up selection text

* create inerstitial

* save wip

* remove not accepted state

* save wip design

* formatting

* update design

* update layout

* Update proving flow tests (#1559)

* Refactor ProveScreen to ProofRequestCard layout and preserve scroll position (#1560)

* Refactor prove screen layout

* fix: amount of hooks rendered needs to be the same for all variants

* long URL ellipsis

* keep titles consistent

* lint

---------

Co-authored-by: Leszek Stachowski <leszek.stachowski@self.xyz>

* wip fix tests

* fix tests

* formatting

* agent feedback

* fix tests

* save wip

* remove text

* fix types

* save working header update

* no transition

* cache document load for proving flow

* save fixes

* small fixes

* match disclosure text

* design updates

* fix approve flow

* fix document type flash

* add min height so text doesn't jump

* update lock

* formatting

* save refactor wip

* don't enable euclid yet

* fix tests

* fix staleness check

* fix select box description

* remove id selector screen

* vertically center

* button updates

* Remove proving document cache (#1567)

* formatting

---------

Co-authored-by: Leszek Stachowski <leszek.stachowski@self.xyz>
This commit is contained in:
Justin Hernandez
2026-01-09 13:56:10 -08:00
committed by GitHub
parent 1e44dc9c8d
commit 850e3b98f9
51 changed files with 4446 additions and 381 deletions

View File

@@ -254,7 +254,7 @@ export const HeldPrimaryButtonProveScreen: React.FC<HeldPrimaryButtonProveScreen
);
}
if (state.matches('ready')) {
return 'Hold to verify';
return 'Press and hold to verify';
}
if (state.matches('verifying')) {
return (

View File

@@ -19,6 +19,9 @@ export const cyan300 = '#67E8F9';
export const emerald500 = '#10B981';
export const green500 = '#22C55E';
export const green600 = '#16A34A';
export const iosSeparator = 'rgba(60,60,67,0.36)';
export const neutral400 = '#A3A3A3';

View File

@@ -30,6 +30,8 @@ export {
cyan300,
emerald500,
green500,
green600,
iosSeparator,
neutral400,
neutral700,
red500,

View File

@@ -0,0 +1,190 @@
// 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, DocumentMetadata, IDDocument } from '@selfxyz/common';
import { attributeToPosition, attributeToPosition_ID } from '@selfxyz/common/constants';
import type { PassportData } from '@selfxyz/common/types/passport';
import type { DocumentCatalog } from '@selfxyz/common/utils/types';
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;
}
/**
* 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;
}
/**
* 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, { data: IDDocument; metadata: DocumentMetadata }>,
): 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;
}

View File

@@ -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';

View File

@@ -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<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<L898902C36UTO7408122F5012319ZE184226B<<<<<10',
dsc: 'mock-dsc',
eContent: [1, 2, 3],
signedAttr: [1, 2, 3],
encryptedDigest: [1, 2, 3],
documentType: 'passport',
documentCategory: 'passport',
mock: false,
};
expect(isDocumentValidForProving(mockMetadata, validPassport)).toBe(true);
});
it('returns false for expired passport', () => {
// Passport expired in 2012
const expiredPassport: PassportData = {
mrz: 'P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<L898902C36UTO7408122F1204159ZE184226B<<<<<10',
dsc: 'mock-dsc',
eContent: [1, 2, 3],
signedAttr: [1, 2, 3],
encryptedDigest: [1, 2, 3],
documentType: 'passport',
documentCategory: 'passport',
mock: false,
};
// Modify MRZ to have expired date (120415 = April 15, 2012)
const mrzWithExpiredDate =
'P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<L898902C36UTO7408122F1204159ZE184226B<<<<<10';
expiredPassport.mrz = mrzWithExpiredDate.slice(0, 57) + '120415' + mrzWithExpiredDate.slice(63);
expect(isDocumentValidForProving(mockMetadata, expiredPassport)).toBe(false);
});
it('returns true if getDocumentAttributes throws error', () => {
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<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<L898902C36UTO7408122F5012319ZE184226B<<<<<10',
dsc: 'mock-dsc',
eContent: [1, 2, 3],
signedAttr: [1, 2, 3],
encryptedDigest: [1, 2, 3],
documentType: 'passport',
documentCategory: 'passport',
mock: false,
};
// MRZ with expiry date 120415 (April 15, 2012 - expired)
const expiredPassport: PassportData = {
...validPassport,
mrz: 'P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<L898902C36UTO7408122F1204159ZE184226B<<<<<10',
};
it('returns undefined for empty catalog', () => {
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');
});
});