diff --git a/__tests__/OnboardingScreens.test.tsx b/__tests__/OnboardingScreens.test.tsx index c2b519fd..a37882b2 100644 --- a/__tests__/OnboardingScreens.test.tsx +++ b/__tests__/OnboardingScreens.test.tsx @@ -12,23 +12,23 @@ import OnTouchProvider from '../src/providers/touch/OnTouchProvider'; import store from '../src/store'; import { OnboardingMachineContext, - OnboardingEvents, - OnboardingEventTypes, + OnboardingMachineEvents, + OnboardingMachineEventTypes, OnboardingMachineInterpreter, - OnboardingStates, + OnboardingMachineStates, } from '../src/types/machines/onboarding'; import BackHandler from 'react-native/Libraries/Utilities/__mocks__/BackHandler'; export const mockPressBack = async (opts?: {onboardingInstance: any}): Promise => { typeof opts?.onboardingInstance === 'object' - ? opts.onboardingInstance.send(OnboardingEvents.PREVIOUS) + ? opts.onboardingInstance.send(OnboardingMachineEvents.PREVIOUS) : await act(() => BackHandler.mockPressBack()); }; jest.setTimeout(60 * 1000); // 60 seconds function createComponent( - onboardingInstance: Interpreter, + onboardingInstance: Interpreter, ): JSX.Element { return ( @@ -62,7 +62,7 @@ describe('Testing onboarding with regular machine, should ', (): void => { const nextButtonText: ReactTestInstance = await screen.findByText(/Next|Go|Accept/); // Welcome screen - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.showIntro); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.showIntro); expect(header).toBeOnTheScreen(); expect(items.length).toBe(1); @@ -72,41 +72,41 @@ describe('Testing onboarding with regular machine, should ', (): void => { await act(() => fireEvent.press(nextButtonText)); // TOS screen - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.acceptAgreement); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.acceptAgreement); expect(header).toBeOnTheScreen(); await act(async (): Promise => fireEvent.press(await screen.findByText(/accept the terms/))); await act(async (): Promise => fireEvent.press(await screen.findByText(/accept the privacy/))); await act(() => fireEvent.press(nextButtonText)); // Personal Details screen - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.enterPersonalDetails); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.enterPersonalDetails); await act(async (): Promise => fireEvent.changeText(await screen.findByText(/First name/), 'Bob')); await act(async (): Promise => fireEvent.changeText(await screen.findByText(/Last name/), 'the Builder')); await act(async (): Promise => fireEvent.changeText(await screen.findByText(/Email address/), 'nou@en.of')); await act(() => fireEvent.press(nextButtonText)); // Pin entry and verification - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.enterPin); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.enterPin); // We need to find the hidden input text, as we are overlay an SVG. We also need to fire an event that would come from the keyboard await act( async (): Promise => fireEvent((await screen.findAllByLabelText('Pin code', {hidden: true}))[0], 'submitEditing', {nativeEvent: {text: '123456'}}), ); - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.verifyPin); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.verifyPin); await act( async (): Promise => fireEvent((await screen.findAllByLabelText('Pin code', {hidden: true}))[1], 'submitEditing', {nativeEvent: {text: '123456'}}), ); // Verification screen - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.verifyPersonalDetails); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.verifyPersonalDetails); await act(() => fireEvent.press(nextButtonText)); // This is where the walletSetup state runs it's setup tasks await new Promise(res => setTimeout(res, 2000)); // Done - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.finishOnboarding); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.finishOnboarding); }); }); @@ -114,7 +114,7 @@ describe('Testing onboarding ui without services, should ', (): void => { test('result in onboarding happy flow to finish', async (): Promise => { const onboardingInstance: OnboardingMachineInterpreter = OnboardingMachine.getInstance({ services: { - [OnboardingStates.setupWallet]: () => Promise.resolve(console.log('done!')), + [OnboardingMachineStates.setupWallet]: () => Promise.resolve(console.log('done!')), }, requireCustomNavigationHook: false, }); @@ -126,7 +126,7 @@ describe('Testing onboarding ui without services, should ', (): void => { const nextButtonText: ReactTestInstance = await screen.findByText(/Next|Go|Accept/); // Welcome screen - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.showIntro); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.showIntro); expect(header).toBeOnTheScreen(); expect(items.length).toBe(1); @@ -136,47 +136,47 @@ describe('Testing onboarding ui without services, should ', (): void => { await act(() => fireEvent.press(nextButtonText)); // TOS screen - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.acceptAgreement); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.acceptAgreement); expect(header).toBeOnTheScreen(); await act(async (): Promise => fireEvent.press(await screen.findByText(/accept the terms/))); await act(async (): Promise => fireEvent.press(await screen.findByText(/accept the privacy/))); await act(() => fireEvent.press(nextButtonText)); // Personal Details screen - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.enterPersonalDetails); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.enterPersonalDetails); await act(async (): Promise => fireEvent.changeText(await screen.findByText(/First name/), 'Bob')); await act(async (): Promise => fireEvent.changeText(await screen.findByText(/Last name/), 'the Builder')); await act(async (): Promise => fireEvent.changeText(await screen.findByText(/Email address/), 'nou@en.of')); await act(() => fireEvent.press(nextButtonText)); // Pin entry and verification - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.enterPin); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.enterPin); // We need to find the hidden input text, as we are overlay an SVG. We also need to fire an event that would come from the keyboard await act( async (): Promise => fireEvent((await screen.findAllByLabelText('Pin code', {hidden: true}))[0], 'submitEditing', {nativeEvent: {text: '123456'}}), ); - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.verifyPin); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.verifyPin); await act( async (): Promise => fireEvent((await screen.findAllByLabelText('Pin code', {hidden: true}))[1], 'submitEditing', {nativeEvent: {text: '123456'}}), ); // Verification screen - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.verifyPersonalDetails); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.verifyPersonalDetails); await act(() => fireEvent.press(nextButtonText)); // This is where the walletSetup state runs it's setup tasks await new Promise(res => setTimeout(res, 50)); // Done - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.finishOnboarding); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.finishOnboarding); }); test('have working guards and finish onboarding', async (): Promise => { const onboardingInstance: OnboardingMachineInterpreter = OnboardingMachine.getInstance({ services: { - [OnboardingStates.setupWallet]: () => Promise.resolve(console.log('done!')), + [OnboardingMachineStates.setupWallet]: () => Promise.resolve(console.log('done!')), }, requireCustomNavigationHook: false, }); @@ -188,7 +188,7 @@ describe('Testing onboarding ui without services, should ', (): void => { const nextButtonText: ReactTestInstance = await screen.findByText(/Next|Go|Accept/); // Welcome screen - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.showIntro); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.showIntro); expect(header).toBeOnTheScreen(); expect(items.length).toBe(1); @@ -202,14 +202,14 @@ describe('Testing onboarding ui without services, should ', (): void => { await act(() => fireEvent.press(nextButtonText)); // TOS screen - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.acceptAgreement); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.acceptAgreement); expect(header).toBeOnTheScreen(); await mockPressBack({onboardingInstance}); // Welcome screen step 3 - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.showIntro); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.showIntro); await act(() => fireEvent.press(nextButtonText)); - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.acceptAgreement); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.acceptAgreement); await act(() => fireEvent.press(nextButtonText)); // Does nothing as accept checkboxes are not pressed yet await act(async (): Promise => fireEvent.press(await screen.findByText(/accept the terms/))); @@ -218,75 +218,75 @@ describe('Testing onboarding ui without services, should ', (): void => { await act(() => fireEvent.press(nextButtonText)); // Personal Details screen - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.enterPersonalDetails); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.enterPersonalDetails); await mockPressBack({onboardingInstance}); // Back to TOS - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.acceptAgreement); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.acceptAgreement); await mockPressBack({onboardingInstance}); // Back to Welcome - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.showIntro); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.showIntro); // TOS with checkboxes checked await act(() => fireEvent.press(nextButtonText)); await act(() => fireEvent.press(nextButtonText)); // Personal Details screen - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.enterPersonalDetails); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.enterPersonalDetails); await act(() => fireEvent.press(nextButtonText)); - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.enterPersonalDetails); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.enterPersonalDetails); await act(async (): Promise => fireEvent.changeText(await screen.findByText(/First name/), 'Bob')); await act(() => fireEvent.press(nextButtonText)); - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.enterPersonalDetails); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.enterPersonalDetails); await act(async (): Promise => fireEvent.changeText(await screen.findByText(/Last name/), 'the Builder')); await act(() => fireEvent.press(nextButtonText)); - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.enterPersonalDetails); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.enterPersonalDetails); await act(async (): Promise => fireEvent.changeText(await screen.findByText(/Email address/), 'nou')); await act(() => fireEvent.press(nextButtonText)); - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.enterPersonalDetails); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.enterPersonalDetails); await act(async (): Promise => fireEvent.changeText(await screen.findByText(/Email address/), 'nou@')); await act(() => fireEvent.press(nextButtonText)); - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.enterPersonalDetails); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.enterPersonalDetails); await act(async (): Promise => fireEvent.changeText(await screen.findByText(/Email address/), 'nou@en.of')); await act(() => fireEvent.press(nextButtonText)); // Pin entry and verification - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.enterPin); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.enterPin); // We need to find the hidden input text, as we are overlay an SVG. We also need to fire an event that would come from the keyboard await act( async (): Promise => fireEvent((await screen.findAllByLabelText('Pin code', {hidden: true}))[0], 'submitEditing', {nativeEvent: {text: '654321'}}), ); - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.verifyPin); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.verifyPin); await act( async (): Promise => fireEvent((await screen.findAllByLabelText('Pin code', {hidden: true}))[1], 'submitEditing', {nativeEvent: {text: '000000'}}), ); - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.verifyPin); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.verifyPin); await act( async (): Promise => fireEvent((await screen.findAllByLabelText('Pin code', {hidden: true}))[1], 'submitEditing', {nativeEvent: {text: '654321'}}), ); - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.verifyPersonalDetails); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.verifyPersonalDetails); await mockPressBack({onboardingInstance}); // Back to PinEntry skipping verify! - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.enterPin); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.enterPin); await mockPressBack({onboardingInstance}); // Back to PinEntry skipping verify! - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.enterPersonalDetails); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.enterPersonalDetails); await act(() => fireEvent.press(nextButtonText)); - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.enterPin); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.enterPin); // We need to find the hidden input text, as we are overlay an SVG. We also need to fire an event that would come from the keyboard await act( async (): Promise => fireEvent((await screen.findAllByLabelText('Pin code', {hidden: true}))[0], 'submitEditing', {nativeEvent: {text: '123456'}}), ); - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.verifyPin); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.verifyPin); await act( async (): Promise => fireEvent((await screen.findAllByLabelText('Pin code', {hidden: true}))[1], 'submitEditing', {nativeEvent: {text: '123456'}}), ); // Verification screen - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.verifyPersonalDetails); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.verifyPersonalDetails); const context: OnboardingMachineContext = onboardingInstance.getSnapshot().context; expect(context).toMatchObject({ @@ -319,7 +319,7 @@ describe('Testing onboarding ui without services, should ', (): void => { await new Promise(res => setTimeout(res, 50)); // Done - expect(onboardingInstance.getSnapshot().value).toBe(OnboardingStates.finishOnboarding); + expect(onboardingInstance.getSnapshot().value).toBe(OnboardingMachineStates.finishOnboarding); const finalContext: OnboardingMachineContext = onboardingInstance.getSnapshot().context; expect(finalContext).toEqual({ diff --git a/src/@config/database/index.ts b/src/@config/database/index.ts index 2cbf0098..59fcab83 100644 --- a/src/@config/database/index.ts +++ b/src/@config/database/index.ts @@ -15,7 +15,7 @@ const sqliteConfig: ExpoConnectionOptions = { migrationsRun: false, // We run migrations from code to ensure proper ordering with Redux synchronize: false, // We do not enable synchronize, as we use migrations from code migrationsTransactionMode: 'each', // protect every migration with a separate transaction - logging: 'all', // 'all' means to enable all logging + logging: ['info', 'warn'], logger: 'advanced-console', }; diff --git a/src/localization/translations/en.json b/src/localization/translations/en.json index bd958ca7..51f20f63 100644 --- a/src/localization/translations/en.json +++ b/src/localization/translations/en.json @@ -156,5 +156,11 @@ "emergency_send_button_caption": "Trigger now!", "emergency_abort_button_caption": "Abort alarm", "emergency_title": "Send emergency alert", - "emergency_subtitle": "Your distress call will be send in {{emergencyAlertDelay}} seconds, or you can trigger it immediately by clicking on the Trigger now! button." + "emergency_subtitle": "Your distress call will be send in {{emergencyAlertDelay}} seconds, or you can trigger it immediately by clicking on the Trigger now! button.", + "siopV2_machine_identifier_error_title": "Getting identifier", + "siopV2_machine_create_config_error_title": "Creating siopV2 config", + "siopV2_machine_get_request_error_title": "Getting siopV2 request", + "siopV2_machine_retrieve_contact_error_title": "Retrieve contact", + "siopV2_machine_add_contact_identity_error_title": "Add contact identity", + "siopV2_machine_send_response_error_title": "Sending siopV2 response" } diff --git a/src/localization/translations/nl.json b/src/localization/translations/nl.json index 616aa478..45dc6001 100644 --- a/src/localization/translations/nl.json +++ b/src/localization/translations/nl.json @@ -156,5 +156,11 @@ "emergency_send_button_caption": "Activeer nu!", "emergency_abort_button_caption": "Alarm afbreken", "emergency_title": "Stuur een noodwaarschuwing", - "emergency_subtitle": "Uw noodoproep wordt over {{emergencyAlertDelay}} seconden verzonden, of u kunt deze onmiddellijk activeren door op de knop Activeer nu! te klikken." + "emergency_subtitle": "Uw noodoproep wordt over {{emergencyAlertDelay}} seconden verzonden, of u kunt deze onmiddellijk activeren door op de knop Activeer nu! te klikken.", + "siopV2_machine_identifier_error_title": "Identifier ophalen", + "siopV2_machine_create_config_error_title": "SiopV2 configuratie maken", + "siopV2_machine_get_request_error_title": "SiopV2 verzoek ophalen", + "siopV2_machine_retrieve_contact_error_title": "Ophalen credential", + "siopV2_machine_add_contact_identity_error_title": "Toevoegen identiteit contact", + "siopV2_machine_send_response_error_title": "SiopV2 antwoord verzenden" } diff --git a/src/machines/oid4vciMachine.tsx b/src/machines/oid4vciMachine.tsx index 0e221e2c..26804578 100644 --- a/src/machines/oid4vciMachine.tsx +++ b/src/machines/oid4vciMachine.tsx @@ -95,7 +95,7 @@ const createOID4VCIMachine = (opts?: CreateOID4VCIMachineOpts): OID4VCIStateMach schema: { events: {} as OID4VCIMachineEventTypes, guards: {} as - | {type: OID4VCIMachineGuards.hasNotContactGuard} + | {type: OID4VCIMachineGuards.hasNoContactGuard} | {type: OID4VCIMachineGuards.selectCredentialGuard} | {type: OID4VCIMachineGuards.requirePinGuard} | {type: OID4VCIMachineGuards.hasNoContactIdentityGuard} @@ -200,7 +200,7 @@ const createOID4VCIMachine = (opts?: CreateOID4VCIMachineOpts): OID4VCIStateMach always: [ { target: OID4VCIMachineStates.addContact, - cond: OID4VCIMachineGuards.hasNotContactGuard, + cond: OID4VCIMachineGuards.hasNoContactGuard, }, { target: OID4VCIMachineStates.selectCredentials, diff --git a/src/machines/onboardingMachine.tsx b/src/machines/onboardingMachine.tsx index 7f03a7da..f8cbc9dd 100644 --- a/src/machines/onboardingMachine.tsx +++ b/src/machines/onboardingMachine.tsx @@ -12,12 +12,12 @@ import { OnboardingMachineContext, NextEvent, OnboardingContext, - OnboardingEvents, - OnboardingEventTypes, - OnboardingGuards, + OnboardingMachineEvents, + OnboardingMachineEventTypes, + OnboardingMachineGuards, OnboardingMachineInterpreter, OnboardingMachineState, - OnboardingStates, + OnboardingMachineStates, PersonalDataEvent, PinSetEvent, PrivacyPolicyEvent, @@ -28,15 +28,15 @@ import { import Debug, {Debugger} from 'debug'; const debug: Debugger = Debug(`${APP_ID}:onboarding`); -const onboardingToSAgreementGuard = (ctx: OnboardingMachineContext, _event: OnboardingEventTypes) => +const onboardingToSAgreementGuard = (ctx: OnboardingMachineContext, _event: OnboardingMachineEventTypes) => ctx.termsConditionsAccepted && ctx.privacyPolicyAccepted; -const onboardingPersonalDataGuard = (ctx: OnboardingMachineContext, _event: OnboardingEventTypes) => { +const onboardingPersonalDataGuard = (ctx: OnboardingMachineContext, _event: OnboardingMachineEventTypes) => { const {firstName, lastName, emailAddress} = ctx.personalData; return firstName && firstName.length > 0 && lastName && lastName.length > 0 && emailAddress && EMAIL_ADDRESS_VALIDATION_REGEX.test(emailAddress); }; -const onboardingPinCodeSetGuard = (ctx: OnboardingMachineContext, _event: OnboardingEventTypes) => { +const onboardingPinCodeSetGuard = (ctx: OnboardingMachineContext, _event: OnboardingMachineEventTypes) => { const {pinCode} = ctx; return pinCode && pinCode.length === 6; }; @@ -71,27 +71,27 @@ const createOnboardingMachine = (opts?: CreateOnboardingMachineOpts) => { pinCode: '', } as OnboardingMachineContext; - return createMachine({ + return createMachine({ id: opts?.machineId ?? 'Onboarding', predictableActionArguments: true, - initial: OnboardingStates.showIntro, + initial: OnboardingMachineStates.showIntro, schema: { - events: {} as OnboardingEventTypes, + events: {} as OnboardingMachineEventTypes, guards: {} as | { - type: OnboardingGuards.onboardingPersonalDataGuard; + type: OnboardingMachineGuards.onboardingPersonalDataGuard; } | { - type: OnboardingGuards.onboardingToSAgreementGuard; + type: OnboardingMachineGuards.onboardingToSAgreementGuard; } | { - type: OnboardingGuards.onboardingPinCodeSetGuard; + type: OnboardingMachineGuards.onboardingPinCodeSetGuard; } | { - type: OnboardingGuards.onboardingPinCodeVerifyGuard; + type: OnboardingMachineGuards.onboardingPinCodeVerifyGuard; }, services: {} as { - [OnboardingStates.setupWallet]: { + [OnboardingMachineStates.setupWallet]: { data: WalletSetupServiceResult; }; }, @@ -101,101 +101,101 @@ const createOnboardingMachine = (opts?: CreateOnboardingMachineOpts) => { }, states: { - [OnboardingStates.showIntro]: { + [OnboardingMachineStates.showIntro]: { on: { - [OnboardingEvents.NEXT]: [ + [OnboardingMachineEvents.NEXT]: [ { - target: OnboardingStates.acceptAgreement, + target: OnboardingMachineStates.acceptAgreement, }, ], }, }, - [OnboardingStates.acceptAgreement]: { + [OnboardingMachineStates.acceptAgreement]: { on: { - [OnboardingEvents.SET_POLICY]: { + [OnboardingMachineEvents.SET_POLICY]: { actions: assign({privacyPolicyAccepted: (_ctx: OnboardingMachineContext, e: PrivacyPolicyEvent) => e.data}), }, - [OnboardingEvents.SET_TOC]: { + [OnboardingMachineEvents.SET_TOC]: { actions: assign({termsConditionsAccepted: (_ctx: OnboardingMachineContext, e: TermsConditionsEvent) => e.data}), }, - [OnboardingEvents.DECLINE]: { - target: OnboardingStates.declineOnboarding, + [OnboardingMachineEvents.DECLINE]: { + target: OnboardingMachineStates.declineOnboarding, }, - [OnboardingEvents.NEXT]: { - cond: OnboardingGuards.onboardingToSAgreementGuard, - target: OnboardingStates.enterPersonalDetails, + [OnboardingMachineEvents.NEXT]: { + cond: OnboardingMachineGuards.onboardingToSAgreementGuard, + target: OnboardingMachineStates.enterPersonalDetails, }, - [OnboardingEvents.PREVIOUS]: {target: OnboardingStates.showIntro}, + [OnboardingMachineEvents.PREVIOUS]: {target: OnboardingMachineStates.showIntro}, }, }, - [OnboardingStates.enterPersonalDetails]: { + [OnboardingMachineStates.enterPersonalDetails]: { on: { - [OnboardingEvents.SET_PERSONAL_DATA]: { + [OnboardingMachineEvents.SET_PERSONAL_DATA]: { actions: assign({personalData: (_ctx: OnboardingMachineContext, e: PersonalDataEvent) => e.data}), }, - [OnboardingEvents.NEXT]: { - cond: OnboardingGuards.onboardingPersonalDataGuard, - target: OnboardingStates.enterPin, + [OnboardingMachineEvents.NEXT]: { + cond: OnboardingMachineGuards.onboardingPersonalDataGuard, + target: OnboardingMachineStates.enterPin, }, - [OnboardingEvents.PREVIOUS]: {target: OnboardingStates.acceptAgreement}, + [OnboardingMachineEvents.PREVIOUS]: {target: OnboardingMachineStates.acceptAgreement}, }, }, - [OnboardingStates.enterPin]: { + [OnboardingMachineStates.enterPin]: { on: { - [OnboardingEvents.SET_PIN]: { + [OnboardingMachineEvents.SET_PIN]: { actions: assign({pinCode: (_ctx: OnboardingMachineContext, e: PinSetEvent) => e.data}), }, - [OnboardingEvents.NEXT]: { - cond: OnboardingGuards.onboardingPinCodeSetGuard, - target: OnboardingStates.verifyPin, + [OnboardingMachineEvents.NEXT]: { + cond: OnboardingMachineGuards.onboardingPinCodeSetGuard, + target: OnboardingMachineStates.verifyPin, }, - [OnboardingEvents.PREVIOUS]: { - target: OnboardingStates.enterPersonalDetails, + [OnboardingMachineEvents.PREVIOUS]: { + target: OnboardingMachineStates.enterPersonalDetails, }, }, }, - [OnboardingStates.verifyPin]: { + [OnboardingMachineStates.verifyPin]: { on: { - [OnboardingEvents.NEXT]: { - cond: OnboardingGuards.onboardingPinCodeVerifyGuard, - target: OnboardingStates.verifyPersonalDetails, + [OnboardingMachineEvents.NEXT]: { + cond: OnboardingMachineGuards.onboardingPinCodeVerifyGuard, + target: OnboardingMachineStates.verifyPersonalDetails, }, - [OnboardingEvents.PREVIOUS]: { - target: OnboardingStates.enterPin, + [OnboardingMachineEvents.PREVIOUS]: { + target: OnboardingMachineStates.enterPin, }, }, }, - [OnboardingStates.verifyPersonalDetails]: { + [OnboardingMachineStates.verifyPersonalDetails]: { on: { - [OnboardingEvents.NEXT]: { - target: OnboardingStates.setupWallet, + [OnboardingMachineEvents.NEXT]: { + target: OnboardingMachineStates.setupWallet, }, - [OnboardingEvents.PREVIOUS]: { - target: OnboardingStates.enterPin, // We are going back to pin entry and then verify + [OnboardingMachineEvents.PREVIOUS]: { + target: OnboardingMachineStates.enterPin, // We are going back to pin entry and then verify }, }, }, - [OnboardingStates.setupWallet]: { + [OnboardingMachineStates.setupWallet]: { invoke: { - id: OnboardingStates.setupWallet, - src: OnboardingStates.setupWallet, + id: OnboardingMachineStates.setupWallet, + src: OnboardingMachineStates.setupWallet, onDone: { - target: OnboardingStates.finishOnboarding, + target: OnboardingMachineStates.finishOnboarding, }, // todo: On Error }, }, - [OnboardingStates.declineOnboarding]: { - id: OnboardingStates.declineOnboarding, - always: OnboardingStates.showIntro, + [OnboardingMachineStates.declineOnboarding]: { + id: OnboardingMachineStates.declineOnboarding, + always: OnboardingMachineStates.showIntro, entry: assign({ ...initialContext, }), // Since we are not allowed to exit an app by Apple/Google, we go back to the onboarding state when the user declines }, - [OnboardingStates.finishOnboarding]: { + [OnboardingMachineStates.finishOnboarding]: { type: 'final', - id: OnboardingStates.finishOnboarding, + id: OnboardingMachineStates.finishOnboarding, entry: assign({ pinCode: '', personalData: undefined, @@ -223,7 +223,7 @@ export class OnboardingMachine { } static stopInstance(): void { - debug(`Stop instance...`); + debug(`Stopping instance...`); if (!OnboardingMachine.hasInstance()) { return; } diff --git a/src/machines/siopV2Machine.tsx b/src/machines/siopV2Machine.tsx new file mode 100644 index 00000000..9f85488a --- /dev/null +++ b/src/machines/siopV2Machine.tsx @@ -0,0 +1,355 @@ +import React from 'react'; +import {assign, createMachine, DoneInvokeEvent, interpret} from 'xstate'; +import {IContact, IDidAuthConfig, IIdentity} from '@sphereon/ssi-sdk.data-store'; +import {PresentationDefinitionWithLocation, VerifiedAuthorizationRequest} from '@sphereon/did-auth-siop'; +import {EvaluationResults, PEX, Status} from '@sphereon/pex'; +import {getSiopRequest, retrieveContact, addContactIdentity, sendResponse, createConfig} from '../services/machines/siopV2MachineService'; +import {siopV2StateNavigationListener} from '../navigation/machines/siopV2StateNavigation'; +import {translate} from '../localization/Localization'; +import { + ContactAliasEvent, + ContactConsentEvent, + CreateContactEvent, + CreateSiopV2MachineOpts, + SiopV2MachineAddContactStates, + SiopV2MachineContext, + SiopV2MachineEvents, + SiopV2MachineEventTypes, + SiopV2MachineGuards, + SiopV2MachineInstanceOpts, + SiopV2MachineInterpreter, + SiopV2MachineServices, + SiopV2MachineState, + SiopV2MachineStates, + SiopV2StateMachine, + SelectCredentialsEvent, + SiopV2AuthorizationRequestData, +} from '../types/machines/siopV2'; +import {ErrorDetails} from '../types'; + +const siopV2HasNoContactGuard = (_ctx: SiopV2MachineContext, _event: SiopV2MachineEventTypes): boolean => { + const {contact} = _ctx; + return contact === undefined; +}; + +const siopV2HasContactGuard = (_ctx: SiopV2MachineContext, _event: SiopV2MachineEventTypes): boolean => { + const {contact} = _ctx; + return contact !== undefined; +}; + +const siopV2CreateContactGuard = (_ctx: SiopV2MachineContext, _event: SiopV2MachineEventTypes): boolean => { + const {contactAlias, hasContactConsent} = _ctx; + + return hasContactConsent && contactAlias !== undefined && contactAlias.length > 0; +}; + +const siopV2HasSelectedRequiredCredentialsGuard = (_ctx: SiopV2MachineContext, _event: SiopV2MachineEventTypes): boolean => { + const {selectedCredentials, authorizationRequestData} = _ctx; + + if (authorizationRequestData === undefined) { + throw new Error('Missing authorization request data in context'); + } + + if (authorizationRequestData.presentationDefinitions === undefined || authorizationRequestData.presentationDefinitions.length === 0) { + throw Error('No presentation definitions present'); + } + + const definitionWithLocation: PresentationDefinitionWithLocation = authorizationRequestData.presentationDefinitions[0]; + const pex: PEX = new PEX(); + const evaluationResults: EvaluationResults = pex.evaluateCredentials(definitionWithLocation.definition, selectedCredentials); + + return evaluationResults.areRequiredCredentialsPresent === Status.INFO; +}; + +const siopV2IsSiopOnlyGuard = (_ctx: SiopV2MachineContext, _event: SiopV2MachineEventTypes): boolean => { + const {authorizationRequestData} = _ctx; + + if (authorizationRequestData === undefined) { + throw new Error('Missing authorization request data in context'); + } + + return authorizationRequestData.presentationDefinitions === undefined; +}; + +const siopV2IsSiopWithOID4VPGuard = (_ctx: SiopV2MachineContext, _event: SiopV2MachineEventTypes): boolean => { + const {authorizationRequestData} = _ctx; + + if (!authorizationRequestData) { + throw new Error('Missing authorization request data in context'); + } + + return authorizationRequestData.presentationDefinitions !== undefined; +}; + +const createSiopV2Machine = (opts?: CreateSiopV2MachineOpts): SiopV2StateMachine => { + const initialContext: SiopV2MachineContext = { + requestData: opts?.requestData, + hasContactConsent: true, + contactAlias: '', + selectedCredentials: [], + }; + + return createMachine({ + id: opts?.machineId ?? 'SIOPV2', + predictableActionArguments: true, + initial: SiopV2MachineStates.createConfig, + schema: { + events: {} as SiopV2MachineEventTypes, + guards: {} as + | {type: SiopV2MachineGuards.hasNoContactGuard} + | {type: SiopV2MachineGuards.hasContactGuard} + | {type: SiopV2MachineGuards.createContactGuard} + | {type: SiopV2MachineGuards.hasSelectedRequiredCredentialsGuard}, + services: {} as { + [SiopV2MachineServices.createConfig]: { + data: IDidAuthConfig; + }; + [SiopV2MachineServices.getSiopRequest]: { + data: VerifiedAuthorizationRequest; + }; + [SiopV2MachineServices.retrieveContact]: { + data: IContact | undefined; + }; + [SiopV2MachineServices.addContactIdentity]: { + data: void; + }; + [SiopV2MachineServices.sendResponse]: { + data: void; + }; + }, + }, + context: initialContext, + states: { + [SiopV2MachineStates.createConfig]: { + id: SiopV2MachineStates.createConfig, + invoke: { + src: SiopV2MachineServices.createConfig, + onDone: { + target: SiopV2MachineStates.getSiopRequest, + actions: assign({ + didAuthConfig: (_ctx: SiopV2MachineContext, _event: DoneInvokeEvent) => _event.data, + }), + }, + onError: { + target: SiopV2MachineStates.handleError, + actions: assign({ + error: (_ctx: SiopV2MachineContext, _event: DoneInvokeEvent): ErrorDetails => ({ + title: translate('siopV2_machine_create_config_error_title'), + message: _event.data.message, + }), + }), + }, + }, + }, + [SiopV2MachineStates.getSiopRequest]: { + id: SiopV2MachineStates.getSiopRequest, + invoke: { + src: SiopV2MachineServices.getSiopRequest, + onDone: { + target: SiopV2MachineStates.retrieveContact, + actions: assign({ + authorizationRequestData: (_ctx: SiopV2MachineContext, _event: DoneInvokeEvent) => _event.data, + }), + }, + onError: { + target: SiopV2MachineStates.handleError, + actions: assign({ + error: (_ctx: SiopV2MachineContext, _event: DoneInvokeEvent): ErrorDetails => ({ + title: translate('siopV2_machine_get_request_error_title'), + message: _event.data.message, + }), + }), + }, + }, + }, + [SiopV2MachineStates.retrieveContact]: { + id: SiopV2MachineStates.retrieveContact, + invoke: { + src: SiopV2MachineServices.retrieveContact, + onDone: { + target: SiopV2MachineStates.transitionFromSetup, + actions: assign({contact: (_ctx: SiopV2MachineContext, _event: DoneInvokeEvent) => _event.data}), + }, + onError: { + target: SiopV2MachineStates.handleError, + actions: assign({ + error: (_ctx: SiopV2MachineContext, _event: DoneInvokeEvent): ErrorDetails => ({ + title: translate('siopV2_machine_retrieve_contact_error_title'), + message: _event.data.message, + }), + }), + }, + }, + }, + [SiopV2MachineStates.transitionFromSetup]: { + id: SiopV2MachineStates.transitionFromSetup, + always: [ + { + target: SiopV2MachineStates.addContact, + cond: SiopV2MachineGuards.hasNoContactGuard, + }, + { + target: SiopV2MachineStates.sendResponse, + cond: SiopV2MachineGuards.siopOnlyGuard, + }, + { + target: SiopV2MachineStates.selectCredentials, + cond: SiopV2MachineGuards.siopWithOID4VPGuard, + }, + ], + }, + [SiopV2MachineStates.addContact]: { + id: SiopV2MachineStates.addContact, + initial: SiopV2MachineAddContactStates.idle, + on: { + [SiopV2MachineEvents.SET_CONTACT_CONSENT]: { + actions: assign({hasContactConsent: (_ctx: SiopV2MachineContext, _event: ContactConsentEvent) => _event.data}), + }, + [SiopV2MachineEvents.SET_CONTACT_ALIAS]: { + actions: assign({contactAlias: (_ctx: SiopV2MachineContext, _event: ContactAliasEvent) => _event.data}), + }, + [SiopV2MachineEvents.CREATE_CONTACT]: { + target: `.${SiopV2MachineAddContactStates.next}`, + actions: assign({contact: (_ctx: SiopV2MachineContext, _event: CreateContactEvent) => _event.data}), + cond: SiopV2MachineGuards.createContactGuard, + }, + [SiopV2MachineEvents.DECLINE]: { + target: SiopV2MachineStates.declined, + }, + [SiopV2MachineEvents.PREVIOUS]: { + target: SiopV2MachineStates.aborted, + }, + }, + states: { + [SiopV2MachineAddContactStates.idle]: {}, + [SiopV2MachineAddContactStates.next]: { + always: { + target: `#${SiopV2MachineStates.selectCredentials}`, + cond: SiopV2MachineGuards.hasContactGuard, + }, + }, + }, + }, + [SiopV2MachineStates.addContactIdentity]: { + id: SiopV2MachineStates.addContactIdentity, + invoke: { + src: SiopV2MachineServices.addContactIdentity, + onDone: { + target: SiopV2MachineStates.selectCredentials, + actions: (_ctx: SiopV2MachineContext, _event: DoneInvokeEvent): void => { + _ctx.contact?.identities.push(_event.data); + }, + }, + onError: { + target: SiopV2MachineStates.handleError, + actions: assign({ + error: (_ctx: SiopV2MachineContext, _event: DoneInvokeEvent): ErrorDetails => ({ + title: translate('siopV2_machine_add_contact_identity_error_title'), + message: _event.data.message, + }), + }), + }, + }, + }, + [SiopV2MachineStates.selectCredentials]: { + id: SiopV2MachineStates.selectCredentials, + on: { + [SiopV2MachineEvents.SET_SELECTED_CREDENTIALS]: { + actions: assign({selectedCredentials: (_ctx: SiopV2MachineContext, _event: SelectCredentialsEvent) => _event.data}), + }, + [SiopV2MachineEvents.NEXT]: { + target: SiopV2MachineStates.sendResponse, + cond: SiopV2MachineGuards.hasSelectedRequiredCredentialsGuard, + }, + [SiopV2MachineEvents.DECLINE]: { + target: SiopV2MachineStates.declined, + }, + [SiopV2MachineEvents.PREVIOUS]: { + target: SiopV2MachineStates.aborted, + }, + }, + }, + [SiopV2MachineStates.sendResponse]: { + id: SiopV2MachineStates.sendResponse, + invoke: { + src: SiopV2MachineServices.sendResponse, + onDone: { + target: SiopV2MachineStates.done, + }, + onError: { + target: SiopV2MachineStates.handleError, + actions: assign({ + error: (_ctx: SiopV2MachineContext, _event: DoneInvokeEvent): ErrorDetails => ({ + title: translate('siopV2_machine_send_response_error_title'), + message: _event.data.message, + }), + }), + }, + }, + }, + [SiopV2MachineStates.handleError]: { + id: SiopV2MachineStates.handleError, + on: { + [SiopV2MachineEvents.NEXT]: { + target: SiopV2MachineStates.error, + }, + [SiopV2MachineEvents.PREVIOUS]: { + target: SiopV2MachineStates.error, + }, + }, + }, + [SiopV2MachineStates.aborted]: { + id: SiopV2MachineStates.aborted, + type: 'final', + }, + [SiopV2MachineStates.declined]: { + id: SiopV2MachineStates.declined, + type: 'final', + }, + [SiopV2MachineStates.error]: { + id: SiopV2MachineStates.error, + type: 'final', + }, + [SiopV2MachineStates.done]: { + id: SiopV2MachineStates.done, + type: 'final', + }, + }, + }); +}; + +export class SiopV2Machine { + static newInstance(opts?: SiopV2MachineInstanceOpts): SiopV2MachineInterpreter { + const instance: SiopV2MachineInterpreter = interpret( + createSiopV2Machine(opts).withConfig({ + services: { + [SiopV2MachineServices.createConfig]: createConfig, + [SiopV2MachineServices.getSiopRequest]: getSiopRequest, + [SiopV2MachineServices.retrieveContact]: retrieveContact, + [SiopV2MachineServices.addContactIdentity]: addContactIdentity, + [SiopV2MachineServices.sendResponse]: sendResponse, + ...opts?.services, + }, + guards: { + siopV2HasNoContactGuard, + siopV2HasContactGuard, + siopV2CreateContactGuard, + siopV2HasSelectedRequiredCredentialsGuard, + siopV2IsSiopOnlyGuard, + siopV2IsSiopWithOID4VPGuard, + ...opts?.guards, + }, + }), + ); + + if (typeof opts?.subscription === 'function') { + instance.onTransition(opts.subscription); + } else if (opts?.requireCustomNavigationHook !== true) { + instance.onTransition((snapshot: SiopV2MachineState): void => { + void siopV2StateNavigationListener(instance, snapshot); + }); + } + + return instance; + } +} diff --git a/src/navigation/machines/oid4vciStateNavigation.tsx b/src/navigation/machines/oid4vciStateNavigation.tsx index 96558d35..13faf244 100644 --- a/src/navigation/machines/oid4vciStateNavigation.tsx +++ b/src/navigation/machines/oid4vciStateNavigation.tsx @@ -24,7 +24,7 @@ import { OID4VCIMachineNavigationArgs, OID4VCIMachineState, OID4VCIMachineStates, - OIDVCIProviderProps, + OID4VCIProviderProps, } from '../../types/machines/oid4vci'; import {MainRoutesEnum, NavigationBarRoutesEnum, PopupImagesEnum, ScreenRoutesEnum} from '../../types'; @@ -215,7 +215,7 @@ const navigateReviewCredentialOffers = async (args: OID4VCIMachineNavigationArgs const navigateFinal = async (args: OID4VCIMachineNavigationArgs): Promise => { const {navigation, oid4vciMachine} = args; - debug(`Stop oid4vci machine...`); + debug(`Stopping oid4vci machine...`); oid4vciMachine.stop(); debug(`Stopped oid4vci machine`); @@ -261,6 +261,7 @@ export const oid4vciStateNavigationListener = async ( ): Promise => { if (state._event.type === 'internal') { // Make sure we do not navigate when triggered by an internal event. We need to stay on current screen + // Make sure we do not navigate when state has not changed return; } const onBack = () => oid4vciMachine.send(OID4VCIMachineEvents.PREVIOUS); @@ -301,7 +302,7 @@ export const oid4vciStateNavigationListener = async ( } }; -export const OID4VCIProvider = (props: OIDVCIProviderProps): JSX.Element => { +export const OID4VCIProvider = (props: OID4VCIProviderProps): JSX.Element => { const {children, customOID4VCIInstance} = props; return {children}; diff --git a/src/navigation/machines/onboardingStateNavigation.tsx b/src/navigation/machines/onboardingStateNavigation.tsx index 9a528b25..af846cc6 100644 --- a/src/navigation/machines/onboardingStateNavigation.tsx +++ b/src/navigation/machines/onboardingStateNavigation.tsx @@ -9,10 +9,10 @@ import { OnboardingMachineContext, OnboardingPersonalData, OnboardingContext as OnboardingContextType, - OnboardingEvents, + OnboardingMachineEvents, OnboardingMachineInterpreter, OnboardingMachineState, - OnboardingStates, + OnboardingMachineStates, OnboardingMachineNavigationArgs, OnboardingProviderProps, } from '../../types/machines/onboarding'; @@ -32,7 +32,7 @@ const navigateTermsOfService = async (args: OnboardingMachineNavigationArgs): Pr const {onboardingMachine, navigation, onNext, onBack, context} = args; const isNextDisabled = (): boolean => { - return onboardingMachine.getSnapshot()?.can(OnboardingEvents.NEXT) !== true; + return onboardingMachine.getSnapshot()?.can(OnboardingMachineEvents.NEXT) !== true; }; navigation.navigate(ScreenRoutesEnum.TERMS_OF_SERVICE, { @@ -40,15 +40,15 @@ const navigateTermsOfService = async (args: OnboardingMachineNavigationArgs): Pr context, onBack, onNext, - onDecline: () => onboardingMachine.send(OnboardingEvents.DECLINE), + onDecline: () => onboardingMachine.send(OnboardingMachineEvents.DECLINE), onAcceptTerms: (accept: boolean) => onboardingMachine.send({ - type: OnboardingEvents.SET_TOC, + type: OnboardingMachineEvents.SET_TOC, data: accept, }), onAcceptPrivacy: (accept: boolean) => onboardingMachine.send({ - type: OnboardingEvents.SET_POLICY, + type: OnboardingMachineEvents.SET_POLICY, data: accept, }), isDisabled: isNextDisabled, @@ -59,7 +59,7 @@ const navigatePersonalDetails = async (args: OnboardingMachineNavigationArgs): P const {onboardingMachine, navigation, onBack, context} = args; const isNextDisabled = (): boolean => { - return onboardingMachine.getSnapshot()?.can(OnboardingEvents.NEXT) !== true; + return onboardingMachine.getSnapshot()?.can(OnboardingMachineEvents.NEXT) !== true; }; navigation.navigate(ScreenRoutesEnum.PERSONAL_DATA, { @@ -67,17 +67,17 @@ const navigatePersonalDetails = async (args: OnboardingMachineNavigationArgs): P isDisabled: isNextDisabled, onPersonalData: (personalData: OnboardingPersonalData) => onboardingMachine.send({ - type: OnboardingEvents.SET_PERSONAL_DATA, + type: OnboardingMachineEvents.SET_PERSONAL_DATA, data: personalData, }), onBack, onNext: (personalData: OnboardingPersonalData): void => { onboardingMachine.send([ { - type: OnboardingEvents.SET_PERSONAL_DATA, + type: OnboardingMachineEvents.SET_PERSONAL_DATA, data: personalData, }, - OnboardingEvents.NEXT, + OnboardingMachineEvents.NEXT, ]); }, }); @@ -92,10 +92,10 @@ const navigateEnterPin = async (args: OnboardingMachineNavigationArgs): Promise< onNext: (pinCode: string): void => { onboardingMachine.send([ { - type: OnboardingEvents.SET_PIN, + type: OnboardingMachineEvents.SET_PIN, data: pinCode, }, - OnboardingEvents.NEXT, + OnboardingMachineEvents.NEXT, ]); }, }); @@ -109,7 +109,7 @@ const navigateVerifyPin = async (args: OnboardingMachineNavigationArgs): Promise onBack, onNext: (pinCode: string): void => { onboardingMachine.send({ - type: OnboardingEvents.NEXT, + type: OnboardingMachineEvents.NEXT, data: pinCode, }); }, @@ -143,11 +143,12 @@ export const onboardingStateNavigationListener = async ( ): Promise => { if (state._event.type === 'internal') { // Make sure we do not navigate when triggered by an internal event. We need to stay on current screen + // Make sure we do not navigate when state has not changed return; } const context: OnboardingMachineContext = onboardingMachine.getSnapshot().context; - const onBack = () => onboardingMachine.send(OnboardingEvents.PREVIOUS); - const onNext = () => onboardingMachine.send(OnboardingEvents.NEXT); + const onBack = () => onboardingMachine.send(OnboardingMachineEvents.PREVIOUS); + const onNext = () => onboardingMachine.send(OnboardingMachineEvents.NEXT); const nav = navigation ?? RootNavigation; if (nav === undefined || !nav.isReady()) { @@ -155,21 +156,21 @@ export const onboardingStateNavigationListener = async ( return; } - if (state.matches(OnboardingStates.showIntro)) { + if (state.matches(OnboardingMachineStates.showIntro)) { return navigateWelcome({onboardingMachine, state, navigation: nav, onNext, onBack, context}); - } else if (state.matches(OnboardingStates.acceptAgreement)) { + } else if (state.matches(OnboardingMachineStates.acceptAgreement)) { return navigateTermsOfService({onboardingMachine, state, navigation: nav, onNext, onBack, context}); - } else if (state.matches(OnboardingStates.enterPersonalDetails)) { + } else if (state.matches(OnboardingMachineStates.enterPersonalDetails)) { return navigatePersonalDetails({onboardingMachine, state, navigation: nav, onNext, onBack, context}); - } else if (state.matches(OnboardingStates.enterPin)) { + } else if (state.matches(OnboardingMachineStates.enterPin)) { return navigateEnterPin({onboardingMachine, state, navigation: nav, onNext, onBack, context}); - } else if (state.matches(OnboardingStates.verifyPin)) { + } else if (state.matches(OnboardingMachineStates.verifyPin)) { return navigateVerifyPin({onboardingMachine, state, navigation: nav, onNext, onBack, context}); - } else if (state.matches(OnboardingStates.verifyPersonalDetails)) { + } else if (state.matches(OnboardingMachineStates.verifyPersonalDetails)) { return navigateVerifyPersonalDetails({onboardingMachine, state, navigation: nav, onNext, onBack, context}); - } else if (state.matches(OnboardingStates.setupWallet)) { + } else if (state.matches(OnboardingMachineStates.setupWallet)) { return navigateSetupWallet({onboardingMachine, state, navigation: nav, onNext, onBack, context}); - } else if (state.matches(OnboardingStates.finishOnboarding)) { + } else if (state.matches(OnboardingMachineStates.finishOnboarding)) { return navigateDone(); } else { return Promise.reject(Error(`Navigation for ${JSON.stringify(state)} is not implemented!`)); // Should not happen, so we throw an error diff --git a/src/navigation/machines/siopV2StateNavigation.tsx b/src/navigation/machines/siopV2StateNavigation.tsx new file mode 100644 index 00000000..a40764ec --- /dev/null +++ b/src/navigation/machines/siopV2StateNavigation.tsx @@ -0,0 +1,289 @@ +import React, {Context, createContext} from 'react'; +import Debug, {Debugger} from 'debug'; +import {NativeStackNavigationProp} from '@react-navigation/native-stack'; +import {translate} from '../../localization/Localization'; +import RootNavigation from './../rootNavigation'; +import {APP_ID} from '../../@config/constants'; +import { + SiopV2Context as SiopV2ContextType, + SiopV2MachineEvents, + SiopV2MachineInterpreter, + SiopV2MachineNavigationArgs, + SiopV2MachineState, + SiopV2MachineStates, + SiopV2ProviderProps, +} from '../../types/machines/siopV2'; +import {MainRoutesEnum, NavigationBarRoutesEnum, PopupImagesEnum, ScreenRoutesEnum} from '../../types'; +import {CreateContactEvent, OID4VCIMachineEvents} from '../../types/machines/oid4vci'; +import {URL} from 'react-native-url-polyfill'; +import {ConnectionTypeEnum, CorrelationIdentifierEnum, IBasicContact, IContact, IdentityRoleEnum} from '@sphereon/ssi-sdk.data-store'; +import {SimpleEventsOf} from 'xstate'; +import {translateCorrelationIdToName} from '../../utils/CredentialUtils'; +import {PresentationDefinitionWithLocation} from '@sphereon/did-auth-siop'; +import {OriginalVerifiableCredential} from '@sphereon/ssi-types'; +import {Format} from '@sphereon/pex-models'; +import {authenticate} from '../../services/authenticationService'; + +const debug: Debugger = Debug(`${APP_ID}:siopV2StateNavigation`); + +const SiopV2Context: Context = createContext({} as SiopV2ContextType); + +const navigateLoading = async (args: SiopV2MachineNavigationArgs): Promise => { + const {navigation} = args; + navigation.navigate(MainRoutesEnum.SIOPV2, { + screen: ScreenRoutesEnum.LOADING, + params: { + message: translate('action_getting_information_message'), + }, + }); +}; + +const navigateSendingCredentials = async (args: SiopV2MachineNavigationArgs): Promise => { + const {navigation} = args; + navigation.navigate(MainRoutesEnum.SIOPV2, { + screen: ScreenRoutesEnum.LOADING, + params: { + message: translate('action_sharing_credentials_message'), + }, + }); +}; + +const navigateAddContact = async (args: SiopV2MachineNavigationArgs): Promise => { + const {navigation, state, siopV2Machine, onBack} = args; + const {hasContactConsent, requestData, authorizationRequestData} = state.context; + + if (authorizationRequestData === undefined) { + return Promise.reject(Error('Missing authorization request data in context')); + } + + if (requestData === undefined) { + return Promise.reject(Error('Missing request data in context')); + } + + const contact: Omit = { + name: authorizationRequestData.name ?? authorizationRequestData.correlationId, + uri: authorizationRequestData.uri && `${authorizationRequestData.uri.protocol}//${authorizationRequestData.uri.hostname}`, + identities: [ + { + alias: authorizationRequestData.correlationId, + roles: [IdentityRoleEnum.ISSUER], + identifier: { + type: CorrelationIdentifierEnum.URL, + correlationId: authorizationRequestData.correlationId, + }, + // TODO WAL-476 add support for correct connection + connection: { + type: ConnectionTypeEnum.OPENID_CONNECT, + config: { + clientId: '138d7bf8-c930-4c6e-b928-97d3a4928b01', + clientSecret: '03b3955f-d020-4f2a-8a27-4e452d4e27a0', + scopes: ['auth'], + issuer: 'https://example.com/app-test', + redirectUrl: 'app:/callback', + dangerouslyAllowInsecureHttpRequests: true, + clientAuthMethod: 'post' as const, + }, + }, + }, + ], + }; + + const onCreate = async (contact: IContact): Promise => { + siopV2Machine.send({ + type: SiopV2MachineEvents.CREATE_CONTACT, + data: contact, + }); + }; + + const onConsentChange = async (hasConsent: boolean): Promise => { + siopV2Machine.send({ + type: SiopV2MachineEvents.SET_CONTACT_CONSENT, + data: hasConsent, + }); + }; + + const onAliasChange = async (alias: string): Promise => { + siopV2Machine.send({ + type: SiopV2MachineEvents.SET_CONTACT_ALIAS, + data: alias, + }); + }; + + const onDecline = async (): Promise => { + siopV2Machine.send(SiopV2MachineEvents.DECLINE); + }; + + const isCreateDisabled = (): boolean => { + return siopV2Machine.getSnapshot()?.can(OID4VCIMachineEvents.CREATE_CONTACT as SimpleEventsOf) !== true; + }; + + navigation.navigate(MainRoutesEnum.SIOPV2, { + screen: ScreenRoutesEnum.CONTACT_ADD, + params: { + name: contact.name, + uri: contact.uri, + identities: contact.identities, + hasConsent: hasContactConsent, + onAliasChange, + onConsentChange, + onCreate, + onDecline, + onBack, + isCreateDisabled, + }, + }); +}; + +const navigateSelectCredentials = async (args: SiopV2MachineNavigationArgs): Promise => { + const {navigation, state, siopV2Machine, onNext, onBack} = args; + const {contact, authorizationRequestData} = state.context; + + if (contact === undefined) { + return Promise.reject(Error('Missing contact in context')); + } + + if (authorizationRequestData === undefined) { + return Promise.reject(Error('Missing authorization request data in context')); + } + + if (authorizationRequestData.presentationDefinitions === undefined || authorizationRequestData.presentationDefinitions.length === 0) { + return Promise.reject(Error('No presentation definitions present')); + } + // TODO currently only supporting 1 presentation definition + if (authorizationRequestData.presentationDefinitions.length > 1) { + return Promise.reject(Error('Multiple presentation definitions present')); + } + const presentationDefinitionWithLocation: PresentationDefinitionWithLocation = authorizationRequestData.presentationDefinitions![0]; + const format: Format | undefined = authorizationRequestData.registrationMetadataPayload.registration?.vp_formats; + const subjectSyntaxTypesSupported: Array | undefined = + authorizationRequestData.registrationMetadataPayload.registration?.subject_syntax_types_supported; + + const onSelect = async (selectedCredentials: Array): Promise => { + siopV2Machine.send({ + type: SiopV2MachineEvents.SET_SELECTED_CREDENTIALS, + data: selectedCredentials, + }); + }; + + const isSendDisabled = (): boolean => { + return siopV2Machine.getSnapshot()?.can(SiopV2MachineEvents.NEXT) !== true; + }; + + const onDecline = async (): Promise => { + siopV2Machine.send(SiopV2MachineEvents.DECLINE); + }; + + const onSend = async (): Promise => { + const onAuthenticate = async (): Promise => { + onNext?.(); + }; + await authenticate(onAuthenticate); + }; + + navigation.navigate(MainRoutesEnum.SIOPV2, { + screen: ScreenRoutesEnum.CREDENTIALS_REQUIRED, + params: { + verifierName: contact.alias, + presentationDefinition: presentationDefinitionWithLocation.definition, + format, + subjectSyntaxTypesSupported, + onDecline, + onSelect, + onSend, + onBack, + isSendDisabled, + }, + }); +}; + +const navigateFinal = async (args: SiopV2MachineNavigationArgs): Promise => { + const {navigation, siopV2Machine} = args; + + debug(`Stopping siopV2 machine...`); + siopV2Machine.stop(); + debug(`Stopped siopV2 machine`); + + navigation.navigate(NavigationBarRoutesEnum.CREDENTIALS, { + screen: ScreenRoutesEnum.CREDENTIALS_OVERVIEW, + }); +}; + +const navigateError = async (args: SiopV2MachineNavigationArgs): Promise => { + const {navigation, state, onBack, onNext} = args; + const {error} = state.context; + + if (!error) { + return Promise.reject(Error('Missing error in context')); + } + + navigation.navigate(MainRoutesEnum.SIOPV2, { + screen: ScreenRoutesEnum.ERROR, + params: { + image: PopupImagesEnum.WARNING, + title: error.title, + details: error.message, + ...(error.detailsMessage && { + detailsPopup: { + buttonCaption: translate('action_view_extra_details'), + title: error.detailsTitle, + details: error.detailsMessage, + }, + }), + primaryButton: { + caption: translate('action_ok_label'), + onPress: onNext, + }, + onBack, + }, + }); +}; + +export const siopV2StateNavigationListener = async ( + siopV2Machine: SiopV2MachineInterpreter, + state: SiopV2MachineState, + navigation?: NativeStackNavigationProp, +): Promise => { + if (state._event.type === 'internal') { + // Make sure we do not navigate when triggered by an internal event. We need to stay on current screen + // Make sure we do not navigate when state has not changed + return; + } + const onBack = () => siopV2Machine.send(SiopV2MachineEvents.PREVIOUS); + const onNext = () => siopV2Machine.send(SiopV2MachineEvents.NEXT); + + const nav = navigation ?? RootNavigation; + if (nav === undefined || !nav.isReady()) { + debug(`navigation not ready yet`); + return; + } + + if ( + state.matches(SiopV2MachineStates.createConfig) || + state.matches(SiopV2MachineStates.getSiopRequest) || + state.matches(SiopV2MachineStates.retrieveContact) || + state.matches(SiopV2MachineStates.transitionFromSetup) + ) { + return navigateLoading({siopV2Machine: siopV2Machine, state, navigation: nav, onNext, onBack}); + } else if (state.matches(SiopV2MachineStates.sendResponse)) { + return navigateSendingCredentials({siopV2Machine: siopV2Machine, state, navigation: nav, onNext, onBack}); + } else if (state.matches(SiopV2MachineStates.addContact)) { + return navigateAddContact({siopV2Machine: siopV2Machine, state, navigation: nav, onNext, onBack}); + } else if (state.matches(SiopV2MachineStates.selectCredentials)) { + return navigateSelectCredentials({siopV2Machine: siopV2Machine, state, navigation: nav, onNext, onBack}); + } else if (state.matches(SiopV2MachineStates.handleError)) { + return navigateError({siopV2Machine: siopV2Machine, state, navigation: nav, onNext, onBack}); + } else if ( + state.matches(SiopV2MachineStates.done) || + state.matches(SiopV2MachineStates.error) || + state.matches(SiopV2MachineStates.aborted) || + state.matches(SiopV2MachineStates.declined) + ) { + return navigateFinal({siopV2Machine: siopV2Machine, state, navigation: nav, onNext, onBack}); + } +}; + +export const SiopV2Provider = (props: SiopV2ProviderProps): JSX.Element => { + const {children, customSiopV2Instance} = props; + + return {children}; +}; diff --git a/src/navigation/navigation.tsx b/src/navigation/navigation.tsx index 9ebb35d7..692be0dc 100644 --- a/src/navigation/navigation.tsx +++ b/src/navigation/navigation.tsx @@ -37,6 +37,7 @@ import Veramo from '../screens/Veramo'; import {login, walletAuthLockState} from '../services/authenticationService'; import {OID4VCIProvider} from './machines/oid4vciStateNavigation'; import {OnboardingProvider} from './machines/onboardingStateNavigation'; +import {SiopV2Provider} from './machines/siopV2StateNavigation'; import { HeaderMenuIconsEnum, IOnboardingProps, @@ -47,6 +48,7 @@ import { StackParamList, SwitchRoutesEnum, WalletAuthLockState, + ISiopV2PProps, } from '../types'; import {OnboardingMachineInterpreter} from '../types/machines/onboarding'; import EmergencyScreen from '../screens/EmergencyScreen'; @@ -99,6 +101,15 @@ const MainStackNavigator = (): JSX.Element => { )} /> + ( + <> + + + + )} + /> ); @@ -355,7 +366,7 @@ const QRStack = (): JSX.Element => { {...props} // TODO rethink back button visibility for Android //showBackButton={Platform.OS === PlatformsEnum.IOS} - headerSubTitle={`${translate('credentials_required_subtitle', {verifierName: route.params.verifier})} ${ + headerSubTitle={`${translate('credentials_required_subtitle', {verifierName: route.params.verifierName})} ${ route.params.presentationDefinition.purpose && `\n\n${route.params.presentationDefinition.purpose}` }`} /> @@ -730,6 +741,137 @@ export const OID4VCIStackWithContext = (props: IOID4VCIProps): JSX.Element => { ); }; +export const SiopV2Stack = (): JSX.Element => { + return ( + + + ({ + headerTitle: translate('contact_add_new_contact_detected_title'), + header: (props: NativeStackHeaderProps) => ( + + ), + })} + /> + ({ + headerTitle: translate('credentials_required_title'), + header: (props: NativeStackHeaderProps) => ( + + ), + })} + /> + ( + + ), + }} + /> + ({ + headerTitle: translate('credentials_select_title'), + header: (props: NativeStackHeaderProps) => ( + + ), + })} + /> + ({ + headerTitle: route.params.headerTitle ? route.params.headerTitle : translate('credential_details_title'), + header: (props: NativeStackHeaderProps) => ( + => + RootNavigation.navigate(ScreenRoutesEnum.CREDENTIAL_RAW_JSON, { + rawCredential: route.params.rawCredential, + }), + icon: HeaderMenuIconsEnum.DOWNLOAD, + }, + ]} + /> + ), + })} + /> + ({ + header: (props: NativeStackHeaderProps) => , + })} + /> + , + }} + /> + + ); +}; + +export const SiopV2StackWithContext = (props: ISiopV2PProps): JSX.Element => { + return ( + + + + ); +}; + /** * Solution below allows to navigate based on the redux state. so there is no need to specifically navigate to another stack, as setting the state does that already * https://reactnavigation.org/docs/auth-flow/ diff --git a/src/providers/authentication/SIOPv2Provider.ts b/src/providers/authentication/SIOPv2Provider.ts index 4ce05b82..a43380cd 100644 --- a/src/providers/authentication/SIOPv2Provider.ts +++ b/src/providers/authentication/SIOPv2Provider.ts @@ -1,8 +1,7 @@ import {CheckLinkedDomain, SupportedVersion, VerifiedAuthorizationRequest} from '@sphereon/did-auth-siop'; import {getIdentifier, getKey} from '@sphereon/ssi-sdk-ext.did-utils'; import {ConnectionTypeEnum, IDidAuthConfig} from '@sphereon/ssi-sdk.data-store'; -import {OpSession, VerifiableCredentialsWithDefinition, VerifiablePresentationWithDefinition} from '@sphereon/ssi-sdk.siopv2-oid4vp-op-auth'; -import {OID4VP} from '@sphereon/ssi-sdk.siopv2-oid4vp-op-auth/dist/session/OID4VP'; +import {OpSession, VerifiableCredentialsWithDefinition, VerifiablePresentationWithDefinition, OID4VP} from '@sphereon/ssi-sdk.siopv2-oid4vp-op-auth'; import {PresentationSubmission} from '@sphereon/ssi-types'; // FIXME we should fix the export of these objects import {IIdentifier} from '@veramo/core'; import Debug, {Debugger} from 'debug'; @@ -17,9 +16,9 @@ export const siopGetRequest = async (config: IDidAuthConfig): Promise await siopRegisterSession({requestJwtOrUri: config.redirectUrl, sessionId: config.sessionId}), ); - console.log(`session: ${JSON.stringify(session.id, null, 2)}`); + debug(`session: ${JSON.stringify(session.id, null, 2)}`); const verifiedAuthorizationRequest = await session.getAuthorizationRequest(); - console.log('Request: ' + JSON.stringify(verifiedAuthorizationRequest, null, 2)); + debug('Request: ' + JSON.stringify(verifiedAuthorizationRequest, null, 2)); return verifiedAuthorizationRequest; }; diff --git a/src/screens/SSIContactAddScreen/index.tsx b/src/screens/SSIContactAddScreen/index.tsx index ef71dafc..fa7dd571 100644 --- a/src/screens/SSIContactAddScreen/index.tsx +++ b/src/screens/SSIContactAddScreen/index.tsx @@ -18,7 +18,19 @@ import { SSIStatusBarDarkModeStyled as StatusBar, SSIContactAddScreenTextInputContainerStyled as TextInputContainer, } from '../../styles/components'; -import {ICreateContactArgs, IUpdateContactArgs, MainRoutesEnum, RootState, ScreenRoutesEnum, StackParamList, ToastTypeEnum} from '../../types'; +import { + ICreateContactArgs, + IUpdateContactArgs, + MainRoutesEnum, + RootState, + ScreenRoutesEnum, + StackParamList, + SwitchRoutesEnum, + ToastTypeEnum, +} from '../../types'; +import {NavigationState} from '@react-navigation/routers'; +import {navigationRef} from '../../navigation/rootNavigation'; +import {Route} from '@react-navigation/native'; interface IProps extends NativeStackScreenProps { createContact: (args: ICreateContactArgs) => Promise; @@ -130,11 +142,12 @@ class SSIContactAddScreen extends PureComponent { }; onDecline = async (): Promise => { - const {onDecline} = this.props.route.params; + const {navigation} = this.props; + const {onDecline, onBack} = this.props.route.params; Keyboard.dismiss(); - this.props.navigation.navigate(MainRoutesEnum.POPUP_MODAL, { + navigation.navigate(MainRoutesEnum.POPUP_MODAL, { title: translate('contact_add_cancel_title'), details: translate('contact_add_cancel_message'), primaryButton: { @@ -144,7 +157,22 @@ class SSIContactAddScreen extends PureComponent { secondaryButton: { caption: translate('action_cancel_label'), // TODO WAL-541 fix navigation hierarchy - onPress: async (): Promise => this.props.navigation.navigate(MainRoutesEnum.HOME, {}), + // FIXME added another hack to determine which stack we are in so that we can navigate back from the popup screen + onPress: async (): Promise => { + const rootState: NavigationState | undefined = navigationRef.current?.getRootState(); + if (!rootState?.routes) { + return; + } + const mainStack = rootState.routes.find((route: Route) => route.name === 'Main')?.state; + if (!mainStack?.routes) { + return; + } + if (mainStack.routes.some((route: any): boolean => route.name === 'SIOPV2')) { + navigation.navigate(MainRoutesEnum.SIOPV2, {}); + } else if (mainStack.routes.some((route: any): boolean => route.name === 'OID4VCI')) { + navigation.navigate(MainRoutesEnum.OID4VCI, {}); + } + }, }, }); }; diff --git a/src/screens/SSICredentialSelectScreen/index.tsx b/src/screens/SSICredentialSelectScreen/index.tsx index e739631a..4d14bea8 100644 --- a/src/screens/SSICredentialSelectScreen/index.tsx +++ b/src/screens/SSICredentialSelectScreen/index.tsx @@ -1,6 +1,7 @@ import {NativeStackScreenProps} from '@react-navigation/native-stack'; import React, {FC} from 'react'; -import {ListRenderItemInfo} from 'react-native'; +import {ListRenderItemInfo, ViewStyle} from 'react-native'; +import {useBackHandler} from '@react-native-community/hooks'; import {SwipeListView} from 'react-native-swipe-list-view'; import {OVERVIEW_INITIAL_NUMBER_TO_RENDER} from '../../@config/constants'; @@ -19,12 +20,19 @@ import {backgroundColors, borderColors} from '@sphereon/ui-components.core'; type Props = NativeStackScreenProps; const SSICredentialsSelectScreen: FC = (props: Props): JSX.Element => { + const {navigation} = props; const {onSelect} = props.route.params; const [credentialSelection, setCredentialSelection] = React.useState(props.route.params.credentialSelection); + useBackHandler((): boolean => { + // FIXME for some reason returning false does not execute default behaviour + navigation.goBack(); + return true; + }); + const setSelection = async (selection: ICredentialSelection, select?: boolean): Promise => { - const newSelection = credentialSelection.map((credentialSelection: ICredentialSelection) => { - const isSelected = select === undefined ? !selection.isSelected : select; + const newSelection: Array = credentialSelection.map((credentialSelection: ICredentialSelection) => { + const isSelected: boolean = select === undefined ? !selection.isSelected : select; credentialSelection.isSelected = credentialSelection.hash == selection.hash ? (credentialSelection.isSelected = isSelected) : (credentialSelection.isSelected = false); return credentialSelection; @@ -54,10 +62,10 @@ const SSICredentialsSelectScreen: FC = (props: Props): JSX.Element => { }; const renderItem = (itemInfo: ListRenderItemInfo): JSX.Element => { - const backgroundStyle = { + const backgroundStyle: ViewStyle = { backgroundColor: itemInfo.index % 2 === 0 ? backgroundColors.secondaryDark : backgroundColors.primaryDark, }; - const style = { + const style: ViewStyle = { ...backgroundStyle, ...(itemInfo.index === credentialSelection.length - 1 && itemInfo.index % 2 !== 0 && {borderBottomWidth: 1, borderBottomColor: borderColors.dark}), @@ -75,6 +83,14 @@ const SSICredentialsSelectScreen: FC = (props: Props): JSX.Element => { ); }; + const onAccept = async (): Promise => { + await onSelect( + credentialSelection + .filter((credentialSelection: ICredentialSelection) => credentialSelection.isSelected) + .map((credentialSelection: ICredentialSelection) => credentialSelection.hash), + ); + }; + return ( @@ -93,14 +109,7 @@ const SSICredentialsSelectScreen: FC = (props: Props): JSX.Element => { primaryButton={{ caption: translate('action_accept_label'), disabled: !credentialSelection.some((credentialSelection: ICredentialSelection) => credentialSelection.isSelected), - onPress: async () => { - await onSelect( - credentialSelection - .filter((credentialSelection: ICredentialSelection) => credentialSelection.isSelected) - .map((credentialSelection: ICredentialSelection) => credentialSelection.hash), - ); - props.navigation.goBack(); - }, + onPress: onAccept, }} /> diff --git a/src/screens/SSICredentialsRequiredScreen/index.tsx b/src/screens/SSICredentialsRequiredScreen/index.tsx index 6d3e9f59..60807a80 100644 --- a/src/screens/SSICredentialsRequiredScreen/index.tsx +++ b/src/screens/SSICredentialsRequiredScreen/index.tsx @@ -1,6 +1,5 @@ import {NativeStackScreenProps} from '@react-navigation/native-stack'; -import {PEX, SelectResults, SubmissionRequirementMatch} from '@sphereon/pex'; -import {Status} from '@sphereon/pex/dist/main/lib/ConstraintUtils'; +import {PEX, SelectResults, SubmissionRequirementMatch, Status} from '@sphereon/pex'; import {InputDescriptorV1, InputDescriptorV2} from '@sphereon/pex-models'; import {ICredentialBranding} from '@sphereon/ssi-sdk.data-store'; import {CredentialMapper, OriginalVerifiableCredential} from '@sphereon/ssi-types'; @@ -20,28 +19,42 @@ import { SSIBasicContainerStyled as Container, SSIStatusBarDarkModeStyled as StatusBar, } from '../../styles/components'; -import {ScreenRoutesEnum, StackParamList} from '../../types'; +import {ICredentialSummary, ScreenRoutesEnum, StackParamList} from '../../types'; import {getMatchingUniqueVerifiableCredential, getOriginalVerifiableCredential} from '../../utils/CredentialUtils'; import {toCredentialSummary} from '../../utils/mappers/credential/CredentialMapper'; import {JSONPath} from '@astronautlabs/jsonpath'; +import {useBackHandler} from '@react-native-community/hooks'; type Props = NativeStackScreenProps; const SSICredentialsRequiredScreen: FC = (props: Props): JSX.Element => { - const {presentationDefinition, format, subjectSyntaxTypesSupported, verifier} = props.route.params; + const {navigation} = props; + const {presentationDefinition, format, subjectSyntaxTypesSupported, onSelect, isSendDisabled, onDecline, onBack} = props.route.params; const [selectedCredentials, setSelectedCredentials] = useState(new Map>()); const [availableCredentials, setAvailableCredentials] = useState(new Map>()); - const pex = new PEX(); + const pex: PEX = new PEX(); - useEffect(() => { - // TODO we need to have one source for these credentials as then all the data is always available + useBackHandler((): boolean => { + if (onBack) { + void onBack(); + // make sure event stops here + return true; + } + + // FIXME for some reason returning false does not execute default behaviour + navigation.goBack(); + return true; + }); + + useEffect((): void => { + // FIXME we need to have one source for these credentials as then all the data is always available getVerifiableCredentialsFromStorage().then((uniqueVCs: Array) => { // We need to go to a wrapped VC first to get an actual original Verifiable Credential in JWT format, as they are stored with a special Proof value in Veramo const originalVcs: Array = uniqueVCs.map( (uniqueVC: UniqueVerifiableCredential) => CredentialMapper.toWrappedVerifiableCredential(uniqueVC.verifiableCredential as OriginalVerifiableCredential).original, ); - const availableVCs = new Map>(); + const availableVCs: Map> = new Map>(); presentationDefinition.input_descriptors.forEach((inputDescriptor: InputDescriptorV1 | InputDescriptorV2) => { const presentationDefinition = { id: inputDescriptor.id, @@ -71,23 +84,19 @@ const SSICredentialsRequiredScreen: FC = (props: Props): JSX.Element => { }); }, []); - useEffect(() => { - const selectedVCs = new Map>(); + useEffect((): void => { + const selectedVCs: Map> = new Map>(); presentationDefinition.input_descriptors.forEach((inputDescriptor: InputDescriptorV1 | InputDescriptorV2) => selectedVCs.set(inputDescriptor.id, []), ); setSelectedCredentials(selectedVCs); }, [presentationDefinition]); - const onDecline = async (): Promise => { - props.navigation.goBack(); - }; - const onSend = async (): Promise => { const {onSend} = props.route.params; - const selectedVCs = getSelectedCredentials(); + const selectedVCs: Array = getSelectedCredentials(); - await onSend(selectedVCs.map(uniqueVC => getOriginalVerifiableCredential(uniqueVC.verifiableCredential))); + await onSend(selectedVCs.map((uniqueVC: UniqueVerifiableCredential) => getOriginalVerifiableCredential(uniqueVC.verifiableCredential))); }; const getSelectedCredentials = (): Array => { @@ -103,54 +112,74 @@ const SSICredentialsRequiredScreen: FC = (props: Props): JSX.Element => { return ( pex.evaluateCredentials( presentationDefinition, - getSelectedCredentials().map(uniqueVC => getOriginalVerifiableCredential(uniqueVC.verifiableCredential)), + getSelectedCredentials().map((uniqueVC: UniqueVerifiableCredential) => getOriginalVerifiableCredential(uniqueVC.verifiableCredential)), ).areRequiredCredentialsPresent === Status.INFO ); }; - const onItemPress = async (inputDescriptorId: string, uniqueVCs: Array, inputDescriptorPurpose?: string) => { - // TODO we need to have one source for these credentials as then all the data is always available + const onItemPress = async ( + inputDescriptorId: string, + uniqueVCs: Array, + inputDescriptorPurpose?: string, + ): Promise => { + // FIXME we need to have one source for these credentials as then all the data is always available const vcHashes: Array<{vcHash: string}> = uniqueVCs.map((uniqueCredential: UniqueVerifiableCredential): {vcHash: string} => ({ vcHash: uniqueCredential.hash, })); const credentialsBranding: Array = await ibGetCredentialBranding({filter: vcHashes}); - props.navigation.navigate(ScreenRoutesEnum.CREDENTIALS_SELECT, { - credentialSelection: await Promise.all( - uniqueVCs.map(async (uniqueVC: UniqueVerifiableCredential) => { - // TODO we need to have one source for these credentials as then all the data is always available - const credentialBranding: ICredentialBranding | undefined = credentialsBranding.find( - (branding: ICredentialBranding) => branding.vcHash === uniqueVC.hash, + const credentialSelection = await Promise.all( + uniqueVCs.map(async (uniqueVC: UniqueVerifiableCredential) => { + // FIXME we need to have one source for these credentials as then all the data is always available + const credentialBranding: ICredentialBranding | undefined = credentialsBranding.find( + (branding: ICredentialBranding): boolean => branding.vcHash === uniqueVC.hash, + ); + const credentialSummary: ICredentialSummary = await toCredentialSummary(uniqueVC, credentialBranding?.localeBranding); + const rawCredential: OriginalVerifiableCredential = await getOriginalVerifiableCredential(uniqueVC.verifiableCredential); + const isSelected: boolean = selectedCredentials + .get(inputDescriptorId)! + .some( + (matchedVC: UniqueVerifiableCredential) => + matchedVC.verifiableCredential.id === uniqueVC.verifiableCredential.id || + matchedVC.verifiableCredential.proof === uniqueVC.verifiableCredential.proof, ); - const credentialSummary = await toCredentialSummary(uniqueVC, credentialBranding?.localeBranding); - const rawCredential = await getOriginalVerifiableCredential(uniqueVC.verifiableCredential); - const isSelected = selectedCredentials - .get(inputDescriptorId)! - .some( - matchedVC => - matchedVC.verifiableCredential.id === uniqueVC.verifiableCredential.id || - matchedVC.verifiableCredential.proof === uniqueVC.verifiableCredential.proof, - ); - return { - hash: credentialSummary.hash, - id: credentialSummary.id, - credential: credentialSummary, - rawCredential: rawCredential, - isSelected: isSelected, - }; - }), - ), - purpose: inputDescriptorPurpose, - // TODO move this to a function, would be nicer - onSelect: async (hashes: Array) => { - const selectedVCs = availableCredentials.get(inputDescriptorId)!.filter(vc => hashes.includes(vc.hash)); - selectedCredentials.set(inputDescriptorId, selectedVCs); - const newSelection = new Map>(); - for (const [key, value] of selectedCredentials) { - newSelection.set(key, value); + return { + hash: credentialSummary.hash, + id: credentialSummary.id, + credential: credentialSummary, + rawCredential: rawCredential, + isSelected: isSelected, + }; + }), + ); + + const onSelectCredential = async (hashes: Array): Promise => { + const selectedVCs: Array = availableCredentials + .get(inputDescriptorId)! + .filter((vc: UniqueVerifiableCredential) => hashes.includes(vc.hash)); + selectedCredentials.set(inputDescriptorId, selectedVCs); + const newSelection: Map> = new Map>(); + for (const [key, value] of selectedCredentials) { + newSelection.set(key, value); + } + + setSelectedCredentials(newSelection); + + if (onSelect) { + const selectedVCs: Array> = []; + for (const uniqueVCs of newSelection.values()) { + selectedVCs.push(uniqueVCs); } - setSelectedCredentials(newSelection); - }, + await onSelect( + selectedVCs.flat().map((uniqueVC: UniqueVerifiableCredential) => getOriginalVerifiableCredential(uniqueVC.verifiableCredential)), + ); + } + }; + + props.navigation.navigate(ScreenRoutesEnum.CREDENTIALS_SELECT, { + credentialSelection, + purpose: inputDescriptorPurpose, + onSelect: onSelectCredential, }); }; @@ -222,7 +251,7 @@ const SSICredentialsRequiredScreen: FC = (props: Props): JSX.Element => { }} primaryButton={{ caption: translate('action_share_label'), - disabled: !isMatchingPresentationDefinition(), + disabled: isSendDisabled ? isSendDisabled() : !isMatchingPresentationDefinition(), onPress: onSend, }} /> diff --git a/src/services/credentialService.ts b/src/services/credentialService.ts index 1f2a948f..8fb54c47 100644 --- a/src/services/credentialService.ts +++ b/src/services/credentialService.ts @@ -1,6 +1,5 @@ import {CredentialMapper, IVerifyResult, OriginalVerifiableCredential} from '@sphereon/ssi-types'; -import {ICreateVerifiableCredentialArgs, UniqueVerifiableCredential, VerifiableCredential} from '@veramo/core'; -import {IVerifyCredentialArgs} from '@veramo/core/src/types/ICredentialVerifier'; +import {ICreateVerifiableCredentialArgs, UniqueVerifiableCredential, VerifiableCredential, IVerifyCredentialArgs} from '@veramo/core'; import agent, { dataStoreDeleteVerifiableCredential, diff --git a/src/services/machines/oid4vciMachineService.ts b/src/services/machines/oid4vciMachineService.ts index 0038c67e..ce080293 100644 --- a/src/services/machines/oid4vciMachineService.ts +++ b/src/services/machines/oid4vciMachineService.ts @@ -3,7 +3,14 @@ import {v4 as uuidv4} from 'uuid'; import {CompactJWT, VerifiableCredential} from '@veramo/core'; import {computeEntryHash} from '@veramo/utils'; import {CredentialResponse, CredentialSupported} from '@sphereon/oid4vci-common'; -import {CorrelationIdentifierEnum, IBasicCredentialLocaleBranding, IBasicIdentity, IContact, IdentityRoleEnum} from '@sphereon/ssi-sdk.data-store'; +import { + CorrelationIdentifierEnum, + IBasicCredentialLocaleBranding, + IBasicIdentity, + IContact, + IdentityRoleEnum, + IIdentity, +} from '@sphereon/ssi-sdk.data-store'; import { CredentialMapper, IIssuer, @@ -129,7 +136,7 @@ export const retrieveCredentialOffers = async ( ); }; -export const addContactIdentity = async (context: Pick): Promise => { +export const addContactIdentity = async (context: Pick): Promise => { const {credentialOffers, contact} = context; if (!contact) { diff --git a/src/services/machines/siopV2MachineService.ts b/src/services/machines/siopV2MachineService.ts new file mode 100644 index 00000000..8d570038 --- /dev/null +++ b/src/services/machines/siopV2MachineService.ts @@ -0,0 +1,158 @@ +import {v4 as uuidv4} from 'uuid'; +import {VerifiedAuthorizationRequest} from '@sphereon/did-auth-siop'; +import { + ConnectionTypeEnum, + CorrelationIdentifierEnum, + IBasicIdentity, + IContact, + IdentityRoleEnum, + IDidAuthConfig, +} from '@sphereon/ssi-sdk.data-store'; +import {siopGetRequest, siopSendAuthorizationResponse} from '../../providers/authentication/SIOPv2Provider'; +import {SiopV2AuthorizationRequestData, SiopV2MachineContext} from '../../types/machines/siopV2'; +import {URL} from 'react-native-url-polyfill'; +import {getContacts} from '../contactService'; +import store from '../../store'; +import {addIdentity} from '../../store/actions/contact.actions'; +import {W3CVerifiableCredential} from '@sphereon/ssi-types'; +import {IIdentifier} from '@veramo/core'; +import {getOrCreatePrimaryIdentifier} from '../identityService'; +import {translateCorrelationIdToName} from '../../utils/CredentialUtils'; + +export const createConfig = async (context: Pick): Promise => { + const {requestData} = context; + + if (requestData?.uri === undefined) { + return Promise.reject(Error('Missing request uri in context')); + } + + const identifier: IIdentifier = await getOrCreatePrimaryIdentifier(); + return { + id: uuidv4(), + // FIXME: Update these values in SSI-SDK. Only the URI (not a redirectURI) would be available at this point + sessionId: uuidv4(), + redirectUrl: requestData.uri, + stateId: requestData.state, + identifier, + }; +}; + +export const getSiopRequest = async ( + context: Pick, +): Promise => { + const {didAuthConfig, requestData} = context; + + if (requestData?.uri === undefined) { + return Promise.reject(Error('Missing request uri in context')); + } + + if (didAuthConfig === undefined) { + return Promise.reject(Error('Missing config in context')); + } + + const verifiedAuthorizationRequest: VerifiedAuthorizationRequest = await siopGetRequest(didAuthConfig); + const name = verifiedAuthorizationRequest.registrationMetadataPayload?.client_name; + const url = + verifiedAuthorizationRequest.responseURI ?? + (requestData.uri.includes('request_uri') + ? decodeURIComponent(requestData.uri.split('?request_uri=')[1].trim()) + : verifiedAuthorizationRequest.issuer ?? verifiedAuthorizationRequest.registrationMetadataPayload?.client_id); + const uri: URL | undefined = url.includes('://') ? new URL(url) : undefined; + const correlationIdName = uri + ? translateCorrelationIdToName(uri.hostname) + : verifiedAuthorizationRequest.issuer + ? translateCorrelationIdToName(verifiedAuthorizationRequest.issuer.split('://')[1]) + : name; + const correlationId: string = uri?.hostname ?? correlationIdName; + const clientId: string | undefined = await verifiedAuthorizationRequest.authorizationRequest.getMergedProperty('client_id'); + + return { + issuer: verifiedAuthorizationRequest.issuer, + correlationId, + registrationMetadataPayload: verifiedAuthorizationRequest.registrationMetadataPayload, + uri, + name, + clientId, + presentationDefinitions: verifiedAuthorizationRequest.presentationDefinitions, + }; +}; + +export const retrieveContact = async ( + context: Pick, +): Promise => { + const {authorizationRequestData} = context; + + if (authorizationRequestData === undefined) { + return Promise.reject(Error('Missing authorization request data in context')); + } + + return getContacts({ + filter: [ + { + identities: { + identifier: { + correlationId: authorizationRequestData.correlationId, + }, + }, + }, + ], + }).then((contacts: Array): IContact | undefined => (contacts.length === 1 ? contacts[0] : undefined)); +}; + +export const addContactIdentity = async (context: Pick): Promise => { + const {contact, authorizationRequestData} = context; + + if (contact === undefined) { + return Promise.reject(Error('Missing contact in context')); + } + + if (authorizationRequestData === undefined) { + return Promise.reject(Error('Missing authorization request data in context')); + } + + // TODO: Makes sense to move these types of common queries/retrievals to the SIOP auth request object + const clientId: string | undefined = authorizationRequestData.clientId ?? authorizationRequestData.issuer; + const correlationId: string | undefined = clientId + ? clientId.startsWith('did:') + ? clientId + : `${new URL(clientId).protocol}//${new URL(clientId).hostname}` + : undefined; + + if (correlationId) { + const identity: IBasicIdentity = { + alias: correlationId, + roles: [IdentityRoleEnum.ISSUER], + identifier: { + type: CorrelationIdentifierEnum.DID, + correlationId, + }, + }; + return store.dispatch(addIdentity({contactId: contact.id, identity})); + } +}; + +export const sendResponse = async ( + context: Pick, +): Promise => { + const {didAuthConfig, authorizationRequestData, selectedCredentials} = context; + + if (didAuthConfig === undefined) { + return Promise.reject(Error('Missing config in context')); + } + + if (authorizationRequestData === undefined) { + return Promise.reject(Error('Missing authorization request data in context')); + } + + await siopSendAuthorizationResponse(ConnectionTypeEnum.SIOPv2_OpenID4VP, { + sessionId: didAuthConfig.sessionId, + ...(authorizationRequestData.presentationDefinitions !== undefined && { + verifiableCredentialsWithDefinition: [ + { + definition: authorizationRequestData.presentationDefinitions![0], // TODO 0 check, check siop only + credentials: selectedCredentials as Array, + }, + ], + }), + }); +}; diff --git a/src/services/qrService.ts b/src/services/qrService.ts index 681a946b..7eaea893 100644 --- a/src/services/qrService.ts +++ b/src/services/qrService.ts @@ -1,36 +1,17 @@ import {URL} from 'react-native-url-polyfill'; -import {Subscription} from 'xstate'; import {v4 as uuidv4} from 'uuid'; import Debug, {Debugger} from 'debug'; -import {IIdentifier, VerifiableCredential} from '@veramo/core'; -import {PresentationDefinitionWithLocation, RPRegistrationMetadataPayload, VerifiedAuthorizationRequest} from '@sphereon/did-auth-siop'; +import {IIdentifier} from '@veramo/core'; +import {VerifiedAuthorizationRequest} from '@sphereon/did-auth-siop'; import {CredentialOfferClient} from '@sphereon/oid4vci-client'; -import {Format} from '@sphereon/pex-models'; -import { - ConnectionTypeEnum, - CorrelationIdentifierEnum, - IBasicConnection, - IBasicIdentity, - IContact, - IdentityRoleEnum, - IDidAuthConfig, - IIdentity, -} from '@sphereon/ssi-sdk.data-store'; -import {OriginalVerifiableCredential, W3CVerifiableCredential} from '@sphereon/ssi-types'; +import {ConnectionTypeEnum, IBasicConnection, IDidAuthConfig} from '@sphereon/ssi-sdk.data-store'; import {APP_ID} from '../@config/constants'; import {translate} from '../localization/Localization'; -import {siopGetRequest, siopSendAuthorizationResponse} from '../providers/authentication/SIOPv2Provider'; +import {siopGetRequest} from '../providers/authentication/SIOPv2Provider'; import JwtVcPresentationProfileProvider from '../providers/credential/JwtVcPresentationProfileProvider'; import {OID4VCIMachine} from '../machines/oid4vciMachine'; -import store from '../store'; -import {addIdentity} from '../store/actions/contact.actions'; -import {oid4vciStateNavigationListener} from '../navigation/machines/oid4vciStateNavigation'; import {authenticate} from './authenticationService'; -import {getContacts} from './contactService'; import {getOrCreatePrimaryIdentifier} from './identityService'; -import {delay} from '../utils/AppUtils'; -import {translateCorrelationIdToName} from '../utils/CredentialUtils'; -import {filterNavigationStack} from '../utils/NavigationUtils'; import {showToast} from '../utils/ToastUtils'; import { IQrAuthentication, @@ -43,7 +24,9 @@ import { ScreenRoutesEnum, ToastTypeEnum, } from '../types'; -import {OID4VCIMachineInterpreter, OID4VCIMachineState} from '../types/machines/oid4vci'; +import {OID4VCIMachineInterpreter} from '../types/machines/oid4vci'; +import {SiopV2MachineInterpreter} from '../types/machines/siopV2'; +import {SiopV2Machine} from '../machines/siopV2Machine'; const debug: Debugger = Debug(`${APP_ID}:qrService`); @@ -104,7 +87,7 @@ export const processQr = async (args: IQrDataArgs): Promise => { return connectSiopV2(args); case QrTypesEnum.OPENID_CREDENTIAL_OFFER: case QrTypesEnum.OPENID_INITIATE_ISSUANCE: - return connectOID4VCIssuance(args); + return connectOID4VCI(args); } }; @@ -169,209 +152,6 @@ const connectDidAuth = async (args: IQrDataArgs): Promise => { }); }; -const connectSiopV2 = async (args: IQrDataArgs): Promise => { - const config = { - // FIXME: Update these values in SSI-SDK. Only the URI (not a redirectURI) would be available at this point - sessionId: uuidv4(), - redirectUrl: args.qrData.uri, - stateId: args.qrData.state, - identifier: await getOrCreatePrimaryIdentifier(), // TODO replace getOrCreatePrimaryIdentifier() when we have proper identities in place - }; - - // Adding a loading screen as the next action is to contact the other side - args.navigation.navigate(NavigationBarRoutesEnum.QR, { - screen: ScreenRoutesEnum.LOADING, - params: { - message: translate('action_getting_information_message'), - }, - }); - - let request: VerifiedAuthorizationRequest; - let registration: RPRegistrationMetadataPayload | undefined; - let url: string; - let name: string | undefined; - try { - request = await siopGetRequest({...config, id: uuidv4()}); - // TODO: Makes sense to move these types of common queries/retrievals to the SIOP auth request object - registration = request.registrationMetadataPayload; - name = registration?.client_name; - url = - request.responseURI ?? - (args.qrData.uri.includes('request_uri') - ? decodeURIComponent(args.qrData.uri.split('?request_uri=')[1].trim()) - : request.issuer ?? request.registrationMetadataPayload?.client_id); - } catch (error: unknown) { - debug(translate('information_retrieve_failed_toast_message', {errorMessage: (error as Error).message})); - args.navigation.navigate(ScreenRoutesEnum.QR_READER, {}); - showToast(ToastTypeEnum.TOAST_ERROR, {message: translate('information_retrieve_failed_toast_message', {errorMessage: (error as Error).message})}); - return; - } - - const uri = url.includes('://') ? new URL(url) : undefined; - const correlationIdName = uri - ? translateCorrelationIdToName(uri.hostname) - : request.issuer - ? translateCorrelationIdToName(request.issuer.split('://')[1]) - : name; - const sendResponse = async ( - presentationDefinitionWithLocation: PresentationDefinitionWithLocation, - credentials: Array, - ): Promise => { - siopSendAuthorizationResponse(ConnectionTypeEnum.SIOPv2_OpenID4VP, { - sessionId: config.sessionId, - verifiableCredentialsWithDefinition: [ - { - definition: presentationDefinitionWithLocation, - credentials: credentials as Array, - }, - ], - }) - .then(() => { - args.navigation.navigate(NavigationBarRoutesEnum.CREDENTIALS, { - screen: ScreenRoutesEnum.CREDENTIALS_OVERVIEW, - }); - showToast(ToastTypeEnum.TOAST_SUCCESS, { - title: translate('credentials_share_success_toast_title'), - message: translate('credentials_share_success_toast_message', {verifierName: registration?.client_name ?? correlationIdName}), - }); - }) - .catch((error: Error) => { - debug(`Unable to present credentials. Error: ${error.message}.`); - args.navigation.navigate(NavigationBarRoutesEnum.CREDENTIALS, {}); - showToast(ToastTypeEnum.TOAST_ERROR, {message: translate('credentials_share_failed_toast_message', {errorMessage: error.message})}); - }); - }; - - const selectRequiredCredentials = async (): Promise => { - // TODO: Makes sense to move these types of common queries/retrievals to the SIOP auth request object - const format: Format | undefined = registration?.vp_formats; - const subjectSyntaxTypesSupported: Array | undefined = registration?.subject_syntax_types_supported; - const clientId: string | undefined = (await request.authorizationRequest.getMergedProperty('client_id')) ?? request.issuer; - const correlationId: string | undefined = clientId - ? clientId.startsWith('did:') - ? clientId - : `${new URL(clientId).protocol}//${new URL(clientId).hostname}` - : undefined; - if (correlationId) { - const contacts: Array = await getContacts({ - filter: [ - { - identities: { - identifier: { - correlationId: request.issuer ?? request.registrationMetadataPayload?.client_id ?? correlationId, - }, - }, - }, - ], - }); - if (contacts.length === 1) { - const hasIdentity: IContact | undefined = contacts.find((contact: IContact) => - contact.identities.some((identity: IIdentity) => identity.identifier.correlationId === correlationId), - ); - if (!hasIdentity) { - const identity: IBasicIdentity = { - alias: correlationId, - roles: [IdentityRoleEnum.VERIFIER], - identifier: { - type: correlationId.startsWith('did:') ? CorrelationIdentifierEnum.DID : CorrelationIdentifierEnum.URL, - correlationId, - }, - ...(!correlationId.startsWith('did:') && { - connection: { - type: ConnectionTypeEnum.SIOPv2, - config: { - ...config, - redirectUrl: correlationId, - }, - }, - }), - }; - store.dispatch(addIdentity({contactId: contacts[0].id, identity})); - } - } - } - - // TODO SIOPv2 and OID4VP are separate. In other words SIOP doesn't require OID4VP. This means that presentation definitions are optional. - // TODO In that case we should skip the required credentials and send the response - if (!request.presentationDefinitions || request.presentationDefinitions.length === 0) { - return Promise.reject(Error('No presentation definitions present')); - } - if (request.presentationDefinitions.length > 1) { - return Promise.reject(Error('Multiple presentation definitions present')); - } - const presentationDefinitionWithLocation: PresentationDefinitionWithLocation = request.presentationDefinitions![0]; - - args.navigation.navigate(NavigationBarRoutesEnum.QR, { - screen: ScreenRoutesEnum.CREDENTIALS_REQUIRED, - params: { - verifier: name ?? correlationIdName ?? correlationId, - // TODO currently only supporting 1 presentation definition - presentationDefinition: presentationDefinitionWithLocation.definition, - format, - subjectSyntaxTypesSupported, - onDecline: async (): Promise => - args.navigation.navigate(NavigationBarRoutesEnum.CREDENTIALS, { - screen: ScreenRoutesEnum.CREDENTIALS_OVERVIEW, - }), - onSend: async (credentials: Array) => - authenticate(async () => { - args.navigation.navigate(ScreenRoutesEnum.LOADING, {message: translate('action_sharing_credentials_message')}); - await sendResponse(presentationDefinitionWithLocation, credentials as Array); - }), - }, - }); - filterNavigationStack({ - navigation: args.navigation, - stack: NavigationBarRoutesEnum.QR, - filter: [ScreenRoutesEnum.LOADING, ScreenRoutesEnum.CONTACT_ADD], - }); - }; - - getContacts({ - filter: [ - { - identities: { - identifier: { - correlationId: uri ? uri.hostname : correlationIdName, - }, - }, - }, - ], - }).then((contacts: Array) => { - if (contacts.length === 0) { - args.navigation.navigate(NavigationBarRoutesEnum.QR, { - screen: ScreenRoutesEnum.CONTACT_ADD, - params: { - name: name ?? correlationIdName ?? uri?.hostname, - uri: `${uri?.protocol}//${uri?.hostname}`, - identities: [ - { - alias: uri?.hostname, - roles: [IdentityRoleEnum.VERIFIER], - identifier: { - type: CorrelationIdentifierEnum.URL, - correlationId: uri?.hostname, - }, - connection: { - type: ConnectionTypeEnum.SIOPv2, - config, - }, - }, - ], - onDecline: async (): Promise => - args.navigation.navigate(NavigationBarRoutesEnum.CREDENTIALS, { - screen: ScreenRoutesEnum.CREDENTIALS_OVERVIEW, - }), - // Adding a delay here, so the store is updated with the new contact. And we only have a delay when a new contact is created - onCreate: () => delay(1000).then(() => selectRequiredCredentials()), - }, - }); - } else { - selectRequiredCredentials(); - } - }); -}; - const connectJwtVcPresentationProfile = async (args: IQrDataArgs): Promise => { if (args.qrData.pin) { const manifest = await new JwtVcPresentationProfileProvider().getManifest(args.qrData); @@ -388,7 +168,12 @@ const connectJwtVcPresentationProfile = async (args: IQrDataArgs): Promise // TODO WAL-301 need to send a response when we do not need a pin code }; -const connectOID4VCIssuance = async (args: IQrDataArgs): Promise => { - const OID4VCIInstance: OID4VCIMachineInterpreter = OID4VCIMachine.newInstance({requestData: args.qrData, requireCustomNavigationHook: false}); +const connectOID4VCI = async (args: IQrDataArgs): Promise => { + const OID4VCIInstance: OID4VCIMachineInterpreter = OID4VCIMachine.newInstance({requestData: args.qrData}); OID4VCIInstance.start(); }; + +const connectSiopV2 = async (args: IQrDataArgs): Promise => { + const SiopV2Instance: SiopV2MachineInterpreter = SiopV2Machine.newInstance({requestData: args.qrData}); + SiopV2Instance.start(); +}; diff --git a/src/types/machines/oid4vci/index.ts b/src/types/machines/oid4vci/index.ts index af076280..54a3cca8 100644 --- a/src/types/machines/oid4vci/index.ts +++ b/src/types/machines/oid4vci/index.ts @@ -96,7 +96,7 @@ export type OID4VCIMachineInstanceOpts = { requireCustomNavigationHook?: boolean; } & CreateOID4VCIMachineOpts; -export type OIDVCIProviderProps = { +export type OID4VCIProviderProps = { children?: ReactNode; customOID4VCIInstance?: OID4VCIMachineInterpreter; }; @@ -126,7 +126,7 @@ export enum OID4VCIMachineEvents { export enum OID4VCIMachineGuards { hasContactGuard = 'oid4vciHasContactGuard', - hasNotContactGuard = 'oid4vciHasNoContactGuard', + hasNoContactGuard = 'oid4vciHasNoContactGuard', selectCredentialGuard = 'oid4vciSelectCredentialsGuard', requirePinGuard = 'oid4vciRequirePinGuard', hasNoContactIdentityGuard = 'oid4vciHasNoContactIdentityGuard', diff --git a/src/types/machines/onboarding/index.ts b/src/types/machines/onboarding/index.ts index c1227776..97ecb235 100644 --- a/src/types/machines/onboarding/index.ts +++ b/src/types/machines/onboarding/index.ts @@ -26,7 +26,7 @@ export type OnboardingMachineContext = { personalData: OnboardingPersonalData; }; -export enum OnboardingStates { +export enum OnboardingMachineStates { showIntro = 'showIntro', acceptAgreement = 'acceptAgreement', enterPersonalDetails = 'enterPersonalDetails', @@ -38,7 +38,7 @@ export enum OnboardingStates { setupWallet = 'setupWallet', } -export enum OnboardingEvents { +export enum OnboardingMachineEvents { NEXT = 'NEXT', PREVIOUS = 'PREVIOUS', DECLINE = 'DECLINE', @@ -48,14 +48,14 @@ export enum OnboardingEvents { SET_PIN = 'SET_PIN', } -export type NextEvent = {type: OnboardingEvents.NEXT; data?: any}; -export type PreviousEvent = {type: OnboardingEvents.PREVIOUS}; -export type PersonalDataEvent = {type: OnboardingEvents.SET_PERSONAL_DATA; data: ISetPersonalDataActionArgs}; -export type TermsConditionsEvent = {type: OnboardingEvents.SET_TOC; data: boolean}; -export type PrivacyPolicyEvent = {type: OnboardingEvents.SET_POLICY; data: boolean}; -export type PinSetEvent = {type: OnboardingEvents.SET_PIN; data: string}; -export type DeclineEvent = {type: OnboardingEvents.DECLINE}; -export type OnboardingEventTypes = +export type NextEvent = {type: OnboardingMachineEvents.NEXT; data?: any}; +export type PreviousEvent = {type: OnboardingMachineEvents.PREVIOUS}; +export type PersonalDataEvent = {type: OnboardingMachineEvents.SET_PERSONAL_DATA; data: ISetPersonalDataActionArgs}; +export type TermsConditionsEvent = {type: OnboardingMachineEvents.SET_TOC; data: boolean}; +export type PrivacyPolicyEvent = {type: OnboardingMachineEvents.SET_POLICY; data: boolean}; +export type PinSetEvent = {type: OnboardingMachineEvents.SET_PIN; data: string}; +export type DeclineEvent = {type: OnboardingMachineEvents.DECLINE}; +export type OnboardingMachineEventTypes = | NextEvent | PreviousEvent | TermsConditionsEvent @@ -64,7 +64,7 @@ export type OnboardingEventTypes = | PinSetEvent | DeclineEvent; -export enum OnboardingGuards { +export enum OnboardingMachineGuards { onboardingToSAgreementGuard = 'onboardingToSAgreementGuard', onboardingPersonalDataGuard = 'onboardingPersonalDataGuard', onboardingPinCodeSetGuard = 'onboardingPinCodeSetGuard', @@ -82,7 +82,7 @@ export type WalletSetupServiceResult = { export type OnboardingMachineInterpreter = Interpreter< OnboardingMachineContext, any, - OnboardingEventTypes, + OnboardingMachineEventTypes, { value: any; context: OnboardingMachineContext; @@ -106,7 +106,13 @@ export type InstanceOnboardingMachineOpts = { requireCustomNavigationHook?: boolean; } & CreateOnboardingMachineOpts; -export type OnboardingMachineState = State; +export type OnboardingMachineState = State< + OnboardingMachineContext, + OnboardingMachineEventTypes, + any, + {value: any; context: OnboardingMachineContext}, + any +>; export type OnboardingMachineNavigationArgs = { onboardingMachine: OnboardingMachineInterpreter; diff --git a/src/types/machines/siopV2/index.ts b/src/types/machines/siopV2/index.ts new file mode 100644 index 00000000..33f85c5a --- /dev/null +++ b/src/types/machines/siopV2/index.ts @@ -0,0 +1,146 @@ +import {ReactNode} from 'react'; +import {BaseActionObject, Interpreter, ResolveTypegenMeta, ServiceMap, State, StateMachine, TypegenDisabled} from 'xstate'; +import {NativeStackNavigationProp} from '@react-navigation/native-stack'; +import {IIdentifier} from '@veramo/core'; +import {VerifiedAuthorizationRequest, PresentationDefinitionWithLocation, RPRegistrationMetadataPayload} from '@sphereon/did-auth-siop'; +import {IContact, IDidAuthConfig} from '@sphereon/ssi-sdk.data-store'; +import {OriginalVerifiableCredential} from '@sphereon/ssi-types'; +import {ErrorDetails} from '../../error'; +import {IQrData} from '../../qr'; + +export type SiopV2AuthorizationRequestData = { + correlationId: string; + registrationMetadataPayload: RPRegistrationMetadataPayload; + issuer?: string; + name?: string; + uri?: URL; + clientId?: string; + presentationDefinitions?: PresentationDefinitionWithLocation[]; +}; + +export type SiopV2MachineContext = { + requestData?: IQrData; // TODO WAL-673 fix type as this is not always a qr code (deeplink) + identifier?: IIdentifier; + didAuthConfig?: IDidAuthConfig; + authorizationRequestData?: SiopV2AuthorizationRequestData; + verifiedAuthorizationRequest?: VerifiedAuthorizationRequest; + contact?: IContact; + hasContactConsent: boolean; + contactAlias: string; + selectedCredentials: Array; + error?: ErrorDetails; +}; + +export enum SiopV2MachineStates { + createConfig = 'createConfig', + getSiopRequest = 'getSiopRequest', + retrieveContact = 'retrieveContact', + transitionFromSetup = 'transitionFromSetup', + addContact = 'addContact', + addContactIdentity = 'addContactIdentity', + selectCredentials = 'selectCredentials', + sendResponse = 'sendResponse', + handleError = 'handleError', + aborted = 'aborted', + declined = 'declined', + error = 'error', + done = 'done', +} + +export enum SiopV2MachineAddContactStates { + idle = 'idle', + next = 'next', +} + +export type SiopV2MachineInterpreter = Interpreter< + SiopV2MachineContext, + any, + SiopV2MachineEventTypes, + {value: any; context: SiopV2MachineContext}, + any +>; + +export type SiopV2MachineState = State; + +export type SiopV2StateMachine = StateMachine< + SiopV2MachineContext, + any, + SiopV2MachineEventTypes, + {value: any; context: SiopV2MachineContext}, + BaseActionObject, + ServiceMap, + ResolveTypegenMeta +>; + +export type CreateSiopV2MachineOpts = { + requestData: IQrData; + machineId?: string; +}; + +export type SiopV2MachineInstanceOpts = { + services?: any; + guards?: any; + subscription?: () => void; + requireCustomNavigationHook?: boolean; +} & CreateSiopV2MachineOpts; + +export type SiopV2ProviderProps = { + children?: ReactNode; + customSiopV2Instance?: SiopV2MachineInterpreter; +}; + +export type SiopV2Context = { + siopV2Instance?: SiopV2MachineInterpreter; +}; + +export type SiopV2MachineNavigationArgs = { + siopV2Machine: SiopV2MachineInterpreter; + state: SiopV2MachineState; + navigation: NativeStackNavigationProp; + onNext?: () => void; + onBack?: () => void; +}; + +export enum SiopV2MachineEvents { + NEXT = 'NEXT', + PREVIOUS = 'PREVIOUS', + DECLINE = 'DECLINE', + SET_CONTACT_ALIAS = 'SET_CONTACT_ALIAS', + SET_CONTACT_CONSENT = 'SET_CONTACT_CONSENT', + CREATE_CONTACT = 'CREATE_CONTACT', + SET_SELECTED_CREDENTIALS = 'SET_SELECTED_CREDENTIALS', +} + +export enum SiopV2MachineGuards { + hasNoContactGuard = 'siopV2HasNoContactGuard', + createContactGuard = 'siopV2CreateContactGuard', + hasContactGuard = 'siopV2HasContactGuard', + hasSelectedRequiredCredentialsGuard = 'siopV2HasSelectedRequiredCredentialsGuard', + siopOnlyGuard = 'siopV2IsSiopOnlyGuard', + siopWithOID4VPGuard = 'siopV2IsSiopWithOID4VPGuard', +} + +export enum SiopV2MachineServices { + getSiopRequest = 'getSiopRequest', + retrieveContact = 'retrieveContact', + addContactIdentity = 'addContactIdentity', + sendResponse = 'sendResponse', + createConfig = 'createConfig', +} + +export type NextEvent = {type: SiopV2MachineEvents.NEXT}; +export type PreviousEvent = {type: SiopV2MachineEvents.PREVIOUS}; +export type DeclineEvent = {type: SiopV2MachineEvents.DECLINE}; +export type ContactConsentEvent = {type: SiopV2MachineEvents.SET_CONTACT_CONSENT; data: boolean}; +export type ContactAliasEvent = {type: SiopV2MachineEvents.SET_CONTACT_ALIAS; data: string}; +export type CreateContactEvent = {type: SiopV2MachineEvents.CREATE_CONTACT; data: IContact}; +export type SelectCredentialsEvent = {type: SiopV2MachineEvents.SET_SELECTED_CREDENTIALS; data: Array}; + +export type SiopV2MachineEventTypes = + | NextEvent + | PreviousEvent + | DeclineEvent + | CreateContactEvent + | ContactConsentEvent + | ContactAliasEvent + | SelectCredentialsEvent; diff --git a/src/types/navigation/index.ts b/src/types/navigation/index.ts index efeb6a88..9563070d 100644 --- a/src/types/navigation/index.ts +++ b/src/types/navigation/index.ts @@ -6,6 +6,7 @@ import {OnboardingMachineContext, OnboardingPersonalData, OnboardingMachineInter import {ICredentialSelection, ICredentialSummary, ICredentialTypeSelection} from '../credential'; import {IButton, PopupBadgesEnum, PopupImagesEnum} from '../component'; import {OID4VCIMachineInterpreter} from '../machines/oid4vci'; +import {SiopV2MachineInterpreter} from '../machines/siopV2'; export type StackParamList = { CredentialsOverview: Record; @@ -33,11 +34,12 @@ export type StackParamList = { NotificationsOverview: Record; Lock: ILockProps; Authentication: Record; - CredentialsRequired: ICredentialsRequiredProps; + CredentialsRequired: ICredentialsRequiredProps & Partial; CredentialsSelect: ICredentialsSelectProps; Loading: ILoadingProps; OID4VCI: IOID4VCIProps; Emergency: Record; + SIOPV2: ISiopV2PProps; }; interface IPersonalDataProps { @@ -80,12 +82,14 @@ export interface ICredentialsSelectProps { } export interface ICredentialsRequiredProps { - verifier: string; format: Format | undefined; subjectSyntaxTypesSupported: string[] | undefined; presentationDefinition: PresentationDefinitionV1 | PresentationDefinitionV2; onDecline: () => Promise; + onSelect?: (credentials: Array) => Promise; onSend: (credentials: Array) => Promise; + isSendDisabled?: () => boolean | (() => boolean); + verifierName: string; } export interface ICredentialDetailsProps { @@ -186,6 +190,7 @@ export enum MainRoutesEnum { ALERT_MODAL = 'AlertModal', POPUP_MODAL = 'PopupModal', OID4VCI = 'OID4VCI', + SIOPV2 = 'SIOPV2', } export enum NavigationBarRoutesEnum { @@ -223,3 +228,7 @@ export enum ScreenRoutesEnum { export interface IOID4VCIProps { customOID4VCIInstance?: OID4VCIMachineInterpreter; } + +export interface ISiopV2PProps { + customSiopV2Instance?: SiopV2MachineInterpreter; +} diff --git a/src/utils/CredentialUtils.ts b/src/utils/CredentialUtils.ts index 9dad5994..9a6418be 100644 --- a/src/utils/CredentialUtils.ts +++ b/src/utils/CredentialUtils.ts @@ -1,7 +1,6 @@ import {IContact, IIdentity} from '@sphereon/ssi-sdk.data-store'; import {CredentialMapper, ICredential, OriginalVerifiableCredential} from '@sphereon/ssi-types'; -import {UniqueVerifiableCredential} from '@veramo/core'; -import {VerifiableCredential} from '@veramo/core/src/types/vc-data-model'; +import {UniqueVerifiableCredential, VerifiableCredential} from '@veramo/core'; import {CredentialStatus} from '@sphereon/ui-components.core'; import store from '../store'; import {ICredentialSummary, IUser, IUserIdentifier} from '../types'; diff --git a/src/utils/NavigationUtils.ts b/src/utils/NavigationUtils.ts deleted file mode 100644 index 16c789a2..00000000 --- a/src/utils/NavigationUtils.ts +++ /dev/null @@ -1,41 +0,0 @@ -import {Route} from '@react-navigation/native'; -import {CommonActions, NavigationState} from '@react-navigation/routers'; -import {navigationRef} from '../navigation/rootNavigation'; - -import {filterNavigationStackArgs, ScreenRoutesEnum} from '../types'; - -/** - * Filters routes from a navigation stack - * @param {Object} args - The arguments for filtering navigation stack - * @param {NativeStackNavigationProp} args.navigation - The navigation object - * @param {NavigationBarRoutesEnum} args.stack - The navigation stack to apply filter to - * @param {Array} args.filter - Routes to be filtered - * @returns {void} - */ -export const filterNavigationStack = (args: filterNavigationStackArgs): void => { - const rootState: NavigationState | undefined = navigationRef.current?.getRootState(); - if (!rootState?.routes) { - return; - } - - const mainStack = rootState.routes.find((route: Route) => route.name === 'Main')?.state; - if (!mainStack?.routes) { - return; - } - // @ts-ignore - const homeStack = mainStack.routes.find((route: Route) => route.name === 'Home').state; - const currentStack = homeStack.routes.find((route: Route) => route.name === args.stack).state; - - if (!currentStack) { - return; - } - - const filteredRoutes = currentStack.routes.filter((route: Route) => !args.filter.includes(route.name)); - - args.navigation.dispatch( - CommonActions.reset({ - index: filteredRoutes.length, // Sets the last route in the stack as the current route - routes: filteredRoutes.map((route: Route) => ({name: route.name, params: route.params})), - }), - ); -};