From 1e51c47977ec032b4e7fb642559b2e6c4fee597b Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Mon, 30 Mar 2026 23:32:24 -0700 Subject: [PATCH] Add disclose proof request, generation, and result route spine (#1891) * save wip * commit * fixes --- packages/webview-app/src/App.tsx | 5 +- .../screens/proving/DiscloseResultScreen.tsx | 111 ++++++++++++ .../proving/ProofGenerationRouteScreen.tsx | 126 ++++++++++++++ .../src/screens/proving/ProvingScreen.tsx | 70 +++----- .../src/utils/provingUtils.test.ts | 159 ++++++++++++++++++ .../webview-app/src/utils/provingUtils.ts | 73 ++++++++ .../src/utils/verificationRequest.test.ts | 17 +- .../src/utils/verificationRequest.ts | 6 + 8 files changed, 520 insertions(+), 47 deletions(-) create mode 100644 packages/webview-app/src/screens/proving/DiscloseResultScreen.tsx create mode 100644 packages/webview-app/src/screens/proving/ProofGenerationRouteScreen.tsx create mode 100644 packages/webview-app/src/utils/provingUtils.test.ts create mode 100644 packages/webview-app/src/utils/provingUtils.ts diff --git a/packages/webview-app/src/App.tsx b/packages/webview-app/src/App.tsx index 032d38c8e..c86f5097d 100644 --- a/packages/webview-app/src/App.tsx +++ b/packages/webview-app/src/App.tsx @@ -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 = () => ( } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/packages/webview-app/src/screens/proving/DiscloseResultScreen.tsx b/packages/webview-app/src/screens/proving/DiscloseResultScreen.tsx new file mode 100644 index 000000000..033137c32 --- /dev/null +++ b/packages/webview-app/src/screens/proving/DiscloseResultScreen.tsx @@ -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( + () => + 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 ( +
+ } + /> +
+ ); +}; diff --git a/packages/webview-app/src/screens/proving/ProofGenerationRouteScreen.tsx b/packages/webview-app/src/screens/proving/ProofGenerationRouteScreen.tsx new file mode 100644 index 000000000..d49fbaf4a --- /dev/null +++ b/packages/webview-app/src/screens/proving/ProofGenerationRouteScreen.tsx @@ -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 ( + + ); +}; diff --git a/packages/webview-app/src/screens/proving/ProvingScreen.tsx b/packages/webview-app/src/screens/proving/ProvingScreen.tsx index 490b8b043..5a6546411 100644 --- a/packages/webview-app/src/screens/proving/ProvingScreen.tsx +++ b/packages/webview-app/src/screens/proving/ProvingScreen.tsx @@ -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 ( } diff --git a/packages/webview-app/src/utils/provingUtils.test.ts b/packages/webview-app/src/utils/provingUtils.test.ts new file mode 100644 index 000000000..1dec9bbab --- /dev/null +++ b/packages/webview-app/src/utils/provingUtils.test.ts @@ -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); + }); + }); +}); diff --git a/packages/webview-app/src/utils/provingUtils.ts b/packages/webview-app/src/utils/provingUtils.ts new file mode 100644 index 000000000..325638508 --- /dev/null +++ b/packages/webview-app/src/utils/provingUtils.ts @@ -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()); +} diff --git a/packages/webview-app/src/utils/verificationRequest.test.ts b/packages/webview-app/src/utils/verificationRequest.test.ts index 739be3c1a..5520226ce 100644 --- a/packages/webview-app/src/utils/verificationRequest.test.ts +++ b/packages/webview-app/src/utils/verificationRequest.test.ts @@ -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', () => { diff --git a/packages/webview-app/src/utils/verificationRequest.ts b/packages/webview-app/src/utils/verificationRequest.ts index d2ef8dada..0edcdec8d 100644 --- a/packages/webview-app/src/utils/verificationRequest.ts +++ b/packages/webview-app/src/utils/verificationRequest.ts @@ -23,6 +23,12 @@ interface TargetOriginOptions { allowWildcard?: boolean; } +export function hasDiscloseRequestContext( + context: Pick, +) { + 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);