Skip to content

Commit 8d11d2c

Browse files
committed
feat(mfa): add the soft prompt step to the state machine
1 parent b20f710 commit 8d11d2c

17 files changed

Lines changed: 261 additions & 189 deletions

File tree

src/components/MultifactorAuthentication/Context/MultifactorAuthenticationInternalApiContext.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ type MultifactorAuthenticationInternalApi = {
2121
/** Notify the machine that the close animation has fully finished. Called by the modal navigator on teardown; the machine then moves from `closing` back to `closed`. */
2222
notifyModalClosed: () => void;
2323

24+
/** Approve the soft prompt. Called by the prompt screen's confirm button; the machine persists the acceptance and moves the flow on. */
25+
approveSoftPrompt: () => void;
26+
2427
/** Centralized back-press / backdrop entry. */
2528
requestCancel: () => void;
2629

src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent
8686

8787
const closeModal = () => send({type: 'CLOSE_MODAL'});
8888
const notifyModalClosed = () => send({type: 'MODAL_CLOSED'});
89+
const approveSoftPrompt = () => send({type: 'SOFT_PROMPT_APPROVED'});
8990

9091
// There is no cancel-confirmation dialog yet, so every cancel path closes the modal directly.
9192
const requestCancel = () => send({type: 'CLOSE_MODAL'});
@@ -100,6 +101,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent
100101
state,
101102
closeModal,
102103
notifyModalClosed,
104+
approveSoftPrompt,
103105
requestCancel,
104106
hideCancelConfirm,
105107
confirmCancel,

src/components/MultifactorAuthentication/Context/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,3 @@ export {default as MultifactorAuthenticationContextProviders} from './Multifacto
22
export {useMultifactorAuthentication} from './MultifactorAuthenticationExternalApiContext';
33
export {useMultifactorAuthenticationActions} from './MultifactorAuthenticationActionsContext';
44
export {useMultifactorAuthenticationState} from './MultifactorAuthenticationStateContext';
5-
export {default as usePromptContent} from './usePromptContent';

src/components/MultifactorAuthentication/Context/state.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,6 @@ type MultifactorAuthenticationState = {
2222
/** Challenge received from backend for authorization (full object with allowCredentials, rpId, challenge) */
2323
authorizationChallenge: AuthenticationChallenge | undefined;
2424

25-
/** Whether user approved the soft prompt for biometric setup */
26-
softPromptApproved: boolean;
27-
2825
/** Whether registration step has been completed */
2926
isRegistrationComplete: boolean;
3027

@@ -46,7 +43,6 @@ const DEFAULT_STATE: MultifactorAuthenticationState = {
4643
validateCode: undefined,
4744
registrationChallenge: undefined,
4845
authorizationChallenge: undefined,
49-
softPromptApproved: false,
5046
isRegistrationComplete: false,
5147
isAuthorizationComplete: false,
5248
isFlowComplete: false,

src/components/MultifactorAuthentication/Context/stateReducer.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,6 @@ function stateReducer(state: MultifactorAuthenticationState, action: Action): Mu
2525
return {...state, registrationChallenge: action.payload};
2626
case 'SET_AUTHORIZATION_CHALLENGE':
2727
return {...state, authorizationChallenge: action.payload};
28-
case 'SET_SOFT_PROMPT_APPROVED':
29-
return {...state, softPromptApproved: action.payload};
3028
case 'SET_REGISTRATION_COMPLETE':
3129
return {...state, isRegistrationComplete: action.payload};
3230
case 'SET_AUTHORIZATION_COMPLETE':

src/components/MultifactorAuthentication/Context/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ type Action =
1212
| {type: 'SET_VALIDATE_CODE'; payload: string | undefined}
1313
| {type: 'SET_REGISTRATION_CHALLENGE'; payload: RegistrationChallenge | undefined}
1414
| {type: 'SET_AUTHORIZATION_CHALLENGE'; payload: AuthenticationChallenge | undefined}
15-
| {type: 'SET_SOFT_PROMPT_APPROVED'; payload: boolean}
1615
| {type: 'SET_REGISTRATION_COMPLETE'; payload: boolean}
1716
| {type: 'SET_AUTHORIZATION_COMPLETE'; payload: boolean}
1817
| {type: 'SET_FLOW_COMPLETE'; payload: boolean}

src/components/MultifactorAuthentication/Context/usePromptContent.ts

Lines changed: 0 additions & 114 deletions
This file was deleted.

src/components/MultifactorAuthentication/machine/mfaMachine.ts

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import {deviceVerificationType} from '@components/MultifactorAuthentication/biometrics/operations';
12
import {navigate as mfaNavigate, resetMfaNavigation} from '@components/MultifactorAuthentication/mfaNavigation';
23

34
import {createUnhandledExceptionMFAError, getMFAFailureError} from '@libs/MultifactorAuthentication/shared/MFAResult';
45
import Navigation from '@libs/Navigation/Navigation';
56

7+
import {hasEverAcceptedSoftPrompt, markHasAcceptedSoftPrompt} from '@userActions/MultifactorAuthentication';
8+
69
import CONST from '@src/CONST';
710
import SCREENS from '@src/SCREENS';
811

@@ -14,15 +17,20 @@ import createActors from './mfaActors';
1417

1518
const MFA_STATE = CONST.MULTIFACTOR_AUTHENTICATION.MFA_STATE;
1619

17-
// Absolute target for the outcome branch. The device check runs under `preparing`, so reaching the
18-
// sibling `outcome` branch needs an id target rather than a relative one.
20+
// Absolute targets for the screen branches. The device check runs under `preparing`, so reaching a
21+
// sibling branch needs an id target rather than a relative one.
1922
const OUTCOME_TARGET = `#${MFA_STATE.OUTCOME}` as const;
23+
const PROMPT_TARGET = `#${MFA_STATE.PROMPT}` as const;
24+
25+
// Which prompt variant the screen renders is a device property, resolved once per platform.
26+
const PROMPT_TYPE = CONST.MULTIFACTOR_AUTHENTICATION.PROMPT_TYPE_MAP[deviceVerificationType];
2027

2128
const DEFAULT_CONTEXT: MfaContext = {
2229
error: undefined,
2330
scenarioName: undefined,
2431
scenario: undefined,
2532
payload: undefined,
33+
softPromptApproved: false,
2634
isCancelConfirmVisible: false,
2735
};
2836

@@ -69,6 +77,13 @@ const MFAMachine = setup({
6977
navigateToFailureOutcome: () => {
7078
Navigation.runAfterTransition(() => mfaNavigate(SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_FAILURE));
7179
},
80+
navigateToPrompt: () => {
81+
Navigation.runAfterTransition(() => mfaNavigate(SCREENS.MULTIFACTOR_AUTHENTICATION.PROMPT, {promptType: PROMPT_TYPE}));
82+
},
83+
approveSoftPrompt: assign({softPromptApproved: true}),
84+
// The durable "asked once" flag lives in Onyx, so future flows on this device can skip the
85+
// prompt. Owned by the machine so every sender of SOFT_PROMPT_APPROVED persists it.
86+
persistSoftPromptAcceptance: () => markHasAcceptedSoftPrompt(),
7287
// Runs on CLOSE_MODAL: drops the cancel-confirmation modal so it cannot linger over the
7388
// closing navigator (CLOSE_MODAL can fire without the flow completing, e.g. an offline cancel).
7489
hideCancelConfirmModal: assign({isCancelConfirmVisible: false}),
@@ -119,11 +134,16 @@ const MFAMachine = setup({
119134
}
120135
return {allowedAuthenticationMethods: context.scenario.allowedAuthenticationMethods};
121136
},
122-
// Every result enters the outcome resolver. A refusal stores its
123-
// blocking MFAError first, so the resolver selects failure from that
124-
// error, while an eligible device with no error selects success.
137+
// An eligible device moves on to the soft prompt, with two overrides.
138+
// A stored error always wins: the flow goes straight to the outcome
139+
// resolver, which selects failure. A user who already accepted the
140+
// soft prompt on this device (read from Onyx at decision time) skips
141+
// it. A refusal stores its blocking MFAError first and resolves the
142+
// same way as a stored error.
125143
onDone: [
126-
{guard: ({event}) => event.output.success, target: OUTCOME_TARGET},
144+
{guard: ({context, event}) => event.output.success && context.error !== undefined, target: OUTCOME_TARGET},
145+
{guard: ({event}) => event.output.success && hasEverAcceptedSoftPrompt(), target: OUTCOME_TARGET},
146+
{guard: ({event}) => event.output.success, target: PROMPT_TARGET},
127147
{target: OUTCOME_TARGET, actions: assign({error: ({event}) => getMFAFailureError(event.output)})},
128148
],
129149
// Expected refusals travel as failed results through onDone, so a
@@ -136,6 +156,24 @@ const MFAMachine = setup({
136156
},
137157
},
138158
},
159+
// The soft-prompt screen: asks the user to consent to biometric verification before
160+
// the flow continues. Entered only when the user has never accepted the prompt on
161+
// this device; the registration vs reinstall variants arrive with the registration
162+
// decision.
163+
[MFA_STATE.PROMPT]: {
164+
id: MFA_STATE.PROMPT,
165+
entry: ['navigateToPrompt'],
166+
initial: MFA_STATE.AWAITING_SOFT_PROMPT,
167+
on: {
168+
SOFT_PROMPT_APPROVED: {
169+
target: MFA_STATE.OUTCOME,
170+
actions: ['approveSoftPrompt', 'persistSoftPromptAcceptance'],
171+
},
172+
},
173+
states: {
174+
[MFA_STATE.AWAITING_SOFT_PROMPT]: {},
175+
},
176+
},
139177
[MFA_STATE.OUTCOME]: {
140178
id: MFA_STATE.OUTCOME,
141179
initial: MFA_STATE.RESOLVING_OUTCOME,

src/components/MultifactorAuthentication/machine/types.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ type MfaContext = {
2929
/** Additional parameters for the current scenario */
3030
payload: MultifactorAuthenticationScenarioAdditionalParams<MultifactorAuthenticationScenario> | undefined;
3131

32+
/**
33+
* Whether the user approved the soft prompt during this flow. The durable acceptance lives in
34+
* Onyx under the device-biometrics key; this flag only tracks the current flow.
35+
*/
36+
softPromptApproved: boolean;
37+
3238
/** Whether the cancel-confirmation modal triggered by a back press is currently visible */
3339
isCancelConfirmVisible: boolean;
3440
};
@@ -59,9 +65,10 @@ type MultifactorAuthenticationInitEvent<T extends MultifactorAuthenticationScena
5965
*
6066
* CLOSE_MODAL requests the close so the flow moves to `closing`. MODAL_CLOSED is the navigator's
6167
* notification that the close animation fully finished, which moves `closing` to `closed` and wipes
62-
* the context.
68+
* the context. SOFT_PROMPT_APPROVED is sent by the prompt screen's confirm button and moves the flow
69+
* past the soft prompt.
6370
*/
64-
type MfaEvent = MultifactorAuthenticationInitEvent | {type: 'CLOSE_MODAL'} | {type: 'MODAL_CLOSED'};
71+
type MfaEvent = MultifactorAuthenticationInitEvent | {type: 'CLOSE_MODAL'} | {type: 'MODAL_CLOSED'} | {type: 'SOFT_PROMPT_APPROVED'};
6572

6673
/** Describes the input the machine passes to the device-check actor. */
6774
type ValidateDeviceInput = {allowedAuthenticationMethods: AllowedAuthenticationMethods};

src/libs/MultifactorAuthentication/shared/VALUES.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,8 @@ const MFA_STATE = {
221221
CLOSING: 'closing',
222222
PREPARING: 'preparing',
223223
VALIDATING_DEVICE: 'validatingDevice',
224+
PROMPT: 'prompt',
225+
AWAITING_SOFT_PROMPT: 'awaitingSoftPrompt',
224226
OUTCOME: 'outcome',
225227
RESOLVING_OUTCOME: 'resolvingOutcome',
226228
SUCCESS: 'success',
@@ -319,6 +321,7 @@ const SHARED_VALUES = {
319321
MODAL_BACKDROP: 'MultifactorAuthenticationModalBackdrop',
320322
OUTCOME_SCREEN: 'MultifactorAuthenticationOutcomeScreen',
321323
OUTCOME_CONFIRM_BUTTON: 'MultifactorAuthenticationOutcomeConfirmButton',
324+
PROMPT_CONFIRM_BUTTON: 'MultifactorAuthenticationPromptConfirmButton',
322325
},
323326
} as const;
324327

0 commit comments

Comments
 (0)