Add disclose proof request, generation, and result route spine (#1891)

* save wip

* commit

* fixes
This commit is contained in:
Justin Hernandez
2026-03-30 23:32:24 -07:00
committed by GitHub
parent 3aa6cb682b
commit 1e51c47977
8 changed files with 520 additions and 47 deletions

View File

@@ -32,9 +32,11 @@ import { SocialSignOnMethodPickerScreen } from './screens/onboarding/SocialSignO
import { SocialSignOnPickerScreen } from './screens/onboarding/SocialSignOnPickerScreen';
import { TourScreen } from './screens/onboarding/TourScreen';
import { DialogueWithCtaScreen } from './screens/proving/DialogueWithCtaScreen';
import { DiscloseResultScreen } from './screens/proving/DiscloseResultScreen';
import { KycPendingScreen } from './screens/proving/KycPendingScreen';
import { KycSuccessScreen } from './screens/proving/KycSuccessScreen';
import { ProofGenerationDialogueScreen } from './screens/proving/ProofGenerationDialogueScreen';
import { ProofGenerationRouteScreen } from './screens/proving/ProofGenerationRouteScreen';
import { ProofGenerationSuccessScreen } from './screens/proving/ProofGenerationSuccessScreen';
import { ProofHistoryScreen } from './screens/proving/ProofHistoryScreen';
import { ProofRequestReceiptScreen } from './screens/proving/ProofRequestReceiptScreen';
@@ -72,7 +74,8 @@ export const App: React.FC = () => (
<Route path="/onboarding/failure" element={<RegistrationFailureScreen />} />
<Route path="/onboarding/kyc-failure" element={<KycFailureScreen />} />
<Route path="/proving" element={<ProvingScreen />} />
<Route path="/proving/result" element={<VerificationResultScreen />} />
<Route path="/proving/generating" element={<ProofGenerationRouteScreen />} />
<Route path="/proving/result" element={<DiscloseResultScreen />} />
<Route path="/settings" element={<SettingsScreen />} />
<Route path="/settings/security" element={<SecurityScreen />} />
<Route path="/settings/notifications" element={<NotificationPreferencesScreen />} />

View File

@@ -0,0 +1,111 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type React from 'react';
import { useCallback, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { colors, StatusState, WarningOctagonIcon } from '@selfxyz/euclid';
import type { BridgeError, VerificationResult } from '@selfxyz/webview-bridge';
import { useSelfClient } from '../../providers/SelfClientProvider';
import { useVerificationRequest } from '../../providers/VerificationRequestProvider';
import { WEB_SAFE_AREA } from '../../utils/insets';
import { normalizeError } from '../../utils/provingUtils';
interface DiscloseResultLocationState {
success?: boolean;
error?: BridgeError | string;
resultSent?: boolean;
}
export const DiscloseResultScreen: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { analytics, haptic, lifecycle } = useSelfClient();
const { request, verificationId } = useVerificationRequest();
const { success = true, error, resultSent = false } = (location.state as DiscloseResultLocationState | null) ?? {};
const normalizedError = normalizeError(error);
const result = useMemo<VerificationResult>(
() =>
success
? {
success: true,
userId: request.userId,
verificationId,
claims: {
resultType: 'proofRequested',
},
}
: {
success: false,
userId: request.userId,
verificationId,
claims: {
resultType: 'proofRequested',
},
error: normalizedError ?? {
code: 'proof_generation_failed',
message: 'The proof request could not be completed.',
},
},
[normalizedError, request.userId, success, verificationId],
);
const onContinue = useCallback(async () => {
haptic.trigger('selection');
let hasDeliveredResult = resultSent;
if (!resultSent && result) {
try {
await lifecycle.setResult(result);
hasDeliveredResult = true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to deliver result';
analytics.trackEvent('verification_result_callback_failed', {
error: message,
});
}
}
if (success) {
if (hasDeliveredResult) {
await lifecycle.dismiss();
}
navigate('/', { replace: true });
return;
}
navigate('/proving', { replace: true });
}, [analytics, haptic, lifecycle, navigate, result, resultSent, success]);
return (
<div
style={{
display: 'flex',
flex: 1,
minHeight: 0,
paddingTop: WEB_SAFE_AREA.insets.top,
paddingBottom: WEB_SAFE_AREA.insets.bottom,
}}
>
<StatusState
variant={success ? 'success' : 'fail'}
title={success ? 'Proof Generated' : 'Proof Generation Failed'}
description={
success
? 'Your identity was shared successfully for this request.'
: (normalizedError?.message ?? 'The proof request could not be completed. Please try again.')
}
animationSource={success ? '/animations/proof-success.json' : undefined}
animationSize={240}
loopAnimation={false}
buttonText={success ? 'Done' : 'Try Again'}
onButtonPress={onContinue}
icon={success ? undefined : <WarningOctagonIcon size={64} color={colors.red500} />}
/>
</div>
);
};

View File

@@ -0,0 +1,126 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type React from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { ProofGenerationScreen as EuclidProofGenerationScreen } from '@selfxyz/euclid';
import { useProvingStore } from '@selfxyz/mobile-sdk-alpha/browser';
import { useSelfClient } from '../../providers/SelfClientProvider';
import { useVerificationRequest } from '../../providers/VerificationRequestProvider';
import { WEB_SAFE_AREA } from '../../utils/insets';
import { getFailureState, getGenerationStep, getIdCardProps } from '../../utils/provingUtils';
import { hasDiscloseRequestContext } from '../../utils/verificationRequest';
export const ProofGenerationRouteScreen: React.FC = () => {
const navigate = useNavigate();
const { client, analytics } = useSelfClient();
const { request, displayLabels } = useVerificationRequest();
const init = useProvingStore(state => state.init);
const setUserConfirmed = useProvingStore(state => state.setUserConfirmed);
const currentState = useProvingStore(state => state.currentState);
const passportData = useProvingStore(state => state.passportData);
const errorCode = useProvingStore(state => state.error_code);
const reason = useProvingStore(state => state.reason);
const hasValidRequestContext = hasDiscloseRequestContext({ request, displayLabels });
const hasInitializedRef = useRef(false);
const hasAutoConfirmedRef = useRef(false);
const hasNavigatedRef = useRef(false);
useEffect(() => {
if (!hasValidRequestContext) {
navigate('/', { replace: true });
return;
}
if (hasInitializedRef.current) {
return;
}
hasInitializedRef.current = true;
void init(client, 'disclose').catch(error => {
if (hasNavigatedRef.current) {
return;
}
hasNavigatedRef.current = true;
const message = error instanceof Error ? error.message : 'The proof request could not be completed.';
analytics.trackEvent('prove_generation_init_failed', { error: message });
navigate('/proving/result', {
replace: true,
state: {
success: false,
error: {
code: 'proof_generation_init_failed',
message,
},
},
});
});
}, [analytics, client, hasValidRequestContext, init, navigate]);
useEffect(() => {
if (hasNavigatedRef.current) {
return;
}
if (currentState === 'passport_data_not_found') {
hasNavigatedRef.current = true;
navigate('/proving/result', {
replace: true,
state: {
success: false,
error: {
code: 'passport_data_not_found',
message: 'No document found. Please register a document first.',
},
},
});
return;
}
if (currentState === 'ready_to_prove' && !hasAutoConfirmedRef.current) {
hasAutoConfirmedRef.current = true;
setUserConfirmed(client);
return;
}
if (currentState === 'completed') {
hasNavigatedRef.current = true;
navigate('/proving/result', {
replace: true,
state: { success: true },
});
return;
}
if (currentState === 'error' || currentState === 'failure' || currentState === 'passport_not_supported') {
hasNavigatedRef.current = true;
navigate('/proving/result', {
replace: true,
state: {
success: false,
error: getFailureState(currentState, errorCode, reason),
},
});
}
}, [client, currentState, errorCode, navigate, reason, setUserConfirmed]);
const idCardProps = useMemo(() => getIdCardProps(passportData?.documentCategory), [passportData?.documentCategory]);
if (!hasValidRequestContext) {
return null;
}
return (
<EuclidProofGenerationScreen
{...WEB_SAFE_AREA}
step={getGenerationStep(currentState ?? 'idle')}
idCardProps={idCardProps}
/>
);
};

View File

@@ -3,30 +3,28 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type React from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { ProofRequestScreen, SelfLogo } from '@selfxyz/euclid';
import type { VerificationResult } from '@selfxyz/webview-bridge';
import { useSelfClient } from '../../providers/SelfClientProvider';
import { useVerificationRequest } from '../../providers/VerificationRequestProvider';
import { WEB_SAFE_AREA } from '../../utils/insets';
function titleCaseDisclosure(disclosure: string): string {
return disclosure
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/\b\w/g, match => match.toUpperCase());
}
import { titleCaseDisclosure } from '../../utils/provingUtils';
import { hasDiscloseRequestContext } from '../../utils/verificationRequest';
export const ProvingScreen: React.FC = () => {
const navigate = useNavigate();
const { analytics, haptic, lifecycle } = useSelfClient();
const { request, displayLabels, requestType, appName, appEndpoint, timestamp, verificationId } =
useVerificationRequest();
const [proving, setProving] = useState(false);
const { request, displayLabels, appName, appEndpoint, timestamp } = useVerificationRequest();
const hasValidRequestContext = hasDiscloseRequestContext({ request, displayLabels });
useEffect(() => {
if (!hasValidRequestContext) {
navigate('/', { replace: true });
}
}, [hasValidRequestContext, navigate]);
const proofItems = useMemo(() => {
if (displayLabels && displayLabels.length > 0) {
@@ -37,48 +35,30 @@ export const ProvingScreen: React.FC = () => {
}));
}, [displayLabels, request.disclosures]);
const onVerify = useCallback(async () => {
const result: VerificationResult = {
success: true,
userId: request.userId,
verificationId,
claims: {
resultType: requestType,
},
};
const onVerify = useCallback(() => {
haptic.trigger('selection');
analytics.trackEvent('prove_verify_pressed');
setProving(true);
navigate('/proving/generating', { replace: true });
}, [analytics, haptic, navigate]);
try {
await lifecycle.setResult(result);
navigate('/proving/result', {
state: { success: true, result, resultSent: true },
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Proving failed';
analytics.trackEvent('prove_verify_failed', { error: message });
navigate('/proving/result', {
state: { success: false, error: message, result, resultSent: false },
});
} finally {
setProving(false);
}
}, [analytics, haptic, lifecycle, navigate, request.userId, requestType, verificationId]);
const onCancel = useCallback(() => {
const onCancel = useCallback(async () => {
haptic.trigger('selection');
analytics.trackEvent('prove_verify_cancelled');
lifecycle.dismiss({ reason: 'user_cancel' });
navigate('/');
try {
await lifecycle.dismiss({ reason: 'user_cancel' });
} finally {
navigate('/', { replace: true });
}
}, [analytics, haptic, lifecycle, navigate]);
if (!hasValidRequestContext) {
return null;
}
return (
<ProofRequestScreen
{...WEB_SAFE_AREA}
variant={proving ? 'loading' : 'default'}
variant="default"
onClose={onCancel}
onConfirm={onVerify}
appIcon={<SelfLogo size={40} />}

View File

@@ -0,0 +1,159 @@
// SPDX-FileCopyrightText: 2025-2026 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 {
getFailureState,
getGenerationStep,
getIdCardProps,
normalizeError,
titleCaseDisclosure,
} from './provingUtils';
describe('provingUtils', () => {
describe('getGenerationStep', () => {
it('should map early states to readingRegistry', () => {
expect(getGenerationStep('parsing_id_document')).toBe('readingRegistry');
expect(getGenerationStep('fetching_data')).toBe('readingRegistry');
});
it('should map proving-phase states to generatingProof', () => {
expect(getGenerationStep('validating_document')).toBe('generatingProof');
expect(getGenerationStep('init_tee_connexion')).toBe('generatingProof');
expect(getGenerationStep('ready_to_prove')).toBe('generatingProof');
expect(getGenerationStep('proving')).toBe('generatingProof');
});
it('should map listening_for_status to awaitingVerification', () => {
expect(getGenerationStep('listening_for_status')).toBe('awaitingVerification');
});
it('should map post_proving to finishingUp', () => {
expect(getGenerationStep('post_proving')).toBe('finishingUp');
});
it('should default unknown states to readingRegistry', () => {
expect(getGenerationStep('idle')).toBe('readingRegistry');
expect(getGenerationStep('completed')).toBe('readingRegistry');
expect(getGenerationStep('')).toBe('readingRegistry');
});
});
describe('getFailureState', () => {
it('should use provided code and reason', () => {
expect(getFailureState('error', 'custom_code', 'Something broke')).toEqual({
code: 'custom_code',
message: 'Something broke',
});
});
it('should fall back to currentState when code is null', () => {
expect(getFailureState('failure', null, 'Reason')).toEqual({
code: 'failure',
message: 'Reason',
});
});
it('should fall back to default message when reason is null', () => {
expect(getFailureState('error', 'some_code', null)).toEqual({
code: 'some_code',
message: 'The proof request could not be completed.',
});
});
it('should fall back to both defaults when code and reason are null', () => {
expect(getFailureState('passport_not_supported', null, null)).toEqual({
code: 'passport_not_supported',
message: 'The proof request could not be completed.',
});
});
});
describe('getIdCardProps', () => {
it('should return passport props by default', () => {
expect(getIdCardProps()).toEqual({ variant: 'passport', title: 'Passport', subtitle: 'Verified Passport' });
expect(getIdCardProps('passport')).toEqual({
variant: 'passport',
title: 'Passport',
subtitle: 'Verified Passport',
});
});
it('should return id-card props', () => {
expect(getIdCardProps('id_card')).toEqual({ variant: 'id-card', title: 'ID Card', subtitle: 'Verified ID' });
});
it('should return aadhaar props', () => {
expect(getIdCardProps('aadhaar')).toEqual({
variant: 'aadhaar',
title: 'Aadhaar',
subtitle: 'Verified IN Aadhaar ID',
});
});
it('should return kyc props', () => {
expect(getIdCardProps('kyc')).toEqual({
variant: 'pending',
title: 'KYC Record',
subtitle: 'Verification document loaded',
});
});
it('should default to passport for unknown categories', () => {
expect(getIdCardProps('drivers_license')).toEqual({
variant: 'passport',
title: 'Passport',
subtitle: 'Verified Passport',
});
});
});
describe('titleCaseDisclosure', () => {
it('should convert underscored keys to title case', () => {
expect(titleCaseDisclosure('full_name')).toBe('Full Name');
});
it('should convert hyphenated keys to title case', () => {
expect(titleCaseDisclosure('date-of-birth')).toBe('Date Of Birth');
});
it('should handle mixed separators', () => {
expect(titleCaseDisclosure('some_mixed-key')).toBe('Some Mixed Key');
});
it('should collapse multiple separators and whitespace', () => {
expect(titleCaseDisclosure('full__name')).toBe('Full Name');
expect(titleCaseDisclosure(' full_name ')).toBe('Full Name');
});
it('should handle single word', () => {
expect(titleCaseDisclosure('nationality')).toBe('Nationality');
});
});
describe('normalizeError', () => {
it('should return undefined for falsy input', () => {
expect(normalizeError(undefined)).toBeUndefined();
expect(normalizeError('')).toBeUndefined();
});
it('should wrap a string into a BridgeError', () => {
expect(normalizeError('Something failed')).toEqual({
code: 'proof_generation_failed',
message: 'Something failed',
});
});
it('should pass through a BridgeError object', () => {
const err = { code: 'custom', message: 'msg' };
expect(normalizeError(err)).toBe(err);
});
it('should preserve details on a BridgeError object', () => {
const err = { code: 'custom', message: 'msg', details: { extra: true } };
expect(normalizeError(err)).toBe(err);
});
});
});

View File

@@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type { IDCardProps } from '@selfxyz/euclid';
import type { BridgeError } from '@selfxyz/webview-bridge';
export type GenerationStep = 'readingRegistry' | 'generatingProof' | 'awaitingVerification' | 'finishingUp';
export function getFailureState(
currentState: string,
code: string | null,
reason: string | null,
): { code: string; message: string } {
return {
code: code ?? currentState ?? 'proof_generation_failed',
message: reason ?? 'The proof request could not be completed.',
};
}
export function getGenerationStep(currentState: string): GenerationStep {
switch (currentState) {
case 'parsing_id_document':
case 'fetching_data':
return 'readingRegistry';
case 'validating_document':
case 'init_tee_connexion':
case 'ready_to_prove':
case 'proving':
return 'generatingProof';
case 'listening_for_status':
return 'awaitingVerification';
case 'post_proving':
return 'finishingUp';
default:
return 'readingRegistry';
}
}
export function getIdCardProps(documentCategory?: string): IDCardProps {
switch (documentCategory) {
case 'id_card':
return { variant: 'id-card', title: 'ID Card', subtitle: 'Verified ID' };
case 'aadhaar':
return { variant: 'aadhaar', title: 'Aadhaar', subtitle: 'Verified IN Aadhaar ID' };
case 'kyc':
return { variant: 'pending', title: 'KYC Record', subtitle: 'Verification document loaded' };
case 'passport':
default:
return { variant: 'passport', title: 'Passport', subtitle: 'Verified Passport' };
}
}
export function normalizeError(error: BridgeError | string | undefined): BridgeError | undefined {
if (!error) {
return undefined;
}
if (typeof error === 'string') {
return {
code: 'proof_generation_failed',
message: error,
};
}
return error;
}
export function titleCaseDisclosure(disclosure: string): string {
return disclosure
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/\b\w/g, match => match.toUpperCase());
}

View File

@@ -5,7 +5,11 @@
import { describe, expect, it } from 'vitest';
import { getPromptMockFromSearch, getPromptMockSearch } from './mockOnboardingFlow';
import { parseBrowserHostTargetOrigin, parseVerificationRequestContext } from './verificationRequest';
import {
hasDiscloseRequestContext,
parseBrowserHostTargetOrigin,
parseVerificationRequestContext,
} from './verificationRequest';
describe('verificationRequest utils', () => {
describe('parseBrowserHostTargetOrigin', () => {
@@ -90,6 +94,17 @@ describe('verificationRequest utils', () => {
expect(context.appEndpoint).toBe('');
expect(context.verificationId).toBeUndefined();
});
it('should require disclose items for the disclose route', () => {
const withDisclosures = parseVerificationRequestContext('?disclosures=full_name');
expect(hasDiscloseRequestContext(withDisclosures)).toBe(true);
const withLabels = parseVerificationRequestContext('?proofItems=Full%20Name');
expect(hasDiscloseRequestContext(withLabels)).toBe(true);
const empty = parseVerificationRequestContext('?userId=user-1');
expect(hasDiscloseRequestContext(empty)).toBe(false);
});
});
describe('prompt mock utils', () => {

View File

@@ -23,6 +23,12 @@ interface TargetOriginOptions {
allowWildcard?: boolean;
}
export function hasDiscloseRequestContext(
context: Pick<ParsedVerificationRequestContext, 'request' | 'displayLabels'>,
) {
return Boolean((context.displayLabels && context.displayLabels.length > 0) || context.request.disclosures?.length);
}
export function parseBrowserHostTargetOrigin(search: string, options: TargetOriginOptions = {}): string | undefined {
const params = new URLSearchParams(search);
return normalizeTargetOrigin(params.get('targetOrigin'), options);